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::collections::VecDeque;
11use std::io::{self, Stdout, Write};
12use std::path::PathBuf;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicBool, Ordering};
15
16use anyhow::Result;
17use crossterm::{
18 cursor::{Hide, Show},
19 event::{
20 self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEventKind,
21 KeyModifiers, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
22 PushKeyboardEnhancementFlags,
23 },
24 execute,
25 terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate, disable_raw_mode, enable_raw_mode},
26};
27use oxicode_agent::AgentEvent;
28use oxicode_agent::config::Mode;
29use oxicode_agent::tools::TodoStateProvider;
30use oxicode_agent::tools::todo::TodoStatus;
31use oxicode_vtui::theme::{ThemeStyles, active_styles};
32use oxicode_vtui::tui::core::{
33 AuthAction, InlineCommand, InlineEvent, InlineHandle, InlineHeaderContext,
34 InlineHeaderStatusBadge, InlineHeaderStatusTone, InlineListItem, InlineListSelection,
35 InlineMessageKind, InlineSegment, InlineTextStyle, OverlayRequest, OverlaySubmission,
36 SecurePromptConfig,
37};
38use ratatui::{
39 Frame, Terminal,
40 backend::CrosstermBackend,
41 layout::{Alignment, Margin, Position, Rect},
42 style::{Color, Modifier, Style},
43 text::{Line, Span},
44 widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph, Wrap},
45};
46use unicode_width::UnicodeWidthStr;
47
48use crate::App;
49use crate::app::agent_hub_registry::HubEntry;
50use crate::app::agent_session::SessionEvent;
51use crate::tui_vt::slash::file_commands::FileCommand;
52use crate::tui_vt::slash::registry::{SlashCtx, SlashOutcome, SlashRegistry};
53use oxicode_vtui::presentation::{BlockDisplayMode, TranscriptLine, VisibleItem, visible_items};
54
55use oxicode_textarea::{EditBuffer, ElementKind, TextArea, TextAreaState};
56
57use ratatui::widgets::FrameExt;
58const MASKED_ELEMENT_KIND: ElementKind = ElementKind(1);
63pub struct Tui {
73 terminal: Terminal<CrosstermBackend<Stdout>>,
74 tty_ok: bool,
75}
76
77impl Tui {
78 pub fn enter() -> Result<Self> {
81 Self::set_panic_hook();
82
83 let tty_ok = enable_raw_mode().is_ok();
84 let mut stdout = io::stdout();
85
86 if tty_ok {
87 let flags = if std::env::var("OXICODE_KITTY_KEYBOARD").as_deref() == Ok("1") {
91 KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
92 | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
93 | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
94 } else {
95 KeyboardEnhancementFlags::REPORT_EVENT_TYPES
96 };
97 let _ = execute!(
98 stdout,
99 Hide,
100 EnableBracketedPaste,
101 PushKeyboardEnhancementFlags(flags)
102 );
103 let _ = stdout.flush();
104 }
105
106 let backend = CrosstermBackend::new(stdout);
107 let mut terminal = Terminal::new(backend)?;
108 if tty_ok {
109 let _ = terminal.clear();
110 }
111
112 Ok(Self { terminal, tty_ok })
113 }
114
115 pub fn exit(&mut self) -> Result<()> {
119 if self.tty_ok {
120 let _ = execute!(
121 self.terminal.backend_mut(),
122 PopKeyboardEnhancementFlags,
123 DisableBracketedPaste
124 );
125 let _ = self.terminal.show_cursor();
126 disable_raw_mode()?;
128 self.tty_ok = false;
129 }
130 Ok(())
131 }
132
133 fn set_panic_hook() {
137 let original_hook = std::panic::take_hook();
138 std::panic::set_hook(Box::new(move |panic_info| {
139 let _ = execute!(io::stdout(), Show);
140 let _ = disable_raw_mode();
141 original_hook(panic_info);
142 }));
143 }
144}
145
146impl Drop for Tui {
147 fn drop(&mut self) {
148 let _ = self.exit();
149 }
150}
151
152#[derive(Default)]
160struct PromptQueue {
161 pending: parking_lot::Mutex<VecDeque<String>>,
162 wake: tokio::sync::Notify,
163}
164
165impl PromptQueue {
166 fn enqueue(&self, prompt: String) {
167 self.pending.lock().push_back(prompt);
168 self.wake.notify_one();
169 }
170
171 fn remove(&self, index: usize) -> Option<String> {
172 self.pending.lock().remove(index)
173 }
174
175 fn move_by(&self, index: usize, delta: isize) -> bool {
176 let mut pending = self.pending.lock();
177 let Some(target) = index.checked_add_signed(delta) else {
178 return false;
179 };
180 if index >= pending.len() || target >= pending.len() {
181 return false;
182 }
183 pending.swap(index, target);
184 true
185 }
186
187 async fn next(&self) -> String {
188 loop {
189 let notified = self.wake.notified();
190 if let Some(prompt) = self.pending.lock().pop_front() {
191 return prompt;
192 }
193 notified.await;
194 }
195 }
196}
197
198pub struct RenderState {
207 pub composer: oxicode_textarea::TextArea,
209 pub transcript: Vec<TranscriptLine>,
211 pub scroll_offset: usize,
214 pub header_context: InlineHeaderContext,
216 pub input_enabled: bool,
218 pub footer_left: Option<String>,
220 pub footer_right: Option<String>,
221 pub prompt_prefix: String,
223 pub placeholder: Option<String>,
225 pub shutdown_requested: bool,
227 pub message_buffer: String,
229 pub agent_hub_open: bool,
231 pub hub_entries: Vec<(String, HubEntry)>,
233 pub pending_quit: bool,
235 pub slash_popup: SlashPopup,
237 pub reasoning_stage: Option<String>,
239 pub thinking_level: String,
241 pub context_tokens: Option<usize>,
244 pub context_window: usize,
246 pub overlay: Option<OverlayState>,
248 pub overlay_model_ids: Vec<String>,
250 pub overlay_catalog_models: Vec<(String, String)>,
253 pub overlay_providers: Vec<String>,
255 pub catalog: Option<std::sync::Arc<dyn oxicode_sdk::ports::catalog::ModelCatalog>>,
258 pub queued_inputs: Vec<String>,
260 pub queue_panel_open: bool,
262 pub queue_selected: usize,
264 pub shell_mode: bool,
266 pub follow_ups: Vec<String>,
268 pub todo_items: Vec<(String, TodoStatus)>,
270 pub todo_provider: Option<Arc<dyn TodoStateProvider>>,
273 pub vim_state: crate::tui_vt::vim::VimState,
275 pub vim_clipboard: String,
277 pub search: Option<SearchState>,
279 pub block_display: std::collections::HashMap<usize, BlockDisplayMode>,
282 pub last_esc_at: Option<std::time::Instant>,
284 pub multiline_mode: bool,
286 pub autonomy_mode: Mode,
290 pub prompt_history: Vec<String>,
292 pub history_pos: Option<usize>,
294 pub next_block_id: usize,
296 pub cancel_grace_until: Option<std::time::Instant>,
299 pub confirmation: Option<ModalConfirmation>,
303 pub tip: Option<EphemeralTip>,
306 pub cwd: PathBuf,
308 pub file_search: Option<crate::tui_vt::file_search::FileSearchState>,
310 pub seen_tips: std::collections::HashMap<&'static str, u32>,
312 pub file_commands: Vec<FileCommand>,
315 pub secure_input_origin: Option<SecureInputOrigin>,
326 pub session_swapper: Option<Arc<crate::app::agent_session_handle::SessionSwapper>>,
331 pub pending_resume: Option<PathBuf>,
336 pub session_state: Option<crate::SessionState>,
342}
343
344impl Default for RenderState {
345 fn default() -> Self {
346 Self {
350 composer: oxicode_textarea::TextArea::new(),
351 transcript: Vec::new(),
352 scroll_offset: usize::MAX,
353 header_context: InlineHeaderContext::default(),
354 input_enabled: false,
355 footer_left: None,
356 footer_right: None,
357 prompt_prefix: String::new(),
358 placeholder: None,
359 shutdown_requested: false,
360 message_buffer: String::new(),
361 agent_hub_open: false,
362 hub_entries: Vec::new(),
363 pending_quit: false,
364 slash_popup: SlashPopup::default(),
365 reasoning_stage: None,
366 thinking_level: "medium".to_string(),
367 context_tokens: None,
368 context_window: 128_000,
369 overlay: None,
370 overlay_model_ids: Vec::new(),
371 overlay_catalog_models: Vec::new(),
372 overlay_providers: Vec::new(),
373 catalog: None,
374 queued_inputs: Vec::new(),
375 queue_panel_open: false,
376 queue_selected: 0,
377 shell_mode: false,
378 follow_ups: Vec::new(),
379 todo_items: Vec::new(),
380 todo_provider: None,
381 vim_state: crate::tui_vt::vim::VimState::default(),
382 vim_clipboard: String::new(),
383 search: None,
384 block_display: std::collections::HashMap::new(),
385 last_esc_at: None,
386 multiline_mode: false,
387 autonomy_mode: Mode::default(),
388 prompt_history: Vec::new(),
389 history_pos: None,
390 next_block_id: 0,
391 cancel_grace_until: None,
392 confirmation: None,
393 tip: None,
394 cwd: PathBuf::new(),
395 file_search: None,
396 seen_tips: std::collections::HashMap::new(),
397 file_commands: Vec::new(),
398 secure_input_origin: None,
399 session_swapper: None,
400 pending_resume: None,
401 session_state: None,
402 }
403 }
404}
405
406#[derive(Clone, Debug, PartialEq, Eq)]
411pub enum SecureInputOrigin {
412 SetKey { provider: String },
416 NewlyAdded { provider: String },
420}
421
422#[derive(Clone, Debug)]
424pub struct SearchState {
425 pub query: String,
426 pub matches: Vec<usize>,
428 pub current: usize,
430}
431
432#[derive(Clone)]
434pub struct SlashPopupItem {
435 pub label: String,
437 pub description: String,
439 pub name: String,
441}
442
443#[derive(Default, Clone)]
448pub struct SlashPopup {
449 pub open: bool,
450 pub items: Vec<SlashPopupItem>,
451 pub selected: usize,
452}
453
454#[derive(Clone, Debug)]
458pub struct OverlayListItem {
459 pub title: String,
460 pub subtitle: Option<String>,
461 pub badge: Option<String>,
462 pub indent: u8,
463 pub search_value: Option<String>,
464 pub selection: Option<oxicode_vtui::tui::core::InlineListSelection>,
466}
467
468#[derive(Clone, Debug)]
473pub struct OverlayState {
474 pub title: String,
475 pub lines: Vec<String>,
476 pub items: Vec<OverlayListItem>,
477 pub selected: usize,
478 pub search: Option<OverlaySearchState>,
479 pub secure_input: Option<OverlaySecureInput>,
480}
481
482#[derive(Clone, Debug)]
490pub struct OverlaySecureInput {
491 pub config: SecurePromptConfig,
492 pub editor: EditBuffer,
493}
494
495#[derive(Clone, Debug)]
499pub struct ModalConfirmation {
500 pub title: String,
501 pub message: String,
502 pub action: ConfirmationAction,
505}
506
507#[derive(Clone, Debug, PartialEq, Eq)]
509pub enum ConfirmationAction {
510 Quit,
512 ClearConversation,
514 RemoveProviderKey(String),
516}
517
518#[derive(Clone, Debug)]
522pub struct EphemeralTip {
523 pub text: String,
524 pub born_tick: u64,
526 pub ttl_ticks: u64,
528 pub key: &'static str,
531 pub ambient: bool,
535}
536
537#[derive(Clone, Debug)]
539pub struct OverlaySearchState {
540 pub label: String,
541 pub placeholder: Option<String>,
542 pub value: String,
543}
544
545impl RenderState {
546 fn new_with_header(header: InlineHeaderContext) -> Self {
547 let mut s = Self::default();
548 s.header_context = header;
549 s.prompt_prefix = "> ".to_string();
550 s.input_enabled = true;
551 s
552 }
553
554 pub fn swapper(&self) -> Arc<crate::app::agent_session_handle::SessionSwapper> {
559 self.session_swapper
560 .clone()
561 .expect("RenderState::session_swapper must be initialized at TUI startup")
562 }
563
564 fn append_line(&mut self, kind: InlineMessageKind, segments: Vec<InlineSegment>) {
566 let block_id = self.block_id_for_kind(kind);
567 self.transcript.push(TranscriptLine {
568 kind,
569 segments,
570 block_id,
571 });
572 }
573
574 fn inline_segment(&mut self, kind: InlineMessageKind, segment: InlineSegment) {
578 if let Some(last) = self.transcript.last_mut()
579 && last.kind == kind
580 {
581 last.segments.push(segment);
582 return;
583 }
584 let block_id = self.block_id_for_kind(kind);
585 self.transcript.push(TranscriptLine {
586 kind,
587 segments: vec![segment],
588 block_id,
589 });
590 }
591
592 fn block_id_for_kind(&mut self, kind: InlineMessageKind) -> usize {
595 if let Some(last) = self.transcript.last()
596 && last.kind == kind
597 {
598 return last.block_id;
599 }
600 let id = self.next_block_id;
601 self.next_block_id += 1;
602 id
603 }
604
605 pub fn start_search(&mut self, query: &str) {
609 let needle = query.to_lowercase();
610 let matches: Vec<usize> = self
611 .transcript
612 .iter()
613 .enumerate()
614 .filter(|(_, line)| {
615 line.segments
616 .iter()
617 .any(|s| s.text.to_lowercase().contains(&needle))
618 })
619 .map(|(i, _)| i)
620 .collect();
621 self.search = Some(SearchState {
622 query: query.to_string(),
623 matches,
624 current: 0,
625 });
626 if let Some(s) = &self.search
628 && let Some(&first) = s.matches.first()
629 {
630 self.scroll_offset = first;
631 }
632 }
633
634 pub fn search_next(&mut self) {
636 if let Some(s) = &mut self.search
637 && !s.matches.is_empty()
638 {
639 s.current = (s.current + 1) % s.matches.len();
640 let line = s.matches[s.current];
641 self.scroll_offset = line;
642 }
643 }
644
645 pub fn search_prev(&mut self) {
647 if let Some(s) = &mut self.search
648 && !s.matches.is_empty()
649 {
650 if s.current == 0 {
651 s.current = s.matches.len() - 1;
652 } else {
653 s.current -= 1;
654 }
655 let line = s.matches[s.current];
656 self.scroll_offset = line;
657 }
658 }
659
660 pub fn block_mode(&self, block_id: usize) -> BlockDisplayMode {
664 self.block_display
665 .get(&block_id)
666 .copied()
667 .unwrap_or_default()
668 }
669
670 pub fn cycle_block_at_view(&mut self) {
673 let offset = self.effective_offset();
674 if let Some(line) = self.transcript.get(offset) {
675 let bid = line.block_id;
676 let next = match self.block_mode(bid) {
677 BlockDisplayMode::Collapsed => BlockDisplayMode::Truncated,
678 BlockDisplayMode::Truncated => BlockDisplayMode::Expanded,
679 BlockDisplayMode::Expanded => BlockDisplayMode::Collapsed,
680 };
681 if next == BlockDisplayMode::Truncated {
684 self.block_display.remove(&bid);
685 } else {
686 self.block_display.insert(bid, next);
687 }
688 }
689 }
690
691 pub fn expand_all(&mut self) {
693 for bid in self.all_block_ids() {
694 self.block_display.insert(bid, BlockDisplayMode::Expanded);
695 }
696 }
697
698 pub fn fold_all(&mut self) {
700 for bid in self.all_block_ids() {
701 self.block_display.insert(bid, BlockDisplayMode::Collapsed);
702 }
703 }
704
705 pub fn truncate_all(&mut self) {
707 self.block_display.clear();
708 }
709
710 fn all_block_ids(&self) -> Vec<usize> {
712 let mut ids = Vec::new();
713 let mut prev: Option<usize> = None;
714 for l in &self.transcript {
715 if prev != Some(l.block_id) {
716 ids.push(l.block_id);
717 prev = Some(l.block_id);
718 }
719 }
720 ids
721 }
722
723 pub fn jump_next_turn(&mut self) {
727 let offset = self.effective_offset();
728 let search_after = self
729 .transcript
730 .iter()
731 .enumerate()
732 .skip(offset + 1)
733 .find(|(_, l)| l.kind == InlineMessageKind::Agent || l.kind == InlineMessageKind::User);
734 if let Some((idx, _)) = search_after {
735 self.scroll_offset = idx;
736 }
737 }
738
739 pub fn jump_prev_turn(&mut self) {
741 let offset = self.effective_offset();
742 let search_before = self
743 .transcript
744 .iter()
745 .enumerate()
746 .take(offset)
747 .rev()
748 .find(|(_, l)| l.kind == InlineMessageKind::User);
749 if let Some((idx, _)) = search_before {
750 self.scroll_offset = idx;
751 }
752 }
753
754 fn effective_offset(&self) -> usize {
756 if self.scroll_offset == usize::MAX {
757 self.transcript.len().saturating_sub(1)
758 } else {
759 self.scroll_offset
760 }
761 }
762
763 pub fn drain_queue_head(&mut self) {
766 if !self.queued_inputs.is_empty() {
767 self.queued_inputs.remove(0);
768 }
769 }
770
771 pub fn show_tip(&mut self, key: &'static str, text: &str, ttl: u64, ambient: bool) {
774 let count = self.seen_tips.entry(key).or_insert(0);
775 if *count >= SEEN_CAP {
776 return;
777 }
778 *count += 1;
779 self.tip = Some(EphemeralTip {
780 text: text.to_string(),
781 born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
782 ttl_ticks: ttl,
783 key,
784 ambient,
785 });
786 }
787}
788
789const SEEN_CAP: u32 = 3;
791
792pub async fn run_tui(app: App) -> Result<()> {
799 let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
802 let git_branch = crate::util::git_utils::get_current_branch(&cwd);
803 super::host::activate_theme(app.settings());
804 let theme_id = oxicode_vtui::theme::active_theme_id();
806 let validation = oxicode_vtui::theme::validate_theme_contrast(&theme_id);
807 if validation.warnings.is_empty() {
808 tracing::debug!("theme '{theme_id}' passed contrast validation");
809 } else {
810 for w in &validation.warnings {
811 tracing::warn!("theme contrast: {w}");
812 }
813 }
814
815 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
818 let (evt_tx, mut evt_rx) = tokio::sync::mpsc::unbounded_channel::<InlineEvent>();
819 let handle = InlineHandle::new_for_tests(cmd_tx);
820
821 let session = build_agent_session(&app).await?;
825 let session_handle = session.clone_handle();
829
830 let session_swapper = Arc::new(crate::app::agent_session_handle::SessionSwapper::new(
834 session_handle.clone(),
835 ));
836
837 let (session_tx, mut session_rx) = tokio::sync::mpsc::unbounded_channel::<SessionEvent>();
845 let _sub_guard = session.subscribe(Box::new(move |event| {
846 let _ = session_tx.send(event.clone());
847 }));
848
849 let header = build_header_context(&app, &cwd, git_branch.as_deref());
851 handle.set_header_context(header.clone());
852
853 let mut tui = Tui::enter()?;
856
857 handle.set_prompt("> ".to_string(), InlineTextStyle::default());
861 handle.set_placeholder(Some(
862 "Describe the task, or type / for commands".to_string(),
863 ));
864
865 let state = Arc::new(parking_lot::Mutex::new(RenderState::new_with_header(
868 header,
869 )));
870 state.lock().cwd = cwd.clone();
871 state.lock().catalog = Some(app.catalog());
872 state.lock().file_commands = crate::tui_vt::slash::file_commands::load_file_commands(&cwd);
873 state.lock().todo_provider = session_handle.todo_provider();
874 state.lock().session_swapper = Some(session_swapper.clone());
875 state.lock().session_state = Some(app.session_state().clone());
876 state.lock().thinking_level = format!("{:?}", session.thinking_level()).to_ascii_lowercase();
877 state.lock().tip = Some(EphemeralTip {
880 text: "Press ? for shortcuts | /help for commands".to_string(),
881 born_tick: 0,
882 ttl_ticks: 900,
883 key: "onboarding",
884 ambient: true,
885 });
886 if std::env::var("SSH_CONNECTION").is_ok() {
888 state.lock().show_tip(
889 "ssh_wrap",
890 "Over SSH? Consider tmux to keep sessions alive",
891 600,
892 true,
893 );
894 }
895 let mode_handle = app.ask_bridge().map(|b| {
899 let handle = b.mode_handle();
900 state.lock().autonomy_mode = Mode::load(&handle);
901 handle
902 });
903 let prompt_queue = Arc::new(PromptQueue::default());
904 spawn_input_thread(
905 state.clone(),
906 evt_tx.clone(),
907 mode_handle,
908 prompt_queue.clone(),
909 );
910
911 spawn_agent_worker(session_swapper.clone(), prompt_queue.clone());
917
918 let result = run_event_loop(
919 &mut tui.terminal,
920 &mut cmd_rx,
921 &mut evt_rx,
922 &mut session_rx,
923 &handle,
924 &state,
925 &session_swapper,
926 &prompt_queue,
927 )
928 .await;
929
930 handle.shutdown();
931 drop(tui);
933
934 result
935}
936
937#[allow(clippy::too_many_arguments)]
942async fn run_event_loop(
943 terminal: &mut Terminal<CrosstermBackend<Stdout>>,
944 cmd_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineCommand>,
945 evt_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineEvent>,
946 session_rx: &mut tokio::sync::mpsc::UnboundedReceiver<SessionEvent>,
947 handle: &InlineHandle,
948 state: &Arc<parking_lot::Mutex<RenderState>>,
949 session_swapper: &Arc<crate::app::agent_session_handle::SessionSwapper>,
950 prompt_queue: &Arc<PromptQueue>,
951) -> Result<()> {
952 while let Ok(cmd) = cmd_rx.try_recv() {
955 apply_command(&mut state.lock(), cmd);
956 }
957
958 {
963 let snapshot = state.lock();
964 let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
965 if let Err(err) = terminal.draw(|frame| render_frame(frame, &snapshot, handle)) {
966 tracing::warn!(?err, "initial tui draw failed");
967 }
968 let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
969 }
970
971 let mut render_tick = tokio::time::interval(std::time::Duration::from_millis(50));
977 render_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
978
979 loop {
980 tokio::select! {
981 biased;
984
985 Some(cmd) = cmd_rx.recv() => {
987 let shutdown = {
988 let mut s = state.lock();
989 apply_command(&mut s, cmd)
990 };
991 if shutdown {
992 break;
993 }
994 }
995
996 Some(event) = session_rx.recv() => {
998 if let SessionEvent::HandoffComplete { doc_path, auto_continue } = &event {
1000 let mut s = state.lock();
1001 s.transcript.clear();
1002 s.message_buffer.clear();
1003 s.scroll_offset = usize::MAX;
1004 s.append_line(
1005 InlineMessageKind::Info,
1006 vec![plain_segment(format!(
1007 "Handoff written to {}. New session started.",
1008 doc_path
1009 ))],
1010 );
1011 let user_typed = !s.composer.text().trim().is_empty();
1012 s.composer.set_text("");
1013 if *auto_continue && !user_typed {
1014 drop(s);
1015 prompt_queue.enqueue(format!(
1016 "Read the handoff document at {} and continue \
1017 from where the previous session left off.",
1018 doc_path
1019 ));
1020 } else if user_typed {
1021 drop(s);
1022 handle.append_line(
1023 InlineMessageKind::Info,
1024 vec![plain_segment(
1025 "Handoff complete. Auto-continue skipped \
1026 because input was non-empty \u{2014} press \
1027 Enter to submit your message in the new \
1028 session."
1029 .to_string(),
1030 )],
1031 );
1032 }
1033 let session = session_swapper.current();
1034 handle_session_event(&mut state.lock(), handle, &event, Some(&session));
1035 } else {
1036 let session = session_swapper.current();
1043 handle_session_event(&mut state.lock(), handle, &event, Some(&session));
1044 }
1045 }
1046
1047 Some(evt) = evt_rx.recv() => {
1049 let outcome = handle_inline_event(
1050 &mut state.lock(),
1051 handle,
1052 &session_swapper.current(),
1053 prompt_queue,
1054 evt,
1055 );
1056 if outcome == LoopOutcome::Exit {
1057 break;
1058 }
1059 }
1060
1061 _ = tokio::signal::ctrl_c() => {
1065 let outcome = {
1066 let mut s = state.lock();
1067 handle_interrupt(&mut s, &session_swapper.current(), handle)
1068 };
1069 if outcome == LoopOutcome::Exit {
1070 break;
1071 }
1072 }
1073
1074 _ = render_tick.tick() => {}
1077 }
1078
1079 if let Ok(size) = terminal.size()
1081 && size.width < 40
1082 {
1083 let mut s = state.lock();
1084 if s.tip.is_none() {
1085 s.show_tip(
1086 "small_screen",
1087 "Terminal too narrow \u{2014} resize for full UI",
1088 300,
1089 true,
1090 );
1091 }
1092 }
1093 let mut snapshot = state.lock();
1096 if let Some(provider) = snapshot.todo_provider.as_ref() {
1099 snapshot.todo_items = flatten_todo_items(&provider.get_phases());
1100 }
1101 let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
1102 let draw_err = terminal
1103 .draw(|frame| render_frame(frame, &snapshot, handle))
1104 .err();
1105 let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
1106 if let Some(err) = draw_err {
1107 tracing::warn!(?err, "tui draw failed");
1108 break;
1109 }
1110 }
1111
1112 Ok(())
1113}
1114
1115#[derive(PartialEq, Eq)]
1116enum LoopOutcome {
1117 Continue,
1118 Exit,
1119}
1120
1121#[derive(PartialEq, Eq, Debug)]
1126enum CancelRoute {
1127 Interrupt,
1130 Exit,
1132}
1133
1134fn route_cancel(is_streaming: bool) -> CancelRoute {
1137 if is_streaming {
1138 CancelRoute::Interrupt
1139 } else {
1140 CancelRoute::Exit
1141 }
1142}
1143
1144fn apply_command(state: &mut RenderState, cmd: InlineCommand) -> bool {
1151 match cmd {
1152 InlineCommand::AppendLine { kind, segments } => {
1153 state.append_line(kind, segments);
1154 }
1155 InlineCommand::Inline { kind, segment } => {
1156 state.inline_segment(kind, segment);
1157 }
1158 InlineCommand::ReplaceLast {
1159 count, kind, lines, ..
1160 } => {
1161 let drop = count.min(state.transcript.len());
1163 for _ in 0..drop {
1164 state.transcript.pop();
1165 }
1166 for line in lines {
1167 state.append_line(kind, line);
1168 }
1169 }
1170 InlineCommand::AppendPastedMessage { kind, text, .. } => {
1171 state.append_line(kind, vec![plain_segment(text)]);
1172 }
1173 InlineCommand::SetPrompt { prefix, .. } => {
1174 state.prompt_prefix = prefix;
1175 }
1176 InlineCommand::SetPlaceholder { hint, .. } => {
1177 state.placeholder = hint;
1178 }
1179 InlineCommand::SetHeaderContext { context } => {
1180 state.header_context = *context;
1181 }
1182 InlineCommand::SetInputStatus { left, right } => {
1183 state.footer_left = left;
1184 state.footer_right = right;
1185 }
1186 InlineCommand::SetInputEnabled(enabled) => {
1187 state.input_enabled = enabled;
1188 }
1189 InlineCommand::SetCursorVisible(_) | InlineCommand::ForceRedraw => {}
1190 InlineCommand::SetReasoningStage(stage) => {
1191 state.reasoning_stage = stage;
1192 }
1193 InlineCommand::SetVimModeEnabled(enabled) => {
1194 state.vim_state.set_enabled(enabled);
1195 }
1196 InlineCommand::SetQueuedInputs { entries } => {
1197 state.queued_inputs = entries;
1198 }
1199 InlineCommand::ShowOverlay { request } => {
1200 state.overlay = Some(materialize_overlay(*request));
1201 }
1202 InlineCommand::CloseOverlay => {
1203 state.overlay = None;
1204 }
1205 InlineCommand::Shutdown => {
1206 state.shutdown_requested = true;
1207 return true;
1208 }
1209 _ => {
1210 tracing::trace!("unhandled InlineCommand (not rendered)");
1213 }
1214 }
1215 false
1216}
1217
1218fn materialize_overlay(request: OverlayRequest) -> OverlayState {
1223 match request {
1224 OverlayRequest::Modal(req) => {
1225 let secure_input = req.secure_prompt.map(|cfg| OverlaySecureInput {
1226 config: cfg,
1227 editor: EditBuffer::new(),
1228 });
1229 OverlayState {
1230 title: req.title,
1231 lines: req.lines,
1232
1233 items: Vec::new(),
1234 selected: 0,
1235 search: None,
1236 secure_input,
1237 }
1238 }
1239 OverlayRequest::List(req) => {
1240 let search = req.search.map(|cfg| OverlaySearchState {
1241 label: cfg.label,
1242 placeholder: cfg.placeholder,
1243 value: String::new(),
1244 });
1245 OverlayState {
1246 title: req.title,
1247 lines: req.lines,
1248 items: req.items.into_iter().map(overlay_item_from).collect(),
1249 selected: 0,
1250 search,
1251 secure_input: None,
1252 }
1253 }
1254 OverlayRequest::Wizard(req) => {
1255 let step_items = req
1259 .steps
1260 .first()
1261 .map(|s| {
1262 s.items
1263 .iter()
1264 .map(|it| overlay_item_from(it.clone()))
1265 .collect()
1266 })
1267 .unwrap_or_default();
1268 let search = req.search.map(|cfg| OverlaySearchState {
1269 label: cfg.label,
1270 placeholder: cfg.placeholder,
1271 value: String::new(),
1272 });
1273 OverlayState {
1274 title: req.title,
1275 lines: Vec::new(),
1276 items: step_items,
1277 selected: 0,
1278 search,
1279 secure_input: None,
1280 }
1281 }
1282 }
1283}
1284fn overlay_item_from(item: InlineListItem) -> OverlayListItem {
1285 OverlayListItem {
1286 title: item.title,
1287 subtitle: item.subtitle,
1288 badge: item.badge,
1289 indent: item.indent,
1290 search_value: item.search_value,
1291 selection: item.selection,
1292 }
1293}
1294
1295fn handle_session_event(
1299 state: &mut RenderState,
1300 handle: &InlineHandle,
1301 event: &SessionEvent,
1302 session: Option<&crate::app::agent_session::AgentSessionHandle>,
1303) {
1304 match event {
1305 SessionEvent::Agent(boxed) => {
1306 let event = *boxed.clone();
1307 if let (AgentEvent::Error { message, .. }, Some(session)) = (&event, session)
1308 && is_missing_api_key_error(message)
1309 {
1310 let provider = provider_from_model_id(&session.model_id());
1311 handle.append_line(
1312 InlineMessageKind::Info,
1313 vec![plain_segment(format!(
1314 "Authentication is required for '{provider}'. Enter an API key to continue."
1315 ))],
1316 );
1317 open_secure_prompt(state, handle, SecureInputOrigin::SetKey { provider });
1318 }
1319 map_agent_event(handle, event, state);
1320 }
1321 SessionEvent::CompactionStart { .. } => {
1322 handle.set_reasoning_stage(Some("Compacting\u{2026}".to_string()));
1323 }
1324 SessionEvent::CompactionEnd { error_message, .. } => {
1325 handle.set_reasoning_stage(None);
1326 if let Some(msg) = error_message {
1327 handle.append_line(
1328 InlineMessageKind::Error,
1329 vec![plain_segment(format!("Compaction failed: {msg}"))],
1330 );
1331 }
1332 }
1333 SessionEvent::ThinkingLevelChanged { level } => {
1334 state.thinking_level = format!("{level:?}").to_ascii_lowercase();
1335 }
1336 SessionEvent::QueueUpdate { .. } => {
1337 let pending = state.transcript.len();
1341 handle.set_input_status(
1342 None,
1343 Some(if pending == 0 {
1344 "ready".to_string()
1345 } else {
1346 "queued".to_string()
1347 }),
1348 );
1349 }
1350 SessionEvent::Advisor { body, .. } => {
1351 handle.append_line(InlineMessageKind::Info, vec![plain_segment(body.clone())]);
1352 }
1353 SessionEvent::SessionInfoChanged => {
1354 }
1357 SessionEvent::HandoffComplete { .. } => {
1358 }
1362 SessionEvent::HandoffFailed { error } => {
1363 handle.append_line(
1364 InlineMessageKind::Error,
1365 vec![plain_segment(format!("Handoff failed: {}", error))],
1366 );
1367 }
1368 }
1369}
1370
1371fn is_missing_api_key_error(message: &str) -> bool {
1375 let message = message.to_ascii_lowercase();
1376 message.contains("missing api key") || message.contains("api key is required")
1377}
1378
1379fn provider_from_model_id(model_id: &str) -> String {
1382 model_id
1383 .split_once('/')
1384 .map(|(provider, _)| provider)
1385 .filter(|provider| !provider.is_empty())
1386 .unwrap_or("provider")
1387 .to_string()
1388}
1389
1390fn map_agent_event(handle: &InlineHandle, event: AgentEvent, state: &mut RenderState) {
1392 match event {
1393 AgentEvent::TextChunk { text } => {
1394 state.reasoning_stage = Some("generating response".to_string());
1395 state.message_buffer.push_str(&text);
1396 handle.inline(InlineMessageKind::Agent, plain_segment(text));
1397 }
1398 AgentEvent::MessageStart { .. } => {
1399 state.reasoning_stage = Some("generating response".to_string());
1400 state.message_buffer.clear();
1401 }
1402 AgentEvent::MessageUpdate { delta, .. } => match &delta {
1403 oxicode_sdk::StreamDelta::Text(text) => {
1404 state.message_buffer.push_str(text);
1405 handle.inline(InlineMessageKind::Agent, plain_segment(text.clone()));
1406 }
1407 oxicode_sdk::StreamDelta::Thinking(text) => {
1408 let mut style = InlineTextStyle::default();
1412 style.effects |= anstyle::Effects::DIMMED;
1413 let seg = InlineSegment {
1414 text: format!("[thinking] {text}"),
1415 style: Arc::new(style),
1416 };
1417 handle.inline(InlineMessageKind::Info, seg);
1418 }
1419 oxicode_sdk::StreamDelta::Sync => {
1420 if !state.message_buffer.is_empty() {
1422 let lines =
1423 oxicode_vtui::tui::ui::markdown::render_markdown(&state.message_buffer);
1424 let count = lines.len();
1425 if count > 0 {
1426 handle.replace_last(count, InlineMessageKind::Agent, lines);
1427 }
1428 state.message_buffer.clear();
1429 }
1430 }
1431 },
1432 AgentEvent::MessageEnd { .. } => {
1433 if !state.message_buffer.is_empty() {
1435 let lines = oxicode_vtui::tui::ui::markdown::render_markdown(&state.message_buffer);
1436 let count = lines.len();
1437 if count > 0 {
1438 handle.replace_last(count, InlineMessageKind::Agent, lines);
1439 }
1440 state.message_buffer.clear();
1441 }
1442 }
1443 AgentEvent::ToolStart { tool_name, .. } => {
1444 handle.append_line(
1445 InlineMessageKind::Tool,
1446 vec![plain_segment(format!("[tool] {tool_name}"))],
1447 );
1448 let stage = format!("tool: {tool_name}");
1449 state.reasoning_stage = Some(stage.clone());
1450 handle.set_reasoning_stage(Some(stage));
1451 }
1452 AgentEvent::ToolComplete { result } => {
1453 if !try_render_diff(&result.content, handle) {
1455 let preview = preview_tool_result(&result.content);
1456 let mut style = InlineTextStyle::default();
1457 style.effects |= anstyle::Effects::DIMMED;
1458 handle.append_line(
1459 InlineMessageKind::Tool,
1460 vec![InlineSegment {
1461 text: format!("[done] {preview}"),
1462 style: Arc::new(style),
1463 }],
1464 );
1465 }
1466 state.reasoning_stage = Some("generating response".to_string());
1467 handle.set_reasoning_stage(Some("generating response".to_string()));
1468 handle.set_input_enabled(true);
1469 }
1470 AgentEvent::ToolError { error, .. } => {
1471 handle.append_line(
1472 InlineMessageKind::Error,
1473 vec![plain_segment(format!("[error] {error}"))],
1474 );
1475 state.reasoning_stage = None;
1476 handle.set_reasoning_stage(None);
1477 handle.set_input_enabled(true);
1478 }
1479 AgentEvent::Error { message, .. } => {
1480 handle.append_line(InlineMessageKind::Error, vec![plain_segment(message)]);
1481 state.reasoning_stage = None;
1482 handle.set_input_enabled(true);
1483 handle.set_input_status(None, None);
1484 }
1485 AgentEvent::Compaction { .. } => {
1486 }
1489 AgentEvent::Cancelled => {
1490 state.reasoning_stage = None;
1491 handle.set_input_enabled(true);
1492 handle.set_input_status(None, Some("cancelled".to_string()));
1493 }
1494 AgentEvent::AutoRetryStart {
1495 attempt,
1496 max_attempts,
1497 ..
1498 } => {
1499 state.reasoning_stage = Some(format!("retrying {attempt} of {max_attempts}"));
1500 handle.set_input_status(None, Some(format!("retry {attempt}/{max_attempts}")));
1501 }
1502 AgentEvent::TurnEnd { .. } => {
1503 crate::tui_vt::notifications::emit_notification("oxicode", "Response complete");
1507 state.drain_queue_head();
1511 handle.set_reasoning_stage(None);
1512 }
1513 AgentEvent::Usage { input_tokens, .. } => {
1514 state.context_tokens = Some(input_tokens);
1519 }
1520 _ => {
1521 tracing::debug!(?event, "ignored AgentEvent variant");
1525 }
1526 }
1527}
1528
1529pub(crate) fn next_provider_actions(has_key: bool, oauth_capable: bool) -> Vec<AuthAction> {
1536 match (has_key, oauth_capable) {
1537 (true, true) => vec![
1538 AuthAction::SetApiKey,
1539 AuthAction::StartOAuth,
1540 AuthAction::RemoveKey,
1541 ],
1542 (true, false) => vec![AuthAction::SetApiKey, AuthAction::RemoveKey],
1543 (false, true) => vec![AuthAction::SetApiKey, AuthAction::StartOAuth],
1544 (false, false) => vec![AuthAction::SetApiKey],
1545 }
1546}
1547
1548pub(crate) fn open_secure_prompt(
1563 state: &mut RenderState,
1564 handle: &InlineHandle,
1565 origin: SecureInputOrigin,
1566) {
1567 let provider = match &origin {
1568 SecureInputOrigin::SetKey { provider } | SecureInputOrigin::NewlyAdded { provider } => {
1569 provider.clone()
1570 }
1571 };
1572 state.secure_input_origin = Some(origin);
1573 handle.show_modal(
1574 format!("Set API key for {provider}"),
1575 vec![
1576 "Paste the API key. Press Enter to save, Esc to cancel.".into(),
1577 "The key is masked on screen; nothing is logged.".into(),
1578 ],
1579 Some(SecurePromptConfig {
1580 label: format!("{provider} key"),
1581 placeholder: Some("sk-...".into()),
1582 mask_input: true,
1583 }),
1584 );
1585}
1586
1587pub(crate) fn handle_auth_action(
1597 provider: &str,
1598 action: &AuthAction,
1599 auth: &Arc<crate::store::auth_storage::AuthStorage>,
1600 handle: &InlineHandle,
1601 state: &mut RenderState,
1602) -> bool {
1603 match action {
1616 AuthAction::SetApiKey => {
1617 open_secure_prompt(
1618 state,
1619 handle,
1620 SecureInputOrigin::SetKey {
1621 provider: provider.to_string(),
1622 },
1623 );
1624 true
1625 }
1626 AuthAction::StartOAuth => {
1627 let spec = match crate::provider_oauth::spec_for(provider) {
1638 Some(s) => s,
1639 None => {
1640 handle.append_line(
1641 InlineMessageKind::Error,
1642 vec![plain_segment(format!(
1643 "OAuth: no OAuth config for '{provider}'."
1644 ))],
1645 );
1646 return false;
1647 }
1648 };
1649 let provider_owned = provider.to_string();
1654 let tx = handle.clone();
1655 let auth_clone = Arc::clone(auth);
1656 tokio::spawn(async move {
1657 run_oauth_flow(provider_owned, spec, tx, auth_clone).await;
1658 });
1659 false
1660 }
1661 AuthAction::RemoveKey => {
1662 state.confirmation = Some(ModalConfirmation {
1663 title: format!("Remove key for {provider}?"),
1664 message: " y \u{2014} remove key n / x \u{2014} cancel".into(),
1665 action: ConfirmationAction::RemoveProviderKey(provider.to_string()),
1666 });
1667 false
1668 }
1669 }
1670}
1671
1672pub(crate) async fn run_oauth_flow(
1700 provider: String,
1701 spec: crate::provider_oauth::ProviderOAuthSpec,
1702 handle: InlineHandle,
1703 auth: Arc<crate::store::auth_storage::AuthStorage>,
1704) {
1705 use std::time::Duration;
1706 let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0u16)).await {
1715 Ok(l) => l,
1716 Err(e) => {
1717 handle.append_line(
1718 InlineMessageKind::Error,
1719 vec![plain_segment(format!(
1720 "OAuth: could not bind loopback listener for '{provider}': {e}"
1721 ))],
1722 );
1723 return;
1724 }
1725 };
1726 let port = match listener.local_addr() {
1727 Ok(addr) => addr.port(),
1728 Err(e) => {
1729 handle.append_line(
1730 InlineMessageKind::Error,
1731 vec![plain_segment(format!(
1732 "OAuth: could not read loopback port for '{provider}': {e}"
1733 ))],
1734 );
1735 return;
1736 }
1737 };
1738
1739 let (verifier, challenge) = crate::provider_oauth::pkce_pair();
1743 let state_token = crate::provider_oauth::pkce_pair().0; let auth_url = crate::provider_oauth::build_auth_url(&spec, port, &state_token, &challenge);
1749 handle.append_line(
1750 InlineMessageKind::Info,
1751 vec![plain_segment(format!(
1752 "OAuth: opening browser for '{provider}' on http://127.0.0.1:{port}{}",
1753 spec.redirect_path
1754 ))],
1755 );
1756 let callback_timeout = match crate::provider_oauth::open_browser(&auth_url) {
1764 Ok(()) => Duration::from_secs(120),
1765 Err(e) => {
1766 handle.append_line(
1767 InlineMessageKind::Info,
1768 vec![plain_segment(format!(
1769 "OAuth: could not open a browser ({e}).\nOpen this URL manually within 5 minutes:\n {auth_url}"
1770 ))],
1771 );
1772 Duration::from_secs(300)
1773 }
1774 };
1775
1776 let callback = match crate::oauth_listener::await_callback(
1779 listener,
1780 state_token.clone(),
1781 spec.redirect_path.clone(),
1782 callback_timeout,
1783 )
1784 .await
1785 {
1786 Ok(c) => c,
1787 Err(crate::oauth_listener::CallbackError::Timeout) => {
1788 handle.append_line(
1789 InlineMessageKind::Error,
1790 vec![plain_segment(format!(
1791 "OAuth: timed out waiting for '{provider}' callback (after {}s)",
1792 callback_timeout.as_secs()
1793 ))],
1794 );
1795 return;
1796 }
1797 Err(e) => {
1798 handle.append_line(
1799 InlineMessageKind::Error,
1800 vec![plain_segment(format!(
1801 "OAuth: callback failed for '{provider}': {e}"
1802 ))],
1803 );
1804 return;
1805 }
1806 };
1807
1808 let tokens =
1810 match crate::provider_oauth::exchange_code(&spec, port, &callback.code, &verifier).await {
1811 Ok(t) => t,
1812 Err(e) => {
1813 handle.append_line(
1814 InlineMessageKind::Error,
1815 vec![plain_segment(format!(
1816 "OAuth: token exchange failed for '{provider}': {e}"
1817 ))],
1818 );
1819 return;
1820 }
1821 };
1822
1823 let new_expires_at: u64 = tokens.expires_at.max(0) as u64;
1829 let access_token_len = tokens.access_token.chars().count();
1830 auth.set_oauth_full(
1833 &provider,
1834 tokens.access_token,
1835 tokens.refresh_token,
1836 new_expires_at,
1837 if tokens.scopes.is_empty() {
1838 None
1839 } else {
1840 Some(tokens.scopes.join(" "))
1841 },
1842 None,
1843 );
1844 handle.append_line(
1845 InlineMessageKind::Info,
1846 vec![plain_segment(format!(
1847 "OAuth: '{provider}' logged in. Token stored ({} chars).",
1848 access_token_len
1849 ))],
1850 );
1851}
1852
1853fn handle_inline_event(
1855 state: &mut RenderState,
1856 handle: &InlineHandle,
1857 session: &crate::app::agent_session::AgentSessionHandle,
1858 prompt_queue: &Arc<PromptQueue>,
1859 evt: InlineEvent,
1860) -> LoopOutcome {
1861 match evt {
1862 InlineEvent::Submit(text) => {
1863 if let Some(path) = state.pending_resume.take() {
1865 let swapper = state.swapper();
1866 let agent_arc = Arc::clone(&session.agent_arc());
1867 let settings = session.settings_clone();
1868 let session_state = state
1869 .session_state
1870 .clone()
1871 .expect("RenderState::session_state must be initialized at TUI startup");
1872 let path_for_log = path.clone();
1873 let handle = handle.clone();
1874 let swapper_for_swap = swapper.clone();
1875 tokio::spawn(async move {
1876 match crate::app::agent_session::resume_from_file(
1877 agent_arc,
1878 settings,
1879 session_state,
1880 &path,
1881 None,
1882 )
1883 .await
1884 {
1885 Ok(new_session) => {
1886 swapper_for_swap.swap(new_session.clone_handle());
1887 let n = new_session.messages().len();
1888 let id = new_session.session_id();
1889 handle.append_line(
1890 InlineMessageKind::Info,
1891 vec![plain_segment(format!(
1892 "Resumed session {id} ({n} messages)"
1893 ))],
1894 );
1895 }
1896 Err(crate::app::agent_session::ResumeError::FileNotFound(p)) => {
1897 handle.append_line(
1898 InlineMessageKind::Error,
1899 vec![plain_segment(format!("No session file: {}", p.display()))],
1900 );
1901 }
1902 Err(crate::app::agent_session::ResumeError::CwdInvalid(cwd)) => {
1903 handle.append_line(
1904 InlineMessageKind::Error,
1905 vec![plain_segment(format!(
1906 "Cannot resume {}: the session was recorded in `{cwd}`, which no longer exists. \
1907 Use /export to save its content, then /clear.",
1908 path_for_log.display()
1909 ))],
1910 );
1911 }
1912 }
1913 });
1914 return LoopOutcome::Continue;
1915 }
1916 let prompt = text.to_string();
1920 state.composer.set_text("");
1921 if prompt.is_empty() {
1922 return LoopOutcome::Continue;
1923 }
1924 state.pending_quit = false;
1925 if prompt.trim_start().starts_with('/') {
1929 state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
1930 let mut ctx = SlashCtx {
1931 session,
1932 handle,
1933 state,
1934 };
1935 return match SlashRegistry::builtins().dispatch(&prompt, &mut ctx) {
1936 SlashOutcome::Quit => LoopOutcome::Exit,
1937 SlashOutcome::Handled => LoopOutcome::Continue,
1938 SlashOutcome::NotHandled => {
1939 if let Some(expanded) = crate::tui_vt::slash::file_commands::try_expand(
1941 &ctx.state.file_commands,
1942 &prompt,
1943 ) {
1944 prompt_queue.enqueue(expanded);
1947 LoopOutcome::Continue
1948 } else {
1949 ctx.reply(
1950 InlineMessageKind::Error,
1951 format!("Unknown command: {}", prompt.trim()),
1952 );
1953 LoopOutcome::Continue
1954 }
1955 }
1956 };
1957 }
1958 state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
1959 if session.is_streaming() {
1963 state.queued_inputs.push(prompt.clone());
1964 state.show_tip(
1965 "send_now",
1966 "Ctrl+Enter sends now | Ctrl+; manages queue",
1967 240,
1968 true,
1969 );
1970 }
1971 prompt_queue.enqueue(prompt);
1974 }
1975 InlineEvent::Cancel => {
1976 return match route_cancel(session.is_streaming()) {
1982 CancelRoute::Interrupt => handle_interrupt(state, session, handle),
1983 CancelRoute::Exit => LoopOutcome::Exit,
1984 };
1985 }
1986 InlineEvent::Exit => {
1987 return LoopOutcome::Exit;
1988 }
1989 InlineEvent::Interrupt => {
1990 return handle_interrupt(state, session, handle);
1991 }
1992 InlineEvent::ScrollLineUp => {
1993 state.scroll_offset = state.scroll_offset.saturating_add(1);
1994 }
1995 InlineEvent::ScrollLineDown => {
1996 state.scroll_offset = state.scroll_offset.saturating_sub(1);
1997 }
1998 InlineEvent::ScrollPageUp => {
1999 state.scroll_offset = state.scroll_offset.saturating_add(10);
2000 }
2001 InlineEvent::ScrollPageDown => {
2002 state.scroll_offset = state.scroll_offset.saturating_sub(10);
2003 }
2004 InlineEvent::CyclePrimaryAgent => {
2005 let _ = session.cycle_model();
2006 }
2007 InlineEvent::CyclePrimaryAgentPrevious => {
2008 let _ = session.cycle_model();
2011 }
2012 InlineEvent::Overlay(overlay_evt) => {
2013 use oxicode_vtui::tui::core::OverlayEvent;
2014 match overlay_evt {
2015 OverlayEvent::Submitted(sub) => {
2016 let mut opened_new_overlay = false;
2026 if let OverlaySubmission::Selection(InlineListSelection::Model(idx)) = &sub
2028 && idx < &state.overlay_model_ids.len()
2029 {
2030 let model_id = state.overlay_model_ids[*idx].clone();
2031 match session.set_model(&model_id) {
2032 Ok(()) => handle.append_line(
2033 InlineMessageKind::Info,
2034 vec![plain_segment(format!("Switched to {model_id}"))],
2035 ),
2036 Err(e) => handle.append_line(
2037 InlineMessageKind::Error,
2038 vec![plain_segment(format!("Failed to set model: {e}"))],
2039 ),
2040 }
2041 }
2042 if let OverlaySubmission::Selection(InlineListSelection::Theme(theme_id)) = &sub
2044 {
2045 match oxicode_vtui::theme::set_active_theme(theme_id) {
2046 Ok(()) => {
2047 let label = oxicode_vtui::theme::theme_label(theme_id)
2048 .unwrap_or(theme_id.as_ref())
2049 .to_string();
2050 handle.append_line(
2051 InlineMessageKind::Info,
2052 vec![plain_segment(format!("Theme: {label}"))],
2053 );
2054 }
2055 Err(e) => handle.append_line(
2056 InlineMessageKind::Error,
2057 vec![plain_segment(format!("Unknown theme: {e}"))],
2058 ),
2059 }
2060 }
2061 if let OverlaySubmission::Selection(InlineListSelection::SlashCommand(name)) =
2063 &sub
2064 {
2065 state.composer.set_text(&format!("/{name} "));
2066 }
2067 if let OverlaySubmission::Selection(InlineListSelection::ConfigAction(key)) =
2069 &sub
2070 {
2071 match key.as_str() {
2072 "thinking_level" => {
2073 if let Some(level) = session.cycle_thinking_level() {
2074 handle.append_line(
2075 InlineMessageKind::Info,
2076 vec![plain_segment(format!("Thinking: {level:?}"))],
2077 );
2078 }
2079 }
2080 "auto_compaction" => {
2081 let enabled = !session.auto_compaction_enabled();
2082 session.set_auto_compaction(enabled);
2083 handle.append_line(
2084 InlineMessageKind::Info,
2085 vec![plain_segment(format!(
2086 "Auto-compaction: {}",
2087 if enabled { "on" } else { "off" }
2088 ))],
2089 );
2090 }
2091 "auto_retry" => {
2092 let enabled = !session.auto_retry_enabled();
2093 session.set_auto_retry(enabled);
2094 handle.append_line(
2095 InlineMessageKind::Info,
2096 vec![plain_segment(format!(
2097 "Auto-retry: {}",
2098 if enabled { "on" } else { "off" }
2099 ))],
2100 );
2101 }
2102 "advisor" => match session.toggle_advisor() {
2103 Ok(enabled) => handle.append_line(
2104 InlineMessageKind::Info,
2105 vec![plain_segment(format!(
2106 "Advisor: {}",
2107 if enabled { "on" } else { "off" }
2108 ))],
2109 ),
2110 Err(e) => handle.append_line(
2111 InlineMessageKind::Error,
2112 vec![plain_segment(format!("Failed to toggle advisor: {e}"))],
2113 ),
2114 },
2115 _ => {}
2116 }
2117 }
2118 if let OverlaySubmission::Selection(InlineListSelection::Session(id)) = &sub {
2121 if session.is_streaming() {
2127 handle.append_line(
2128 InlineMessageKind::Error,
2129 vec![plain_segment(
2130 "Cannot resume while agent is running. Use /cancel first.",
2131 )],
2132 );
2133 } else {
2134 let path = crate::tui_vt::slash::registry::sessions_dir()
2135 .join(format!("{id}.jsonl"));
2136 if !path.is_file() {
2137 handle.append_line(
2138 InlineMessageKind::Error,
2139 vec![plain_segment(format!(
2140 "No session file: {}",
2141 path.display()
2142 ))],
2143 );
2144 } else {
2145 state.pending_resume = Some(path);
2146 }
2147 }
2148 }
2149 if let OverlaySubmission::Selection(InlineListSelection::CatalogModel(idx)) =
2151 &sub
2152 && idx < &state.overlay_catalog_models.len()
2153 {
2154 let (provider, model_id) = &state.overlay_catalog_models[*idx];
2155 let full = format!("{provider}/{model_id}");
2156 match session.set_model(&full) {
2157 Ok(()) => handle.append_line(
2158 InlineMessageKind::Info,
2159 vec![plain_segment(format!("Switched to {full}"))],
2160 ),
2161 Err(e) => handle.append_line(
2162 InlineMessageKind::Error,
2163 vec![plain_segment(format!("Failed to set model: {e}"))],
2164 ),
2165 }
2166 }
2167 if let OverlaySubmission::Selection(InlineListSelection::ProviderRow(idx)) =
2174 &sub
2175 && idx < &state.overlay_providers.len()
2176 {
2177 let name = state.overlay_providers[*idx].clone();
2178 let auth = crate::store::auth_storage::shared_auth_storage();
2179 let has_key = auth.has(&name);
2180 let oauth_capable = crate::provider_oauth::spec_for(&name).is_some();
2181 let actions = next_provider_actions(has_key, oauth_capable);
2182 if actions.len() == 1 {
2183 opened_new_overlay |=
2185 handle_auth_action(&name, &actions[0], &auth, handle, state);
2186 } else {
2187 let items: Vec<InlineListItem> = actions
2189 .iter()
2190 .map(|a| InlineListItem {
2191 title: match a {
2192 AuthAction::SetApiKey => "Set API key".into(),
2193 AuthAction::StartOAuth => "Login with OAuth".into(),
2194 AuthAction::RemoveKey => "Remove key".into(),
2195 },
2196 subtitle: None,
2197 badge: None,
2198 indent: 0,
2199 selection: Some(InlineListSelection::ProviderAction {
2200 provider: name.clone(),
2201 action: a.clone(),
2202 }),
2203 search_value: None,
2204 })
2205 .collect();
2206 handle.show_list_modal(
2207 name.clone(),
2208 vec!["Pick an action".into()],
2209 items,
2210 None,
2211 None,
2212 );
2213 opened_new_overlay = true;
2214 }
2215 }
2216 if let OverlaySubmission::Selection(InlineListSelection::ProviderAction {
2222 provider,
2223 action,
2224 }) = &sub
2225 {
2226 let auth = crate::store::auth_storage::shared_auth_storage();
2227 opened_new_overlay |=
2228 handle_auth_action(provider, action, &auth, handle, state);
2229 }
2230 if let OverlaySubmission::SecureInput(text) = &sub
2237 && let Some(origin) = state.secure_input_origin.take()
2238 {
2239 let provider = match &origin {
2240 SecureInputOrigin::SetKey { provider }
2241 | SecureInputOrigin::NewlyAdded { provider } => provider.clone(),
2242 };
2243 let auth = crate::store::auth_storage::shared_auth_storage();
2244 auth.set_api_key(&provider, text.clone());
2245 let refreshed = session.refresh_api_key();
2251 let msg = match origin {
2252 SecureInputOrigin::SetKey { .. } => format!(
2253 "Saved API key for '{provider}'. {}",
2254 match refreshed {
2255 Ok(()) => "Ready to retry your message.",
2256 Err(_) => "Restart this session before retrying.",
2257 }
2258 ),
2259 SecureInputOrigin::NewlyAdded { .. } => format!(
2260 "Added and configured '{provider}'. {}",
2261 match refreshed {
2262 Ok(()) => "Use /models to choose a model, or send a message.",
2263 Err(_) => "Restart this session before using it.",
2264 }
2265 ),
2266 };
2267 handle.append_line(InlineMessageKind::Info, vec![plain_segment(msg)]);
2268 }
2269 state.overlay_catalog_models.clear();
2270 state.overlay_providers.clear();
2271 state.overlay_model_ids.clear();
2272 if !opened_new_overlay {
2273 handle.close_overlay();
2274 }
2275 }
2276 OverlayEvent::Cancelled => {
2277 handle.close_overlay();
2278 }
2279 OverlayEvent::SelectionChanged(_) => {}
2280 }
2281 }
2282 _ => {
2283 }
2287 }
2288 LoopOutcome::Continue
2289}
2290
2291struct StreamingGuard<'a>(&'a std::sync::atomic::AtomicBool);
2298
2299impl Drop for StreamingGuard<'_> {
2300 fn drop(&mut self) {
2301 use std::sync::atomic::Ordering;
2302 self.0.store(false, Ordering::SeqCst);
2303 }
2304}
2305
2306fn handle_interrupt(
2319 state: &mut RenderState,
2320 session: &crate::app::agent_session::AgentSessionHandle,
2321 _handle: &InlineHandle,
2322) -> LoopOutcome {
2323 if state.confirmation.is_some() {
2325 return LoopOutcome::Exit;
2326 }
2327 if state.pending_quit {
2330 state.confirmation = Some(quit_confirmation());
2331 state.pending_quit = false;
2332 return LoopOutcome::Continue;
2333 }
2334 if session.is_streaming() {
2338 let s = session.clone();
2339 tokio::spawn(async move {
2340 s.abort().await;
2341 });
2342 state.footer_left = Some("Stopping\u{2026} press Ctrl+C again to confirm quit".to_string());
2343 state.pending_quit = true;
2344 } else {
2345 state.footer_left = None;
2346 state.confirmation = Some(quit_confirmation());
2347 }
2348 LoopOutcome::Continue
2349}
2350
2351fn quit_confirmation() -> ModalConfirmation {
2353 ModalConfirmation {
2354 title: "Quit oxicode?".into(),
2355 message: " y \u{2014} quit now n / x \u{2014} stay".into(),
2356 action: ConfirmationAction::Quit,
2357 }
2358}
2359
2360pub(super) fn clear_confirmation() -> ModalConfirmation {
2362 ModalConfirmation {
2363 title: "Clear conversation?".into(),
2364 message: " y \u{2014} clear all n / x \u{2014} cancel".into(),
2365 action: ConfirmationAction::ClearConversation,
2366 }
2367}
2368
2369fn spawn_input_thread(
2375 state: Arc<parking_lot::Mutex<RenderState>>,
2376 evt_tx: tokio::sync::mpsc::UnboundedSender<InlineEvent>,
2377 mode_handle: Option<std::sync::Arc<std::sync::atomic::AtomicU8>>,
2378 prompt_queue: Arc<PromptQueue>,
2379) -> std::thread::JoinHandle<()> {
2380 std::thread::spawn(move || {
2381 loop {
2389 match event::poll(std::time::Duration::from_millis(50)) {
2390 Ok(true) => {}
2391 Ok(false) => continue,
2392 Err(_) => break,
2393 }
2394 let event = match event::read() {
2395 Ok(ev) => ev,
2396 Err(_) => continue,
2397 };
2398
2399 let mut pasted = String::new();
2402 let mut key_event = None;
2403 match event {
2404 Event::Key(k) if k.kind == KeyEventKind::Press => key_event = Some(k),
2405 Event::Paste(p) => pasted = p,
2406 _ => {}
2407 }
2408
2409 if !pasted.is_empty() {
2410 let routed_to_secure = {
2415 let mut s = state.lock();
2416 if let Some(overlay) = s.overlay.as_mut() {
2417 if let Some(secure) = overlay.secure_input.as_mut() {
2418 let trimmed = pasted.trim_end_matches('\n');
2422 for ch in trimmed.chars() {
2423 if ch.is_ascii_graphic() || ch == ' ' {
2424 let _ = secure
2425 .editor
2426 .apply(oxicode_textarea::EditCommand::Insert(ch));
2427 }
2428 }
2429 true
2430 } else {
2431 false
2432 }
2433 } else {
2434 false
2435 }
2436 };
2437 if routed_to_secure {
2438 continue;
2439 }
2440 let mut s = state.lock();
2441 s.composer.insert_str(&pasted);
2442 refresh_input_popups(&mut s);
2449 continue;
2450 }
2451
2452 let Some(key) = key_event else { continue };
2453
2454 if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
2458 let _ = evt_tx.send(InlineEvent::Interrupt);
2459 continue;
2460 }
2461
2462 if key.code == KeyCode::Char('m') && key.modifiers.contains(KeyModifiers::CONTROL) {
2464 let mut s = state.lock();
2465 s.multiline_mode = !s.multiline_mode;
2466 continue;
2467 }
2468
2469 if key.code == KeyCode::Char('p') && key.modifiers.contains(KeyModifiers::CONTROL) {
2471 let mut s = state.lock();
2472 s.overlay = Some(build_command_palette());
2473 continue;
2474 }
2475
2476 if key.code == KeyCode::Char(';') && key.modifiers.contains(KeyModifiers::CONTROL) {
2478 let mut s = state.lock();
2479 s.queue_panel_open = !s.queue_panel_open;
2480 if s.queue_panel_open {
2481 s.queue_selected = 0;
2482 }
2483 continue;
2484 }
2485
2486 if key.code == KeyCode::Char('e') && key.modifiers.contains(KeyModifiers::CONTROL) {
2488 let mut s = state.lock();
2489 s.fold_all();
2490 continue;
2491 }
2492
2493 if key.code == KeyCode::Enter && key.modifiers.contains(KeyModifiers::CONTROL) {
2496 let submitted = {
2497 let mut s = state.lock();
2498 let buf = if s.slash_popup.open && !s.slash_popup.items.is_empty() {
2499 format!("/{}", s.slash_popup.items[s.slash_popup.selected].name)
2500 } else {
2501 let buf = s.composer.text().to_string();
2502 s.composer.set_text("");
2503 buf
2504 };
2505 s.slash_popup = SlashPopup::default();
2506 s.history_pos = None;
2507 if !buf.is_empty() && !buf.starts_with('/') {
2508 s.prompt_history.insert(0, buf.clone());
2509 s.prompt_history.truncate(100);
2510 }
2511 buf
2512 };
2513 if !submitted.is_empty() {
2514 let _ = evt_tx.send(InlineEvent::Interrupt);
2515 let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
2516 }
2517 continue;
2518 }
2519
2520 {
2523 let s = state.lock();
2524 if s.confirmation.is_some() {
2525 drop(s);
2526 handle_confirmation_key(&state, &evt_tx, key.code);
2527 continue;
2528 }
2529 }
2530
2531 {
2537 let s = state.lock();
2538 if s.overlay.is_some() {
2539 drop(s);
2540 if handle_overlay_key(&state, &evt_tx, key.code) {
2541 continue;
2542 }
2543 }
2544 }
2545
2546 {
2550 let s = state.lock();
2551 if s.file_search.is_some() {
2552 drop(s);
2553 if handle_file_search_key(&state, &evt_tx, key.code) {
2554 continue;
2555 }
2556 }
2557 }
2558
2559 match key.code {
2560 KeyCode::BackTab => {
2562 if let Some(h) = &mode_handle {
2563 let new_mode = Mode::load(h).toggle();
2564 h.store(new_mode.as_u8(), std::sync::atomic::Ordering::SeqCst);
2565 let label = new_mode.label();
2566 let detail = if new_mode.is_auto() {
2567 "autonomous — no questions, runs to completion"
2568 } else {
2569 "interactive — may ask questions"
2570 };
2571 let mut s = state.lock();
2572 s.autonomy_mode = new_mode;
2573 s.tip = Some(EphemeralTip {
2574 text: format!("Mode: {label} — {detail}"),
2575 born_tick: 0,
2576 ttl_ticks: 240,
2577 key: "mode_toggle",
2578 ambient: false,
2579 });
2580 }
2581 continue;
2582 }
2583 KeyCode::Enter => {
2584 let send = !state.lock().multiline_mode
2587 || key
2588 .modifiers
2589 .contains(crossterm::event::KeyModifiers::SHIFT);
2590
2591 if !send {
2592 let mut s = state.lock();
2593 s.composer.insert_str("\n");
2594 continue;
2595 }
2596
2597 let shell_cmd = state.lock().shell_mode;
2599 if shell_cmd {
2600 let submitted = {
2601 let mut s = state.lock();
2602 let buf = s.composer.text().to_string();
2603 s.composer.set_text("");
2604 s.shell_mode = false;
2605 s.history_pos = None;
2606 if !buf.is_empty() {
2607 s.prompt_history.insert(0, buf.clone());
2608 s.prompt_history.truncate(100);
2609 }
2610 buf
2611 };
2612 if !submitted.is_empty() {
2613 let prompt = format!("Run this shell command: `{submitted}`");
2614 let _ = evt_tx.send(InlineEvent::Submit(prompt.into()));
2615 }
2616 continue;
2617 }
2618
2619 let submitted = {
2620 let mut s = state.lock();
2621 let buf = if s.slash_popup.open && !s.slash_popup.items.is_empty() {
2622 let item = &s.slash_popup.items[s.slash_popup.selected];
2623 format!("/{}", item.name)
2624 } else {
2625 let buf = s.composer.text().to_string();
2626 s.composer.set_text("");
2627 buf
2628 };
2629 s.slash_popup = SlashPopup::default();
2630 s.history_pos = None;
2631 if !buf.is_empty() && !buf.starts_with('/') {
2633 s.prompt_history.insert(0, buf.clone());
2634 s.prompt_history.truncate(100);
2635 }
2636 buf
2637 };
2638 let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
2639 }
2640 KeyCode::Esc => {
2641 let mut s = state.lock();
2648 if s.shell_mode {
2649 s.shell_mode = false;
2650 s.composer.set_text("");
2651 } else if s.slash_popup.open {
2652 s.slash_popup = SlashPopup::default();
2653 } else if !s.composer.is_empty() {
2654 let now = std::time::Instant::now();
2655 let is_double = s
2656 .last_esc_at
2657 .map(|t| now.duration_since(t).as_millis() < 800)
2658 .unwrap_or(false);
2659 if is_double {
2660 s.composer.set_text("");
2661 s.last_esc_at = None;
2662 } else {
2663 s.last_esc_at = Some(now);
2664 s.tip = Some(EphemeralTip {
2667 text: "Press Esc again to clear input".to_string(),
2668 born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
2669 ttl_ticks: 120,
2670 key: "esc_clear",
2671 ambient: false,
2672 });
2673 }
2674 } else {
2675 let now = std::time::Instant::now();
2676 let in_grace = s.cancel_grace_until.map(|t| t > now).unwrap_or(false);
2677 if in_grace {
2678 } else {
2680 s.cancel_grace_until = Some(now + std::time::Duration::from_secs(1));
2681 s.last_esc_at = None;
2682 drop(s);
2683 let _ = evt_tx.send(InlineEvent::Cancel);
2684 }
2685 }
2686 }
2687 KeyCode::Tab => {
2688 let mut s = state.lock();
2690 if s.slash_popup.open && !s.slash_popup.items.is_empty() {
2691 let name = s.slash_popup.items[s.slash_popup.selected].name.clone();
2692 s.composer.set_text(&format!("/{} ", name));
2693 refresh_input_popups(&mut s);
2694 }
2695 }
2696 KeyCode::Backspace => {
2697 let mut s = state.lock();
2698 s.composer.input(crossterm::event::KeyEvent::new(
2699 KeyCode::Backspace,
2700 KeyModifiers::NONE,
2701 ));
2702 refresh_input_popups(&mut s);
2703 }
2704 KeyCode::Delete => {
2705 let mut s = state.lock();
2706 s.composer.input(crossterm::event::KeyEvent::new(
2707 KeyCode::Delete,
2708 KeyModifiers::NONE,
2709 ));
2710 refresh_input_popups(&mut s);
2711 }
2712 KeyCode::Left => {
2713 let mut s = state.lock();
2714 s.composer.input(crossterm::event::KeyEvent::new(
2715 KeyCode::Left,
2716 KeyModifiers::NONE,
2717 ));
2718 }
2719 KeyCode::Right => {
2720 let mut s = state.lock();
2721 s.composer.input(crossterm::event::KeyEvent::new(
2722 KeyCode::Right,
2723 KeyModifiers::NONE,
2724 ));
2725 }
2726 KeyCode::Up => {
2727 let mut s = state.lock();
2728 if s.slash_popup.open && !s.slash_popup.items.is_empty() {
2729 let len = s.slash_popup.items.len();
2730 s.slash_popup.selected = if s.slash_popup.selected == 0 {
2731 len - 1
2732 } else {
2733 s.slash_popup.selected - 1
2734 };
2735 } else if s.queue_panel_open
2736 && !s.queued_inputs.is_empty()
2737 && s.composer.is_empty()
2738 {
2739 s.queue_selected = if s.queue_selected == 0 {
2740 s.queued_inputs.len() - 1
2741 } else {
2742 s.queue_selected - 1
2743 };
2744 } else if s.composer.is_empty() && !s.prompt_history.is_empty() {
2745 let pos = s.history_pos.unwrap_or(0);
2747 let next = (pos + 1).min(s.prompt_history.len() - 1);
2748 s.history_pos = Some(next);
2749 let entry = s.prompt_history[next].clone();
2750 s.composer.set_text(&entry);
2751 drop(s);
2752 let _ = evt_tx.send(InlineEvent::ScrollLineUp);
2753 }
2754 }
2755 KeyCode::Down => {
2756 let mut s = state.lock();
2757 if s.slash_popup.open && !s.slash_popup.items.is_empty() {
2758 let len = s.slash_popup.items.len();
2759 s.slash_popup.selected = if s.slash_popup.selected + 1 >= len {
2760 0
2761 } else {
2762 s.slash_popup.selected + 1
2763 };
2764 } else if s.queue_panel_open
2765 && !s.queued_inputs.is_empty()
2766 && s.composer.is_empty()
2767 {
2768 s.queue_selected = if s.queue_selected + 1 >= s.queued_inputs.len() {
2769 0
2770 } else {
2771 s.queue_selected + 1
2772 };
2773 } else {
2774 drop(s);
2775 let _ = evt_tx.send(InlineEvent::ScrollLineDown);
2776 }
2777 }
2778 KeyCode::PageUp => {
2779 let _ = evt_tx.send(InlineEvent::ScrollPageUp);
2780 }
2781 KeyCode::PageDown => {
2782 let _ = evt_tx.send(InlineEvent::ScrollPageDown);
2783 }
2784 KeyCode::Char(ch) => {
2785 let mut s = state.lock();
2786 if s.file_search.is_some()
2790 && ch == '!'
2791 && s.composer.text()[..s.composer.cursor()].ends_with('@')
2792 {
2793 let cwd = s.cwd.clone();
2794 if let Some(fs) = s.file_search.as_mut() {
2795 fs.toggle_hidden(&cwd);
2796 }
2797 continue;
2798 }
2799 if s.agent_hub_open && ch == 'q' {
2800 s.agent_hub_open = false;
2801 } else if s.vim_state.enabled() && !s.slash_popup.open {
2802 let s = &mut *s;
2805 let vkey =
2806 crossterm::event::KeyEvent::new(KeyCode::Char(ch), key.modifiers);
2807 let mut editor = InputEditor::new(&mut s.composer);
2808 let outcome = crate::tui_vt::vim::handle_key(
2809 &mut s.vim_state,
2810 &mut editor,
2811 &mut s.vim_clipboard,
2812 &vkey,
2813 );
2814 if outcome.handled {
2815 refresh_input_popups(s);
2816 }
2817 } else if s.composer.is_empty() && !s.slash_popup.open {
2818 if ch == '!' && !s.shell_mode {
2820 s.shell_mode = true;
2821 continue;
2822 }
2823 if s.queue_panel_open && !s.queued_inputs.is_empty() {
2827 let idx = s.queue_selected.min(s.queued_inputs.len() - 1);
2828 match ch {
2829 'x' | 'X' => {
2830 let _ = prompt_queue.remove(idx);
2831 s.queued_inputs.remove(idx);
2832 if s.queue_selected >= s.queued_inputs.len()
2833 && !s.queued_inputs.is_empty()
2834 {
2835 s.queue_selected = s.queued_inputs.len() - 1;
2836 }
2837 continue;
2838 }
2839 'e' => {
2840 if let Some(entry) = prompt_queue.remove(idx) {
2841 s.queued_inputs.remove(idx);
2842 s.composer.set_text(&entry);
2843 s.queue_panel_open = false;
2844 continue;
2845 }
2846 }
2847 'J' => {
2848 if prompt_queue.move_by(idx, 1)
2849 && idx + 1 < s.queued_inputs.len()
2850 {
2851 s.queued_inputs.swap(idx, idx + 1);
2852 s.queue_selected = idx + 1;
2853 }
2854 continue;
2855 }
2856 'K' => {
2857 if idx > 0 && prompt_queue.move_by(idx, -1) {
2858 s.queued_inputs.swap(idx, idx - 1);
2859 s.queue_selected = idx - 1;
2860 }
2861 continue;
2862 }
2863 _ => {} }
2865 }
2866 match ch {
2871 '?' => {
2872 s.overlay = Some(OverlayState {
2873 title: "Keyboard Shortcuts".into(),
2874 lines: cheatsheet_lines(),
2875 items: vec![],
2876 selected: 0,
2877 search: None,
2878 secure_input: None,
2879 });
2880 }
2881 'e' => s.cycle_block_at_view(),
2882 'E' => s.expand_all(),
2883 'J' => s.jump_next_turn(),
2884 'K' => s.jump_prev_turn(),
2885 'n' if s.search.is_some() => s.search_next(),
2886 'N' if s.search.is_some() => s.search_prev(),
2887 _ => {
2888 s.composer.input(crossterm::event::KeyEvent::new(
2889 KeyCode::Char(ch),
2890 key.modifiers,
2891 ));
2892 refresh_input_popups(&mut s);
2893 }
2894 }
2895 } else {
2896 s.composer.input(crossterm::event::KeyEvent::new(
2897 KeyCode::Char(ch),
2898 key.modifiers,
2899 ));
2900 refresh_input_popups(&mut s);
2901 }
2902 if s.tip.is_none() && s.composer.text().to_lowercase().contains("plan") {
2904 s.show_tip(
2905 "plan_nudge",
2906 "Try /compact to summarize and plan ahead",
2907 180,
2908 true,
2909 );
2910 }
2911 }
2912 _ => {}
2913 }
2914 }
2915 })
2916}
2917
2918fn handle_confirmation_key(
2922 state: &Arc<parking_lot::Mutex<RenderState>>,
2923 evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
2924 code: KeyCode,
2925) {
2926 let mut s = state.lock();
2927 let Some(confirm) = s.confirmation.clone() else {
2928 return;
2929 };
2930 match code {
2931 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
2932 s.confirmation = None;
2933 drop(s);
2934 match confirm.action {
2935 ConfirmationAction::Quit => {
2936 let _ = evt_tx.send(InlineEvent::Exit);
2937 }
2938 ConfirmationAction::ClearConversation => {
2939 let _ = evt_tx.send(InlineEvent::Submit("/clear --yes".into()));
2943 }
2944 ConfirmationAction::RemoveProviderKey(name) => {
2945 let _ = evt_tx.send(InlineEvent::Submit(
2949 format!("/providers remove {name} --yes").into(),
2950 ));
2951 }
2952 }
2953 }
2954 KeyCode::Char('n')
2955 | KeyCode::Char('N')
2956 | KeyCode::Char('x')
2957 | KeyCode::Char('X')
2958 | KeyCode::Esc => {
2959 s.confirmation = None;
2960 }
2961 _ => {}
2962 }
2963}
2964
2965fn handle_overlay_key(
2970 state: &Arc<parking_lot::Mutex<RenderState>>,
2971 evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
2972 code: KeyCode,
2973) -> bool {
2974 use oxicode_vtui::tui::core::{OverlayEvent, OverlaySubmission};
2975
2976 let mut s = state.lock();
2977 let Some(overlay) = s.overlay.as_mut() else {
2978 return false;
2979 };
2980 if let Some(secure) = overlay.secure_input.as_mut() {
2983 use oxicode_textarea::EditCommand;
2984 match code {
2985 KeyCode::Backspace => {
2986 if secure.editor.cursor_byte() > 0 {
2990 let _ = secure.editor.apply(EditCommand::DeleteGraphemeBackward);
2991 }
2992 }
2993 KeyCode::Left => {
2994 let _ = secure.editor.apply(EditCommand::MoveGraphemeLeft);
2995 }
2996 KeyCode::Right => {
2997 let _ = secure.editor.apply(EditCommand::MoveGraphemeRight);
2998 }
2999 KeyCode::Enter => {
3000 let submission = OverlaySubmission::SecureInput(secure.editor.text().to_string());
3005 drop(s);
3006 state.lock().overlay = None;
3007 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Submitted(submission)));
3008 }
3009 KeyCode::Esc => {
3010 drop(s);
3011 state.lock().overlay = None;
3012 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
3013 }
3014 KeyCode::Char(ch) if ch.is_ascii_graphic() || ch == ' ' => {
3015 let _ = secure.editor.apply(EditCommand::Insert(ch));
3018 }
3019 _ => {} }
3021 return true;
3022 }
3023
3024 match code {
3025 KeyCode::Esc => {
3026 drop(s);
3028 state.lock().overlay = None;
3029 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
3030 }
3031 KeyCode::Enter => {
3032 let submission = if let Some(item) = overlay.items.get(overlay.selected) {
3035 match item.selection.clone() {
3036 Some(sel) => sel,
3037 None => {
3038 return true;
3044 }
3045 }
3046 } else {
3047 drop(s);
3048 state.lock().overlay = None;
3049 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
3050 return true;
3051 };
3052 let title = overlay.title.clone();
3053 let selected = overlay.selected;
3054 drop(s);
3055 state.lock().overlay = None;
3056 tracing::debug!(
3057 overlay = %title,
3058 selected,
3059 "overlay submitted"
3060 );
3061 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Submitted(
3062 OverlaySubmission::Selection(submission),
3063 )));
3064 }
3065 KeyCode::Up => {
3066 let len = overlay_filtered_indices(overlay).len();
3067 if len == 0 {
3068 return true;
3069 }
3070 let pos = overlay_filtered_indices(overlay)
3071 .iter()
3072 .position(|&i| i == overlay.selected)
3073 .unwrap_or(0);
3074 let new_pos = if pos == 0 { len - 1 } else { pos - 1 };
3075 overlay.selected = overlay_filtered_indices(overlay)[new_pos];
3076 }
3077 KeyCode::Down => {
3078 let filtered = overlay_filtered_indices(overlay);
3079 let len = filtered.len();
3080 if len == 0 {
3081 return true;
3082 }
3083 let pos = filtered
3084 .iter()
3085 .position(|&i| i == overlay.selected)
3086 .unwrap_or(0);
3087 let new_pos = if pos + 1 >= len { 0 } else { pos + 1 };
3088 overlay.selected = filtered[new_pos];
3089 }
3090 KeyCode::Backspace => {
3091 if let Some(search) = overlay.search.as_mut() {
3092 search.value.pop();
3093 overlay.selected = 0;
3094 }
3095 }
3096 KeyCode::Char(ch) => {
3097 if let Some(search) = overlay.search.as_mut() {
3098 search.value.push(ch);
3099 overlay.selected = 0;
3100 }
3101 }
3102 _ => {
3103 }
3105 }
3106 true
3107}
3108
3109fn overlay_filtered_indices(overlay: &OverlayState) -> Vec<usize> {
3114 let needle = overlay
3115 .search
3116 .as_ref()
3117 .map(|s| s.value.to_lowercase())
3118 .unwrap_or_default();
3119 if needle.is_empty() {
3120 return (0..overlay.items.len()).collect();
3121 }
3122 overlay
3123 .items
3124 .iter()
3125 .enumerate()
3126 .filter_map(|(idx, item)| {
3127 let title_hit = item.title.to_lowercase().contains(&needle);
3128 let sv_hit = item
3129 .search_value
3130 .as_deref()
3131 .map(|v| v.to_lowercase().contains(&needle))
3132 .unwrap_or(false);
3133 if title_hit || sv_hit { Some(idx) } else { None }
3134 })
3135 .collect()
3136}
3137
3138fn handle_file_search_key(
3144 state: &Arc<parking_lot::Mutex<RenderState>>,
3145 _evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
3146 code: KeyCode,
3147) -> bool {
3148 match code {
3149 KeyCode::Up => {
3150 let mut s = state.lock();
3151 if let Some(fs) = s.file_search.as_mut() {
3152 fs.up();
3153 true
3154 } else {
3155 false
3156 }
3157 }
3158 KeyCode::Down => {
3159 let mut s = state.lock();
3160 if let Some(fs) = s.file_search.as_mut() {
3161 fs.down();
3162 true
3163 } else {
3164 false
3165 }
3166 }
3167 KeyCode::Tab | KeyCode::Enter => {
3168 let mut s = state.lock();
3169 if s.file_search
3170 .as_ref()
3171 .and_then(|fs| fs.selected_result())
3172 .is_some()
3173 {
3174 accept_file_search(&mut s, false);
3175 true
3176 } else {
3177 s.file_search = None;
3179 false
3180 }
3181 }
3182 KeyCode::Esc => {
3183 let mut s = state.lock();
3184 s.file_search = None;
3185 true
3186 }
3187 _ => false,
3188 }
3189}
3190
3191fn spawn_agent_worker(
3197 session_swapper: Arc<crate::app::agent_session_handle::SessionSwapper>,
3198 prompt_queue: Arc<PromptQueue>,
3199) {
3200 std::thread::spawn(move || {
3201 let runtime = match tokio::runtime::Builder::new_current_thread()
3202 .enable_all()
3203 .build()
3204 {
3205 Ok(rt) => rt,
3206 Err(err) => {
3207 tracing::error!(?err, "failed to build agent worker runtime");
3208 return;
3209 }
3210 };
3211
3212 runtime.block_on(async move {
3213 let local = tokio::task::LocalSet::new();
3214 local
3215 .run_until(async move {
3216 loop {
3217 let prompt = prompt_queue.next().await;
3218 run_one_prompt(&session_swapper.current(), prompt).await;
3219 }
3220 })
3221 .await;
3222 });
3223 });
3224}
3225
3226async fn run_one_prompt(session: &crate::app::agent_session::AgentSessionHandle, prompt: String) {
3227 let session_for_forward = session.clone();
3228 let (event_tx, event_rx) = std::sync::mpsc::channel::<AgentEvent>();
3229
3230 let forwarder = std::thread::spawn(move || {
3234 while let Ok(event) = event_rx.recv() {
3235 session_for_forward.forward_event_to_extensions(&event);
3236 }
3237 });
3238
3239 use std::sync::atomic::Ordering;
3243 session.reset_should_stop();
3244 let streaming = session.streaming_flag();
3245 streaming.store(true, Ordering::SeqCst);
3246 let _stream_guard = StreamingGuard(&streaming);
3247
3248 let agent = session.agent_ref();
3249 let local = tokio::task::LocalSet::new();
3250 let result = local
3251 .run_until(agent.run_with_channel(prompt, event_tx))
3252 .await;
3253
3254 let _ = forwarder.join();
3257 if let Err(err) = result {
3258 tracing::warn!(?err, "agent run failed");
3259 }
3260}
3261
3262fn build_header_context(
3271 app: &App,
3272 cwd: &std::path::Path,
3273 git_branch: Option<&str>,
3274) -> InlineHeaderContext {
3275 let workspace_name = cwd
3276 .file_name()
3277 .map(|n| n.to_string_lossy().into_owned())
3278 .unwrap_or_else(|| "oxicode".to_string());
3279 let model_id = app.model_id();
3280 let provider = model_id
3281 .split_once('/')
3282 .map(|(p, _)| p.to_string())
3283 .unwrap_or_else(|| "Provider".to_string());
3284 let branch = git_branch.unwrap_or("\u{2014}").to_string();
3285 let mut ctx = InlineHeaderContext::default();
3286 ctx.app_name = "oxicode".to_string();
3287 ctx.provider = provider;
3288 ctx.model = model_id.clone();
3289 ctx.git = format!("git: {workspace_name}@{branch}");
3290 ctx.tools = "Tools: ready".to_string();
3291 ctx.search_tools = Some(InlineHeaderStatusBadge {
3292 text: workspace_name,
3293 tone: InlineHeaderStatusTone::Ready,
3294 });
3295 ctx.persistent_memory = Some(InlineHeaderStatusBadge {
3296 text: branch,
3297 tone: InlineHeaderStatusTone::Ready,
3298 });
3299 ctx.editor_context = Some(model_id);
3300 ctx
3301}
3302
3303async fn build_agent_session(app: &App) -> Result<crate::app::agent_session::AgentSession> {
3306 use crate::app::agent_session_runtime::{
3307 CreateAgentSessionFromServicesOptions, CreateAgentSessionServicesOptions,
3308 create_agent_session_from_services, create_agent_session_services,
3309 };
3310 use crate::store::session::SessionManager;
3311
3312 let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
3313 let hook_runner = Arc::clone(&app.oxicode().ports().hooks);
3314 let services = create_agent_session_services(
3315 CreateAgentSessionServicesOptions::new(cwd.clone()),
3316 Some(hook_runner),
3317 )?;
3318 let services = Arc::new(services);
3319
3320 let model_id = app.model_id();
3321 let tools = app.agent_tools();
3322
3323 let session_manager = SessionManager::create(&cwd.to_string_lossy(), None);
3324
3325 let result = create_agent_session_from_services(CreateAgentSessionFromServicesOptions {
3326 services,
3327 session_manager,
3328 model_id: if model_id.is_empty() {
3329 None
3330 } else {
3331 Some(model_id)
3332 },
3333 thinking_level: None,
3334 scoped_models: Vec::new(),
3335 tool_registry: Some(tools),
3336 oxicode: Some(app.oxicode().clone()),
3337 session_state: Some(app.session_state().clone()),
3340 })
3341 .await?;
3342
3343 if let Some(msg) = result.model_fallback_message {
3344 tracing::warn!(message = %msg, "agent session model fallback");
3345 }
3346 Ok(result.session)
3347}
3348
3349fn cheatsheet_lines() -> Vec<String> {
3355 vec![
3356 "".into(),
3357 " Navigation".into(),
3358 " j / ↓ Scroll down".into(),
3359 " k / ↑ Scroll up".into(),
3360 " J (Shift+j) Next turn".into(),
3361 " K (Shift+k) Previous turn".into(),
3362 " PgDn / PgUp Page scroll".into(),
3363 " g / G Top / bottom".into(),
3364 "".into(),
3365 " Blocks".into(),
3366 " e Cycle block (collapse/truncate/expand)".into(),
3367 " E Expand all blocks".into(),
3368 " Ctrl+E Collapse all blocks".into(),
3369 "".into(),
3370 " Search".into(),
3371 " /find <q> Search transcript".into(),
3372 " n / N Next / previous match".into(),
3373 "".into(),
3374 " Commands".into(),
3375 " /theme Cycle color theme".into(),
3376 " /model Pick a model".into(),
3377 " /vim Toggle vim mode".into(),
3378 " /compact Compact context".into(),
3379 " /clear Clear conversation".into(),
3380 " Ctrl+C Cancel run (then y to quit)".into(),
3381 " Ctrl+Enter Send now (abort + submit)".into(),
3382 " Ctrl+M Toggle multiline input".into(),
3383 " Shift+Tab Toggle Auto mode (no questions, runs to end)".into(),
3384 " Ctrl+; Toggle queue panel".into(),
3385 "".into(),
3386 " Special Input".into(),
3387 " @ File picker (fuzzy search)".into(),
3388 " @! Toggle hidden files in picker".into(),
3389 " ! Shell mode (bash command)".into(),
3390 ]
3391}
3392
3393fn build_command_palette() -> OverlayState {
3396 use oxicode_vtui::tui::core::{InlineListItem, InlineListSelection};
3397
3398 let catalog = SlashRegistry::builtin_commands();
3399 let mut items: Vec<InlineListItem> = catalog
3400 .iter()
3401 .map(|(name, desc, aliases)| {
3402 let title = if aliases.is_empty() {
3403 format!("/{name}")
3404 } else {
3405 format!(
3406 "/{name} ({})",
3407 aliases
3408 .iter()
3409 .map(|a| format!("/{a}"))
3410 .collect::<Vec<_>>()
3411 .join(", ")
3412 )
3413 };
3414 InlineListItem {
3415 title,
3416 subtitle: Some(desc.to_string()),
3417 badge: None,
3418 indent: 0,
3419 selection: Some(InlineListSelection::SlashCommand(name.to_string())),
3420 search_value: Some(format!("{name} {desc}")),
3421 }
3422 })
3423 .collect();
3424 items.sort_by(|a, b| a.title.cmp(&b.title));
3425
3426 OverlayState {
3427 title: "Command Palette".into(),
3428 lines: vec!["Type to filter, Enter to select".into()],
3429 items: items
3430 .into_iter()
3431 .map(|item| OverlayListItem {
3432 title: item.title,
3433 subtitle: item.subtitle,
3434 badge: item.badge,
3435 indent: item.indent,
3436 search_value: item.search_value,
3437 selection: item.selection,
3438 })
3439 .collect(),
3440 selected: 0,
3441 search: Some(OverlaySearchState {
3442 label: "search".into(),
3443 placeholder: Some("filter commands\u{2026}".into()),
3444 value: String::new(),
3445 }),
3446 secure_input: None,
3447 }
3448}
3449
3450static FRAME_TICK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
3452static TITLE_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
3454const TITLE_SPINNER: &[&str] = &["-", "\\", "|", "/"];
3456
3457fn wave_brightness(tick: u64, row: u16, wave_rows: u16, speed: f64) -> f64 {
3460 let phase =
3461 (tick as f64 * speed) + (row as f64 / wave_rows.max(1) as f64) * std::f64::consts::TAU;
3462 let s = phase.sin();
3463 s * s
3464}
3465
3466fn blend_rgb(base: Color, target: Color, ratio: f64) -> Color {
3468 match (base, target) {
3469 (Color::Rgb(br, bg, bb), Color::Rgb(tr, tg, tb)) => {
3470 let r = (br as f64 + (tr as f64 - br as f64) * ratio).round() as u8;
3471 let g = (bg as f64 + (tg as f64 - bg as f64) * ratio).round() as u8;
3472 let b = (bb as f64 + (tb as f64 - bb as f64) * ratio).round() as u8;
3473 Color::Rgb(r, g, b)
3474 }
3475 _ => base,
3476 }
3477}
3478
3479fn accent_color_for_kind(kind: InlineMessageKind, styles: &ThemeStyles) -> Color {
3481 match kind {
3482 InlineMessageKind::User => color_from_anstyle(styles.primary.get_fg_color()),
3483 InlineMessageKind::Agent => color_from_anstyle(styles.response.get_fg_color()),
3484 InlineMessageKind::Tool => color_from_anstyle(styles.tool.get_fg_color()),
3485 InlineMessageKind::Error => color_from_anstyle(styles.error.get_fg_color()),
3486 InlineMessageKind::Warning => color_from_anstyle(styles.status.get_fg_color()),
3487 InlineMessageKind::Info => color_from_anstyle(styles.info.get_fg_color()),
3488 InlineMessageKind::Policy => color_from_anstyle(styles.mcp.get_fg_color()),
3489 InlineMessageKind::Pty => color_from_anstyle(styles.pty_output.get_fg_color()),
3490 }
3491}
3492
3493fn render_frame(frame: &mut Frame<'_>, state: &RenderState, _handle: &InlineHandle) {
3499 let area = frame.area();
3500 let bg = active_styles().background;
3505 frame
3506 .buffer_mut()
3507 .set_style(area, Style::default().bg(color_from_anstyle(Some(bg))));
3508 let layout = super::frame_layout::render_chrome(frame, area, state);
3509 let tick = FRAME_TICK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3510 {
3512 let running = state.reasoning_stage.is_some();
3513 let was_running = TITLE_RUNNING.swap(running, std::sync::atomic::Ordering::Relaxed);
3514 if running || was_running {
3515 let title = if running {
3516 let spin = TITLE_SPINNER[(tick as usize) % TITLE_SPINNER.len()];
3517 let model = state
3518 .header_context
3519 .editor_context
3520 .as_deref()
3521 .unwrap_or("oxicode");
3522 format!("{spin} oxicode \u{2014} {model}")
3523 } else {
3524 "oxicode".to_string()
3525 };
3526 use std::io::Write;
3527 let _ = write!(std::io::stderr(), "\x1b]2;{}\x07", title);
3528 let _ = std::io::stderr().flush();
3529 }
3530 }
3531 render_transcript(frame, layout.scrollback, state, tick);
3532 let mut pinned_area = layout.scrollback;
3533 if !state.queued_inputs.is_empty() {
3534 let used = render_queue_pane(frame, pinned_area, state);
3535 pinned_area.y = pinned_area.y.saturating_add(used);
3536 pinned_area.height = pinned_area.height.saturating_sub(used);
3537 }
3538 if !state.todo_items.is_empty() {
3539 render_todo_pane(frame, pinned_area, &state.todo_items);
3540 }
3541 if let Some(stage) = &state.reasoning_stage {
3545 render_reasoning_indicator(frame, layout.prompt, stage);
3546 } else if !state.follow_ups.is_empty() {
3547 render_follow_ups(frame, layout.prompt, &state.follow_ups);
3548 } else {
3549 let occluded = state.overlay.is_some() || state.confirmation.is_some();
3551 if let Some(tip) = &state.tip
3552 && tip_is_visible(tip, tick)
3553 && !(tip.ambient && occluded)
3554 {
3555 render_tip(frame, layout.prompt, &tip.text);
3556 }
3557 }
3558 render_composer(frame, layout.prompt, state);
3559 if state.slash_popup.open {
3560 render_slash_popup(frame, layout.prompt, state);
3561 }
3562 if state.file_search.is_some() {
3563 render_file_search_dropdown(frame, layout.prompt, state);
3564 }
3565 if state.agent_hub_open {
3566 render_agent_hub(frame, area, state);
3567 }
3568 if let Some(overlay) = &state.overlay {
3569 render_overlay(frame, area, overlay);
3570 }
3571 if let Some(confirm) = &state.confirmation {
3572 render_confirmation(frame, area, confirm);
3573 }
3574}
3575
3576fn render_confirmation(frame: &mut Frame, area: Rect, confirm: &ModalConfirmation) {
3578 let styles = active_styles();
3579 let accent = color_from_anstyle(styles.error.get_fg_color());
3580 let inner_w = confirm
3581 .title
3582 .chars()
3583 .count()
3584 .max(confirm.message.chars().count())
3585 .max(36) as u16;
3586 let width = inner_w + 4;
3587 let height = 5;
3588 let x = area.x + area.width.saturating_sub(width) / 2;
3589 let y = area.y + area.height.saturating_sub(height) / 2;
3590 let popup_area = Rect {
3591 x,
3592 y,
3593 width,
3594 height,
3595 };
3596 let block = Block::default()
3597 .borders(Borders::ALL)
3598 .border_type(BorderType::Rounded)
3599 .title(Span::styled(
3600 format!(" {} ", confirm.title),
3601 Style::default().fg(accent).bold(),
3602 ))
3603 .border_style(Style::default().fg(accent));
3604 let msg = Line::styled(
3605 confirm.message.clone(),
3606 Style::default().fg(color_from_anstyle(Some(styles.foreground))),
3607 );
3608 frame.render_widget(
3609 Paragraph::new(vec![Line::default(), msg]).block(block),
3610 popup_area,
3611 );
3612}
3613
3614fn render_agent_hub(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
3618 let rows = state.hub_entries.len() as u16;
3619 let height = rows.saturating_add(4).min(area.height.saturating_sub(1));
3620 let width = area.width.clamp(30, 80);
3621 let rect = Rect {
3622 x: area.x + (area.width.saturating_sub(width)) / 2,
3623 y: area.y + (area.height.saturating_sub(height)) / 2,
3624 width,
3625 height,
3626 };
3627 frame.render_widget(Clear, rect);
3628
3629 let title = Line::from(Span::styled(
3630 " Agent Hub ",
3631 Style::default().add_modifier(Modifier::BOLD),
3632 ));
3633 let block = Block::default().borders(Borders::ALL).title(title);
3634
3635 let items: Vec<ListItem<'_>> = if state.hub_entries.is_empty() {
3636 vec![ListItem::new(Line::from(Span::raw(
3637 "No agents registered.",
3638 )))]
3639 } else {
3640 state
3641 .hub_entries
3642 .iter()
3643 .map(|(id, e)| {
3644 ListItem::new(Line::from(vec![
3645 Span::raw(format!("{:?} ", e.kind)),
3646 Span::raw(e.display_name.clone()),
3647 Span::raw(format!(" — {:?} ({})", e.status, id)),
3648 ]))
3649 })
3650 .collect()
3651 };
3652 frame.render_widget(List::new(items).block(block), rect);
3653}
3654
3655fn render_overlay(frame: &mut Frame<'_>, area: Rect, overlay: &OverlayState) {
3660 let styles = active_styles();
3661 if let Some(secure) = &overlay.secure_input {
3664 let lines_count = overlay.lines.len();
3666 let desired_h = (lines_count as u16).saturating_add(1).saturating_add(2); let height = desired_h.min(area.height.saturating_sub(2));
3668 let width = area.width.clamp(30, 80);
3669 let rect = Rect {
3670 x: area.x + (area.width.saturating_sub(width)) / 2,
3671 y: area.y + (area.height.saturating_sub(height)) / 2,
3672 width,
3673 height,
3674 };
3675 frame.render_widget(Clear, rect);
3676
3677 let title = Line::from(Span::styled(
3678 format!(" {} ", overlay.title),
3679 Style::default()
3680 .fg(color_from_anstyle(styles.primary.get_fg_color()))
3681 .add_modifier(Modifier::BOLD),
3682 ));
3683 let block = Block::default()
3684 .borders(Borders::ALL)
3685 .border_type(BorderType::Plain)
3686 .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
3687 .title(title);
3688 let inner = block.inner(rect);
3689 frame.render_widget(&block, rect);
3690
3691 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
3692
3693 let mut row = inner.top();
3694 for line_text in &overlay.lines {
3695 let row_area = Rect {
3696 x: inner.left(),
3697 y: row,
3698 width: inner.width,
3699 height: 1,
3700 };
3701 let line = Line::from(Span::styled(
3702 line_text.clone(),
3703 Style::default().fg(secondary),
3704 ));
3705 frame.render_widget(Paragraph::new(line), row_area);
3706 row = row.saturating_add(1);
3707 }
3708
3709 let label = &secure.config.label;
3716 let label_prefix = format!("{label}: ");
3717 let prefix_columns = UnicodeWidthStr::width(label_prefix.as_str()) as u16;
3718 let prefix_area = Rect {
3719 x: inner.left(),
3720 y: row,
3721 width: prefix_columns.min(inner.width),
3722 height: 1,
3723 };
3724 frame.render_widget(
3725 Paragraph::new(Line::from(Span::styled(
3726 label_prefix.clone(),
3727 Style::default().fg(secondary),
3728 ))),
3729 prefix_area,
3730 );
3731 let textarea_area = Rect {
3732 x: inner.left().saturating_add(prefix_columns),
3733 y: row,
3734 width: inner.width.saturating_sub(prefix_columns),
3735 height: 1,
3736 };
3737 let inner_left = textarea_area.left();
3738 let inner_right = textarea_area.right().saturating_sub(1);
3739
3740 let value = secure.editor.text();
3741 if value.is_empty() {
3742 if let Some(placeholder) = secure.config.placeholder.as_deref() {
3745 frame.render_widget(
3746 Paragraph::new(Line::from(Span::styled(
3747 placeholder.to_string(),
3748 Style::default().fg(secondary).dim(),
3749 ))),
3750 textarea_area,
3751 );
3752 }
3753 if textarea_area.width > 0 {
3754 frame.set_cursor_position(Position::new(inner_left, row));
3755 }
3756 return;
3757 }
3758
3759 let display_line: Line<'static> = if secure.config.mask_input {
3762 Line::from("*".repeat(value.chars().count()))
3763 } else {
3764 Line::from(value.to_string())
3769 };
3770 let mut ta = TextArea::new();
3771 ta.set_text(value);
3772 ta.replace_range_with_element(
3773 0..value.len(),
3774 value,
3775 MASKED_ELEMENT_KIND,
3776 Some(display_line),
3777 );
3778 ta.set_cursor(secure.editor.cursor_byte());
3782 frame.render_widget_ref(&ta, textarea_area);
3783 if let Some((cx, cy)) = ta.cursor_pos_with_state(textarea_area, TextAreaState::default()) {
3786 let caret_x = cx.min(inner_right);
3787 frame.set_cursor_position(Position::new(caret_x, cy));
3788 }
3789 return;
3790 }
3791 let visible_max = (area.height as usize).saturating_sub(7).max(3);
3794
3795 let filtered: Vec<usize> = match &overlay.search {
3797 Some(search) if !search.value.is_empty() => {
3798 let needle = search.value.to_lowercase();
3799 overlay
3800 .items
3801 .iter()
3802 .enumerate()
3803 .filter_map(|(idx, item)| {
3804 let title_match = item.title.to_lowercase().contains(&needle);
3805 let sv_match = item
3806 .search_value
3807 .as_deref()
3808 .map(|v| v.to_lowercase().contains(&needle))
3809 .unwrap_or(false);
3810 if title_match || sv_match {
3811 Some(idx)
3812 } else {
3813 None
3814 }
3815 })
3816 .collect()
3817 }
3818 _ => (0..overlay.items.len()).collect(),
3819 };
3820
3821 let has_search = overlay.search.is_some();
3822 let lines_count = overlay.lines.len();
3823 let items_count = filtered.len().min(visible_max);
3824 let height_inner = (lines_count + items_count + if has_search { 1 } else { 0 }) as u16;
3825 let desired_h = height_inner.saturating_add(3); let height = desired_h.min(area.height.saturating_sub(2));
3827 let width = area.width.clamp(30, 80);
3828 let rect = Rect {
3829 x: area.x + (area.width.saturating_sub(width)) / 2,
3830 y: area.y + (area.height.saturating_sub(height)) / 2,
3831 width,
3832 height,
3833 };
3834 frame.render_widget(Clear, rect);
3835
3836 let title = Line::from(Span::styled(
3837 format!(" {} ", overlay.title),
3838 Style::default()
3839 .fg(color_from_anstyle(styles.primary.get_fg_color()))
3840 .add_modifier(Modifier::BOLD),
3841 ));
3842 let block = Block::default()
3843 .borders(Borders::ALL)
3844 .border_type(BorderType::Plain)
3845 .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
3846 .title(title);
3847 let inner = block.inner(rect);
3848 frame.render_widget(&block, rect);
3849
3850 let primary = color_from_anstyle(styles.primary.get_fg_color());
3851 let fg = color_from_anstyle(Some(styles.foreground));
3852 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
3853
3854 let selected_filtered_pos = filtered
3856 .iter()
3857 .position(|&idx| idx == overlay.selected)
3858 .unwrap_or(0);
3859
3860 let mut row = inner.top();
3861 if let Some(search) = &overlay.search {
3863 let prompt = format!("{}: {}", search.label, search.value);
3864 let line = Line::from(vec![
3865 Span::styled(
3866 format!("{}: ", search.label),
3867 Style::default().fg(secondary),
3868 ),
3869 Span::styled(
3870 if search.value.is_empty() {
3871 search
3872 .placeholder
3873 .clone()
3874 .unwrap_or_else(|| "type to filter\u{2026}".to_string())
3875 } else {
3876 search.value.clone()
3877 },
3878 if search.value.is_empty() {
3879 Style::default().fg(secondary).add_modifier(Modifier::DIM)
3880 } else {
3881 Style::default().fg(fg)
3882 },
3883 ),
3884 ]);
3885 let _ = prompt; let row_area = Rect {
3887 x: inner.left(),
3888 y: row,
3889 width: inner.width,
3890 height: 1,
3891 };
3892 frame.render_widget(Paragraph::new(line), row_area);
3893 row = row.saturating_add(1);
3894 }
3895
3896 for line_text in &overlay.lines {
3898 let row_area = Rect {
3899 x: inner.left(),
3900 y: row,
3901 width: inner.width,
3902 height: 1,
3903 };
3904 let line = Line::from(Span::styled(
3905 line_text.clone(),
3906 Style::default().fg(secondary),
3907 ));
3908 frame.render_widget(Paragraph::new(line), row_area);
3909 row = row.saturating_add(1);
3910 }
3911
3912 if filtered.is_empty() {
3914 let row_area = Rect {
3915 x: inner.left(),
3916 y: row,
3917 width: inner.width,
3918 height: 1,
3919 };
3920 let empty_text = if overlay.search.is_some() {
3921 " (no matches)"
3922 } else {
3923 " (no items)"
3924 };
3925 frame.render_widget(
3926 Paragraph::new(Line::from(Span::styled(
3927 empty_text,
3928 Style::default().fg(secondary).add_modifier(Modifier::DIM),
3929 ))),
3930 row_area,
3931 );
3932 } else {
3933 let first_visible = selected_filtered_pos
3934 .saturating_sub(visible_max / 2)
3935 .min(filtered.len().saturating_sub(visible_max));
3936 for &item_idx in filtered.iter().skip(first_visible).take(visible_max) {
3937 let item = &overlay.items[item_idx];
3938 let is_selected = item_idx == overlay.selected;
3939 let marker = if is_selected { "> " } else { " " };
3940 let indent = " ".repeat(item.indent as usize);
3941 let item_style = if is_selected {
3942 Style::default().fg(primary).add_modifier(Modifier::BOLD)
3943 } else {
3944 Style::default().fg(fg)
3945 };
3946 let mut spans = vec![
3947 Span::styled(marker, item_style),
3948 Span::styled(indent, item_style),
3949 Span::styled(item.title.clone(), item_style),
3950 ];
3951 if let Some(badge) = &item.badge {
3952 spans.push(Span::raw(" "));
3953 spans.push(Span::styled(
3954 badge.clone(),
3955 Style::default().fg(secondary).add_modifier(Modifier::DIM),
3956 ));
3957 }
3958 if let Some(subtitle) = &item.subtitle {
3959 spans.push(Span::raw(" "));
3960 spans.push(Span::styled(
3961 subtitle.clone(),
3962 Style::default().fg(secondary),
3963 ));
3964 }
3965 let line = Line::from(spans);
3966 let row_area = Rect {
3967 x: inner.left(),
3968 y: row,
3969 width: inner.width,
3970 height: 1,
3971 };
3972 frame.render_widget(Paragraph::new(line), row_area);
3973 row = row.saturating_add(1);
3974 }
3975 }
3976
3977 if row < inner.bottom() {
3980 let hint = if overlay.items.iter().any(|item| item.selection.is_some()) {
3981 "Enter select | Up/Down move | Esc close"
3982 } else {
3983 "Esc close"
3984 };
3985 frame.render_widget(
3986 Paragraph::new(Line::from(Span::styled(
3987 hint,
3988 Style::default().fg(secondary).add_modifier(Modifier::DIM),
3989 ))),
3990 Rect {
3991 x: inner.left(),
3992 y: inner.bottom().saturating_sub(1),
3993 width: inner.width,
3994 height: 1,
3995 },
3996 );
3997 }
3998}
3999
4000fn render_transcript(frame: &mut Frame<'_>, area: Rect, state: &RenderState, tick: u64) {
4001 if state.transcript.is_empty() {
4002 render_welcome(frame, area, state);
4003 return;
4004 }
4005 let styles = active_styles();
4006 let bg_color = color_from_anstyle(Some(styles.background));
4007
4008 let accent_w: u16 = 1;
4010 let scrollbar_w: u16 = 1;
4011 let content_area = Rect {
4012 x: area.x + accent_w,
4013 y: area.y,
4014 width: area.width.saturating_sub(accent_w + scrollbar_w),
4015 height: area.height,
4016 };
4017
4018 let search_set: std::collections::HashSet<usize> = state
4021 .search
4022 .as_ref()
4023 .map(|s| s.matches.iter().copied().collect())
4024 .unwrap_or_default();
4025 let current_match = state
4026 .search
4027 .as_ref()
4028 .and_then(|s| (!s.matches.is_empty()).then(|| s.matches[s.current]));
4029
4030 let mut display: Vec<(usize, InlineMessageKind, Line<'_>)> =
4031 Vec::with_capacity(state.transcript.len());
4032 let dim_style = Style::default()
4033 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
4034 .add_modifier(Modifier::DIM);
4035 for item in visible_items(&state.transcript, |block_id| state.block_mode(block_id)) {
4036 match item {
4037 VisibleItem::Line {
4038 source_index,
4039 folded,
4040 } => {
4041 let tl = &state.transcript[source_index];
4042 let is_match = search_set.contains(&source_index);
4043 let line = transcript_line_marked(
4044 tl,
4045 &styles,
4046 folded,
4047 is_match,
4048 current_match == Some(source_index),
4049 );
4050 display.push((source_index, tl.kind, line));
4051 }
4052 VisibleItem::Gap {
4053 source_index,
4054 hidden_lines,
4055 } => {
4056 let tl = &state.transcript[source_index];
4057 let gap = Line::styled(format!(" \u{2026} +{hidden_lines} lines"), dim_style);
4058 display.push((source_index, tl.kind, gap));
4059 }
4060 }
4061 }
4062
4063 let total = display.len();
4065 let raw_start = if state.scroll_offset == usize::MAX {
4066 total.saturating_sub(content_area.height as usize)
4067 } else {
4068 display
4069 .iter()
4070 .position(|(orig_idx, _, _)| *orig_idx >= state.scroll_offset)
4071 .unwrap_or(total.saturating_sub(1))
4072 };
4073 let start = effective_scroll_offset(raw_start, total, content_area.height as usize);
4074
4075 let sticky_first: Option<usize> = display.get(start).and_then(|(orig_idx, _, _)| {
4079 let bid = state.transcript.get(*orig_idx)?.block_id;
4080 let first_idx = state.transcript.iter().position(|l| l.block_id == bid)?;
4081 (first_idx != *orig_idx).then_some(first_idx)
4082 });
4083 let sticky_h: u16 = if sticky_first.is_some() { 1 } else { 0 };
4084 let body_top = content_area.top() + sticky_h;
4085
4086 let running = state.reasoning_stage.is_some();
4088 const WAVE_ROWS: u16 = 32;
4089 const WAVE_SPEED: f64 = 0.15;
4090
4091 const FADE_ROWS: usize = 5;
4096 let sticky_opacity: f64 = if let Some(sidx) = sticky_first {
4097 let sticky_bid = state.transcript[sidx].block_id;
4098 let next_offset = display.iter().skip(start).position(|(orig_idx, _, _)| {
4101 state
4102 .transcript
4103 .get(*orig_idx)
4104 .map(|l| l.block_id != sticky_bid)
4105 .unwrap_or(false)
4106 });
4107 match next_offset {
4108 Some(off) if off <= FADE_ROWS => off as f64 / FADE_ROWS as f64,
4109 _ => 1.0,
4110 }
4111 } else {
4112 1.0
4113 };
4114
4115 if let Some(sidx) = sticky_first {
4118 let tl = &state.transcript[sidx];
4119 let accent_base = accent_color_for_kind(tl.kind, &styles);
4120 let rail_blend = 0.7 * sticky_opacity;
4121 let bg_blend = 0.1 * sticky_opacity;
4122 if sticky_opacity > 0.05
4123 && let Some(cell) = frame.buffer_mut().cell_mut((area.x, content_area.top()))
4124 {
4125 cell.set_char('\u{2503}');
4126 cell.set_style(Style::default().fg(blend_rgb(bg_color, accent_base, rail_blend)));
4127 }
4128 let line = transcript_line_marked(tl, &styles, false, false, false);
4129 let row = Rect {
4130 x: content_area.x,
4131 y: content_area.top(),
4132 width: content_area.width,
4133 height: 1,
4134 };
4135 if bg_blend > 0.01 {
4136 frame.buffer_mut().set_style(
4137 row,
4138 Style::default().bg(blend_rgb(bg_color, accent_base, bg_blend)),
4139 );
4140 }
4141 frame.render_widget(Paragraph::new(line), row);
4142 }
4143
4144 let mut y = body_top;
4146 let width = content_area.width.max(1) as usize;
4147 let mut visual_row: u16 = 0;
4148 for (_, kind, line) in display.into_iter().skip(start) {
4149 if y >= content_area.bottom() {
4150 break;
4151 }
4152 let text_w = line.width();
4153 let wrapped_h = if text_w == 0 {
4154 1
4155 } else {
4156 text_w.div_ceil(width).max(1) as u16
4157 };
4158
4159 let accent_base = accent_color_for_kind(kind, &styles);
4161 for row_offset in 0..wrapped_h {
4162 let paint_y = y + row_offset;
4163 if paint_y >= content_area.bottom() {
4164 break;
4165 }
4166 let brightness = if running {
4167 0.4 + 0.6 * wave_brightness(tick, visual_row + row_offset, WAVE_ROWS, WAVE_SPEED)
4168 } else {
4169 0.7
4170 };
4171 let rail_color = blend_rgb(bg_color, accent_base, brightness);
4172 if let Some(cell) = frame.buffer_mut().cell_mut((area.x, paint_y)) {
4173 cell.set_char('\u{2503}'); cell.set_style(Style::default().fg(rail_color));
4175 }
4176 }
4177
4178 let row = Rect {
4179 x: content_area.x,
4180 y,
4181 width: content_area.width,
4182 height: wrapped_h.min(content_area.bottom().saturating_sub(y)),
4183 };
4184 frame.render_widget(Paragraph::new(line).wrap(Wrap { trim: false }), row);
4185 y += wrapped_h;
4186 visual_row += wrapped_h;
4187 }
4188
4189 let body_viewport = (content_area.height as usize).saturating_sub(sticky_h as usize);
4192 if total > body_viewport {
4193 let follow = state.scroll_offset == usize::MAX;
4194 render_scrollbar(
4195 frame,
4196 area.right().saturating_sub(1),
4197 area.top(),
4198 area.height,
4199 total,
4200 body_viewport,
4201 start,
4202 follow,
4203 &styles,
4204 bg_color,
4205 );
4206 }
4207}
4208
4209#[allow(clippy::too_many_arguments)]
4214fn render_scrollbar(
4215 frame: &mut Frame,
4216 x: u16,
4217 top: u16,
4218 height: u16,
4219 total: usize,
4220 viewport: usize,
4221 start: usize,
4222 follow: bool,
4223 styles: &ThemeStyles,
4224 bg: Color,
4225) {
4226 if height == 0 {
4227 return;
4228 }
4229 let ratio = (start as f64 / total.max(1) as f64).clamp(0.0, 1.0);
4230 let thumb_h = (((viewport as f64 / total.max(1) as f64) * height as f64).ceil() as u16)
4231 .max(1)
4232 .min(height);
4233 let track_h = height.saturating_sub(thumb_h);
4234 let thumb_y = (ratio * track_h as f64).round() as u16;
4235
4236 let accent = color_from_anstyle(styles.primary.get_fg_color());
4237 let thumb_color = if follow {
4239 blend_rgb(bg, accent, 0.35)
4240 } else {
4241 accent
4242 };
4243 let rail_color = blend_rgb(bg, accent, 0.1);
4244
4245 for row in 0..height {
4246 let y = top + row;
4247 let is_thumb = row >= thumb_y && row < thumb_y + thumb_h;
4248 let (ch, color) = if is_thumb {
4249 ('\u{2588}', thumb_color) } else {
4251 ('\u{2502}', rail_color) };
4253 if let Some(cell) = frame.buffer_mut().cell_mut((x, y)) {
4254 cell.set_char(ch);
4255 cell.set_style(Style::default().fg(color));
4256 }
4257 }
4258}
4259
4260fn transcript_line_marked<'a>(
4263 line: &'a TranscriptLine,
4264 styles: &'a ThemeStyles,
4265 folded: bool,
4266 is_match: bool,
4267 is_current: bool,
4268) -> Line<'a> {
4269 let (kind_style, marker) = match line.kind {
4270 InlineMessageKind::Agent => (
4271 Style::default().fg(color_from_anstyle(styles.response.get_fg_color())),
4272 "assistant",
4273 ),
4274 InlineMessageKind::User => (
4275 Style::default().fg(color_from_anstyle(styles.primary.get_fg_color())),
4276 "you",
4277 ),
4278 InlineMessageKind::Tool => (
4279 Style::default().fg(color_from_anstyle(styles.tool.get_fg_color())),
4280 "tool",
4281 ),
4282 InlineMessageKind::Error => (
4283 Style::default().fg(color_from_anstyle(styles.error.get_fg_color())),
4284 "error",
4285 ),
4286 InlineMessageKind::Warning => (
4287 Style::default().fg(color_from_anstyle(styles.status.get_fg_color())),
4288 "warning",
4289 ),
4290 InlineMessageKind::Info => (
4291 Style::default().fg(color_from_anstyle(styles.info.get_fg_color())),
4292 "info",
4293 ),
4294 InlineMessageKind::Policy => (
4295 Style::default().fg(color_from_anstyle(styles.mcp.get_fg_color())),
4296 "policy",
4297 ),
4298 InlineMessageKind::Pty => (
4299 Style::default().fg(color_from_anstyle(styles.pty_output.get_fg_color())),
4300 "shell",
4301 ),
4302 };
4303
4304 let prefix = if folded {
4306 format!("[+] {marker}: ")
4307 } else {
4308 format!("{marker}: ")
4309 };
4310
4311 let highlight = if is_current {
4313 Some(Style::default().reversed())
4314 } else if is_match {
4315 Some(Style::default().add_modifier(Modifier::UNDERLINED))
4316 } else {
4317 None
4318 };
4319
4320 let mut spans = Vec::with_capacity(line.segments.len() + 1);
4321 spans.push(Span::styled(prefix, kind_style));
4322 for segment in &line.segments {
4323 let mut style = segment_style(segment, kind_style, styles);
4324 if let Some(h) = highlight {
4325 style = style.patch(h);
4326 }
4327 spans.push(Span::styled(segment.text.clone(), style));
4328 }
4329 Line::from(spans)
4330}
4331
4332fn segment_style(segment: &InlineSegment, fallback: Style, styles: &ThemeStyles) -> Style {
4333 let mut style = fallback;
4334 let inline = segment.style.as_ref();
4335 if let Some(color) = inline.color {
4336 style = style.fg(color_from_anstyle(Some(color)));
4337 } else {
4338 style = style.fg(color_from_anstyle(styles.response.get_fg_color()));
4342 }
4343 if inline.effects.contains(anstyle::Effects::BOLD) {
4344 style = style.add_modifier(Modifier::BOLD);
4345 }
4346 if inline.effects.contains(anstyle::Effects::ITALIC) {
4347 style = style.add_modifier(Modifier::ITALIC);
4348 }
4349 if inline.effects.contains(anstyle::Effects::UNDERLINE) {
4350 style = style.add_modifier(Modifier::UNDERLINED);
4351 }
4352 if inline.effects.contains(anstyle::Effects::DIMMED) {
4353 style = style.add_modifier(Modifier::DIM);
4354 }
4355 style
4356}
4357fn render_composer(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
4358 let styles = active_styles();
4359 let prefix_style = Style::default()
4360 .fg(color_from_anstyle(styles.primary.get_fg_color()))
4361 .bold();
4362
4363 let prefix = state.prompt_prefix.clone();
4364 let placeholder = state.placeholder.clone();
4365
4366 let mut prefix_w: u16 = 0;
4374 let mut line_spans = Vec::new();
4375 if let Some(label) = state.vim_state.status_label() {
4376 let seg = format!("[{label}] ");
4377 prefix_w = prefix_w.saturating_add(seg.width() as u16);
4378 line_spans.push(Span::styled(
4379 seg,
4380 Style::default()
4381 .fg(color_from_anstyle(styles.tool.get_fg_color()))
4382 .add_modifier(Modifier::BOLD),
4383 ));
4384 }
4385 if state.autonomy_mode.is_auto() {
4386 let seg = "[auto] ";
4387 prefix_w = prefix_w.saturating_add(seg.width() as u16);
4388 line_spans.push(Span::styled(
4389 seg,
4390 Style::default()
4391 .fg(Color::Yellow)
4392 .add_modifier(Modifier::BOLD),
4393 ));
4394 }
4395 prefix_w = prefix_w.saturating_add(UnicodeWidthStr::width(prefix.as_str()) as u16);
4396 line_spans.push(Span::styled(prefix, prefix_style));
4397 if state.shell_mode {
4398 let seg = "! ";
4399 prefix_w = prefix_w.saturating_add(seg.width() as u16);
4400 line_spans.push(Span::styled(
4401 seg,
4402 Style::default()
4403 .fg(Color::Yellow)
4404 .add_modifier(Modifier::BOLD),
4405 ));
4406 }
4407
4408 let block = Block::default()
4409 .borders(Borders::ALL)
4410 .border_type(BorderType::Plain)
4411 .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
4412 .title(composer_context_line(state, area.width));
4416
4417 let inner = area.inner(Margin::new(1, 1));
4422 let textarea_area = Rect {
4423 x: inner.left().saturating_add(prefix_w),
4424 y: inner.top(),
4425 width: inner.width.saturating_sub(prefix_w),
4426 height: inner.height,
4427 };
4428 if state.composer.is_empty()
4429 && let Some(ph) = placeholder.as_deref()
4430 {
4431 line_spans.push(Span::styled(
4433 ph.to_string(),
4434 Style::default()
4435 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
4436 .dim(),
4437 ));
4438 let paragraph = Paragraph::new(Line::from(line_spans))
4439 .block(block)
4440 .wrap(Wrap { trim: false });
4441 frame.render_widget(paragraph, area);
4442 if state.input_enabled {
4443 frame.set_cursor_position(Position::new(
4446 inner.left().saturating_add(prefix_w),
4447 area.top().saturating_add(1),
4448 ));
4449 }
4450 return;
4451 }
4452 let prefix_area = Rect {
4457 x: inner.left(),
4458 y: inner.top(),
4459 width: prefix_w,
4460 height: inner.height,
4461 };
4462 let frame_paragraph = Paragraph::new(Line::from(Vec::<Span>::new()))
4465 .block(block)
4466 .wrap(Wrap { trim: false });
4467 frame.render_widget(frame_paragraph, area);
4468 frame.render_widget(Paragraph::new(Line::from(line_spans)), prefix_area);
4469 frame.render_widget_ref(&state.composer, textarea_area);
4470
4471 if state.input_enabled
4472 && let Some((cx, cy)) = state
4473 .composer
4474 .cursor_pos_with_state(textarea_area, TextAreaState::default())
4475 {
4476 frame.set_cursor_position(Position::new(cx, cy));
4483 }
4484}
4485
4486fn composer_context_line<'a>(state: &'a RenderState, width: u16) -> Line<'a> {
4493 let styles = active_styles();
4494 let primary = color_from_anstyle(styles.primary.get_fg_color());
4495 let fg = color_from_anstyle(Some(styles.foreground));
4496 let muted = color_from_anstyle(styles.secondary.get_fg_color());
4497 let info = color_from_anstyle(styles.info.get_fg_color());
4498
4499 let model = state
4500 .header_context
4501 .model
4502 .strip_prefix(&format!("{}/", state.header_context.provider))
4503 .unwrap_or(&state.header_context.model);
4504 let workspace = state
4505 .cwd
4506 .file_name()
4507 .map(|name| name.to_string_lossy().into_owned())
4508 .filter(|name| !name.is_empty())
4509 .unwrap_or_else(|| "workspace".to_string());
4510 let branch = state
4511 .header_context
4512 .persistent_memory
4513 .as_ref()
4514 .map(|badge| badge.text.as_str())
4515 .filter(|branch| !branch.is_empty())
4516 .unwrap_or("—");
4517 let context = match state.context_tokens {
4518 Some(used) => {
4519 let percent = used.saturating_mul(100) / state.context_window.max(1);
4520 format!(
4521 "{}/{} {percent}%",
4522 compact_token_count(used),
4523 compact_token_count(state.context_window)
4524 )
4525 }
4526 None => format!("0/{}", compact_token_count(state.context_window)),
4527 };
4528
4529 let mut spans = vec![Span::styled(
4530 " OXICODE ",
4531 Style::default().fg(primary).add_modifier(Modifier::BOLD),
4532 )];
4533 let mut field = |label: &str, value: &str, value_style: Style| {
4534 spans.push(Span::styled(" | ", Style::default().fg(muted)));
4535 spans.push(Span::styled(label.to_string(), Style::default().fg(muted)));
4536 spans.push(Span::styled(value.to_string(), value_style));
4537 };
4538
4539 field(
4540 "MODEL ",
4541 model,
4542 Style::default().fg(fg).add_modifier(Modifier::BOLD),
4543 );
4544 if width >= 58 {
4545 field("THINK ", &state.thinking_level, Style::default().fg(info));
4546 }
4547 if width >= 82 {
4548 field("DIR ", &workspace, Style::default().fg(fg));
4549 }
4550 if width >= 104 {
4551 field("GIT ", branch, Style::default().fg(fg));
4552 }
4553 if width >= 124 {
4554 field("CTX ", &context, Style::default().fg(info));
4555 }
4556 if width >= 148
4557 && let Some(stage) = &state.reasoning_stage
4558 {
4559 field(
4560 "RUN ",
4561 stage,
4562 Style::default().fg(primary).add_modifier(Modifier::BOLD),
4563 );
4564 }
4565 spans.push(Span::raw(" "));
4566 Line::from(spans)
4567}
4568
4569fn compact_token_count(tokens: usize) -> String {
4570 if tokens >= 1_000 {
4571 let whole = tokens / 1_000;
4572 let decimal = (tokens % 1_000) / 100;
4573 if decimal == 0 {
4574 format!("{whole}K")
4575 } else {
4576 format!("{whole}.{decimal}K")
4577 }
4578 } else {
4579 tokens.to_string()
4580 }
4581}
4582
4583fn render_welcome(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
4588 let styles = active_styles();
4589 let primary = color_from_anstyle(styles.primary.get_fg_color());
4590 let fg = color_from_anstyle(Some(styles.foreground));
4591 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
4592 let workspace = state
4593 .cwd
4594 .file_name()
4595 .map(|name| name.to_string_lossy().into_owned())
4596 .filter(|name| !name.is_empty())
4597 .unwrap_or_else(|| "workspace".to_string());
4598 let lines = vec![
4599 Line::from(Span::styled(
4600 "OXICODE",
4601 Style::default().fg(primary).add_modifier(Modifier::BOLD),
4602 )),
4603 Line::from(Span::styled(
4604 "Terminal coding assistant",
4605 Style::default().fg(secondary).add_modifier(Modifier::DIM),
4606 )),
4607 Line::from(""),
4608 Line::from(vec![
4609 Span::styled("WORKSPACE ", Style::default().fg(secondary)),
4610 Span::styled(
4611 workspace,
4612 Style::default().fg(fg).add_modifier(Modifier::BOLD),
4613 ),
4614 ]),
4615 Line::from(vec![
4616 Span::styled("MODEL ", Style::default().fg(secondary)),
4617 Span::styled(
4618 format!(
4619 "{} / {}",
4620 state.header_context.provider, state.header_context.model
4621 ),
4622 Style::default().fg(fg),
4623 ),
4624 ]),
4625 Line::from(""),
4626 Line::from(Span::styled(
4627 "Enter send / commands @ attach a file",
4628 Style::default().fg(fg),
4629 )),
4630 Line::from(Span::styled(
4631 "? shortcuts /model change model /help all commands",
4632 Style::default().fg(secondary),
4633 )),
4634 ];
4635 let height = lines.len().min(area.height as usize) as u16;
4636 let card = Rect {
4637 x: area.x,
4638 y: area.y + area.height.saturating_sub(height) / 2,
4639 width: area.width,
4640 height,
4641 };
4642 frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), card);
4643}
4644
4645fn render_reasoning_indicator(frame: &mut Frame<'_>, composer_area: Rect, stage: &str) {
4647 let styles = active_styles();
4648 let indicator_area = Rect {
4649 x: composer_area.x,
4650 y: composer_area.top().saturating_sub(1),
4651 width: composer_area.width,
4652 height: 1,
4653 };
4654 let line = Line::from(vec![
4655 Span::styled(
4656 "RUNNING",
4657 Style::default()
4658 .fg(color_from_anstyle(styles.primary.get_fg_color()))
4659 .add_modifier(Modifier::BOLD),
4660 ),
4661 Span::styled(
4662 " | ",
4663 Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
4664 ),
4665 Span::styled(
4666 stage.to_string(),
4667 Style::default()
4668 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
4669 .add_modifier(Modifier::DIM),
4670 ),
4671 ]);
4672 frame.render_widget(Paragraph::new(line), indicator_area);
4673}
4674
4675fn render_queue_pane(frame: &mut Frame<'_>, scrollback: Rect, state: &RenderState) -> u16 {
4677 let styles = active_styles();
4678 let entries = &state.queued_inputs;
4679 let interactive = state.queue_panel_open;
4680 let selected = state.queue_selected.min(entries.len().saturating_sub(1));
4681 let height = if interactive {
4682 entries.len() as u16 + 1
4683 } else {
4684 1
4685 };
4686 let area = Rect {
4687 x: scrollback.x,
4688 y: scrollback.y,
4689 width: scrollback.width,
4690 height,
4691 };
4692 let info = color_from_anstyle(styles.info.get_fg_color());
4693 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
4694 let primary = color_from_anstyle(styles.primary.get_fg_color());
4695 if !interactive {
4696 frame.render_widget(
4697 Paragraph::new(Line::from(vec![
4698 Span::styled(
4699 format!("QUEUED {}", entries.len()),
4700 Style::default().fg(primary).add_modifier(Modifier::BOLD),
4701 ),
4702 Span::styled(
4703 " | Ctrl+; manage",
4704 Style::default().fg(secondary).add_modifier(Modifier::DIM),
4705 ),
4706 ])),
4707 area,
4708 );
4709 return height;
4710 }
4711 let items: Vec<Line<'_>> = entries
4712 .iter()
4713 .enumerate()
4714 .map(|(i, e)| {
4715 let prefix = format!("#{} ", i + 1);
4716 let prefix_style = if i == selected {
4717 Style::default().fg(primary).add_modifier(Modifier::BOLD)
4718 } else {
4719 Style::default().fg(info)
4720 };
4721 let text_style = if i == selected {
4722 Style::default().fg(primary).add_modifier(Modifier::BOLD)
4723 } else {
4724 Style::default().fg(secondary)
4725 };
4726 let marker = if i == selected { "> " } else { " " };
4727 Line::from(vec![
4728 Span::styled(prefix, prefix_style),
4729 Span::styled(marker, prefix_style),
4730 Span::styled(e.clone(), text_style),
4731 ])
4732 })
4733 .collect();
4734 frame.render_widget(
4735 Paragraph::new(items).block(Block::default().borders(Borders::TOP).border_style(
4736 Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
4737 )),
4738 area,
4739 );
4740 height
4741}
4742
4743fn flatten_todo_items(
4745 phases: &[oxicode_agent::tools::todo::TodoPhase],
4746) -> Vec<(String, TodoStatus)> {
4747 phases
4748 .iter()
4749 .flat_map(|p| p.tasks.iter().map(|t| (t.content.clone(), t.status)))
4750 .collect()
4751}
4752
4753fn render_todo_pane(frame: &mut Frame<'_>, scrollback: Rect, items: &[(String, TodoStatus)]) {
4755 let styles = active_styles();
4756 let height = items.len() as u16 + 1;
4757 let area = Rect {
4758 x: scrollback.x,
4759 y: scrollback.y,
4760 width: scrollback.width,
4761 height,
4762 };
4763 let lines: Vec<Line<'_>> = items
4764 .iter()
4765 .map(|(text, status)| {
4766 let (marker, color) = match status {
4769 TodoStatus::Completed => ("done", Some(styles.foreground)),
4770 TodoStatus::InProgress => ("now", styles.primary.get_fg_color()),
4771 TodoStatus::Blocked => ("wait", styles.info.get_fg_color()),
4772 TodoStatus::Abandoned => ("skip", styles.error.get_fg_color()),
4773 TodoStatus::Pending => ("todo", styles.secondary.get_fg_color()),
4774 };
4775 let text_style = if *status == TodoStatus::Completed {
4776 Style::default()
4777 .fg(color_from_anstyle(Some(styles.foreground)))
4778 .add_modifier(Modifier::CROSSED_OUT)
4779 } else {
4780 Style::default().fg(color_from_anstyle(Some(styles.foreground)))
4781 };
4782 Line::from(vec![
4783 Span::styled(
4784 format!("{marker} "),
4785 Style::default().fg(color_from_anstyle(color)),
4786 ),
4787 Span::styled(text.clone(), text_style),
4788 ])
4789 })
4790 .collect();
4791 frame.render_widget(Paragraph::new(lines), area);
4792}
4793
4794fn render_follow_ups(frame: &mut Frame<'_>, composer_area: Rect, chips: &[String]) {
4796 let styles = active_styles();
4797 let area = Rect {
4798 x: composer_area.x,
4799 y: composer_area.top().saturating_sub(1),
4800 width: composer_area.width,
4801 height: 1,
4802 };
4803 let mut spans = vec![Span::styled(
4804 "Suggestions: ",
4805 Style::default()
4806 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
4807 .add_modifier(Modifier::DIM),
4808 )];
4809 for (i, chip) in chips.iter().enumerate() {
4810 if i > 0 {
4811 spans.push(Span::raw(" "));
4812 }
4813 spans.push(Span::styled(
4814 format!("[{}]", chip),
4815 Style::default().fg(color_from_anstyle(styles.primary.get_fg_color())),
4816 ));
4817 }
4818 frame.render_widget(Paragraph::new(Line::from(spans)), area);
4819}
4820
4821fn tip_is_visible(tip: &EphemeralTip, now_tick: u64) -> bool {
4823 now_tick.saturating_sub(tip.born_tick) < tip.ttl_ticks
4824}
4825
4826fn render_tip(frame: &mut Frame, composer_area: Rect, text: &str) {
4828 let styles = active_styles();
4829 let area = Rect {
4830 x: composer_area.x,
4831 y: composer_area.top().saturating_sub(1),
4832 width: composer_area.width,
4833 height: 1,
4834 };
4835 let line = Line::styled(
4836 format!(" note: {text}"),
4837 Style::default()
4838 .fg(color_from_anstyle(styles.info.get_fg_color()))
4839 .add_modifier(Modifier::DIM),
4840 );
4841 frame.render_widget(Paragraph::new(line), area);
4842}
4843
4844fn render_slash_popup(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
4847 let styles = active_styles();
4848 let items = &state.slash_popup.items;
4849 if items.is_empty() {
4850 return;
4851 }
4852
4853 let max_visible = 7usize;
4854 let visible = items.len().min(max_visible);
4855 let popup_h = visible as u16 + 3; let width = composer_area.width.min(64);
4857 let popup_area = Rect {
4858 x: composer_area.left(),
4859 y: composer_area.top().saturating_sub(popup_h),
4860 width,
4861 height: popup_h,
4862 };
4863 frame.render_widget(Clear, popup_area);
4864
4865 let border_color = color_from_anstyle(styles.secondary.get_fg_color());
4866 let title = Line::from(Span::styled(
4867 " COMMANDS ",
4868 Style::default()
4869 .fg(color_from_anstyle(styles.primary.get_fg_color()))
4870 .add_modifier(Modifier::BOLD),
4871 ));
4872 let block = Block::default()
4873 .borders(Borders::ALL)
4874 .border_type(BorderType::Plain)
4875 .border_style(Style::default().fg(border_color))
4876 .title(title);
4877 let inner = block.inner(popup_area);
4878 frame.render_widget(&block, popup_area);
4879
4880 let max_label = items
4882 .iter()
4883 .take(visible)
4884 .map(|i| i.label.chars().count())
4885 .max()
4886 .unwrap_or(0);
4887
4888 let primary = color_from_anstyle(styles.primary.get_fg_color());
4889 let fg = color_from_anstyle(Some(styles.foreground));
4890 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
4891
4892 for (i, item) in items.iter().take(visible).enumerate() {
4893 let is_selected = i == state.slash_popup.selected;
4894 let y = inner.top() + i as u16;
4895 let row_area = Rect {
4896 x: inner.left(),
4897 y,
4898 width: inner.width,
4899 height: 1,
4900 };
4901
4902 let marker = if is_selected { "> " } else { " " };
4903 let label_style = if is_selected {
4904 Style::default().fg(primary).add_modifier(Modifier::BOLD)
4905 } else {
4906 Style::default().fg(fg)
4907 };
4908 let label_padded = format!("{:<width$}", item.label, width = max_label);
4909 let line = Line::from(vec![
4910 Span::styled(marker, label_style),
4911 Span::styled(label_padded, label_style),
4912 Span::raw(" "),
4913 Span::styled(&item.description, Style::default().fg(secondary)),
4914 ]);
4915 frame.render_widget(Paragraph::new(line), row_area);
4916 }
4917 frame.render_widget(
4918 Paragraph::new(Line::from(Span::styled(
4919 "Enter insert | Up/Down move | Esc close",
4920 Style::default().fg(secondary).add_modifier(Modifier::DIM),
4921 ))),
4922 Rect {
4923 x: inner.left(),
4924 y: inner.bottom().saturating_sub(1),
4925 width: inner.width,
4926 height: 1,
4927 },
4928 );
4929}
4930
4931fn render_file_search_dropdown(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
4935 let styles = active_styles();
4936 let Some(fs) = &state.file_search else {
4937 return;
4938 };
4939 let items = &fs.results;
4940 if items.is_empty() {
4941 return;
4942 }
4943
4944 let max_visible = 10usize;
4945 let visible = items.len().min(max_visible);
4946 let popup_h = visible as u16 + 3; let width = composer_area.width.min(72);
4948 let popup_area = Rect {
4949 x: composer_area.left(),
4950 y: composer_area.top().saturating_sub(popup_h),
4951 width,
4952 height: popup_h,
4953 };
4954 frame.render_widget(Clear, popup_area);
4955
4956 let border_color = color_from_anstyle(styles.secondary.get_fg_color());
4957 let title_str = if fs.hidden_mode {
4958 " FILES: HIDDEN "
4959 } else {
4960 " FILES "
4961 };
4962 let title = Line::from(Span::styled(
4963 title_str,
4964 Style::default()
4965 .fg(color_from_anstyle(styles.primary.get_fg_color()))
4966 .add_modifier(Modifier::BOLD),
4967 ));
4968 let block = Block::default()
4969 .borders(Borders::ALL)
4970 .border_type(BorderType::Plain)
4971 .border_style(Style::default().fg(border_color))
4972 .title(title);
4973 let inner = block.inner(popup_area);
4974 frame.render_widget(&block, popup_area);
4975
4976 let primary = color_from_anstyle(styles.primary.get_fg_color());
4977 let fg = color_from_anstyle(Some(styles.foreground));
4978 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
4979
4980 for (i, result) in items.iter().take(visible).enumerate() {
4981 let is_selected = i == fs.selected;
4982 let y = inner.top() + i as u16;
4983 let row_area = Rect {
4984 x: inner.left(),
4985 y,
4986 width: inner.width,
4987 height: 1,
4988 };
4989
4990 let marker = if is_selected { "> " } else { " " };
4991 let path_style = if is_selected {
4992 Style::default().fg(primary).add_modifier(Modifier::BOLD)
4993 } else {
4994 Style::default().fg(fg)
4995 };
4996 let line = Line::from(vec![
4997 Span::styled(marker, path_style),
4998 Span::styled(&result.path, path_style),
4999 ]);
5000 frame.render_widget(Paragraph::new(line), row_area);
5001 }
5002
5003 if popup_h >= 4 {
5005 let hint_y = inner.bottom().saturating_sub(1);
5006 let hint_area = Rect {
5007 x: inner.left(),
5008 y: hint_y,
5009 width: inner.width,
5010 height: 1,
5011 };
5012 let count = items.len();
5013 let hint = format!("{count} files | Tab accept | Esc cancel");
5014 let _ = secondary; frame.render_widget(
5016 Paragraph::new(Line::from(Span::styled(
5017 hint,
5018 Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
5019 )))
5020 .style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color()))),
5021 hint_area,
5022 );
5023 }
5024}
5025
5026struct InputEditor<'a> {
5038 composer: &'a mut oxicode_textarea::TextArea,
5039}
5040
5041impl<'a> InputEditor<'a> {
5042 fn new(composer: &'a mut oxicode_textarea::TextArea) -> Self {
5043 Self { composer }
5044 }
5045}
5046
5047impl<'a> crate::tui_vt::vim::Editor for InputEditor<'a> {
5048 fn content(&self) -> &str {
5049 self.composer.text()
5050 }
5051 fn cursor(&self) -> usize {
5052 self.composer.cursor()
5053 }
5054 fn set_cursor(&mut self, pos: usize) {
5055 self.composer.set_cursor(pos);
5056 }
5057 fn move_left(&mut self) {
5058 let new_pos = self.composer.cursor().saturating_sub(1);
5061 self.composer.set_cursor(new_pos);
5062 }
5063 fn move_right(&mut self) {
5064 let new_pos = self.composer.cursor().saturating_add(1);
5065 self.composer.set_cursor(new_pos);
5066 }
5067 fn delete_char_forward(&mut self) {
5068 self.composer.input(crossterm::event::KeyEvent::new(
5069 crossterm::event::KeyCode::Delete,
5070 crossterm::event::KeyModifiers::NONE,
5071 ));
5072 }
5073 fn insert_text(&mut self, text: &str) {
5074 self.composer.insert_str(text);
5075 }
5076 fn replace(&mut self, content: String, cursor: usize) {
5077 self.composer.set_text(&content);
5078 self.composer.set_cursor(cursor);
5079 }
5080 fn replace_range(&mut self, start: usize, end: usize, text: &str) {
5081 self.composer.replace_range(start..end, text);
5082 }
5083}
5084
5085pub(crate) fn plain_segment(text: impl Into<String>) -> InlineSegment {
5090 InlineSegment {
5091 text: text.into(),
5092 style: Arc::new(InlineTextStyle::default()),
5093 }
5094}
5095
5096pub(super) fn effective_scroll_offset(offset: usize, total: usize, viewport: usize) -> usize {
5097 if offset == usize::MAX {
5098 return total.saturating_sub(viewport);
5099 }
5100 let max_start = total.saturating_sub(viewport);
5102 offset.min(max_start)
5103}
5104
5105fn slash_filter(token: &str, file_commands: &[FileCommand]) -> Vec<SlashPopupItem> {
5116 let builtins = SlashRegistry::builtin_commands();
5117 let builtin_names: std::collections::HashSet<&str> =
5118 builtins.iter().map(|(n, _, _)| *n).collect();
5119
5120 let mut items: Vec<SlashPopupItem> = builtins
5121 .into_iter()
5122 .filter(|(name, _, aliases)| {
5123 token.is_empty()
5124 || name.starts_with(token)
5125 || aliases.iter().any(|a| a.starts_with(token))
5126 })
5127 .map(|(name, desc, aliases)| {
5128 let mut label = format!("/{name}");
5129 for a in &aliases {
5130 label.push_str(&format!(", /{a}"));
5131 }
5132 SlashPopupItem {
5133 label,
5134 description: desc.to_string(),
5135 name: name.to_string(),
5136 }
5137 })
5138 .collect();
5139
5140 for fc in file_commands {
5142 if builtin_names.contains(fc.name.as_str())
5143 || fc
5144 .aliases
5145 .iter()
5146 .any(|alias| builtin_names.contains(alias.as_str()))
5147 {
5148 continue;
5149 }
5150 if token.is_empty()
5151 || fc.name.starts_with(token)
5152 || fc.aliases.iter().any(|a| a.starts_with(token))
5153 {
5154 let mut label = format!("/{}", fc.name);
5155 for a in &fc.aliases {
5156 label.push_str(&format!(", /{a}"));
5157 }
5158 items.push(SlashPopupItem {
5159 label,
5160 description: fc.description.clone(),
5161 name: fc.name.clone(),
5162 });
5163 }
5164 }
5165
5166 items
5167}
5168
5169fn refresh_slash_popup(state: &mut RenderState) {
5174 let buf = state.composer.text();
5175 let active = buf.starts_with('/') && !buf[1..].contains(' ');
5176 if !active {
5177 state.slash_popup.open = false;
5178 state.slash_popup.items.clear();
5179 state.slash_popup.selected = 0;
5180 return;
5181 }
5182 let token = &buf[1..];
5183 let items = slash_filter(token, &state.file_commands);
5184 state.slash_popup.open = !items.is_empty();
5185 if items.is_empty() {
5186 state.slash_popup.selected = 0;
5187 } else {
5188 state.slash_popup.selected = state.slash_popup.selected.min(items.len() - 1);
5189 }
5190 state.slash_popup.items = items;
5191}
5192fn refresh_input_popups(state: &mut RenderState) {
5196 refresh_slash_popup(state);
5197 refresh_file_search(state);
5198}
5199
5200fn refresh_file_search(state: &mut RenderState) {
5206 use crate::tui_vt::file_search;
5207 if state.slash_popup.open {
5209 state.file_search = None;
5210 return;
5211 }
5212 match file_search::parse_at_cursor(state.composer.text(), state.composer.cursor()) {
5213 Some(token) => match &mut state.file_search {
5214 None => {
5215 let cwd = state.cwd.clone();
5216 state.file_search = Some(file_search::open(&cwd, token.at_offset, false));
5217 }
5218 Some(fs) => {
5219 if fs.query != token.path_query {
5220 fs.refresh(&token.path_query);
5221 }
5222 }
5223 },
5224 None => state.file_search = None,
5225 }
5226}
5227
5228fn accept_file_search(state: &mut RenderState, line_mode: bool) -> bool {
5233 use crate::tui_vt::file_search;
5234 let Some(fs) = &state.file_search else {
5235 return false;
5236 };
5237 let Some(result) = fs.selected_result().cloned() else {
5238 return false;
5239 };
5240 let at_offset = fs.at_offset;
5241 let text = file_search::insertion_text(&result.path, None, line_mode);
5242 let cursor_end = state.composer.cursor();
5243 state.composer.replace_range(
5245 at_offset..cursor_end.min(state.composer.text().len()),
5246 &text,
5247 );
5248 state.composer.set_cursor(at_offset + text.len());
5249 state.file_search = None;
5250 true
5251}
5252
5253fn preview_tool_result(content: &str) -> String {
5254 const MAX: usize = 500;
5255 if content.chars().count() <= MAX {
5256 return content.to_string();
5257 }
5258 let truncated: String = content.chars().take(MAX).collect();
5259 format!("{truncated}\u{2026}")
5260}
5261
5262fn try_render_diff(content: &str, handle: &InlineHandle) -> bool {
5266 let lines: Vec<&str> = content.lines().collect();
5267 if !lines.iter().any(|l| l.starts_with("@@")) {
5271 return false;
5272 }
5273 let additions = lines
5274 .iter()
5275 .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
5276 .count();
5277 let deletions = lines
5278 .iter()
5279 .filter(|l| l.starts_with('-') && !l.starts_with("---"))
5280 .count();
5281 if additions + deletions < 2 {
5282 return false;
5283 }
5284
5285 let styles = active_styles();
5286 let green = styles.secondary.get_fg_color();
5287 let red = styles.error.get_fg_color();
5288 const MAX_DIFF_LINES: usize = 30;
5289
5290 let mut hdr_style = InlineTextStyle::default();
5292 hdr_style.effects |= anstyle::Effects::DIMMED;
5293 handle.append_line(
5294 InlineMessageKind::Tool,
5295 vec![InlineSegment {
5296 text: format!("[diff] +{additions} -{deletions}"),
5297 style: Arc::new(hdr_style),
5298 }],
5299 );
5300
5301 for line in lines.iter().take(MAX_DIFF_LINES) {
5303 let mut style = InlineTextStyle::default();
5304 if line.starts_with('+') && !line.starts_with("+++") {
5305 style.color = green;
5306 } else if line.starts_with('-') && !line.starts_with("---") {
5307 style.color = red;
5308 } else {
5309 style.effects |= anstyle::Effects::DIMMED;
5310 }
5311 handle.append_line(
5312 InlineMessageKind::Tool,
5313 vec![InlineSegment {
5314 text: format!(" {line}"),
5315 style: Arc::new(style),
5316 }],
5317 );
5318 }
5319
5320 if lines.len() > MAX_DIFF_LINES {
5321 let mut more_style = InlineTextStyle::default();
5322 more_style.effects |= anstyle::Effects::DIMMED;
5323 handle.append_line(
5324 InlineMessageKind::Tool,
5325 vec![InlineSegment {
5326 text: format!(" \u{2026} {} more lines", lines.len() - MAX_DIFF_LINES),
5327 style: Arc::new(more_style),
5328 }],
5329 );
5330 }
5331
5332 true
5333}
5334
5335fn color_from_anstyle(color: Option<anstyle::Color>) -> Color {
5336 match color {
5337 Some(anstyle::Color::Ansi(a)) => ansi_to_ratatui(a),
5338 Some(anstyle::Color::Ansi256(idx)) => Color::Indexed(idx.0),
5339 Some(anstyle::Color::Rgb(rgb)) => Color::Rgb(rgb.0, rgb.1, rgb.2),
5340 None => Color::Reset,
5341 }
5342}
5343fn ansi_to_ratatui(color: anstyle::AnsiColor) -> Color {
5344 use anstyle::AnsiColor as A;
5345 match color {
5346 A::Black => Color::Black,
5347 A::Red => Color::Red,
5348 A::Green => Color::Green,
5349 A::Yellow => Color::Yellow,
5350 A::Blue => Color::Blue,
5351 A::Magenta => Color::Magenta,
5352 A::Cyan => Color::Cyan,
5353 A::White => Color::Gray,
5354 A::BrightBlack => Color::DarkGray,
5355 A::BrightRed => Color::LightRed,
5356 A::BrightGreen => Color::LightGreen,
5357 A::BrightYellow => Color::LightYellow,
5358 A::BrightBlue => Color::LightBlue,
5359 A::BrightMagenta => Color::LightMagenta,
5360 A::BrightCyan => Color::LightCyan,
5361 A::BrightWhite => Color::White,
5362 }
5363}
5364
5365#[allow(dead_code, clippy::declare_interior_mutable_const)]
5368const _ATOMIC_REFS: (AtomicBool, Ordering) = (AtomicBool::new(false), Ordering::SeqCst);
5369
5370#[cfg(test)]
5371mod slash_popup_tests {
5372 use super::*;
5373
5374 #[test]
5375 fn empty_token_lists_all_commands() {
5376 let items = slash_filter("", &[]);
5377 assert!(items.len() >= 7);
5379 assert!(items.iter().any(|i| i.name == "quit"));
5380 assert!(items.iter().any(|i| i.name == "clear"));
5381 assert!(items.iter().any(|i| i.name == "model"));
5382 }
5383
5384 #[test]
5385 fn prefix_filter_matches_name() {
5386 let items = slash_filter("qu", &[]);
5387 assert_eq!(items.len(), 1);
5388 assert_eq!(items[0].name, "quit");
5389 assert!(items[0].label.contains("/quit"));
5390 }
5391
5392 #[test]
5393 fn prefix_filter_matches_alias() {
5394 let items = slash_filter("cl", &[]);
5396 let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
5397 assert!(names.contains(&"clear"));
5398 }
5399
5400 #[test]
5401 fn file_commands_appear_in_filter() {
5402 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
5403 "review",
5404 "---\ndescription: proj cmd\naliases: cr\n---\nbody",
5405 );
5406 let items = slash_filter("", &[fc]);
5407 assert!(items.iter().any(|i| i.name == "review"));
5408 assert!(items.iter().any(|i| i.name == "quit")); }
5410
5411 #[test]
5412 fn file_commands_filtered_by_prefix() {
5413 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
5414 "review",
5415 "---\ndescription: x\n---\nbody",
5416 );
5417 let items = slash_filter("rev", &[fc]);
5418 assert!(items.iter().any(|i| i.name == "review"));
5419 }
5420
5421 #[test]
5422 fn file_commands_shadowed_by_builtins_are_dropped() {
5423 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
5428 "quit",
5429 "---\ndescription: hijack\n---\nbody",
5430 );
5431 let items = slash_filter("", &[fc]);
5432 let quit_count = items.iter().filter(|i| i.name == "quit").count();
5433 assert_eq!(quit_count, 1, "shadowed file command must not appear");
5434 assert!(
5436 items
5437 .iter()
5438 .any(|i| i.name == "quit" && !i.description.contains("hijack"))
5439 );
5440 }
5441
5442 #[test]
5443 fn file_commands_with_builtin_aliases_are_dropped() {
5444 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
5445 "review",
5446 "---\ndescription: hijack\naliases: quit\n---\nbody",
5447 );
5448 let items = slash_filter("", &[fc]);
5449 assert!(!items.iter().any(|item| item.name == "review"));
5450 }
5451
5452 #[test]
5453 fn popup_opens_on_slash() {
5454 let mut state = RenderState::default();
5455 state.composer.set_text("/");
5456 refresh_input_popups(&mut state);
5457 assert!(state.slash_popup.open);
5458 assert!(!state.slash_popup.items.is_empty());
5459 }
5460
5461 #[test]
5462 fn popup_closes_on_space() {
5463 let mut state = RenderState::default();
5464 state.composer.set_text("/quit ");
5465 refresh_input_popups(&mut state);
5466 assert!(!state.slash_popup.open);
5467 }
5468
5469 #[test]
5470 fn popup_closes_on_non_slash() {
5471 let mut state = RenderState::default();
5472 state.composer.set_text("hello");
5473 refresh_input_popups(&mut state);
5474 assert!(!state.slash_popup.open);
5475 }
5476
5477 #[test]
5478 fn popup_filters_as_user_types() {
5479 let mut state = RenderState::default();
5480 state.composer.set_text("/m");
5481 refresh_input_popups(&mut state);
5482 assert!(state.slash_popup.open);
5483 assert!(
5486 state
5487 .slash_popup
5488 .items
5489 .iter()
5490 .all(|i| i.name.starts_with('m'))
5491 );
5492 }
5493
5494 #[test]
5495 fn popup_selection_clamps_on_shrink() {
5496 let mut state = RenderState::default();
5497 state.composer.set_text("/");
5498 refresh_input_popups(&mut state);
5499 let full_count = state.slash_popup.items.len();
5500 state.slash_popup.selected = full_count - 1;
5501 state.composer.set_text("/qu");
5503 refresh_input_popups(&mut state);
5504 assert!(state.slash_popup.selected < state.slash_popup.items.len());
5505 }
5506}
5507
5508#[cfg(test)]
5509mod render_tests {
5510 use super::*;
5511 use oxicode_vtui::tui::core::{InlineHandle, OverlayEvent};
5512 use ratatui::{Terminal, backend::TestBackend};
5513 use tokio::sync::mpsc;
5514
5515 fn render_frame_to_string(state: &RenderState) -> String {
5520 let backend = TestBackend::new(80, 24);
5521 let mut terminal = Terminal::new(backend).expect("backend");
5522 let (tx, _rx) = mpsc::unbounded_channel();
5523 let handle = InlineHandle::new_for_tests(tx);
5524 terminal
5525 .draw(|f| render_frame(f, state, &handle))
5526 .expect("draw");
5527 let buf = terminal.backend().buffer();
5528 let area = buf.area();
5529 let mut out = String::new();
5530 for y in 0..area.height {
5531 for x in 0..area.width {
5532 if let Some(cell) = buf.cell((x, y)) {
5533 out.push_str(cell.symbol());
5534 }
5535 }
5536 out.push('\n');
5537 }
5538 out
5539 }
5540
5541 fn terminal_caret(state: &RenderState) -> Option<(u16, u16)> {
5544 let backend = TestBackend::new(80, 24);
5545 let mut terminal = Terminal::new(backend).expect("backend");
5546 let (tx, _rx) = mpsc::unbounded_channel();
5547 let handle = InlineHandle::new_for_tests(tx);
5548 terminal
5549 .draw(|f| render_frame(f, state, &handle))
5550 .expect("draw");
5551 terminal
5552 .get_cursor_position()
5553 .ok()
5554 .map(|position| (position.x, position.y))
5555 }
5556
5557 #[test]
5558 fn composer_caret_aligns_after_ascii() {
5559 let mut state = RenderState::default();
5560 state.prompt_prefix = "> ".to_string();
5561 state.input_enabled = true;
5562 let mut composer = oxicode_textarea::TextArea::new();
5563 composer.set_text("hello");
5564 composer.set_cursor(5);
5565 state.composer = composer;
5566 let caret = terminal_caret(&state);
5567 assert_eq!(
5571 caret,
5572 Some((9, 21)),
5573 "ASCII caret must sit right after '> hello'"
5574 );
5575 }
5576
5577 #[test]
5578 fn composer_caret_aligns_after_cjk_display_columns() {
5579 let mut state = RenderState::default();
5580 state.prompt_prefix = "> ".to_string();
5581 state.input_enabled = true;
5582 let body = "안녕";
5583 let mut composer = oxicode_textarea::TextArea::new();
5584 composer.set_text(body);
5585 composer.set_cursor(body.len()); state.composer = composer;
5587 let caret = terminal_caret(&state);
5588 assert_eq!(
5590 caret,
5591 Some((8, 21)),
5592 "CJK caret must sit after 4 display columns (not 6 bytes)"
5593 );
5594 }
5595
5596 #[test]
5597 fn composer_caret_aligns_after_mixed_ascii_cjk() {
5598 let body = "hi안녕";
5599 let mut state = RenderState::default();
5600 state.prompt_prefix = "> ".to_string();
5601 state.input_enabled = true;
5602 let mut composer = oxicode_textarea::TextArea::new();
5603 composer.set_text(body);
5604 composer.set_cursor(body.len()); state.composer = composer;
5606 let caret = terminal_caret(&state);
5607 assert_eq!(
5609 caret,
5610 Some((10, 21)),
5611 "Mixed caret must sit after 6 display columns"
5612 );
5613 }
5614
5615 #[test]
5616 fn agent_session_event_reaches_the_transcript_bridge() {
5617 let (tx, mut rx) = mpsc::unbounded_channel();
5618 let handle = InlineHandle::new_for_tests(tx);
5619 let mut state = RenderState::default();
5620
5621 handle_session_event(
5622 &mut state,
5623 &handle,
5624 &SessionEvent::Agent(Box::new(AgentEvent::TextChunk {
5625 text: "streamed reply".to_string(),
5626 })),
5627 None,
5628 );
5629
5630 let command = rx
5631 .try_recv()
5632 .expect("an agent event must produce a render command");
5633 apply_command(&mut state, command);
5634 assert_eq!(state.transcript.len(), 1);
5635 assert_eq!(state.transcript[0].kind, InlineMessageKind::Agent);
5636 assert_eq!(state.transcript[0].segments[0].text, "streamed reply");
5637 }
5638
5639 #[test]
5640 fn missing_key_errors_are_distinguished_from_other_provider_failures() {
5641 assert!(is_missing_api_key_error(
5642 "Provider stream error: Missing API key — configure a credential"
5643 ));
5644 assert!(!is_missing_api_key_error("Provider returned HTTP 429"));
5645 assert_eq!(
5646 provider_from_model_id("deepseek/deepseek-v4-flash"),
5647 "deepseek"
5648 );
5649 }
5650
5651 #[test]
5652 fn prompt_queue_mutations_change_the_execution_queue() {
5653 let queue = PromptQueue::default();
5654 queue.enqueue("first".to_string());
5655 queue.enqueue("second".to_string());
5656 queue.enqueue("third".to_string());
5657
5658 assert!(queue.move_by(2, -1));
5659 assert_eq!(queue.remove(0).as_deref(), Some("first"));
5660 let pending: Vec<_> = queue.pending.lock().iter().cloned().collect();
5661 assert_eq!(pending, ["third", "second"]);
5662 }
5663
5664 #[test]
5665 fn welcome_screen_shown_when_transcript_empty() {
5666 let state = RenderState::default();
5667 let rendered = render_frame_to_string(&state);
5668 assert!(
5669 rendered.contains("OXICODE") && rendered.contains("WORKSPACE"),
5670 "welcome banner must appear when transcript is empty"
5671 );
5672 }
5673
5674 #[test]
5675 fn composer_is_painted() {
5676 let mut state = RenderState::default();
5680 state.input_enabled = true;
5681 state.prompt_prefix = "> ".to_string();
5682 let rendered = render_frame_to_string(&state);
5683 assert!(
5684 rendered.contains('>'),
5685 "composer prompt prefix must be painted"
5686 );
5687 }
5688
5689 #[test]
5690 fn slash_popup_renders_command_list() {
5691 let mut state = RenderState::default();
5692 state.slash_popup.open = true;
5693 state.slash_popup.items = slash_filter("", &[]);
5694 let rendered = render_frame_to_string(&state);
5695 assert!(rendered.contains("COMMANDS"), "popup title must render");
5696 assert!(rendered.contains("/quit"), "popup must list /quit");
5697 }
5698
5699 #[test]
5700 fn composer_and_popup_render_together() {
5701 let mut state = RenderState::default();
5702 state.prompt_prefix = "> ".to_string();
5703 state.composer.set_text("/qu");
5704 state.slash_popup.open = true;
5705 state.slash_popup.items = slash_filter("qu", &[]);
5706 let rendered = render_frame_to_string(&state);
5707 assert!(rendered.contains("COMMANDS"), "popup must render");
5708 assert!(rendered.contains("/quit"), "popup must list /quit");
5709 assert!(rendered.contains('>'), "composer must still render");
5710 }
5711
5712 #[test]
5713 fn transcript_wraps_long_lines() {
5714 let mut state = RenderState::default();
5716 state.transcript.push(TranscriptLine {
5717 kind: InlineMessageKind::Agent,
5718 segments: vec![plain_segment(
5719 "This is a very long agent response line that should wrap across multiple terminal rows when rendered at a narrow width.".to_string()
5720 )],
5721 block_id: 0,
5722 });
5723 let backend = TestBackend::new(40, 24);
5724 let mut terminal = Terminal::new(backend).expect("backend");
5725 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5726 let handle = InlineHandle::new_for_tests(tx);
5727 terminal
5728 .draw(|f| render_frame(f, &state, &handle))
5729 .expect("draw");
5730 let buf = terminal.backend().buffer();
5731 let mut full = String::new();
5734 for y in 0..buf.area.height {
5735 for x in 0..buf.area.width {
5736 if let Some(cell) = buf.cell((x, y)) {
5737 full.push_str(cell.symbol());
5738 }
5739 }
5740 full.push('\n');
5741 }
5742 assert!(
5743 full.contains("wrap"),
5744 "long line must wrap, not clip — text should be visible past col 40"
5745 );
5746 }
5747
5748 fn sample_overlay_items() -> Vec<OverlayListItem> {
5751 vec![
5752 OverlayListItem {
5753 title: "model-a".to_string(),
5754 subtitle: Some("first".to_string()),
5755 badge: Some("ready".to_string()),
5756 indent: 0,
5757 search_value: None,
5758 selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(0)),
5759 },
5760 OverlayListItem {
5761 title: "model-b".to_string(),
5762 subtitle: None,
5763 badge: None,
5764 indent: 0,
5765 search_value: None,
5766 selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(1)),
5767 },
5768 OverlayListItem {
5769 title: "model-c".to_string(),
5770 subtitle: None,
5771 badge: None,
5772 indent: 0,
5773 search_value: None,
5774 selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(2)),
5775 },
5776 ]
5777 }
5778
5779 #[test]
5780 fn overlay_renders_title_and_items() {
5781 let mut state = RenderState::default();
5782 state.overlay = Some(OverlayState {
5783 title: "Select model".to_string(),
5784 lines: vec!["Pick one".to_string()],
5785 items: sample_overlay_items(),
5786 selected: 0,
5787 search: None,
5788 secure_input: None,
5789 });
5790 let rendered = render_frame_to_string(&state);
5791 assert!(
5792 rendered.contains("Select model"),
5793 "overlay title must render"
5794 );
5795 assert!(rendered.contains("model-a"), "first item must render");
5796 assert!(rendered.contains("model-b"), "second item must render");
5797 assert!(rendered.contains("model-c"), "third item must render");
5798 assert!(
5799 rendered.contains("Pick one"),
5800 "descriptive line must render"
5801 );
5802 }
5803
5804 #[test]
5805 fn overlay_search_filters_items() {
5806 let mut state = RenderState::default();
5807 state.overlay = Some(OverlayState {
5808 title: "Select".to_string(),
5809 lines: Vec::new(),
5810 items: sample_overlay_items(),
5811 selected: 0,
5812 search: Some(OverlaySearchState {
5813 label: "filter".to_string(),
5814 placeholder: Some("type".to_string()),
5815 value: "model-b".to_string(),
5816 }),
5817 secure_input: None,
5818 });
5819 let rendered = render_frame_to_string(&state);
5820 assert!(rendered.contains("model-b"), "matching item must render");
5821 assert!(
5822 !rendered.contains("model-a"),
5823 "non-matching item must not render (got: {})",
5824 rendered
5825 );
5826 assert!(
5827 !rendered.contains("model-c"),
5828 "non-matching item must not render"
5829 );
5830 }
5831
5832 #[test]
5833 fn overlay_keyboard_nav_moves_selection() {
5834 let mut state = RenderState::default();
5835 state.overlay = Some(OverlayState {
5836 title: "Select".to_string(),
5837 lines: Vec::new(),
5838 items: sample_overlay_items(),
5839 selected: 0,
5840 search: None,
5841 secure_input: None,
5842 });
5843 let state_arc = Arc::new(parking_lot::Mutex::new(state));
5844 let (tx, mut _rx) = mpsc::unbounded_channel();
5845
5846 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 0);
5848
5849 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
5851 assert!(consumed, "Down must be consumed while overlay is open");
5852 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 1);
5853
5854 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
5856 assert!(consumed);
5857 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 2);
5858
5859 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
5861 assert!(consumed);
5862 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 0);
5863
5864 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Up);
5866 assert!(consumed);
5867 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 2);
5868
5869 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
5871 assert!(consumed);
5872 assert!(
5873 state_arc.lock().overlay.is_none(),
5874 "overlay must be cleared after Enter"
5875 );
5876 let evt = _rx.try_recv().expect("submit event must arrive");
5877 match evt {
5878 InlineEvent::Overlay(OverlayEvent::Submitted(_)) => {}
5879 other => panic!("expected Submitted overlay event, got {other:?}"),
5880 }
5881 }
5882
5883 #[test]
5884 fn overlay_esc_closes_and_emits_cancelled() {
5885 let mut state = RenderState::default();
5886 state.overlay = Some(OverlayState {
5887 title: "Select".to_string(),
5888 lines: Vec::new(),
5889 items: sample_overlay_items(),
5890 selected: 0,
5891 search: None,
5892 secure_input: None,
5893 });
5894 let state_arc = Arc::new(parking_lot::Mutex::new(state));
5895 let (tx, mut rx) = mpsc::unbounded_channel();
5896
5897 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Esc);
5898 assert!(consumed);
5899 assert!(
5900 state_arc.lock().overlay.is_none(),
5901 "overlay must be cleared after Esc"
5902 );
5903 let evt = rx.try_recv().expect("cancel event must arrive");
5904 assert!(
5905 matches!(evt, InlineEvent::Overlay(OverlayEvent::Cancelled)),
5906 "expected Cancelled overlay event"
5907 );
5908 }
5909
5910 #[test]
5911 fn overlay_enter_on_readonly_item_is_noop() {
5912 let mut state = RenderState::default();
5916 state.overlay = Some(OverlayState {
5917 title: "Tools".to_string(),
5918 lines: Vec::new(),
5919 items: vec![OverlayListItem {
5920 title: "read".to_string(),
5921 subtitle: Some("Read a file".to_string()),
5922 badge: None,
5923 indent: 0,
5924 search_value: None,
5925 selection: None,
5926 }],
5927 selected: 0,
5928 search: None,
5929 secure_input: None,
5930 });
5931 let state_arc = Arc::new(parking_lot::Mutex::new(state));
5932 let (tx, mut rx) = mpsc::unbounded_channel();
5933
5934 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
5935 assert!(consumed, "Enter must be consumed even on read-only items");
5936 assert!(
5937 state_arc.lock().overlay.is_some(),
5938 "overlay must stay open when Enter hits a read-only item"
5939 );
5940 assert!(
5941 rx.try_recv().is_err(),
5942 "no overlay event must be emitted for a read-only Enter"
5943 );
5944 }
5945
5946 #[test]
5947 fn overlay_chars_route_to_search_field() {
5948 let mut state = RenderState::default();
5949 state.overlay = Some(OverlayState {
5950 title: "Select".to_string(),
5951 lines: Vec::new(),
5952 items: sample_overlay_items(),
5953 selected: 0,
5954 search: Some(OverlaySearchState {
5955 label: "filter".to_string(),
5956 placeholder: None,
5957 value: String::new(),
5958 }),
5959 secure_input: None,
5960 });
5961 let state_arc = Arc::new(parking_lot::Mutex::new(state));
5962 let (tx, _rx) = mpsc::unbounded_channel();
5963
5964 handle_overlay_key(&state_arc, &tx, KeyCode::Char('m'));
5965 handle_overlay_key(&state_arc, &tx, KeyCode::Char('o'));
5966 handle_overlay_key(&state_arc, &tx, KeyCode::Backspace);
5967 let value = state_arc
5968 .lock()
5969 .overlay
5970 .as_ref()
5971 .unwrap()
5972 .search
5973 .as_ref()
5974 .unwrap()
5975 .value
5976 .clone();
5977 assert_eq!(value, "m", "Backspace should drop last char");
5978 }
5979
5980 #[test]
5981 fn overlay_key_no_op_when_no_overlay_open() {
5982 let state = RenderState::default();
5983 let state_arc = Arc::new(parking_lot::Mutex::new(state));
5984 let (tx, _rx) = mpsc::unbounded_channel();
5985 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
5986 assert!(
5987 !consumed,
5988 "handle_overlay_key must return false when no overlay is open"
5989 );
5990 }
5991
5992 #[test]
5993 fn apply_command_show_overlay_populates_state() {
5994 use oxicode_vtui::tui::core::{InlineListItem, ListOverlayRequest};
5995 let mut state = RenderState::default();
5996 let items = vec![
5997 InlineListItem {
5998 title: "alpha".to_string(),
5999 subtitle: None,
6000 badge: None,
6001 indent: 0,
6002 selection: None,
6003 search_value: None,
6004 },
6005 InlineListItem {
6006 title: "beta".to_string(),
6007 subtitle: None,
6008 badge: None,
6009 indent: 0,
6010 selection: None,
6011 search_value: None,
6012 },
6013 ];
6014 let request = OverlayRequest::List(ListOverlayRequest {
6015 title: "Pick".to_string(),
6016 lines: vec!["desc".to_string()],
6017 footer_hint: None,
6018 items,
6019 selected: None,
6020 search: None,
6021 hotkeys: Vec::new(),
6022 });
6023 let shutdown = apply_command(
6024 &mut state,
6025 InlineCommand::ShowOverlay {
6026 request: Box::new(request),
6027 },
6028 );
6029 assert!(!shutdown, "ShowOverlay must not request shutdown");
6030 let overlay = state.overlay.as_ref().expect("overlay must be Some");
6031 assert_eq!(overlay.title, "Pick");
6032 assert_eq!(overlay.items.len(), 2);
6033 assert_eq!(overlay.items[0].title, "alpha");
6034 assert_eq!(overlay.items[1].title, "beta");
6035 assert_eq!(overlay.lines.len(), 1);
6036
6037 apply_command(&mut state, InlineCommand::CloseOverlay);
6039 assert!(state.overlay.is_none(), "CloseOverlay must clear state");
6040 }
6041
6042 #[test]
6043 fn materialize_overlay_modal_with_secure_prompt_populates_secure_input() {
6044 use oxicode_vtui::tui::core::{ModalOverlayRequest, SecurePromptConfig};
6045 let request = OverlayRequest::Modal(ModalOverlayRequest {
6046 title: "API key".into(),
6047 lines: vec!["Paste your key".into()],
6048 secure_prompt: Some(SecurePromptConfig {
6049 label: "Key".into(),
6050 placeholder: Some("sk-...".into()),
6051 mask_input: true,
6052 }),
6053 });
6054 let state = materialize_overlay(request);
6055 let secure = state
6056 .secure_input
6057 .expect("secure_input must be Some when secure_prompt is Some");
6058 assert_eq!(secure.config.label, "Key");
6059 assert!(secure.config.mask_input);
6060 assert_eq!(secure.editor.text(), "");
6061 assert_eq!(secure.editor.cursor_byte(), 0);
6062 }
6063
6064 #[test]
6065 fn materialize_overlay_modal_without_secure_prompt_has_none_secure_input() {
6066 use oxicode_vtui::tui::core::ModalOverlayRequest;
6067 let request = OverlayRequest::Modal(ModalOverlayRequest {
6068 title: "Confirm".into(),
6069 lines: vec!["y/n".into()],
6070 secure_prompt: None,
6071 });
6072 let state = materialize_overlay(request);
6073 assert!(
6074 state.secure_input.is_none(),
6075 "secure_input must be None when secure_prompt is None"
6076 );
6077 }
6078
6079 fn three_block_transcript() -> Vec<TranscriptLine> {
6082 vec![
6084 TranscriptLine {
6085 kind: InlineMessageKind::User,
6086 segments: vec![plain_segment("hi")],
6087 block_id: 0,
6088 },
6089 TranscriptLine {
6090 kind: InlineMessageKind::Agent,
6091 segments: vec![plain_segment("hello")],
6092 block_id: 1,
6093 },
6094 TranscriptLine {
6095 kind: InlineMessageKind::Agent,
6096 segments: vec![plain_segment("world")],
6097 block_id: 1,
6098 },
6099 TranscriptLine {
6100 kind: InlineMessageKind::User,
6101 segments: vec![plain_segment("bye")],
6102 block_id: 2,
6103 },
6104 ]
6105 }
6106
6107 #[test]
6108 fn default_block_mode_is_truncated() {
6109 let state = RenderState::default();
6110 assert_eq!(state.block_mode(0), BlockDisplayMode::Truncated);
6111 assert!(state.block_display.is_empty(), "default needs no map entry");
6112 }
6113
6114 #[test]
6115 fn fold_all_collapses_every_block() {
6116 let mut state = RenderState::default();
6117 state.transcript = three_block_transcript();
6118 state.fold_all();
6119 assert_eq!(state.block_display.len(), 3, "3 distinct block ids");
6120 assert_eq!(state.block_mode(0), BlockDisplayMode::Collapsed);
6121 assert_eq!(state.block_mode(1), BlockDisplayMode::Collapsed);
6122 assert_eq!(state.block_mode(2), BlockDisplayMode::Collapsed);
6123 }
6124
6125 #[test]
6126 fn expand_all_after_fold_all_shows_expanded() {
6127 let mut state = RenderState::default();
6128 state.transcript = three_block_transcript();
6129 state.fold_all();
6130 state.expand_all();
6131 assert_eq!(state.block_display.len(), 3);
6132 assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
6133 assert_eq!(state.block_mode(2), BlockDisplayMode::Expanded);
6134 }
6135
6136 #[test]
6137 fn truncate_all_resets_to_default() {
6138 let mut state = RenderState::default();
6139 state.transcript = three_block_transcript();
6140 state.fold_all();
6141 state.truncate_all();
6142 assert!(state.block_display.is_empty());
6143 assert_eq!(state.block_mode(1), BlockDisplayMode::Truncated);
6144 }
6145
6146 #[test]
6147 fn fold_all_on_empty_transcript_is_noop() {
6148 let mut state = RenderState::default();
6149 state.fold_all();
6150 assert!(state.block_display.is_empty());
6151 }
6152
6153 #[test]
6154 fn cycle_block_advances_through_three_states() {
6155 let mut state = RenderState::default();
6156 state.transcript = three_block_transcript();
6157 state.scroll_offset = 0; state.cycle_block_at_view();
6160 assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
6161 state.cycle_block_at_view();
6163 assert_eq!(state.block_mode(0), BlockDisplayMode::Collapsed);
6164 state.cycle_block_at_view();
6166 assert_eq!(state.block_mode(0), BlockDisplayMode::Truncated);
6167 assert!(!state.block_display.contains_key(&0));
6168 }
6169
6170 #[test]
6171 fn cancel_grace_field_defaults_none() {
6172 let state = RenderState::default();
6173 assert!(
6174 state.cancel_grace_until.is_none(),
6175 "cancel_grace_until must default to None"
6176 );
6177 }
6178
6179 #[test]
6180 fn cancel_routes_to_interrupt_when_streaming() {
6181 assert_eq!(
6182 route_cancel(true),
6183 CancelRoute::Interrupt,
6184 "Esc while streaming must route through the interrupt path"
6185 );
6186 }
6187
6188 #[test]
6189 fn cancel_routes_to_exit_when_idle() {
6190 assert_eq!(
6191 route_cancel(false),
6192 CancelRoute::Exit,
6193 "Esc while idle must exit immediately (one-press quit)"
6194 );
6195 }
6196 #[test]
6197 fn scrollbar_paints_thumb_when_content_overflows() {
6198 let mut state = RenderState::default();
6201 for i in 0..40u32 {
6202 state.transcript.push(TranscriptLine {
6203 kind: InlineMessageKind::Agent,
6204 segments: vec![plain_segment(format!("line {i}"))],
6205 block_id: i as usize,
6206 });
6207 }
6208 let rendered = render_frame_to_string(&state);
6209 assert!(
6210 rendered.contains('\u{2588}'),
6211 "scrollbar thumb (█) must render when transcript overflows the viewport"
6212 );
6213 }
6214
6215 #[test]
6216 fn scrollbar_absent_when_content_fits_viewport() {
6217 let mut state = RenderState::default();
6219 state.transcript.push(TranscriptLine {
6220 kind: InlineMessageKind::Agent,
6221 segments: vec![plain_segment("hi")],
6222 block_id: 0,
6223 });
6224 let rendered = render_frame_to_string(&state);
6225 assert!(
6226 !rendered.contains('\u{2588}'),
6227 "no scrollbar thumb when content fits the viewport"
6228 );
6229 }
6230
6231 #[test]
6234 fn confirmation_modal_renders_title() {
6235 let mut state = RenderState::default();
6236 state.confirmation = Some(quit_confirmation());
6237 let rendered = render_frame_to_string(&state);
6238 assert!(
6239 rendered.contains("Quit oxicode?"),
6240 "confirmation title must render"
6241 );
6242 }
6243
6244 #[test]
6245 fn confirmation_yes_sends_exit_and_closes() {
6246 let mut state = RenderState::default();
6247 state.confirmation = Some(quit_confirmation());
6248 let state_arc = Arc::new(parking_lot::Mutex::new(state));
6249 let (tx, mut rx) = mpsc::unbounded_channel();
6250 handle_confirmation_key(&state_arc, &tx, KeyCode::Char('y'));
6251 assert!(
6252 state_arc.lock().confirmation.is_none(),
6253 "yes must close the modal"
6254 );
6255 let ev = rx.try_recv().expect("yes must send an event");
6256 assert!(matches!(ev, InlineEvent::Exit), "yes must send Exit");
6257 }
6258
6259 #[test]
6260 fn confirmation_no_closes_without_event() {
6261 let mut state = RenderState::default();
6262 state.confirmation = Some(quit_confirmation());
6263 let state_arc = Arc::new(parking_lot::Mutex::new(state));
6264 let (tx, mut rx) = mpsc::unbounded_channel();
6265 handle_confirmation_key(&state_arc, &tx, KeyCode::Char('n'));
6266 assert!(
6267 state_arc.lock().confirmation.is_none(),
6268 "no must close the modal"
6269 );
6270 assert!(rx.try_recv().is_err(), "no must not send an event");
6271 }
6272 #[test]
6275 fn tip_banner_renders_when_active() {
6276 let mut state = RenderState::default();
6277 let now_tick = FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed);
6278 state.tip = Some(EphemeralTip {
6279 text: "hello-tip-marker".to_string(),
6280 born_tick: now_tick,
6281 ttl_ticks: 100,
6282 key: "test",
6283 ambient: false,
6284 });
6285 let rendered = render_frame_to_string(&state);
6286 assert!(
6287 rendered.contains("hello-tip-marker"),
6288 "active tip must render above the composer"
6289 );
6290 }
6291
6292 #[test]
6293 fn tip_visible_within_ttl_window() {
6294 let tip = EphemeralTip {
6295 text: "x".to_string(),
6296 born_tick: 10,
6297 ttl_ticks: 5,
6298 key: "test",
6299 ambient: false,
6300 };
6301 assert!(tip_is_visible(&tip, 12), "within TTL must be visible");
6302 assert!(
6303 !tip_is_visible(&tip, 15),
6304 "at TTL boundary (born + ttl) must expire"
6305 );
6306 assert!(!tip_is_visible(&tip, 99), "past TTL must expire");
6307 }
6308
6309 #[test]
6312 fn sticky_header_pins_block_head_when_scrolled_into_body() {
6313 let mut state = RenderState::default();
6316 for i in 0..40u32 {
6317 state.transcript.push(TranscriptLine {
6318 kind: InlineMessageKind::Agent,
6319 segments: vec![plain_segment(format!("body-line-{i:02}"))],
6320 block_id: 0,
6321 });
6322 }
6323 state.scroll_offset = 10;
6324 let rendered = render_frame_to_string(&state);
6325 assert!(
6326 rendered.contains("body-line-00"),
6327 "sticky header must pin the block head when scrolled into the body"
6328 );
6329 }
6330
6331 #[test]
6332 fn sticky_header_absent_when_viewport_at_block_head() {
6333 let mut state = RenderState::default();
6335 for i in 0..40u32 {
6336 state.transcript.push(TranscriptLine {
6337 kind: InlineMessageKind::Agent,
6338 segments: vec![plain_segment(format!("head-line-{i:02}"))],
6339 block_id: 0,
6340 });
6341 }
6342 state.scroll_offset = 0;
6343 let rendered = render_frame_to_string(&state);
6344 assert!(rendered.contains("head-line-00"));
6347 }
6348
6349 #[test]
6352 fn turn_end_drains_queue_head() {
6353 let mut state = RenderState::default();
6354 state.queued_inputs = vec!["queued-1".into(), "queued-2".into()];
6355 state.drain_queue_head();
6356 assert_eq!(
6357 state.queued_inputs.len(),
6358 1,
6359 "drain_queue_head must drop the head (now running)"
6360 );
6361 assert_eq!(state.queued_inputs[0], "queued-2");
6362 }
6363
6364 #[test]
6367 fn render_frame_paints_transcript_content() {
6368 let mut state = RenderState::default();
6372 state.transcript.push(TranscriptLine {
6373 kind: InlineMessageKind::Agent,
6374 segments: vec![plain_segment("frame-content-marker-xyz")],
6375 block_id: 0,
6376 });
6377 let rendered = render_frame_to_string(&state);
6378 assert!(
6379 rendered.contains("frame-content-marker-xyz"),
6380 "render_frame must paint transcript content"
6381 );
6382 }
6383
6384 #[test]
6385 fn file_search_dropdown_renders_results() {
6386 use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
6387 let mut state = RenderState::default();
6388 state.input_enabled = true;
6389 state.file_search = Some(FileSearchState {
6390 query: "main".into(),
6391 at_offset: 0,
6392 hidden_mode: false,
6393 results: vec![
6394 FileSearchResult {
6395 path: "src/main.rs".into(),
6396 score: 100,
6397 },
6398 FileSearchResult {
6399 path: "tests/main.rs".into(),
6400 score: 50,
6401 },
6402 ],
6403 selected: 0,
6404 index: vec![],
6405 line_mode: false,
6406 });
6407 let rendered = render_frame_to_string(&state);
6408 assert!(rendered.contains("FILES"), "dropdown title must render");
6409 assert!(
6410 rendered.contains("src/main.rs"),
6411 "dropdown must show file paths"
6412 );
6413 }
6414
6415 #[test]
6416 fn file_search_dropdown_hidden_mode_title() {
6417 use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
6418 let mut state = RenderState::default();
6419 state.input_enabled = true;
6420 state.file_search = Some(FileSearchState {
6421 query: "".into(),
6422 at_offset: 0,
6423 hidden_mode: true,
6424 results: vec![FileSearchResult {
6425 path: ".env".into(),
6426 score: 0,
6427 }],
6428 selected: 0,
6429 index: vec![],
6430 line_mode: false,
6431 });
6432 let rendered = render_frame_to_string(&state);
6433 assert!(
6434 rendered.contains("HIDDEN"),
6435 "hidden mode must be indicated in title"
6436 );
6437 }
6438
6439 #[test]
6440 fn file_search_and_composer_render_together() {
6441 use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
6442 let mut state = RenderState::default();
6443 state.input_enabled = true;
6444 state.prompt_prefix = "> ".into();
6445 state.composer.set_text("@main");
6446 state.file_search = Some(FileSearchState {
6447 query: "main".into(),
6448 at_offset: 0,
6449 hidden_mode: false,
6450 results: vec![FileSearchResult {
6451 path: "src/main.rs".into(),
6452 score: 100,
6453 }],
6454 selected: 0,
6455 index: vec![],
6456 line_mode: false,
6457 });
6458 let rendered = render_frame_to_string(&state);
6459 assert!(rendered.contains('>'), "composer must still render");
6461 assert!(
6462 rendered.contains("src/main.rs"),
6463 "dropdown must render alongside composer"
6464 );
6465 }
6466
6467 #[test]
6468 fn flatten_todo_items_preserves_order_and_status() {
6469 use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
6470 let phases = vec![
6471 TodoPhase {
6472 name: "A".into(),
6473 tasks: vec![
6474 TodoItem {
6475 content: "write code".into(),
6476 status: TodoStatus::InProgress,
6477 notes: None,
6478 block_reason: None,
6479 },
6480 TodoItem {
6481 content: "write tests".into(),
6482 status: TodoStatus::Pending,
6483 notes: None,
6484 block_reason: None,
6485 },
6486 ],
6487 },
6488 TodoPhase {
6489 name: "B".into(),
6490 tasks: vec![TodoItem {
6491 content: "waiting on review".into(),
6492 status: TodoStatus::Blocked,
6493 notes: None,
6494 block_reason: None,
6495 }],
6496 },
6497 ];
6498 let flat = flatten_todo_items(&phases);
6499 assert_eq!(flat.len(), 3);
6500 assert_eq!(flat[0], ("write code".to_string(), TodoStatus::InProgress));
6501 assert_eq!(flat[1], ("write tests".to_string(), TodoStatus::Pending));
6502 assert_eq!(
6503 flat[2],
6504 ("waiting on review".to_string(), TodoStatus::Blocked)
6505 );
6506 }
6507
6508 #[test]
6509 fn todo_pane_renders_when_items_present() {
6510 let mut state = RenderState::default();
6513 state.todo_items = vec![
6514 ("active task".to_string(), TodoStatus::InProgress),
6515 ("open task".to_string(), TodoStatus::Pending),
6516 ];
6517 let rendered = render_frame_to_string(&state);
6518 assert!(
6519 rendered.contains("active task"),
6520 "in-progress task must render"
6521 );
6522 assert!(rendered.contains("open task"), "pending task must render");
6523 assert!(rendered.contains("now"), "in-progress status must render");
6525 }
6526
6527 #[test]
6528 fn todo_pane_hidden_when_empty() {
6529 let state = RenderState::default();
6530 let rendered = render_frame_to_string(&state);
6531 assert!(!rendered.contains("done"), "no completed state when empty");
6533 }
6534
6535 #[test]
6536 fn render_overlay_secure_input_shows_label_mask_value_and_placeholder() {
6537 use ratatui::{Terminal, backend::TestBackend};
6538 let backend = TestBackend::new(80, 24);
6539 let mut terminal = Terminal::new(backend).unwrap();
6540 let overlay = OverlayState {
6541 title: "OpenAI key".into(),
6542 lines: vec!["Paste your API key".into()],
6543 items: Vec::new(),
6544 selected: 0,
6545 search: None,
6546 secure_input: Some(OverlaySecureInput {
6547 config: SecurePromptConfig {
6548 label: "Key".into(),
6549 placeholder: Some("sk-...".into()),
6550 mask_input: true,
6551 },
6552 editor: oxicode_textarea::EditBuffer::from_parts("sk-abc", 6),
6553 }),
6554 };
6555 terminal
6556 .draw(|f| render_overlay(f, f.area(), &overlay))
6557 .unwrap();
6558 let buf = terminal.backend().buffer().clone();
6559 let text: String = buf
6561 .content()
6562 .iter()
6563 .map(|c| c.symbol())
6564 .collect::<Vec<_>>()
6565 .join("");
6566 assert!(text.contains("Key:"));
6567 assert!(text.contains("******"));
6568 assert!(!text.contains("sk-abc"));
6569 }
6570
6571 #[test]
6572 fn render_overlay_secure_input_placeholder_when_empty() {
6573 use ratatui::{Terminal, backend::TestBackend};
6574 let backend = TestBackend::new(80, 24);
6575 let mut terminal = Terminal::new(backend).unwrap();
6576 let overlay = OverlayState {
6577 title: "OpenAI key".into(),
6578 lines: vec!["Paste your API key".into()],
6579 items: Vec::new(),
6580 selected: 0,
6581 search: None,
6582 secure_input: Some(OverlaySecureInput {
6583 config: SecurePromptConfig {
6584 label: "Key".into(),
6585 placeholder: Some("sk-...".into()),
6586 mask_input: true,
6587 },
6588 editor: oxicode_textarea::EditBuffer::new(),
6589 }),
6590 };
6591 terminal
6592 .draw(|f| render_overlay(f, f.area(), &overlay))
6593 .unwrap();
6594 let buf = terminal.backend().buffer().clone();
6595 let text: String = buf
6596 .content()
6597 .iter()
6598 .map(|c| c.symbol())
6599 .collect::<Vec<_>>()
6600 .join("");
6601 assert!(text.contains("sk-..."));
6602 }
6603 }
6608
6609#[cfg(test)]
6610mod secure_input_tests {
6611 use super::*;
6612 use oxicode_vtui::tui::core::OverlaySubmission;
6613
6614 #[test]
6615 fn overlay_submission_secure_input_is_routed_to_host() {
6616 let _ = OverlaySubmission::SecureInput("sk-test".into());
6619 let serialized = format!("{:?}", OverlaySubmission::SecureInput("x".into()));
6620 assert!(serialized.contains("SecureInput"));
6621 }
6622
6623 #[test]
6624 fn providers_action_matrix_branches_correctly() {
6625 assert_eq!(
6629 next_provider_actions(true, true),
6630 vec![
6631 AuthAction::SetApiKey,
6632 AuthAction::StartOAuth,
6633 AuthAction::RemoveKey,
6634 ],
6635 "has key + oauth-capable: replace, oauth, remove"
6636 );
6637 assert_eq!(
6638 next_provider_actions(true, false),
6639 vec![AuthAction::SetApiKey, AuthAction::RemoveKey],
6640 "has key, key-only provider: replace, remove"
6641 );
6642 assert_eq!(
6643 next_provider_actions(false, true),
6644 vec![AuthAction::SetApiKey, AuthAction::StartOAuth],
6645 "no key + oauth-capable: set key, oauth"
6646 );
6647 assert_eq!(
6648 next_provider_actions(false, false),
6649 vec![AuthAction::SetApiKey],
6650 "no key + key-only provider: set key only"
6651 );
6652 }
6653
6654 fn render_secure_to_text(secure: &OverlaySecureInput) -> String {
6666 use ratatui::{Terminal, backend::TestBackend};
6667 let backend = TestBackend::new(80, 24);
6668 let mut terminal = Terminal::new(backend).unwrap();
6669 let overlay = OverlayState {
6670 title: "OpenAI key".into(),
6671 lines: vec!["Paste your API key".into()],
6672 items: Vec::new(),
6673 selected: 0,
6674 search: None,
6675 secure_input: Some(secure.clone()),
6676 };
6677 terminal
6678 .draw(|f| render_overlay(f, f.area(), &overlay))
6679 .unwrap();
6680 terminal
6681 .backend()
6682 .buffer()
6683 .content()
6684 .iter()
6685 .map(|c| c.symbol())
6686 .collect::<Vec<_>>()
6687 .join("")
6688 }
6689
6690 #[test]
6691 fn masked_render_shows_asterisks_not_value() {
6692 let mut editor = oxicode_textarea::EditBuffer::new();
6696 let _ = editor.insert_str("ABCDE");
6697 let rendered = render_secure_to_text(&OverlaySecureInput {
6698 config: SecurePromptConfig {
6699 label: "Key".into(),
6700 placeholder: Some("sk-...".into()),
6701 mask_input: true,
6702 },
6703 editor,
6704 });
6705 assert!(rendered.contains("*****"), "mask must render asterisks");
6706 assert!(
6707 !rendered.contains("ABCDE"),
6708 "masked render must NEVER carry the real value"
6709 );
6710 assert!(rendered.contains("Key:"), "label prefix must still render");
6711 }
6712
6713 #[test]
6714 fn masked_render_caret_lands_after_mask() {
6715 let mut editor = oxicode_textarea::EditBuffer::new();
6719 let _ = editor.insert_str("ABCD");
6720 let secure = OverlaySecureInput {
6721 config: SecurePromptConfig {
6722 label: "Key".into(),
6723 placeholder: Some("sk-...".into()),
6724 mask_input: true,
6725 },
6726 editor,
6727 };
6728 use ratatui::{Terminal, backend::TestBackend};
6731 let backend = TestBackend::new(80, 24);
6732 let mut terminal = Terminal::new(backend).unwrap();
6733 let overlay = OverlayState {
6734 title: "OpenAI key".into(),
6735 lines: vec!["Paste your API key".into()],
6736 items: Vec::new(),
6737 selected: 0,
6738 search: None,
6739 secure_input: Some(secure.clone()),
6740 };
6741 terminal
6742 .draw(|f| render_overlay(f, f.area(), &overlay))
6743 .unwrap();
6744 let value = secure.editor.text();
6747 let mut ta = oxicode_textarea::TextArea::new();
6748 ta.set_text(value);
6749 ta.replace_range_with_element(
6750 0..value.len(),
6751 value,
6752 MASKED_ELEMENT_KIND,
6753 Some(Line::from("*".repeat(value.chars().count()))),
6754 );
6755 ta.set_cursor(secure.editor.cursor_byte());
6756 let caret = ta
6757 .cursor_pos_with_state(
6758 Rect {
6759 x: 0,
6760 y: 0,
6761 width: 80,
6762 height: 24,
6763 },
6764 oxicode_textarea::TextAreaState::default(),
6765 )
6766 .expect("caret must be visible");
6767 assert_eq!(caret.0, 4);
6770 }
6771
6772 #[test]
6773 fn backspace_removes_previous_grapheme() {
6774 let mut editor = oxicode_textarea::EditBuffer::new();
6780 let _ = editor.insert_str("XYZ");
6781 assert_eq!(editor.text(), "XYZ");
6782 assert_eq!(editor.cursor_byte(), 3);
6783 let _ = editor.apply(oxicode_textarea::EditCommand::DeleteGraphemeBackward);
6784 assert_eq!(editor.text(), "XY");
6785 assert_eq!(editor.cursor_byte(), 2);
6786 let _ = editor.apply(oxicode_textarea::EditCommand::DeleteGraphemeBackward);
6787 assert_eq!(editor.text(), "X");
6788 let _ = editor.apply(oxicode_textarea::EditCommand::DeleteGraphemeBackward);
6789 assert_eq!(editor.text(), "");
6790 assert_eq!(editor.cursor_byte(), 0);
6791 }
6792
6793 #[test]
6794 fn empty_editor_renders_placeholder_not_asterisks() {
6795 let rendered = render_secure_to_text(&OverlaySecureInput {
6797 config: SecurePromptConfig {
6798 label: "Key".into(),
6799 placeholder: Some("sk-...".into()),
6800 mask_input: true,
6801 },
6802 editor: oxicode_textarea::EditBuffer::new(),
6803 });
6804 assert!(rendered.contains("sk-..."));
6805 assert!(!rendered.contains("*"));
6806 }
6807
6808 #[test]
6809 fn paste_filter_drops_newline_and_non_ascii_via_edit_command() {
6810 let mut editor = oxicode_textarea::EditBuffer::new();
6815 let pasted = "sk-xyz\nABC\u{1F600}";
6816 let trimmed = pasted.trim_end_matches('\n');
6817 for ch in trimmed.chars() {
6818 if ch.is_ascii_graphic() || ch == ' ' {
6819 let _ = editor.apply(oxicode_textarea::EditCommand::Insert(ch));
6820 }
6821 }
6822 assert_eq!(editor.text(), "sk-xyzABC");
6823 assert_eq!(editor.cursor_byte(), 9);
6824 }
6825}
6826#[cfg(test)]
6837mod provider_overlay_tests {
6838 use super::*;
6839 use crate::app::agent_session::{AgentSession, AgentSessionHandle};
6840 use crate::store::session::SessionManager;
6841 use crate::store::settings::Settings;
6842 use oxicode_agent::{Agent, AgentConfig};
6843 use oxicode_sdk::{Provider, ProviderError, ProviderEvent};
6844 use oxicode_vtui::tui::core::OverlayEvent;
6845 use std::pin::Pin;
6846 use std::sync::Arc;
6847 use std::task::{Context as TaskContext, Poll};
6848
6849 struct EmptyStream;
6852 impl futures::Stream for EmptyStream {
6853 type Item = ProviderEvent;
6854 fn poll_next(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
6855 Poll::Ready(None)
6856 }
6857 }
6858
6859 struct StubProvider;
6860 impl Provider for StubProvider {
6861 fn stream<'a>(
6862 &'a self,
6863 _model: &'a oxicode_sdk::Model,
6864 _context: &'a oxicode_sdk::Context,
6865 _options: Option<oxicode_sdk::StreamOptions>,
6866 ) -> Pin<
6867 Box<
6868 dyn Future<
6869 Output = Result<
6870 Pin<Box<dyn futures::Stream<Item = ProviderEvent> + Send>>,
6871 ProviderError,
6872 >,
6873 > + Send
6874 + 'a,
6875 >,
6876 > {
6877 Box::pin(async move {
6878 Ok::<_, ProviderError>(Box::pin(EmptyStream)
6879 as Pin<Box<dyn futures::Stream<Item = ProviderEvent> + Send>>)
6880 })
6881 }
6882 }
6883
6884 fn make_session() -> AgentSessionHandle {
6885 let provider = Arc::new(StubProvider);
6886 let config = AgentConfig::new("anthropic/claude-sonnet-4-20250514");
6887 let agent = Arc::new(Agent::new(
6888 provider,
6889 config,
6890 Arc::new(oxicode_agent::ToolRegistry::new()),
6891 ));
6892 let settings = Settings::default();
6893 let session_manager = SessionManager::in_memory("/tmp/test_providers");
6894 let session = AgentSession::new(
6895 agent,
6896 settings,
6897 session_manager,
6898 "/tmp/test_providers".to_string(),
6899 crate::SessionState::default(),
6900 );
6901 session.clone_handle()
6902 }
6903
6904 fn summarise(cmds: &[InlineCommand]) -> String {
6907 let mut show = 0;
6908 let mut close = 0;
6909 let mut other = 0;
6910 for c in cmds {
6911 match c {
6912 InlineCommand::ShowOverlay { .. } => show += 1,
6913 InlineCommand::CloseOverlay => close += 1,
6914 _ => other += 1,
6915 }
6916 }
6917 format!("[ShowOverlay={show}, CloseOverlay={close}, other={other}]")
6918 }
6919
6920 #[test]
6927 fn provider_row_opens_action_menu_without_close() {
6928 let session = make_session();
6932 let mut state = RenderState::default();
6933 state.overlay_providers = vec!["openai".to_string()];
6934 state.overlay = Some(OverlayState {
6935 title: "Providers".to_string(),
6936 lines: Vec::new(),
6937 items: Vec::new(),
6938 selected: 0,
6939 search: None,
6940 secure_input: None,
6941 });
6942
6943 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
6944 let handle = InlineHandle::new_for_tests(cmd_tx);
6945 let prompt_queue = Arc::new(PromptQueue::default());
6946
6947 let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
6948 InlineListSelection::ProviderRow(0),
6949 )));
6950 let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
6951
6952 let cmds: Vec<InlineCommand> = {
6953 let mut out = Vec::new();
6954 while let Ok(cmd) = cmd_rx.try_recv() {
6955 out.push(cmd);
6956 }
6957 out
6958 };
6959 let show_count = cmds
6960 .iter()
6961 .filter(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
6962 .count();
6963 assert_eq!(
6964 show_count,
6965 1,
6966 "submitting a provider row must ShowOverlay exactly once (commands: {})",
6967 summarise(&cmds)
6968 );
6969
6970 let show_idx = cmds
6974 .iter()
6975 .position(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
6976 .expect("ShowOverlay must be present");
6977 let trailing = &cmds[show_idx + 1..];
6978 assert!(
6979 !trailing
6980 .iter()
6981 .any(|c| matches!(c, InlineCommand::CloseOverlay)),
6982 "no CloseOverlay may follow the action-menu ShowOverlay (commands: {})",
6983 summarise(&cmds)
6984 );
6985
6986 assert!(
6989 state.overlay_providers.is_empty(),
6990 "overlay_providers must be cleared after dispatch (got {:?})",
6991 state.overlay_providers
6992 );
6993 }
6994
6995 #[test]
7002 fn provider_row_set_api_key_opens_secure_prompt_without_close() {
7003 let session = make_session();
7005 let mut state = RenderState::default();
7006 state.overlay_providers = vec!["cerebras".to_string()];
7007 state.overlay = Some(OverlayState {
7008 title: "Providers".to_string(),
7009 lines: Vec::new(),
7010 items: Vec::new(),
7011 selected: 0,
7012 search: None,
7013 secure_input: None,
7014 });
7015
7016 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
7017 let handle = InlineHandle::new_for_tests(cmd_tx);
7018 let prompt_queue = Arc::new(PromptQueue::default());
7019
7020 let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
7021 InlineListSelection::ProviderRow(0),
7022 )));
7023 let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
7024
7025 let cmds: Vec<InlineCommand> = {
7026 let mut out = Vec::new();
7027 while let Ok(cmd) = cmd_rx.try_recv() {
7028 out.push(cmd);
7029 }
7030 out
7031 };
7032 let show_count = cmds
7033 .iter()
7034 .filter(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
7035 .count();
7036 assert_eq!(
7037 show_count,
7038 1,
7039 "submitting a provider row must ShowOverlay exactly once (commands: {})",
7040 summarise(&cmds)
7041 );
7042
7043 let show_idx = cmds
7044 .iter()
7045 .position(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
7046 .expect("ShowOverlay must be present");
7047 let trailing = &cmds[show_idx + 1..];
7048 assert!(
7049 !trailing
7050 .iter()
7051 .any(|c| matches!(c, InlineCommand::CloseOverlay)),
7052 "no CloseOverlay may follow the secure-prompt ShowOverlay (commands: {})",
7053 summarise(&cmds)
7054 );
7055
7056 assert_eq!(
7060 state.secure_input_origin,
7061 Some(SecureInputOrigin::SetKey {
7062 provider: "cerebras".to_string(),
7063 }),
7064 "secure_input_origin must be stashed by SetApiKey"
7065 );
7066 }
7067
7068 #[test]
7072 fn catalog_model_selection_still_closes_overlay() {
7073 let session = make_session();
7074 let mut state = RenderState::default();
7075 state.overlay_catalog_models = vec![(
7076 "anthropic".to_string(),
7077 "claude-sonnet-4-20250514".to_string(),
7078 )];
7079 state.overlay = Some(OverlayState {
7080 title: "Models".to_string(),
7081 lines: Vec::new(),
7082 items: Vec::new(),
7083 selected: 0,
7084 search: None,
7085 secure_input: None,
7086 });
7087
7088 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
7089 let handle = InlineHandle::new_for_tests(cmd_tx);
7090 let prompt_queue = Arc::new(PromptQueue::default());
7091
7092 let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
7093 InlineListSelection::CatalogModel(0),
7094 )));
7095 let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
7096
7097 let cmds: Vec<InlineCommand> = {
7098 let mut out = Vec::new();
7099 while let Ok(cmd) = cmd_rx.try_recv() {
7100 out.push(cmd);
7101 }
7102 out
7103 };
7104 assert!(
7105 cmds.iter()
7106 .any(|c| matches!(c, InlineCommand::CloseOverlay)),
7107 "catalog model selection must close the overlay (commands: {})",
7108 summarise(&cmds)
7109 );
7110 }
7111
7112 fn secure_input_origin_carries_provider_independently_of_variant() {
7118 let set = SecureInputOrigin::SetKey {
7119 provider: "openai".to_string(),
7120 };
7121 let added = SecureInputOrigin::NewlyAdded {
7122 provider: "minimax".to_string(),
7123 };
7124 assert_eq!(
7128 match &set {
7129 SecureInputOrigin::SetKey { provider }
7130 | SecureInputOrigin::NewlyAdded { provider } => provider,
7131 },
7132 "openai"
7133 );
7134 assert_eq!(
7135 match &added {
7136 SecureInputOrigin::SetKey { provider }
7137 | SecureInputOrigin::NewlyAdded { provider } => provider,
7138 },
7139 "minimax"
7140 );
7141 assert_ne!(set, added);
7143 }
7144
7145 #[test]
7154 fn session_picker_resume_refused_while_streaming() {
7155 let session = make_session();
7156 session
7160 .streaming_flag()
7161 .store(true, std::sync::atomic::Ordering::SeqCst);
7162
7163 let mut state = RenderState::default();
7164 assert!(
7166 state.pending_resume.is_none(),
7167 "precondition: pending_resume must start None"
7168 );
7169
7170 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
7171 let handle = InlineHandle::new_for_tests(cmd_tx);
7172 let prompt_queue = Arc::new(PromptQueue::default());
7173
7174 let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
7175 InlineListSelection::Session("some-id".to_string()),
7176 )));
7177 let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
7178
7179 assert!(
7181 state.pending_resume.is_none(),
7182 "streaming session must not enqueue pending_resume (got {:?})",
7183 state.pending_resume
7184 );
7185
7186 let mut cmds: Vec<InlineCommand> = Vec::new();
7188 while let Ok(cmd) = cmd_rx.try_recv() {
7189 cmds.push(cmd);
7190 }
7191 let mut found_error = false;
7192 let mut error_text = String::new();
7193 for cmd in &cmds {
7194 if let InlineCommand::AppendLine { kind, segments } = cmd
7195 && matches!(kind, InlineMessageKind::Error)
7196 {
7197 error_text = segments
7198 .iter()
7199 .map(|s| s.text.as_str())
7200 .collect::<Vec<_>>()
7201 .join("");
7202 found_error = true;
7203 }
7204 }
7205 assert!(
7206 found_error,
7207 "expected an error AppendLine (commands: {})",
7208 summarise(&cmds)
7209 );
7210 assert!(
7211 error_text.contains("Cannot resume while agent is running"),
7212 "error text must match the direct-path wording (got {error_text:?})"
7213 );
7214
7215 session
7218 .streaming_flag()
7219 .store(false, std::sync::atomic::Ordering::SeqCst);
7220 }
7221}