1#![allow(
2 clippy::field_reassign_with_default,
3 clippy::let_and_return,
4 clippy::borrow_interior_mutable_const,
5 clippy::derivable_impls
6)]
7use std::io::{self, Stdout, Write};
11use std::path::PathBuf;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use anyhow::Result;
16use crossterm::{
17 cursor::{Hide, Show},
18 event::{
19 self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEventKind,
20 KeyModifiers, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
21 PushKeyboardEnhancementFlags,
22 },
23 execute,
24 terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate, disable_raw_mode, enable_raw_mode},
25};
26use oxicode_agent::AgentEvent;
27use oxicode_agent::config::Mode;
28use oxicode_agent::tools::TodoStateProvider;
29use oxicode_agent::tools::todo::TodoStatus;
30use oxicode_vtui::theme::{ThemeStyles, active_styles};
31use oxicode_vtui::tui::core::{
32 AuthAction, InlineCommand, InlineEvent, InlineHandle, InlineHeaderContext,
33 InlineHeaderStatusBadge, InlineHeaderStatusTone, InlineListItem, InlineListSelection,
34 InlineMessageKind, InlineSegment, InlineTextStyle, OverlayRequest, OverlaySubmission,
35 SecurePromptConfig,
36};
37use ratatui::{
38 Frame, Terminal,
39 backend::CrosstermBackend,
40 layout::{Alignment, Rect},
41 style::{Color, Modifier, Style},
42 text::{Line, Span},
43 widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph, Wrap},
44};
45
46use crate::App;
47use crate::app::agent_hub_registry::HubEntry;
48use crate::app::agent_session::SessionEvent;
49use crate::tui_vt::slash::file_commands::FileCommand;
50use crate::tui_vt::slash::registry::{SlashCtx, SlashOutcome, SlashRegistry};
51
52pub struct Tui {
63 terminal: Terminal<CrosstermBackend<Stdout>>,
64 tty_ok: bool,
65}
66
67impl Tui {
68 pub fn enter() -> Result<Self> {
71 Self::set_panic_hook();
72
73 let tty_ok = enable_raw_mode().is_ok();
74 let mut stdout = io::stdout();
75
76 if tty_ok {
77 let flags = if std::env::var("OXICODE_KITTY_KEYBOARD").as_deref() == Ok("1") {
81 KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
82 | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
83 | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
84 } else {
85 KeyboardEnhancementFlags::REPORT_EVENT_TYPES
86 };
87 let _ = execute!(
88 stdout,
89 Hide,
90 EnableBracketedPaste,
91 PushKeyboardEnhancementFlags(flags)
92 );
93 let _ = stdout.flush();
94 }
95
96 let backend = CrosstermBackend::new(stdout);
97 let mut terminal = Terminal::new(backend)?;
98 if tty_ok {
99 let _ = terminal.clear();
100 }
101
102 Ok(Self { terminal, tty_ok })
103 }
104
105 pub fn exit(&mut self) -> Result<()> {
109 if self.tty_ok {
110 let _ = execute!(
111 self.terminal.backend_mut(),
112 PopKeyboardEnhancementFlags,
113 DisableBracketedPaste
114 );
115 let _ = self.terminal.show_cursor();
116 disable_raw_mode()?;
118 self.tty_ok = false;
119 }
120 Ok(())
121 }
122
123 fn set_panic_hook() {
127 let original_hook = std::panic::take_hook();
128 std::panic::set_hook(Box::new(move |panic_info| {
129 let _ = execute!(io::stdout(), Show);
130 let _ = disable_raw_mode();
131 original_hook(panic_info);
132 }));
133 }
134}
135
136impl Drop for Tui {
137 fn drop(&mut self) {
138 let _ = self.exit();
139 }
140}
141
142#[derive(Default)]
149pub struct RenderState {
150 pub input_buffer: String,
152 pub input_cursor: usize,
154 pub transcript: Vec<TranscriptLine>,
156 pub scroll_offset: usize,
159 pub header_context: InlineHeaderContext,
161 pub input_enabled: bool,
163 pub footer_left: Option<String>,
165 pub footer_right: Option<String>,
166 pub prompt_prefix: String,
168 pub placeholder: Option<String>,
170 pub shutdown_requested: bool,
172 pub message_buffer: String,
174 pub agent_hub_open: bool,
176 pub hub_entries: Vec<(String, HubEntry)>,
178 pub pending_quit: bool,
180 pub slash_popup: SlashPopup,
182 pub reasoning_stage: Option<String>,
184 pub overlay: Option<OverlayState>,
186 pub overlay_model_ids: Vec<String>,
188 pub overlay_catalog_models: Vec<(String, String)>,
191 pub overlay_providers: Vec<String>,
193 pub catalog: Option<std::sync::Arc<dyn oxicode_sdk::ports::catalog::ModelCatalog>>,
196 pub queued_inputs: Vec<String>,
198 pub queue_panel_open: bool,
200 pub queue_selected: usize,
202 pub shell_mode: bool,
204 pub follow_ups: Vec<String>,
206 pub todo_items: Vec<(String, TodoStatus)>,
208 pub todo_provider: Option<Arc<dyn TodoStateProvider>>,
211 pub vim_state: oxicode_vtui::vim::VimState,
213 pub vim_clipboard: String,
215 pub search: Option<SearchState>,
217 pub block_display: std::collections::HashMap<usize, BlockDisplayMode>,
220 pub last_esc_at: Option<std::time::Instant>,
222 pub multiline_mode: bool,
224 pub autonomy_mode: Mode,
228 pub prompt_history: Vec<String>,
230 pub history_pos: Option<usize>,
232 pub next_block_id: usize,
234 pub cancel_grace_until: Option<std::time::Instant>,
237 pub confirmation: Option<ModalConfirmation>,
241 pub tip: Option<EphemeralTip>,
244 pub cwd: PathBuf,
246 pub file_search: Option<crate::tui_vt::file_search::FileSearchState>,
248 pub seen_tips: std::collections::HashMap<&'static str, u32>,
250 pub file_commands: Vec<FileCommand>,
253 pub secure_input_target: Option<String>,
258}
259
260#[derive(Debug, Clone)]
262pub struct TranscriptLine {
263 pub kind: InlineMessageKind,
264 pub segments: Vec<InlineSegment>,
265 pub block_id: usize,
268}
269
270#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
276pub enum BlockDisplayMode {
277 Collapsed,
279 #[default]
281 Truncated,
282 Expanded,
284}
285
286#[derive(Clone, Debug)]
288pub struct SearchState {
289 pub query: String,
290 pub matches: Vec<usize>,
292 pub current: usize,
294}
295
296#[derive(Clone)]
298pub struct SlashPopupItem {
299 pub label: String,
301 pub description: String,
303 pub name: String,
305}
306
307#[derive(Default, Clone)]
312pub struct SlashPopup {
313 pub open: bool,
314 pub items: Vec<SlashPopupItem>,
315 pub selected: usize,
316}
317
318#[derive(Clone, Debug)]
322pub struct OverlayListItem {
323 pub title: String,
324 pub subtitle: Option<String>,
325 pub badge: Option<String>,
326 pub indent: u8,
327 pub search_value: Option<String>,
328 pub selection: Option<oxicode_vtui::tui::core::InlineListSelection>,
330}
331
332#[derive(Clone, Debug)]
337pub struct OverlayState {
338 pub title: String,
339 pub lines: Vec<String>,
340 pub items: Vec<OverlayListItem>,
341 pub selected: usize,
342 pub search: Option<OverlaySearchState>,
343 pub secure_input: Option<OverlaySecureInput>,
344}
345
346#[derive(Clone, Debug)]
351pub struct OverlaySecureInput {
352 pub config: SecurePromptConfig,
353 pub value: String,
354 pub cursor: usize,
355}
356
357#[derive(Clone, Debug)]
361pub struct ModalConfirmation {
362 pub title: String,
363 pub message: String,
364 pub action: ConfirmationAction,
367}
368
369#[derive(Clone, Debug, PartialEq, Eq)]
371pub enum ConfirmationAction {
372 Quit,
374 ClearConversation,
376 RemoveProviderKey(String),
378}
379
380#[derive(Clone, Debug)]
384pub struct EphemeralTip {
385 pub text: String,
386 pub born_tick: u64,
388 pub ttl_ticks: u64,
390 pub key: &'static str,
393 pub ambient: bool,
397}
398
399#[derive(Clone, Debug)]
401pub struct OverlaySearchState {
402 pub label: String,
403 pub placeholder: Option<String>,
404 pub value: String,
405}
406
407impl RenderState {
408 fn new_with_header(header: InlineHeaderContext) -> Self {
409 let mut s = Self::default();
410 s.header_context = header;
411 s.prompt_prefix = "> ".to_string();
412 s.input_enabled = true;
413 s
414 }
415
416 fn append_line(&mut self, kind: InlineMessageKind, segments: Vec<InlineSegment>) {
418 let block_id = self.block_id_for_kind(kind);
419 self.transcript.push(TranscriptLine {
420 kind,
421 segments,
422 block_id,
423 });
424 }
425
426 fn inline_segment(&mut self, kind: InlineMessageKind, segment: InlineSegment) {
430 if let Some(last) = self.transcript.last_mut()
431 && last.kind == kind
432 {
433 last.segments.push(segment);
434 return;
435 }
436 let block_id = self.block_id_for_kind(kind);
437 self.transcript.push(TranscriptLine {
438 kind,
439 segments: vec![segment],
440 block_id,
441 });
442 }
443
444 fn block_id_for_kind(&mut self, kind: InlineMessageKind) -> usize {
447 if let Some(last) = self.transcript.last()
448 && last.kind == kind
449 {
450 return last.block_id;
451 }
452 let id = self.next_block_id;
453 self.next_block_id += 1;
454 id
455 }
456
457 pub fn start_search(&mut self, query: &str) {
461 let needle = query.to_lowercase();
462 let matches: Vec<usize> = self
463 .transcript
464 .iter()
465 .enumerate()
466 .filter(|(_, line)| {
467 line.segments
468 .iter()
469 .any(|s| s.text.to_lowercase().contains(&needle))
470 })
471 .map(|(i, _)| i)
472 .collect();
473 self.search = Some(SearchState {
474 query: query.to_string(),
475 matches,
476 current: 0,
477 });
478 if let Some(s) = &self.search
480 && let Some(&first) = s.matches.first()
481 {
482 self.scroll_offset = first;
483 }
484 }
485
486 pub fn search_next(&mut self) {
488 if let Some(s) = &mut self.search
489 && !s.matches.is_empty()
490 {
491 s.current = (s.current + 1) % s.matches.len();
492 let line = s.matches[s.current];
493 self.scroll_offset = line;
494 }
495 }
496
497 pub fn search_prev(&mut self) {
499 if let Some(s) = &mut self.search
500 && !s.matches.is_empty()
501 {
502 if s.current == 0 {
503 s.current = s.matches.len() - 1;
504 } else {
505 s.current -= 1;
506 }
507 let line = s.matches[s.current];
508 self.scroll_offset = line;
509 }
510 }
511
512 pub fn block_mode(&self, block_id: usize) -> BlockDisplayMode {
516 self.block_display
517 .get(&block_id)
518 .copied()
519 .unwrap_or_default()
520 }
521
522 pub fn cycle_block_at_view(&mut self) {
525 let offset = self.effective_offset();
526 if let Some(line) = self.transcript.get(offset) {
527 let bid = line.block_id;
528 let next = match self.block_mode(bid) {
529 BlockDisplayMode::Collapsed => BlockDisplayMode::Truncated,
530 BlockDisplayMode::Truncated => BlockDisplayMode::Expanded,
531 BlockDisplayMode::Expanded => BlockDisplayMode::Collapsed,
532 };
533 if next == BlockDisplayMode::Truncated {
536 self.block_display.remove(&bid);
537 } else {
538 self.block_display.insert(bid, next);
539 }
540 }
541 }
542
543 pub fn expand_all(&mut self) {
545 for bid in self.all_block_ids() {
546 self.block_display.insert(bid, BlockDisplayMode::Expanded);
547 }
548 }
549
550 pub fn fold_all(&mut self) {
552 for bid in self.all_block_ids() {
553 self.block_display.insert(bid, BlockDisplayMode::Collapsed);
554 }
555 }
556
557 pub fn truncate_all(&mut self) {
559 self.block_display.clear();
560 }
561
562 fn all_block_ids(&self) -> Vec<usize> {
564 let mut ids = Vec::new();
565 let mut prev: Option<usize> = None;
566 for l in &self.transcript {
567 if prev != Some(l.block_id) {
568 ids.push(l.block_id);
569 prev = Some(l.block_id);
570 }
571 }
572 ids
573 }
574
575 pub fn jump_next_turn(&mut self) {
579 let offset = self.effective_offset();
580 let search_after = self
581 .transcript
582 .iter()
583 .enumerate()
584 .skip(offset + 1)
585 .find(|(_, l)| l.kind == InlineMessageKind::Agent || l.kind == InlineMessageKind::User);
586 if let Some((idx, _)) = search_after {
587 self.scroll_offset = idx;
588 }
589 }
590
591 pub fn jump_prev_turn(&mut self) {
593 let offset = self.effective_offset();
594 let search_before = self
595 .transcript
596 .iter()
597 .enumerate()
598 .take(offset)
599 .rev()
600 .find(|(_, l)| l.kind == InlineMessageKind::User);
601 if let Some((idx, _)) = search_before {
602 self.scroll_offset = idx;
603 }
604 }
605
606 fn effective_offset(&self) -> usize {
608 if self.scroll_offset == usize::MAX {
609 self.transcript.len().saturating_sub(1)
610 } else {
611 self.scroll_offset
612 }
613 }
614
615 pub fn drain_queue_head(&mut self) {
618 if !self.queued_inputs.is_empty() {
619 self.queued_inputs.remove(0);
620 }
621 }
622
623 pub fn show_tip(&mut self, key: &'static str, text: &str, ttl: u64, ambient: bool) {
626 let count = self.seen_tips.entry(key).or_insert(0);
627 if *count >= SEEN_CAP {
628 return;
629 }
630 *count += 1;
631 self.tip = Some(EphemeralTip {
632 text: text.to_string(),
633 born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
634 ttl_ticks: ttl,
635 key,
636 ambient,
637 });
638 }
639}
640
641const SEEN_CAP: u32 = 3;
643
644pub async fn run_tui(app: App) -> Result<()> {
651 let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
654 let git_branch = crate::util::git_utils::get_current_branch(&cwd);
655 super::host::activate_theme(app.settings());
656 let theme_id = oxicode_vtui::theme::active_theme_id();
658 let validation = oxicode_vtui::theme::validate_theme_contrast(&theme_id);
659 if validation.warnings.is_empty() {
660 tracing::debug!("theme '{theme_id}' passed contrast validation");
661 } else {
662 for w in &validation.warnings {
663 tracing::warn!("theme contrast: {w}");
664 }
665 }
666
667 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
670 let (evt_tx, mut evt_rx) = tokio::sync::mpsc::unbounded_channel::<InlineEvent>();
671 let handle = InlineHandle::new_for_tests(cmd_tx);
672
673 let session = build_agent_session(&app).await?;
677 let session_handle = session.clone_handle();
681
682 let (session_tx, mut session_rx) = tokio::sync::mpsc::unbounded_channel::<SessionEvent>();
690 let _sub_guard = session.subscribe(Box::new(move |event| {
691 let _ = session_tx.send(event.clone());
692 }));
693
694 let header = build_header_context(&app, &cwd, git_branch.as_deref());
696 handle.set_header_context(header.clone());
697
698 let mut tui = Tui::enter()?;
701
702 handle.set_prompt("> ".to_string(), InlineTextStyle::default());
706 handle.set_placeholder(Some("Describe what you want to build\u{2026}".to_string()));
707
708 let state = Arc::new(parking_lot::Mutex::new(RenderState::new_with_header(
711 header,
712 )));
713 state.lock().cwd = cwd.clone();
714 state.lock().catalog = Some(app.catalog());
715 state.lock().file_commands = crate::tui_vt::slash::file_commands::load_file_commands(&cwd);
716 state.lock().todo_provider = session_handle.todo_provider();
717 state.lock().tip = Some(EphemeralTip {
720 text: "Press ? for shortcuts \u{00b7} /help for commands".to_string(),
721 born_tick: 0,
722 ttl_ticks: 900,
723 key: "onboarding",
724 ambient: true,
725 });
726 if std::env::var("SSH_CONNECTION").is_ok() {
728 state.lock().show_tip(
729 "ssh_wrap",
730 "Over SSH? Consider tmux to keep sessions alive",
731 600,
732 true,
733 );
734 }
735 let mode_handle = app.ask_bridge().map(|b| {
739 let handle = b.mode_handle();
740 state.lock().autonomy_mode = Mode::load(&handle);
741 handle
742 });
743 spawn_input_thread(state.clone(), evt_tx.clone(), mode_handle);
744
745 let prompt_tx = spawn_agent_worker(session_handle.clone());
751
752 let result = run_event_loop(
753 &mut tui.terminal,
754 &mut cmd_rx,
755 &mut evt_rx,
756 &mut session_rx,
757 &handle,
758 &state,
759 &session_handle,
760 prompt_tx.clone(),
761 )
762 .await;
763
764 drop(prompt_tx);
767 handle.shutdown();
768 drop(tui);
770
771 result
772}
773
774#[allow(clippy::too_many_arguments)]
779async fn run_event_loop(
780 terminal: &mut Terminal<CrosstermBackend<Stdout>>,
781 cmd_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineCommand>,
782 evt_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineEvent>,
783 session_rx: &mut tokio::sync::mpsc::UnboundedReceiver<SessionEvent>,
784 handle: &InlineHandle,
785 state: &Arc<parking_lot::Mutex<RenderState>>,
786 session: &crate::app::agent_session::AgentSessionHandle,
787 prompt_tx: tokio::sync::mpsc::UnboundedSender<String>,
788) -> Result<()> {
789 while let Ok(cmd) = cmd_rx.try_recv() {
792 apply_command(&mut state.lock(), cmd);
793 }
794
795 {
800 let snapshot = state.lock();
801 let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
802 if let Err(err) = terminal.draw(|frame| render_frame(frame, &snapshot, handle)) {
803 tracing::warn!(?err, "initial tui draw failed");
804 }
805 let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
806 }
807
808 let mut render_tick = tokio::time::interval(std::time::Duration::from_millis(50));
814 render_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
815
816 loop {
817 tokio::select! {
818 biased;
821
822 Some(cmd) = cmd_rx.recv() => {
824 let shutdown = {
825 let mut s = state.lock();
826 apply_command(&mut s, cmd)
827 };
828 if shutdown {
829 break;
830 }
831 }
832
833 Some(event) = session_rx.recv() => {
835 handle_session_event(&mut state.lock(), handle, &event);
836 }
837
838 Some(evt) = evt_rx.recv() => {
840 let outcome = handle_inline_event(
841 &mut state.lock(),
842 handle,
843 session,
844 &prompt_tx,
845 evt,
846 );
847 if outcome == LoopOutcome::Exit {
848 break;
849 }
850 }
851
852 _ = tokio::signal::ctrl_c() => {
856 let outcome = {
857 let mut s = state.lock();
858 handle_interrupt(&mut s, session, handle)
859 };
860 if outcome == LoopOutcome::Exit {
861 break;
862 }
863 }
864
865 _ = render_tick.tick() => {}
868 }
869
870 if let Ok(size) = terminal.size()
872 && size.width < 40
873 {
874 let mut s = state.lock();
875 if s.tip.is_none() {
876 s.show_tip(
877 "small_screen",
878 "Terminal too narrow \u{2014} resize for full UI",
879 300,
880 true,
881 );
882 }
883 }
884 let mut snapshot = state.lock();
887 if let Some(provider) = snapshot.todo_provider.as_ref() {
890 snapshot.todo_items = flatten_todo_items(&provider.get_phases());
891 }
892 let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
893 let draw_err = terminal
894 .draw(|frame| render_frame(frame, &snapshot, handle))
895 .err();
896 let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
897 if let Some(err) = draw_err {
898 tracing::warn!(?err, "tui draw failed");
899 break;
900 }
901 }
902
903 Ok(())
904}
905
906#[derive(PartialEq, Eq)]
907enum LoopOutcome {
908 Continue,
909 Exit,
910}
911
912#[derive(PartialEq, Eq, Debug)]
917enum CancelRoute {
918 Interrupt,
921 Exit,
923}
924
925fn route_cancel(is_streaming: bool) -> CancelRoute {
928 if is_streaming {
929 CancelRoute::Interrupt
930 } else {
931 CancelRoute::Exit
932 }
933}
934
935fn apply_command(state: &mut RenderState, cmd: InlineCommand) -> bool {
942 match cmd {
943 InlineCommand::AppendLine { kind, segments } => {
944 state.append_line(kind, segments);
945 }
946 InlineCommand::Inline { kind, segment } => {
947 state.inline_segment(kind, segment);
948 }
949 InlineCommand::ReplaceLast {
950 count, kind, lines, ..
951 } => {
952 let drop = count.min(state.transcript.len());
954 for _ in 0..drop {
955 state.transcript.pop();
956 }
957 for line in lines {
958 state.append_line(kind, line);
959 }
960 }
961 InlineCommand::AppendPastedMessage { kind, text, .. } => {
962 state.append_line(kind, vec![plain_segment(text)]);
963 }
964 InlineCommand::SetPrompt { prefix, .. } => {
965 state.prompt_prefix = prefix;
966 }
967 InlineCommand::SetPlaceholder { hint, .. } => {
968 state.placeholder = hint;
969 }
970 InlineCommand::SetHeaderContext { context } => {
971 state.header_context = *context;
972 }
973 InlineCommand::SetInputStatus { left, right } => {
974 state.footer_left = left;
975 state.footer_right = right;
976 }
977 InlineCommand::SetInputEnabled(enabled) => {
978 state.input_enabled = enabled;
979 }
980 InlineCommand::SetCursorVisible(_) | InlineCommand::ForceRedraw => {}
981 InlineCommand::SetReasoningStage(stage) => {
982 state.reasoning_stage = stage;
983 }
984 InlineCommand::SetVimModeEnabled(enabled) => {
985 state.vim_state.set_enabled(enabled);
986 }
987 InlineCommand::SetQueuedInputs { entries } => {
988 state.queued_inputs = entries;
989 }
990 InlineCommand::ShowOverlay { request } => {
991 state.overlay = Some(materialize_overlay(*request));
992 }
993 InlineCommand::CloseOverlay => {
994 state.overlay = None;
995 }
996 InlineCommand::Shutdown => {
997 state.shutdown_requested = true;
998 return true;
999 }
1000 _ => {
1001 tracing::trace!("unhandled InlineCommand (not rendered)");
1004 }
1005 }
1006 false
1007}
1008
1009fn materialize_overlay(request: OverlayRequest) -> OverlayState {
1014 match request {
1015 OverlayRequest::Modal(req) => {
1016 let secure_input = req.secure_prompt.map(|cfg| OverlaySecureInput {
1017 config: cfg,
1018 value: String::new(),
1019 cursor: 0,
1020 });
1021 OverlayState {
1022 title: req.title,
1023 lines: req.lines,
1024 items: Vec::new(),
1025 selected: 0,
1026 search: None,
1027 secure_input,
1028 }
1029 }
1030 OverlayRequest::List(req) => {
1031 let search = req.search.map(|cfg| OverlaySearchState {
1032 label: cfg.label,
1033 placeholder: cfg.placeholder,
1034 value: String::new(),
1035 });
1036 OverlayState {
1037 title: req.title,
1038 lines: req.lines,
1039 items: req.items.into_iter().map(overlay_item_from).collect(),
1040 selected: 0,
1041 search,
1042 secure_input: None,
1043 }
1044 }
1045 OverlayRequest::Wizard(req) => {
1046 let step_items = req
1050 .steps
1051 .first()
1052 .map(|s| {
1053 s.items
1054 .iter()
1055 .map(|it| overlay_item_from(it.clone()))
1056 .collect()
1057 })
1058 .unwrap_or_default();
1059 let search = req.search.map(|cfg| OverlaySearchState {
1060 label: cfg.label,
1061 placeholder: cfg.placeholder,
1062 value: String::new(),
1063 });
1064 OverlayState {
1065 title: req.title,
1066 lines: Vec::new(),
1067 items: step_items,
1068 selected: 0,
1069 search,
1070 secure_input: None,
1071 }
1072 }
1073 }
1074}
1075fn overlay_item_from(item: InlineListItem) -> OverlayListItem {
1076 OverlayListItem {
1077 title: item.title,
1078 subtitle: item.subtitle,
1079 badge: item.badge,
1080 indent: item.indent,
1081 search_value: item.search_value,
1082 selection: item.selection,
1083 }
1084}
1085
1086fn handle_session_event(state: &mut RenderState, handle: &InlineHandle, event: &SessionEvent) {
1090 match event {
1091 SessionEvent::Agent(boxed) => {
1092 map_agent_event(handle, *boxed.clone(), state);
1093 }
1094 SessionEvent::CompactionStart { .. } => {
1095 handle.set_reasoning_stage(Some("Compacting\u{2026}".to_string()));
1096 }
1097 SessionEvent::CompactionEnd { error_message, .. } => {
1098 handle.set_reasoning_stage(None);
1099 if let Some(msg) = error_message {
1100 handle.append_line(
1101 InlineMessageKind::Error,
1102 vec![plain_segment(format!("Compaction failed: {msg}"))],
1103 );
1104 }
1105 }
1106 SessionEvent::ThinkingLevelChanged { .. } => {
1107 }
1110 SessionEvent::QueueUpdate { .. } => {
1111 let pending = state.transcript.len();
1115 handle.set_input_status(
1116 None,
1117 Some(if pending == 0 {
1118 "ready".to_string()
1119 } else {
1120 "queued".to_string()
1121 }),
1122 );
1123 }
1124 SessionEvent::Advisor { body, .. } => {
1125 handle.append_line(InlineMessageKind::Info, vec![plain_segment(body.clone())]);
1126 }
1127 SessionEvent::SessionInfoChanged => {
1128 }
1131 }
1132}
1133
1134fn map_agent_event(handle: &InlineHandle, event: AgentEvent, state: &mut RenderState) {
1136 match event {
1137 AgentEvent::TextChunk { text } => {
1138 state.message_buffer.push_str(&text);
1139 handle.inline(InlineMessageKind::Agent, plain_segment(text));
1140 }
1141 AgentEvent::MessageStart { .. } => {
1142 state.message_buffer.clear();
1143 }
1144 AgentEvent::MessageUpdate { delta, .. } => match &delta {
1145 oxicode_sdk::StreamDelta::Text(text) => {
1146 state.message_buffer.push_str(text);
1147 handle.inline(InlineMessageKind::Agent, plain_segment(text.clone()));
1148 }
1149 oxicode_sdk::StreamDelta::Thinking(text) => {
1150 let mut style = InlineTextStyle::default();
1153 style.effects |= anstyle::Effects::DIMMED;
1154 let seg = InlineSegment {
1155 text: format!("\u{2733} {text}"),
1156 style: Arc::new(style),
1157 };
1158 handle.inline(InlineMessageKind::Info, seg);
1159 }
1160 oxicode_sdk::StreamDelta::Sync => {
1161 if !state.message_buffer.is_empty() {
1163 let lines =
1164 oxicode_vtui::tui::ui::markdown::render_markdown(&state.message_buffer);
1165 let count = lines.len();
1166 if count > 0 {
1167 handle.replace_last(count, InlineMessageKind::Agent, lines);
1168 }
1169 state.message_buffer.clear();
1170 }
1171 }
1172 },
1173 AgentEvent::MessageEnd { .. } => {
1174 if !state.message_buffer.is_empty() {
1176 let lines = oxicode_vtui::tui::ui::markdown::render_markdown(&state.message_buffer);
1177 let count = lines.len();
1178 if count > 0 {
1179 handle.replace_last(count, InlineMessageKind::Agent, lines);
1180 }
1181 state.message_buffer.clear();
1182 }
1183 }
1184 AgentEvent::ToolStart { tool_name, .. } => {
1185 handle.append_line(
1186 InlineMessageKind::Tool,
1187 vec![plain_segment(format!("\u{2699} {tool_name}"))],
1188 );
1189 handle.set_reasoning_stage(Some(format!("tool: {tool_name}")));
1190 }
1191 AgentEvent::ToolComplete { result } => {
1192 if !try_render_diff(&result.content, handle) {
1194 let preview = preview_tool_result(&result.content);
1195 let mut style = InlineTextStyle::default();
1196 style.effects |= anstyle::Effects::DIMMED;
1197 handle.append_line(
1198 InlineMessageKind::Tool,
1199 vec![InlineSegment {
1200 text: format!("\u{2713} {preview}"),
1201 style: Arc::new(style),
1202 }],
1203 );
1204 }
1205 handle.set_reasoning_stage(None);
1206 handle.set_input_enabled(true);
1207 }
1208 AgentEvent::ToolError { error, .. } => {
1209 handle.append_line(
1210 InlineMessageKind::Error,
1211 vec![plain_segment(format!("\u{2717} {error}"))],
1212 );
1213 handle.set_reasoning_stage(None);
1214 handle.set_input_enabled(true);
1215 }
1216 AgentEvent::Error { message, .. } => {
1217 handle.append_line(InlineMessageKind::Error, vec![plain_segment(message)]);
1218 handle.set_input_enabled(true);
1219 handle.set_input_status(None, None);
1220 }
1221 AgentEvent::Compaction { .. } => {
1222 }
1225 AgentEvent::Cancelled => {
1226 handle.set_input_enabled(true);
1227 handle.set_input_status(None, Some("cancelled".to_string()));
1228 }
1229 AgentEvent::AutoRetryStart {
1230 attempt,
1231 max_attempts,
1232 ..
1233 } => {
1234 handle.set_input_status(None, Some(format!("retry {attempt}/{max_attempts}")));
1235 }
1236 AgentEvent::TurnEnd { .. } => {
1237 crate::tui_vt::notifications::emit_notification("oxicode", "Response complete");
1241 state.drain_queue_head();
1245 handle.set_reasoning_stage(None);
1246 }
1247 _ => {
1248 tracing::debug!(?event, "ignored AgentEvent variant");
1252 }
1253 }
1254}
1255
1256pub(crate) fn next_provider_actions(has_key: bool, oauth_capable: bool) -> Vec<AuthAction> {
1263 match (has_key, oauth_capable) {
1264 (true, true) => vec![
1265 AuthAction::SetApiKey,
1266 AuthAction::StartOAuth,
1267 AuthAction::RemoveKey,
1268 ],
1269 (true, false) => vec![AuthAction::RemoveKey],
1270 (false, true) => vec![AuthAction::SetApiKey, AuthAction::StartOAuth],
1271 (false, false) => vec![AuthAction::SetApiKey],
1272 }
1273}
1274
1275pub(crate) fn handle_auth_action(
1284 provider: &str,
1285 action: &AuthAction,
1286 auth: &Arc<crate::store::auth_storage::AuthStorage>,
1287 handle: &InlineHandle,
1288 state: &mut RenderState,
1289) {
1290 match action {
1291 AuthAction::SetApiKey => {
1292 state.secure_input_target = Some(provider.to_string());
1293 handle.show_modal(
1294 format!("Set API key for {provider}"),
1295 vec![
1296 "Paste the API key. Press Enter to save, Esc to cancel.".into(),
1297 "The key is masked on screen; nothing is logged.".into(),
1298 ],
1299 Some(SecurePromptConfig {
1300 label: format!("{provider} key"),
1301 placeholder: Some("sk-...".into()),
1302 mask_input: true,
1303 }),
1304 );
1305 }
1306 AuthAction::StartOAuth => {
1307 let spec = match crate::provider_oauth::spec_for(provider) {
1318 Some(s) => s,
1319 None => {
1320 handle.append_line(
1321 InlineMessageKind::Error,
1322 vec![plain_segment(format!(
1323 "OAuth: no OAuth config for '{provider}'."
1324 ))],
1325 );
1326 return;
1327 }
1328 };
1329 let provider_owned = provider.to_string();
1334 let tx = handle.clone();
1335 let auth_clone = Arc::clone(auth);
1336 tokio::spawn(async move {
1337 run_oauth_flow(provider_owned, spec, tx, auth_clone).await;
1338 });
1339 }
1340 AuthAction::RemoveKey => {
1341 state.confirmation = Some(ModalConfirmation {
1342 title: format!("Remove key for {provider}?"),
1343 message: " y \u{2014} remove key n / x \u{2014} cancel".into(),
1344 action: ConfirmationAction::RemoveProviderKey(provider.to_string()),
1345 });
1346 }
1347 }
1348}
1349
1350pub(crate) async fn run_oauth_flow(
1378 provider: String,
1379 spec: crate::provider_oauth::ProviderOAuthSpec,
1380 handle: InlineHandle,
1381 auth: Arc<crate::store::auth_storage::AuthStorage>,
1382) {
1383 use std::time::Duration;
1384 let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0u16)).await {
1393 Ok(l) => l,
1394 Err(e) => {
1395 handle.append_line(
1396 InlineMessageKind::Error,
1397 vec![plain_segment(format!(
1398 "OAuth: could not bind loopback listener for '{provider}': {e}"
1399 ))],
1400 );
1401 return;
1402 }
1403 };
1404 let port = match listener.local_addr() {
1405 Ok(addr) => addr.port(),
1406 Err(e) => {
1407 handle.append_line(
1408 InlineMessageKind::Error,
1409 vec![plain_segment(format!(
1410 "OAuth: could not read loopback port for '{provider}': {e}"
1411 ))],
1412 );
1413 return;
1414 }
1415 };
1416
1417 let (verifier, challenge) = crate::provider_oauth::pkce_pair();
1421 let state_token = crate::provider_oauth::pkce_pair().0; let auth_url = crate::provider_oauth::build_auth_url(&spec, port, &state_token, &challenge);
1427 handle.append_line(
1428 InlineMessageKind::Info,
1429 vec![plain_segment(format!(
1430 "OAuth: opening browser for '{provider}' on http://127.0.0.1:{port}{}",
1431 spec.redirect_path
1432 ))],
1433 );
1434 let callback_timeout = match crate::provider_oauth::open_browser(&auth_url) {
1442 Ok(()) => Duration::from_secs(120),
1443 Err(e) => {
1444 handle.append_line(
1445 InlineMessageKind::Info,
1446 vec![plain_segment(format!(
1447 "OAuth: could not open a browser ({e}).\nOpen this URL manually within 5 minutes:\n {auth_url}"
1448 ))],
1449 );
1450 Duration::from_secs(300)
1451 }
1452 };
1453
1454 let callback = match crate::oauth_listener::await_callback(
1457 listener,
1458 state_token.clone(),
1459 spec.redirect_path.clone(),
1460 callback_timeout,
1461 )
1462 .await
1463 {
1464 Ok(c) => c,
1465 Err(crate::oauth_listener::CallbackError::Timeout) => {
1466 handle.append_line(
1467 InlineMessageKind::Error,
1468 vec![plain_segment(format!(
1469 "OAuth: timed out waiting for '{provider}' callback (after {}s)",
1470 callback_timeout.as_secs()
1471 ))],
1472 );
1473 return;
1474 }
1475 Err(e) => {
1476 handle.append_line(
1477 InlineMessageKind::Error,
1478 vec![plain_segment(format!(
1479 "OAuth: callback failed for '{provider}': {e}"
1480 ))],
1481 );
1482 return;
1483 }
1484 };
1485
1486 let tokens =
1488 match crate::provider_oauth::exchange_code(&spec, port, &callback.code, &verifier).await {
1489 Ok(t) => t,
1490 Err(e) => {
1491 handle.append_line(
1492 InlineMessageKind::Error,
1493 vec![plain_segment(format!(
1494 "OAuth: token exchange failed for '{provider}': {e}"
1495 ))],
1496 );
1497 return;
1498 }
1499 };
1500
1501 let new_expires_at: u64 = tokens.expires_at.max(0) as u64;
1507 let access_token_len = tokens.access_token.chars().count();
1508 auth.set_oauth_full(
1511 &provider,
1512 tokens.access_token,
1513 tokens.refresh_token,
1514 new_expires_at,
1515 if tokens.scopes.is_empty() {
1516 None
1517 } else {
1518 Some(tokens.scopes.join(" "))
1519 },
1520 None,
1521 );
1522 handle.append_line(
1523 InlineMessageKind::Info,
1524 vec![plain_segment(format!(
1525 "OAuth: '{provider}' logged in. Token stored ({} chars).",
1526 access_token_len
1527 ))],
1528 );
1529}
1530
1531fn handle_inline_event(
1533 state: &mut RenderState,
1534 handle: &InlineHandle,
1535 session: &crate::app::agent_session::AgentSessionHandle,
1536 prompt_tx: &tokio::sync::mpsc::UnboundedSender<String>,
1537 evt: InlineEvent,
1538) -> LoopOutcome {
1539 match evt {
1540 InlineEvent::Submit(text) => {
1541 let prompt = text.to_string();
1545 state.input_buffer.clear();
1546 state.input_cursor = 0;
1547 if prompt.is_empty() {
1548 return LoopOutcome::Continue;
1549 }
1550 state.pending_quit = false;
1551 if prompt.trim_start().starts_with('/') {
1555 state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
1556 let mut ctx = SlashCtx {
1557 session,
1558 handle,
1559 state,
1560 };
1561 return match SlashRegistry::builtins().dispatch(&prompt, &mut ctx) {
1562 SlashOutcome::Quit => LoopOutcome::Exit,
1563 SlashOutcome::Handled => LoopOutcome::Continue,
1564 SlashOutcome::NotHandled => {
1565 if let Some(expanded) = crate::tui_vt::slash::file_commands::try_expand(
1567 &ctx.state.file_commands,
1568 &prompt,
1569 ) {
1570 let _ = prompt_tx.send(expanded);
1573 LoopOutcome::Continue
1574 } else {
1575 ctx.reply(
1576 InlineMessageKind::Error,
1577 format!("Unknown command: {}", prompt.trim()),
1578 );
1579 LoopOutcome::Continue
1580 }
1581 }
1582 };
1583 }
1584 state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
1585 if session.is_streaming() {
1589 state.queued_inputs.push(prompt.clone());
1590 state.show_tip(
1591 "send_now",
1592 "Ctrl+Enter sends now \u{00b7} Ctrl+; manages queue",
1593 240,
1594 true,
1595 );
1596 }
1597 let _ = prompt_tx.send(prompt);
1600 }
1601 InlineEvent::Cancel => {
1602 return match route_cancel(session.is_streaming()) {
1608 CancelRoute::Interrupt => handle_interrupt(state, session, handle),
1609 CancelRoute::Exit => LoopOutcome::Exit,
1610 };
1611 }
1612 InlineEvent::Exit => {
1613 return LoopOutcome::Exit;
1614 }
1615 InlineEvent::Interrupt => {
1616 return handle_interrupt(state, session, handle);
1617 }
1618 InlineEvent::ScrollLineUp => {
1619 state.scroll_offset = state.scroll_offset.saturating_add(1);
1620 }
1621 InlineEvent::ScrollLineDown => {
1622 state.scroll_offset = state.scroll_offset.saturating_sub(1);
1623 }
1624 InlineEvent::ScrollPageUp => {
1625 state.scroll_offset = state.scroll_offset.saturating_add(10);
1626 }
1627 InlineEvent::ScrollPageDown => {
1628 state.scroll_offset = state.scroll_offset.saturating_sub(10);
1629 }
1630 InlineEvent::CyclePrimaryAgent => {
1631 let _ = session.cycle_model();
1632 }
1633 InlineEvent::CyclePrimaryAgentPrevious => {
1634 let _ = session.cycle_model();
1637 }
1638 InlineEvent::Overlay(overlay_evt) => {
1639 use oxicode_vtui::tui::core::OverlayEvent;
1640 match overlay_evt {
1641 OverlayEvent::Submitted(sub) => {
1642 if let OverlaySubmission::Selection(InlineListSelection::Model(idx)) = &sub
1644 && idx < &state.overlay_model_ids.len()
1645 {
1646 let model_id = state.overlay_model_ids[*idx].clone();
1647 match session.set_model(&model_id) {
1648 Ok(()) => handle.append_line(
1649 InlineMessageKind::Info,
1650 vec![plain_segment(format!("Switched to {model_id}"))],
1651 ),
1652 Err(e) => handle.append_line(
1653 InlineMessageKind::Error,
1654 vec![plain_segment(format!("Failed to set model: {e}"))],
1655 ),
1656 }
1657 }
1658 if let OverlaySubmission::Selection(InlineListSelection::Theme(theme_id)) = &sub
1660 {
1661 match oxicode_vtui::theme::set_active_theme(theme_id) {
1662 Ok(()) => {
1663 let label = oxicode_vtui::theme::theme_label(theme_id)
1664 .unwrap_or(theme_id.as_ref())
1665 .to_string();
1666 handle.append_line(
1667 InlineMessageKind::Info,
1668 vec![plain_segment(format!("Theme: {label}"))],
1669 );
1670 }
1671 Err(e) => handle.append_line(
1672 InlineMessageKind::Error,
1673 vec![plain_segment(format!("Unknown theme: {e}"))],
1674 ),
1675 }
1676 }
1677 if let OverlaySubmission::Selection(InlineListSelection::SlashCommand(name)) =
1679 &sub
1680 {
1681 state.input_buffer = format!("/{name} ");
1682 state.input_cursor = state.input_buffer.len();
1683 }
1684 if let OverlaySubmission::Selection(InlineListSelection::ConfigAction(key)) =
1686 &sub
1687 {
1688 match key.as_str() {
1689 "thinking_level" => {
1690 if let Some(level) = session.cycle_thinking_level() {
1691 handle.append_line(
1692 InlineMessageKind::Info,
1693 vec![plain_segment(format!("Thinking: {level:?}"))],
1694 );
1695 }
1696 }
1697 "auto_compaction" => {
1698 let enabled = !session.auto_compaction_enabled();
1699 session.set_auto_compaction(enabled);
1700 handle.append_line(
1701 InlineMessageKind::Info,
1702 vec![plain_segment(format!(
1703 "Auto-compaction: {}",
1704 if enabled { "on" } else { "off" }
1705 ))],
1706 );
1707 }
1708 "auto_retry" => {
1709 let enabled = !session.auto_retry_enabled();
1710 session.set_auto_retry(enabled);
1711 handle.append_line(
1712 InlineMessageKind::Info,
1713 vec![plain_segment(format!(
1714 "Auto-retry: {}",
1715 if enabled { "on" } else { "off" }
1716 ))],
1717 );
1718 }
1719 "advisor" => match session.toggle_advisor() {
1720 Ok(enabled) => handle.append_line(
1721 InlineMessageKind::Info,
1722 vec![plain_segment(format!(
1723 "Advisor: {}",
1724 if enabled { "on" } else { "off" }
1725 ))],
1726 ),
1727 Err(e) => handle.append_line(
1728 InlineMessageKind::Error,
1729 vec![plain_segment(format!("Failed to toggle advisor: {e}"))],
1730 ),
1731 },
1732 _ => {}
1733 }
1734 }
1735 if let OverlaySubmission::Selection(InlineListSelection::Session(id)) = &sub {
1738 state.input_buffer = format!("/resume {id}");
1739 state.input_cursor = state.input_buffer.len();
1740 }
1741 if let OverlaySubmission::Selection(InlineListSelection::CatalogModel(idx)) =
1743 &sub
1744 && idx < &state.overlay_catalog_models.len()
1745 {
1746 let (provider, model_id) = &state.overlay_catalog_models[*idx];
1747 let full = format!("{provider}/{model_id}");
1748 match session.set_model(&full) {
1749 Ok(()) => handle.append_line(
1750 InlineMessageKind::Info,
1751 vec![plain_segment(format!("Switched to {full}"))],
1752 ),
1753 Err(e) => handle.append_line(
1754 InlineMessageKind::Error,
1755 vec![plain_segment(format!("Failed to set model: {e}"))],
1756 ),
1757 }
1758 }
1759 if let OverlaySubmission::Selection(InlineListSelection::ProviderRow(idx)) =
1766 &sub
1767 && idx < &state.overlay_providers.len()
1768 {
1769 let name = state.overlay_providers[*idx].clone();
1770 let auth = crate::store::auth_storage::shared_auth_storage();
1771 let has_key = auth.has(&name);
1772 let oauth_capable = crate::provider_oauth::spec_for(&name).is_some();
1773 let actions = next_provider_actions(has_key, oauth_capable);
1774 if actions.len() == 1 {
1775 handle_auth_action(&name, &actions[0], &auth, handle, state);
1777 } else {
1778 let items: Vec<InlineListItem> = actions
1780 .iter()
1781 .map(|a| InlineListItem {
1782 title: match a {
1783 AuthAction::SetApiKey => "Set API key".into(),
1784 AuthAction::StartOAuth => "Login with OAuth".into(),
1785 AuthAction::RemoveKey => "Remove key".into(),
1786 },
1787 subtitle: None,
1788 badge: None,
1789 indent: 0,
1790 selection: Some(InlineListSelection::ProviderAction {
1791 provider: name.clone(),
1792 action: a.clone(),
1793 }),
1794 search_value: None,
1795 })
1796 .collect();
1797 handle.show_list_modal(
1798 name.clone(),
1799 vec!["Pick an action".into()],
1800 items,
1801 None,
1802 None,
1803 );
1804 }
1805 }
1806 if let OverlaySubmission::Selection(InlineListSelection::ProviderAction {
1812 provider,
1813 action,
1814 }) = &sub
1815 {
1816 let auth = crate::store::auth_storage::shared_auth_storage();
1817 handle_auth_action(provider, action, &auth, handle, state);
1818 }
1819 if let OverlaySubmission::SecureInput(text) = &sub
1826 && let Some(provider) = state.secure_input_target.take()
1827 {
1828 let auth = crate::store::auth_storage::shared_auth_storage();
1829 auth.set_api_key(&provider, text.clone());
1830 handle.append_line(
1831 InlineMessageKind::Info,
1832 vec![plain_segment(format!(
1833 "Saved API key for '{provider}' ({} chars).",
1834 text.chars().count()
1835 ))],
1836 );
1837 }
1838 state.overlay_catalog_models.clear();
1839 state.overlay_providers.clear();
1840 state.overlay_model_ids.clear();
1841 handle.close_overlay();
1842 }
1843 OverlayEvent::Cancelled => {
1844 handle.close_overlay();
1845 }
1846 OverlayEvent::SelectionChanged(_) => {}
1847 }
1848 }
1849 _ => {
1850 }
1854 }
1855 LoopOutcome::Continue
1856}
1857
1858struct StreamingGuard<'a>(&'a std::sync::atomic::AtomicBool);
1865
1866impl Drop for StreamingGuard<'_> {
1867 fn drop(&mut self) {
1868 use std::sync::atomic::Ordering;
1869 self.0.store(false, Ordering::SeqCst);
1870 }
1871}
1872
1873fn handle_interrupt(
1886 state: &mut RenderState,
1887 session: &crate::app::agent_session::AgentSessionHandle,
1888 _handle: &InlineHandle,
1889) -> LoopOutcome {
1890 if state.confirmation.is_some() {
1892 return LoopOutcome::Exit;
1893 }
1894 if state.pending_quit {
1897 state.confirmation = Some(quit_confirmation());
1898 state.pending_quit = false;
1899 return LoopOutcome::Continue;
1900 }
1901 if session.is_streaming() {
1905 let s = session.clone();
1906 tokio::spawn(async move {
1907 s.abort().await;
1908 });
1909 state.footer_left = Some("Stopping\u{2026} press Ctrl+C again to confirm quit".to_string());
1910 state.pending_quit = true;
1911 } else {
1912 state.footer_left = None;
1913 state.confirmation = Some(quit_confirmation());
1914 }
1915 LoopOutcome::Continue
1916}
1917
1918fn quit_confirmation() -> ModalConfirmation {
1920 ModalConfirmation {
1921 title: "Quit oxicode?".into(),
1922 message: " y \u{2014} quit now n / x \u{2014} stay".into(),
1923 action: ConfirmationAction::Quit,
1924 }
1925}
1926
1927pub(super) fn clear_confirmation() -> ModalConfirmation {
1929 ModalConfirmation {
1930 title: "Clear conversation?".into(),
1931 message: " y \u{2014} clear all n / x \u{2014} cancel".into(),
1932 action: ConfirmationAction::ClearConversation,
1933 }
1934}
1935
1936fn spawn_input_thread(
1942 state: Arc<parking_lot::Mutex<RenderState>>,
1943 evt_tx: tokio::sync::mpsc::UnboundedSender<InlineEvent>,
1944 mode_handle: Option<std::sync::Arc<std::sync::atomic::AtomicU8>>,
1945) -> std::thread::JoinHandle<()> {
1946 std::thread::spawn(move || {
1947 loop {
1955 match event::poll(std::time::Duration::from_millis(50)) {
1956 Ok(true) => {}
1957 Ok(false) => continue,
1958 Err(_) => break,
1959 }
1960 let event = match event::read() {
1961 Ok(ev) => ev,
1962 Err(_) => continue,
1963 };
1964
1965 let mut pasted = String::new();
1968 let mut key_event = None;
1969 match event {
1970 Event::Key(k) if k.kind == KeyEventKind::Press => key_event = Some(k),
1971 Event::Paste(p) => pasted = p,
1972 _ => {}
1973 }
1974
1975 if !pasted.is_empty() {
1976 let routed_to_secure = {
1981 let mut s = state.lock();
1982 if let Some(overlay) = s.overlay.as_mut() {
1983 if let Some(secure) = overlay.secure_input.as_mut() {
1984 let (v, c) = insert_paste_into_secure_input(
1985 &secure.value,
1986 secure.cursor,
1987 &pasted,
1988 );
1989 secure.value = v;
1990 secure.cursor = c;
1991 true
1992 } else {
1993 false
1994 }
1995 } else {
1996 false
1997 }
1998 };
1999 if routed_to_secure {
2000 continue;
2001 }
2002 let mut s = state.lock();
2003 let cursor = s.input_cursor;
2004 s.input_buffer.insert_str(cursor, &pasted);
2005 s.input_cursor = cursor + pasted.len();
2006 continue;
2007 }
2008
2009 let Some(key) = key_event else { continue };
2010
2011 if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
2015 let _ = evt_tx.send(InlineEvent::Interrupt);
2016 continue;
2017 }
2018
2019 if key.code == KeyCode::Char('m') && key.modifiers.contains(KeyModifiers::CONTROL) {
2021 let mut s = state.lock();
2022 s.multiline_mode = !s.multiline_mode;
2023 continue;
2024 }
2025
2026 if key.code == KeyCode::Char('p') && key.modifiers.contains(KeyModifiers::CONTROL) {
2028 let mut s = state.lock();
2029 s.overlay = Some(build_command_palette());
2030 continue;
2031 }
2032
2033 if key.code == KeyCode::Char(';') && key.modifiers.contains(KeyModifiers::CONTROL) {
2035 let mut s = state.lock();
2036 s.queue_panel_open = !s.queue_panel_open;
2037 if s.queue_panel_open {
2038 s.queue_selected = 0;
2039 }
2040 continue;
2041 }
2042
2043 if key.code == KeyCode::Char('e') && key.modifiers.contains(KeyModifiers::CONTROL) {
2045 let mut s = state.lock();
2046 s.fold_all();
2047 continue;
2048 }
2049
2050 if key.code == KeyCode::Enter && key.modifiers.contains(KeyModifiers::CONTROL) {
2053 let submitted = {
2054 let mut s = state.lock();
2055 let buf = if s.slash_popup.open && !s.slash_popup.items.is_empty() {
2056 format!("/{}", s.slash_popup.items[s.slash_popup.selected].name)
2057 } else {
2058 std::mem::take(&mut s.input_buffer)
2059 };
2060 s.input_cursor = 0;
2061 s.slash_popup = SlashPopup::default();
2062 s.history_pos = None;
2063 if !buf.is_empty() && !buf.starts_with('/') {
2064 s.prompt_history.insert(0, buf.clone());
2065 s.prompt_history.truncate(100);
2066 }
2067 buf
2068 };
2069 if !submitted.is_empty() {
2070 let _ = evt_tx.send(InlineEvent::Interrupt);
2071 let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
2072 }
2073 continue;
2074 }
2075
2076 {
2079 let s = state.lock();
2080 if s.confirmation.is_some() {
2081 drop(s);
2082 handle_confirmation_key(&state, &evt_tx, key.code);
2083 continue;
2084 }
2085 }
2086
2087 {
2093 let s = state.lock();
2094 if s.overlay.is_some() {
2095 drop(s);
2096 if handle_overlay_key(&state, &evt_tx, key.code) {
2097 continue;
2098 }
2099 }
2100 }
2101
2102 {
2106 let s = state.lock();
2107 if s.file_search.is_some() {
2108 drop(s);
2109 if handle_file_search_key(&state, &evt_tx, key.code) {
2110 continue;
2111 }
2112 }
2113 }
2114
2115 match key.code {
2116 KeyCode::BackTab => {
2118 if let Some(h) = &mode_handle {
2119 let new_mode = Mode::load(h).toggle();
2120 h.store(new_mode.as_u8(), std::sync::atomic::Ordering::SeqCst);
2121 let label = new_mode.label();
2122 let detail = if new_mode.is_auto() {
2123 "autonomous — no questions, runs to completion"
2124 } else {
2125 "interactive — may ask questions"
2126 };
2127 let mut s = state.lock();
2128 s.autonomy_mode = new_mode;
2129 s.tip = Some(EphemeralTip {
2130 text: format!("Mode: {label} — {detail}"),
2131 born_tick: 0,
2132 ttl_ticks: 240,
2133 key: "mode_toggle",
2134 ambient: false,
2135 });
2136 }
2137 continue;
2138 }
2139 KeyCode::Enter => {
2140 let send = !state.lock().multiline_mode
2143 || key
2144 .modifiers
2145 .contains(crossterm::event::KeyModifiers::SHIFT);
2146
2147 if !send {
2148 let mut s = state.lock();
2149 let cursor = s.input_cursor;
2150 s.input_buffer.insert(cursor, '\n');
2151 s.input_cursor = cursor + 1;
2152 continue;
2153 }
2154
2155 let shell_cmd = state.lock().shell_mode;
2157 if shell_cmd {
2158 let submitted = {
2159 let mut s = state.lock();
2160 let buf = std::mem::take(&mut s.input_buffer);
2161 s.input_cursor = 0;
2162 s.shell_mode = false;
2163 s.history_pos = None;
2164 if !buf.is_empty() {
2165 s.prompt_history.insert(0, buf.clone());
2166 s.prompt_history.truncate(100);
2167 }
2168 buf
2169 };
2170 if !submitted.is_empty() {
2171 let prompt = format!("Run this shell command: `{submitted}`");
2172 let _ = evt_tx.send(InlineEvent::Submit(prompt.into()));
2173 }
2174 continue;
2175 }
2176
2177 let submitted = {
2178 let mut s = state.lock();
2179 let buf = if s.slash_popup.open && !s.slash_popup.items.is_empty() {
2180 let item = &s.slash_popup.items[s.slash_popup.selected];
2181 format!("/{}", item.name)
2182 } else {
2183 std::mem::take(&mut s.input_buffer)
2184 };
2185 s.input_cursor = 0;
2186 s.slash_popup = SlashPopup::default();
2187 s.history_pos = None;
2188 if !buf.is_empty() && !buf.starts_with('/') {
2190 s.prompt_history.insert(0, buf.clone());
2191 s.prompt_history.truncate(100);
2192 }
2193 buf
2194 };
2195 let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
2196 }
2197 KeyCode::Esc => {
2198 let mut s = state.lock();
2205 if s.shell_mode {
2206 s.shell_mode = false;
2207 s.input_buffer.clear();
2208 s.input_cursor = 0;
2209 } else if s.slash_popup.open {
2210 s.slash_popup = SlashPopup::default();
2211 } else if !s.input_buffer.is_empty() {
2212 let now = std::time::Instant::now();
2213 let is_double = s
2214 .last_esc_at
2215 .map(|t| now.duration_since(t).as_millis() < 800)
2216 .unwrap_or(false);
2217 if is_double {
2218 s.input_buffer.clear();
2219 s.input_cursor = 0;
2220 s.last_esc_at = None;
2221 } else {
2222 s.last_esc_at = Some(now);
2223 s.tip = Some(EphemeralTip {
2226 text: "Press Esc again to clear input".to_string(),
2227 born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
2228 ttl_ticks: 120,
2229 key: "esc_clear",
2230 ambient: false,
2231 });
2232 }
2233 } else {
2234 let now = std::time::Instant::now();
2235 let in_grace = s.cancel_grace_until.map(|t| t > now).unwrap_or(false);
2236 if in_grace {
2237 } else {
2239 s.cancel_grace_until = Some(now + std::time::Duration::from_secs(1));
2240 s.last_esc_at = None;
2241 drop(s);
2242 let _ = evt_tx.send(InlineEvent::Cancel);
2243 }
2244 }
2245 }
2246 KeyCode::Tab => {
2247 let mut s = state.lock();
2250 if s.slash_popup.open && !s.slash_popup.items.is_empty() {
2251 let name = s.slash_popup.items[s.slash_popup.selected].name.clone();
2252 s.input_buffer = format!("/{} ", name);
2253 s.input_cursor = s.input_buffer.len();
2254 refresh_input_popups(&mut s);
2255 }
2256 }
2257 KeyCode::Backspace => {
2258 let mut s = state.lock();
2259 if s.input_cursor > 0 {
2260 let cursor = s.input_cursor;
2261 let prev = s
2264 .input_buffer
2265 .char_indices()
2266 .take_while(|(i, _)| *i < cursor)
2267 .last()
2268 .map(|(i, _)| i)
2269 .unwrap_or(0);
2270 s.input_buffer.replace_range(prev..cursor, "");
2271 s.input_cursor = prev;
2272 }
2273 refresh_input_popups(&mut s);
2274 }
2275 KeyCode::Delete => {
2276 let mut s = state.lock();
2277 if s.input_cursor < s.input_buffer.len() {
2278 let cursor = s.input_cursor;
2279 let next = s.input_buffer[cursor..]
2280 .char_indices()
2281 .nth(1)
2282 .map(|(i, _)| cursor + i)
2283 .unwrap_or(s.input_buffer.len());
2284 s.input_buffer.replace_range(cursor..next, "");
2285 }
2286 refresh_input_popups(&mut s);
2287 }
2288 KeyCode::Left => {
2289 let mut s = state.lock();
2290 s.input_cursor = s.input_cursor.saturating_sub(1);
2291 }
2292 KeyCode::Right => {
2293 let mut s = state.lock();
2294 let len = s.input_buffer.len();
2295 s.input_cursor = (s.input_cursor + 1).min(len);
2296 }
2297 KeyCode::Up => {
2298 let mut s = state.lock();
2299 if s.slash_popup.open && !s.slash_popup.items.is_empty() {
2300 let len = s.slash_popup.items.len();
2301 s.slash_popup.selected = if s.slash_popup.selected == 0 {
2302 len - 1
2303 } else {
2304 s.slash_popup.selected - 1
2305 };
2306 } else if s.queue_panel_open
2307 && !s.queued_inputs.is_empty()
2308 && s.input_buffer.is_empty()
2309 {
2310 s.queue_selected = if s.queue_selected == 0 {
2311 s.queued_inputs.len() - 1
2312 } else {
2313 s.queue_selected - 1
2314 };
2315 } else if s.input_buffer.is_empty() && !s.prompt_history.is_empty() {
2316 let pos = s.history_pos.unwrap_or(0);
2318 let next = (pos + 1).min(s.prompt_history.len() - 1);
2319 s.history_pos = Some(next);
2320 s.input_buffer = s.prompt_history[next].clone();
2321 s.input_cursor = s.input_buffer.len();
2322 } else {
2323 drop(s);
2324 let _ = evt_tx.send(InlineEvent::ScrollLineUp);
2325 }
2326 }
2327 KeyCode::Down => {
2328 let mut s = state.lock();
2329 if s.slash_popup.open && !s.slash_popup.items.is_empty() {
2330 let len = s.slash_popup.items.len();
2331 s.slash_popup.selected = if s.slash_popup.selected + 1 >= len {
2332 0
2333 } else {
2334 s.slash_popup.selected + 1
2335 };
2336 } else if s.queue_panel_open
2337 && !s.queued_inputs.is_empty()
2338 && s.input_buffer.is_empty()
2339 {
2340 s.queue_selected = if s.queue_selected + 1 >= s.queued_inputs.len() {
2341 0
2342 } else {
2343 s.queue_selected + 1
2344 };
2345 } else {
2346 drop(s);
2347 let _ = evt_tx.send(InlineEvent::ScrollLineDown);
2348 }
2349 }
2350 KeyCode::PageUp => {
2351 let _ = evt_tx.send(InlineEvent::ScrollPageUp);
2352 }
2353 KeyCode::PageDown => {
2354 let _ = evt_tx.send(InlineEvent::ScrollPageDown);
2355 }
2356 KeyCode::Char(ch) => {
2357 let mut s = state.lock();
2358 if s.file_search.is_some()
2362 && ch == '!'
2363 && s.input_buffer[..s.input_cursor].ends_with('@')
2364 {
2365 let cwd = s.cwd.clone();
2366 if let Some(fs) = s.file_search.as_mut() {
2367 fs.toggle_hidden(&cwd);
2368 }
2369 continue;
2370 }
2371 if s.agent_hub_open && ch == 'q' {
2372 s.agent_hub_open = false;
2373 } else if s.vim_state.enabled() && !s.slash_popup.open {
2374 let s = &mut *s;
2377 let vkey =
2378 crossterm::event::KeyEvent::new(KeyCode::Char(ch), key.modifiers);
2379 let mut editor = InputEditor {
2380 buffer: &mut s.input_buffer,
2381 cursor: &mut s.input_cursor,
2382 };
2383 let outcome = oxicode_vtui::vim::handle_key(
2384 &mut s.vim_state,
2385 &mut editor,
2386 &mut s.vim_clipboard,
2387 &vkey,
2388 );
2389 if outcome.handled {
2390 refresh_input_popups(s);
2391 } else {
2392 let cursor = s.input_cursor;
2393 s.input_buffer.insert(cursor, ch);
2394 s.input_cursor = cursor + ch.len_utf8();
2395 refresh_input_popups(s);
2396 }
2397 } else if s.input_buffer.is_empty() && !s.slash_popup.open {
2398 if ch == '!' && !s.shell_mode {
2400 s.shell_mode = true;
2401 continue;
2402 }
2403 if s.queue_panel_open && !s.queued_inputs.is_empty() {
2407 let idx = s.queue_selected.min(s.queued_inputs.len() - 1);
2408 match ch {
2409 'x' | 'X' => {
2410 s.queued_inputs.remove(idx);
2411 if s.queue_selected >= s.queued_inputs.len()
2412 && !s.queued_inputs.is_empty()
2413 {
2414 s.queue_selected = s.queued_inputs.len() - 1;
2415 }
2416 continue;
2417 }
2418 'e' => {
2419 let entry = s.queued_inputs.remove(idx);
2420 s.input_buffer = entry;
2421 s.input_cursor = s.input_buffer.len();
2422 s.queue_panel_open = false;
2423 continue;
2424 }
2425 'J' => {
2426 if idx + 1 < s.queued_inputs.len() {
2427 s.queued_inputs.swap(idx, idx + 1);
2428 s.queue_selected = idx + 1;
2429 }
2430 continue;
2431 }
2432 'K' => {
2433 if idx > 0 {
2434 s.queued_inputs.swap(idx, idx - 1);
2435 s.queue_selected = idx - 1;
2436 }
2437 continue;
2438 }
2439 _ => {} }
2441 }
2442 match ch {
2447 '?' => {
2448 s.overlay = Some(OverlayState {
2449 title: "Keyboard Shortcuts".into(),
2450 lines: cheatsheet_lines(),
2451 items: vec![],
2452 selected: 0,
2453 search: None,
2454 secure_input: None,
2455 });
2456 }
2457 'e' => s.cycle_block_at_view(),
2458 'E' => s.expand_all(),
2459 'J' => s.jump_next_turn(),
2460 'K' => s.jump_prev_turn(),
2461 'n' if s.search.is_some() => s.search_next(),
2462 'N' if s.search.is_some() => s.search_prev(),
2463 _ => {
2464 let cursor = s.input_cursor;
2465 s.input_buffer.insert(cursor, ch);
2466 s.input_cursor = cursor + ch.len_utf8();
2467 refresh_input_popups(&mut s);
2468 }
2469 }
2470 } else {
2471 let cursor = s.input_cursor;
2472 s.input_buffer.insert(cursor, ch);
2473 s.input_cursor = cursor + ch.len_utf8();
2474 refresh_input_popups(&mut s);
2475 }
2476 if s.tip.is_none() && s.input_buffer.to_lowercase().contains("plan") {
2478 s.show_tip(
2479 "plan_nudge",
2480 "Try /compact to summarize and plan ahead",
2481 180,
2482 true,
2483 );
2484 }
2485 }
2486 _ => {}
2487 }
2488 }
2489 })
2490}
2491
2492fn handle_confirmation_key(
2496 state: &Arc<parking_lot::Mutex<RenderState>>,
2497 evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
2498 code: KeyCode,
2499) {
2500 let mut s = state.lock();
2501 let Some(confirm) = s.confirmation.clone() else {
2502 return;
2503 };
2504 match code {
2505 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
2506 s.confirmation = None;
2507 drop(s);
2508 match confirm.action {
2509 ConfirmationAction::Quit => {
2510 let _ = evt_tx.send(InlineEvent::Exit);
2511 }
2512 ConfirmationAction::ClearConversation => {
2513 let _ = evt_tx.send(InlineEvent::Submit("/clear --yes".into()));
2517 }
2518 ConfirmationAction::RemoveProviderKey(name) => {
2519 let _ = evt_tx.send(InlineEvent::Submit(
2523 format!("/providers remove {name} --yes").into(),
2524 ));
2525 }
2526 }
2527 }
2528 KeyCode::Char('n')
2529 | KeyCode::Char('N')
2530 | KeyCode::Char('x')
2531 | KeyCode::Char('X')
2532 | KeyCode::Esc => {
2533 s.confirmation = None;
2534 }
2535 _ => {}
2536 }
2537}
2538
2539fn handle_overlay_key(
2544 state: &Arc<parking_lot::Mutex<RenderState>>,
2545 evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
2546 code: KeyCode,
2547) -> bool {
2548 use oxicode_vtui::tui::core::{OverlayEvent, OverlaySubmission};
2549
2550 let mut s = state.lock();
2551 let Some(overlay) = s.overlay.as_mut() else {
2552 return false;
2553 };
2554 if let Some(secure) = overlay.secure_input.as_mut() {
2557 match code {
2558 KeyCode::Backspace => {
2559 let (v, c) = backspace_secure_input(&secure.value, secure.cursor);
2560 secure.value = v;
2561 secure.cursor = c;
2562 }
2563 KeyCode::Left => {
2564 if secure.cursor > 0 {
2565 let mut p = secure.cursor - 1;
2566 while !secure.value.is_char_boundary(p) {
2567 p -= 1;
2568 }
2569 secure.cursor = p;
2570 }
2571 }
2572 KeyCode::Right => {
2573 if secure.cursor < secure.value.len() {
2574 let mut n = secure.cursor + 1;
2575 while n < secure.value.len() && !secure.value.is_char_boundary(n) {
2576 n += 1;
2577 }
2578 secure.cursor = n;
2579 }
2580 }
2581 KeyCode::Enter => {
2582 let submission = OverlaySubmission::SecureInput(secure.value.clone());
2583 drop(s);
2584 state.lock().overlay = None;
2585 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Submitted(submission)));
2586 }
2587 KeyCode::Esc => {
2588 drop(s);
2589 state.lock().overlay = None;
2590 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
2591 }
2592 KeyCode::Char(ch) if ch.is_ascii_graphic() || ch == ' ' => {
2593 let (v, n) = insert_char_into_secure_input(&secure.value, secure.cursor, ch);
2594 secure.value = v;
2595 secure.cursor = n;
2596 }
2597 _ => {} }
2599 return true;
2600 }
2601
2602 match code {
2603 KeyCode::Esc => {
2604 drop(s);
2606 state.lock().overlay = None;
2607 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
2608 }
2609 KeyCode::Enter => {
2610 let submission = if let Some(item) = overlay.items.get(overlay.selected) {
2613 match item.selection.clone() {
2614 Some(sel) => sel,
2615 None => {
2616 return true;
2622 }
2623 }
2624 } else {
2625 drop(s);
2626 state.lock().overlay = None;
2627 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
2628 return true;
2629 };
2630 let title = overlay.title.clone();
2631 let selected = overlay.selected;
2632 drop(s);
2633 state.lock().overlay = None;
2634 tracing::debug!(
2635 overlay = %title,
2636 selected,
2637 "overlay submitted"
2638 );
2639 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Submitted(
2640 OverlaySubmission::Selection(submission),
2641 )));
2642 }
2643 KeyCode::Up => {
2644 let len = overlay_filtered_indices(overlay).len();
2645 if len == 0 {
2646 return true;
2647 }
2648 let pos = overlay_filtered_indices(overlay)
2649 .iter()
2650 .position(|&i| i == overlay.selected)
2651 .unwrap_or(0);
2652 let new_pos = if pos == 0 { len - 1 } else { pos - 1 };
2653 overlay.selected = overlay_filtered_indices(overlay)[new_pos];
2654 }
2655 KeyCode::Down => {
2656 let filtered = overlay_filtered_indices(overlay);
2657 let len = filtered.len();
2658 if len == 0 {
2659 return true;
2660 }
2661 let pos = filtered
2662 .iter()
2663 .position(|&i| i == overlay.selected)
2664 .unwrap_or(0);
2665 let new_pos = if pos + 1 >= len { 0 } else { pos + 1 };
2666 overlay.selected = filtered[new_pos];
2667 }
2668 KeyCode::Backspace => {
2669 if let Some(search) = overlay.search.as_mut() {
2670 search.value.pop();
2671 overlay.selected = 0;
2672 }
2673 }
2674 KeyCode::Char(ch) => {
2675 if let Some(search) = overlay.search.as_mut() {
2676 search.value.push(ch);
2677 overlay.selected = 0;
2678 }
2679 }
2680 _ => {
2681 }
2683 }
2684 true
2685}
2686
2687fn overlay_filtered_indices(overlay: &OverlayState) -> Vec<usize> {
2692 let needle = overlay
2693 .search
2694 .as_ref()
2695 .map(|s| s.value.to_lowercase())
2696 .unwrap_or_default();
2697 if needle.is_empty() {
2698 return (0..overlay.items.len()).collect();
2699 }
2700 overlay
2701 .items
2702 .iter()
2703 .enumerate()
2704 .filter_map(|(idx, item)| {
2705 let title_hit = item.title.to_lowercase().contains(&needle);
2706 let sv_hit = item
2707 .search_value
2708 .as_deref()
2709 .map(|v| v.to_lowercase().contains(&needle))
2710 .unwrap_or(false);
2711 if title_hit || sv_hit { Some(idx) } else { None }
2712 })
2713 .collect()
2714}
2715
2716fn handle_file_search_key(
2722 state: &Arc<parking_lot::Mutex<RenderState>>,
2723 _evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
2724 code: KeyCode,
2725) -> bool {
2726 match code {
2727 KeyCode::Up => {
2728 let mut s = state.lock();
2729 if let Some(fs) = s.file_search.as_mut() {
2730 fs.up();
2731 true
2732 } else {
2733 false
2734 }
2735 }
2736 KeyCode::Down => {
2737 let mut s = state.lock();
2738 if let Some(fs) = s.file_search.as_mut() {
2739 fs.down();
2740 true
2741 } else {
2742 false
2743 }
2744 }
2745 KeyCode::Tab | KeyCode::Enter => {
2746 let mut s = state.lock();
2747 if s.file_search
2748 .as_ref()
2749 .and_then(|fs| fs.selected_result())
2750 .is_some()
2751 {
2752 accept_file_search(&mut s, false);
2753 true
2754 } else {
2755 s.file_search = None;
2757 false
2758 }
2759 }
2760 KeyCode::Esc => {
2761 let mut s = state.lock();
2762 s.file_search = None;
2763 true
2764 }
2765 _ => false,
2766 }
2767}
2768
2769fn spawn_agent_worker(
2775 session: crate::app::agent_session::AgentSessionHandle,
2776) -> tokio::sync::mpsc::UnboundedSender<String> {
2777 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
2778
2779 std::thread::spawn(move || {
2780 let runtime = match tokio::runtime::Builder::new_current_thread()
2781 .enable_all()
2782 .build()
2783 {
2784 Ok(rt) => rt,
2785 Err(err) => {
2786 tracing::error!(?err, "failed to build agent worker runtime");
2787 return;
2788 }
2789 };
2790
2791 runtime.block_on(async move {
2792 let local = tokio::task::LocalSet::new();
2793 local
2794 .run_until(async move {
2795 while let Some(prompt) = prompt_rx.recv().await {
2796 run_one_prompt(&session, prompt).await;
2797 }
2798 })
2799 .await;
2800 });
2801 });
2802
2803 prompt_tx
2804}
2805
2806async fn run_one_prompt(session: &crate::app::agent_session::AgentSessionHandle, prompt: String) {
2807 let session_for_forward = session.clone();
2808 let (event_tx, event_rx) = std::sync::mpsc::channel::<AgentEvent>();
2809
2810 let forwarder = std::thread::spawn(move || {
2814 while let Ok(event) = event_rx.recv() {
2815 session_for_forward.forward_event_to_extensions(&event);
2816 }
2817 });
2818
2819 use std::sync::atomic::Ordering;
2823 session.reset_should_stop();
2824 let streaming = session.streaming_flag();
2825 streaming.store(true, Ordering::SeqCst);
2826 let _stream_guard = StreamingGuard(&streaming);
2827
2828 let agent = session.agent_ref();
2829 let local = tokio::task::LocalSet::new();
2830 let result = local
2831 .run_until(agent.run_with_channel(prompt, event_tx))
2832 .await;
2833
2834 let _ = forwarder.join();
2837 if let Err(err) = result {
2838 tracing::warn!(?err, "agent run failed");
2839 }
2840}
2841
2842fn build_header_context(
2851 app: &App,
2852 cwd: &std::path::Path,
2853 git_branch: Option<&str>,
2854) -> InlineHeaderContext {
2855 let workspace_name = cwd
2856 .file_name()
2857 .map(|n| n.to_string_lossy().into_owned())
2858 .unwrap_or_else(|| "oxicode".to_string());
2859 let model_id = app.model_id();
2860 let provider = model_id
2861 .split_once('/')
2862 .map(|(p, _)| p.to_string())
2863 .unwrap_or_else(|| "Provider".to_string());
2864 let branch = git_branch.unwrap_or("\u{2014}").to_string();
2865 let mut ctx = InlineHeaderContext::default();
2866 ctx.app_name = "oxicode".to_string();
2867 ctx.provider = provider;
2868 ctx.model = model_id.clone();
2869 ctx.git = format!("git: {workspace_name}@{branch}");
2870 ctx.tools = "Tools: ready".to_string();
2871 ctx.search_tools = Some(InlineHeaderStatusBadge {
2872 text: workspace_name,
2873 tone: InlineHeaderStatusTone::Ready,
2874 });
2875 ctx.persistent_memory = Some(InlineHeaderStatusBadge {
2876 text: branch,
2877 tone: InlineHeaderStatusTone::Ready,
2878 });
2879 ctx.editor_context = Some(model_id);
2880 ctx
2881}
2882
2883async fn build_agent_session(app: &App) -> Result<crate::app::agent_session::AgentSession> {
2886 use crate::app::agent_session_runtime::{
2887 CreateAgentSessionFromServicesOptions, CreateAgentSessionServicesOptions,
2888 create_agent_session_from_services, create_agent_session_services,
2889 };
2890 use crate::store::session::SessionManager;
2891
2892 let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
2893 let hook_runner = Arc::clone(&app.oxicode().ports().hooks);
2894 let services = create_agent_session_services(
2895 CreateAgentSessionServicesOptions::new(cwd.clone()),
2896 Some(hook_runner),
2897 )?;
2898 let services = Arc::new(services);
2899
2900 let model_id = app.model_id();
2901 let tools = app.agent_tools();
2902
2903 let session_manager = SessionManager::create(&cwd.to_string_lossy(), None);
2904
2905 let result = create_agent_session_from_services(CreateAgentSessionFromServicesOptions {
2906 services,
2907 session_manager,
2908 model_id: if model_id.is_empty() {
2909 None
2910 } else {
2911 Some(model_id)
2912 },
2913 thinking_level: None,
2914 scoped_models: Vec::new(),
2915 tool_registry: Some(tools),
2916 session_state: Some(app.session_state().clone()),
2919 })
2920 .await?;
2921
2922 if let Some(msg) = result.model_fallback_message {
2923 tracing::warn!(message = %msg, "agent session model fallback");
2924 }
2925 Ok(result.session)
2926}
2927
2928fn cheatsheet_lines() -> Vec<String> {
2934 vec![
2935 "".into(),
2936 " Navigation".into(),
2937 " j / ↓ Scroll down".into(),
2938 " k / ↑ Scroll up".into(),
2939 " J (Shift+j) Next turn".into(),
2940 " K (Shift+k) Previous turn".into(),
2941 " PgDn / PgUp Page scroll".into(),
2942 " g / G Top / bottom".into(),
2943 "".into(),
2944 " Blocks".into(),
2945 " e Cycle block (collapse/truncate/expand)".into(),
2946 " E Expand all blocks".into(),
2947 " Ctrl+E Collapse all blocks".into(),
2948 "".into(),
2949 " Search".into(),
2950 " /find <q> Search transcript".into(),
2951 " n / N Next / previous match".into(),
2952 "".into(),
2953 " Commands".into(),
2954 " /theme Cycle color theme".into(),
2955 " /model Pick a model".into(),
2956 " /vim Toggle vim mode".into(),
2957 " /compact Compact context".into(),
2958 " /clear Clear conversation".into(),
2959 " Ctrl+C Cancel run (then y to quit)".into(),
2960 " Ctrl+Enter Send now (abort + submit)".into(),
2961 " Ctrl+M Toggle multiline input".into(),
2962 " Shift+Tab Toggle Auto mode (no questions, runs to end)".into(),
2963 " Ctrl+; Toggle queue panel".into(),
2964 "".into(),
2965 " Special Input".into(),
2966 " @ File picker (fuzzy search)".into(),
2967 " @! Toggle hidden files in picker".into(),
2968 " ! Shell mode (bash command)".into(),
2969 ]
2970}
2971
2972fn build_command_palette() -> OverlayState {
2975 use oxicode_vtui::tui::core::{InlineListItem, InlineListSelection};
2976
2977 let catalog = SlashRegistry::builtin_commands();
2978 let mut items: Vec<InlineListItem> = catalog
2979 .iter()
2980 .map(|(name, desc, aliases)| {
2981 let title = if aliases.is_empty() {
2982 format!("/{name}")
2983 } else {
2984 format!(
2985 "/{name} ({})",
2986 aliases
2987 .iter()
2988 .map(|a| format!("/{a}"))
2989 .collect::<Vec<_>>()
2990 .join(", ")
2991 )
2992 };
2993 InlineListItem {
2994 title,
2995 subtitle: Some(desc.to_string()),
2996 badge: None,
2997 indent: 0,
2998 selection: Some(InlineListSelection::SlashCommand(name.to_string())),
2999 search_value: Some(format!("{name} {desc}")),
3000 }
3001 })
3002 .collect();
3003 items.sort_by(|a, b| a.title.cmp(&b.title));
3004
3005 OverlayState {
3006 title: "Command Palette".into(),
3007 lines: vec!["Type to filter, Enter to select".into()],
3008 items: items
3009 .into_iter()
3010 .map(|item| OverlayListItem {
3011 title: item.title,
3012 subtitle: item.subtitle,
3013 badge: item.badge,
3014 indent: item.indent,
3015 search_value: item.search_value,
3016 selection: item.selection,
3017 })
3018 .collect(),
3019 selected: 0,
3020 search: Some(OverlaySearchState {
3021 label: "search".into(),
3022 placeholder: Some("filter commands\u{2026}".into()),
3023 value: String::new(),
3024 }),
3025 secure_input: None,
3026 }
3027}
3028
3029static FRAME_TICK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
3031static TITLE_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
3033const TITLE_SPINNER: &[&str] = &[
3035 "\u{2807}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}", "\u{2827}",
3036];
3037
3038fn wave_brightness(tick: u64, row: u16, wave_rows: u16, speed: f64) -> f64 {
3041 let phase =
3042 (tick as f64 * speed) + (row as f64 / wave_rows.max(1) as f64) * std::f64::consts::TAU;
3043 let s = phase.sin();
3044 s * s
3045}
3046
3047fn blend_rgb(base: Color, target: Color, ratio: f64) -> Color {
3049 match (base, target) {
3050 (Color::Rgb(br, bg, bb), Color::Rgb(tr, tg, tb)) => {
3051 let r = (br as f64 + (tr as f64 - br as f64) * ratio).round() as u8;
3052 let g = (bg as f64 + (tg as f64 - bg as f64) * ratio).round() as u8;
3053 let b = (bb as f64 + (tb as f64 - bb as f64) * ratio).round() as u8;
3054 Color::Rgb(r, g, b)
3055 }
3056 _ => base,
3057 }
3058}
3059
3060fn accent_color_for_kind(kind: InlineMessageKind, styles: &ThemeStyles) -> Color {
3062 match kind {
3063 InlineMessageKind::User => color_from_anstyle(styles.primary.get_fg_color()),
3064 InlineMessageKind::Agent => color_from_anstyle(styles.response.get_fg_color()),
3065 InlineMessageKind::Tool => color_from_anstyle(styles.tool.get_fg_color()),
3066 InlineMessageKind::Error => color_from_anstyle(styles.error.get_fg_color()),
3067 InlineMessageKind::Warning => color_from_anstyle(styles.status.get_fg_color()),
3068 InlineMessageKind::Info => color_from_anstyle(styles.info.get_fg_color()),
3069 InlineMessageKind::Policy => color_from_anstyle(styles.mcp.get_fg_color()),
3070 InlineMessageKind::Pty => color_from_anstyle(styles.pty_output.get_fg_color()),
3071 }
3072}
3073
3074fn render_frame(frame: &mut Frame<'_>, state: &RenderState, _handle: &InlineHandle) {
3080 let area = frame.area();
3081 let bg = active_styles().background;
3086 frame
3087 .buffer_mut()
3088 .set_style(area, Style::default().bg(color_from_anstyle(Some(bg))));
3089 let layout = super::frame_layout::render_chrome(frame, area, state);
3090 let tick = FRAME_TICK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3091 {
3093 let running = state.reasoning_stage.is_some();
3094 let was_running = TITLE_RUNNING.swap(running, std::sync::atomic::Ordering::Relaxed);
3095 if running || was_running {
3096 let title = if running {
3097 let spin = TITLE_SPINNER[(tick as usize) % TITLE_SPINNER.len()];
3098 let model = state
3099 .header_context
3100 .editor_context
3101 .as_deref()
3102 .unwrap_or("oxicode");
3103 format!("{spin} oxicode \u{2014} {model}")
3104 } else {
3105 "oxicode".to_string()
3106 };
3107 use std::io::Write;
3108 let _ = write!(std::io::stderr(), "\x1b]2;{}\x07", title);
3109 let _ = std::io::stderr().flush();
3110 }
3111 }
3112 render_transcript(frame, layout.scrollback, state, tick);
3113 if !state.queued_inputs.is_empty() {
3114 render_queue_pane(frame, layout.scrollback, state);
3115 }
3116 if !state.todo_items.is_empty() {
3117 render_todo_pane(frame, layout.scrollback, &state.todo_items);
3118 }
3119 if !state.follow_ups.is_empty() {
3120 render_follow_ups(frame, layout.prompt, &state.follow_ups);
3121 }
3122 if let Some(stage) = &state.reasoning_stage {
3123 render_reasoning_indicator(frame, layout.prompt, stage);
3124 }
3125 render_composer(frame, layout.prompt, state);
3126 let occluded = state.overlay.is_some() || state.confirmation.is_some();
3128 if let Some(tip) = &state.tip
3129 && tip_is_visible(tip, tick)
3130 && !(tip.ambient && occluded)
3131 {
3132 render_tip(frame, layout.prompt, &tip.text);
3133 }
3134 if state.slash_popup.open {
3135 render_slash_popup(frame, layout.prompt, state);
3136 }
3137 if state.file_search.is_some() {
3138 render_file_search_dropdown(frame, layout.prompt, state);
3139 }
3140 if state.agent_hub_open {
3141 render_agent_hub(frame, area, state);
3142 }
3143 if let Some(overlay) = &state.overlay {
3144 render_overlay(frame, area, overlay);
3145 }
3146 if let Some(confirm) = &state.confirmation {
3147 render_confirmation(frame, area, confirm);
3148 }
3149}
3150
3151fn render_confirmation(frame: &mut Frame, area: Rect, confirm: &ModalConfirmation) {
3153 let styles = active_styles();
3154 let accent = color_from_anstyle(styles.error.get_fg_color());
3155 let inner_w = confirm
3156 .title
3157 .chars()
3158 .count()
3159 .max(confirm.message.chars().count())
3160 .max(36) as u16;
3161 let width = inner_w + 4;
3162 let height = 5;
3163 let x = area.x + area.width.saturating_sub(width) / 2;
3164 let y = area.y + area.height.saturating_sub(height) / 2;
3165 let popup_area = Rect {
3166 x,
3167 y,
3168 width,
3169 height,
3170 };
3171 let block = Block::default()
3172 .borders(Borders::ALL)
3173 .border_type(BorderType::Rounded)
3174 .title(Span::styled(
3175 format!(" {} ", confirm.title),
3176 Style::default().fg(accent).bold(),
3177 ))
3178 .border_style(Style::default().fg(accent));
3179 let msg = Line::styled(
3180 confirm.message.clone(),
3181 Style::default().fg(color_from_anstyle(Some(styles.foreground))),
3182 );
3183 frame.render_widget(
3184 Paragraph::new(vec![Line::default(), msg]).block(block),
3185 popup_area,
3186 );
3187}
3188
3189fn render_agent_hub(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
3193 let rows = state.hub_entries.len() as u16;
3194 let height = rows.saturating_add(4).min(area.height.saturating_sub(1));
3195 let width = area.width.clamp(30, 80);
3196 let rect = Rect {
3197 x: area.x + (area.width.saturating_sub(width)) / 2,
3198 y: area.y + (area.height.saturating_sub(height)) / 2,
3199 width,
3200 height,
3201 };
3202 frame.render_widget(Clear, rect);
3203
3204 let title = Line::from(Span::styled(
3205 " Agent Hub ",
3206 Style::default().add_modifier(Modifier::BOLD),
3207 ));
3208 let block = Block::default().borders(Borders::ALL).title(title);
3209
3210 let items: Vec<ListItem<'_>> = if state.hub_entries.is_empty() {
3211 vec![ListItem::new(Line::from(Span::raw(
3212 "No agents registered.",
3213 )))]
3214 } else {
3215 state
3216 .hub_entries
3217 .iter()
3218 .map(|(id, e)| {
3219 ListItem::new(Line::from(vec![
3220 Span::raw(format!("{:?} ", e.kind)),
3221 Span::raw(e.display_name.clone()),
3222 Span::raw(format!(" — {:?} ({})", e.status, id)),
3223 ]))
3224 })
3225 .collect()
3226 };
3227 frame.render_widget(List::new(items).block(block), rect);
3228}
3229
3230fn render_overlay(frame: &mut Frame<'_>, area: Rect, overlay: &OverlayState) {
3235 let styles = active_styles();
3236 if let Some(secure) = &overlay.secure_input {
3239 let lines_count = overlay.lines.len();
3241 let desired_h = (lines_count as u16).saturating_add(1).saturating_add(2); let height = desired_h.min(area.height.saturating_sub(2));
3243 let width = area.width.clamp(30, 80);
3244 let rect = Rect {
3245 x: area.x + (area.width.saturating_sub(width)) / 2,
3246 y: area.y + (area.height.saturating_sub(height)) / 2,
3247 width,
3248 height,
3249 };
3250 frame.render_widget(Clear, rect);
3251
3252 let title = Line::from(Span::styled(
3253 format!(" {} ", overlay.title),
3254 Style::default()
3255 .fg(color_from_anstyle(styles.primary.get_fg_color()))
3256 .add_modifier(Modifier::BOLD),
3257 ));
3258 let block = Block::default()
3259 .borders(Borders::ALL)
3260 .border_type(BorderType::Rounded)
3261 .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
3262 .title(title);
3263 let inner = block.inner(rect);
3264 frame.render_widget(&block, rect);
3265
3266 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
3267 let fg = color_from_anstyle(Some(styles.foreground));
3268
3269 let mut row = inner.top();
3270 for line_text in &overlay.lines {
3271 let row_area = Rect {
3272 x: inner.left(),
3273 y: row,
3274 width: inner.width,
3275 height: 1,
3276 };
3277 let line = Line::from(Span::styled(
3278 line_text.clone(),
3279 Style::default().fg(secondary),
3280 ));
3281 frame.render_widget(Paragraph::new(line), row_area);
3282 row = row.saturating_add(1);
3283 }
3284
3285 let label = &secure.config.label;
3288 let display: String = if secure.value.is_empty() {
3289 secure
3290 .config
3291 .placeholder
3292 .clone()
3293 .unwrap_or_else(|| "(empty)".to_string())
3294 } else if secure.config.mask_input {
3295 "*".repeat(secure.value.chars().count())
3299 } else {
3300 secure.value.clone()
3301 };
3302 let row_area = Rect {
3303 x: inner.left(),
3304 y: row,
3305 width: inner.width,
3306 height: 1,
3307 };
3308 let line = Line::from(vec![
3309 Span::styled(format!("{label}: "), Style::default().fg(secondary)),
3310 Span::styled(display, Style::default().fg(fg)),
3311 ]);
3312 frame.render_widget(Paragraph::new(line), row_area);
3313 return;
3314 }
3315 let visible_max = (area.height as usize).saturating_sub(6).max(3);
3316
3317 let filtered: Vec<usize> = match &overlay.search {
3319 Some(search) if !search.value.is_empty() => {
3320 let needle = search.value.to_lowercase();
3321 overlay
3322 .items
3323 .iter()
3324 .enumerate()
3325 .filter_map(|(idx, item)| {
3326 let title_match = item.title.to_lowercase().contains(&needle);
3327 let sv_match = item
3328 .search_value
3329 .as_deref()
3330 .map(|v| v.to_lowercase().contains(&needle))
3331 .unwrap_or(false);
3332 if title_match || sv_match {
3333 Some(idx)
3334 } else {
3335 None
3336 }
3337 })
3338 .collect()
3339 }
3340 _ => (0..overlay.items.len()).collect(),
3341 };
3342
3343 let has_search = overlay.search.is_some();
3344 let lines_count = overlay.lines.len();
3345 let items_count = filtered.len().min(visible_max);
3346 let height_inner = (lines_count + items_count + if has_search { 1 } else { 0 }) as u16;
3347 let desired_h = height_inner.saturating_add(2); let height = desired_h.min(area.height.saturating_sub(2));
3349 let width = area.width.clamp(30, 80);
3350 let rect = Rect {
3351 x: area.x + (area.width.saturating_sub(width)) / 2,
3352 y: area.y + (area.height.saturating_sub(height)) / 2,
3353 width,
3354 height,
3355 };
3356 frame.render_widget(Clear, rect);
3357
3358 let title = Line::from(Span::styled(
3359 format!(" {} ", overlay.title),
3360 Style::default()
3361 .fg(color_from_anstyle(styles.primary.get_fg_color()))
3362 .add_modifier(Modifier::BOLD),
3363 ));
3364 let block = Block::default()
3365 .borders(Borders::ALL)
3366 .border_type(BorderType::Rounded)
3367 .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
3368 .title(title);
3369 let inner = block.inner(rect);
3370 frame.render_widget(&block, rect);
3371
3372 let primary = color_from_anstyle(styles.primary.get_fg_color());
3373 let fg = color_from_anstyle(Some(styles.foreground));
3374 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
3375
3376 let selected_filtered_pos = filtered
3378 .iter()
3379 .position(|&idx| idx == overlay.selected)
3380 .unwrap_or(0);
3381
3382 let mut row = inner.top();
3383 if let Some(search) = &overlay.search {
3385 let prompt = format!("{}: {}", search.label, search.value);
3386 let line = Line::from(vec![
3387 Span::styled(
3388 format!("{}: ", search.label),
3389 Style::default().fg(secondary),
3390 ),
3391 Span::styled(
3392 if search.value.is_empty() {
3393 search
3394 .placeholder
3395 .clone()
3396 .unwrap_or_else(|| "type to filter\u{2026}".to_string())
3397 } else {
3398 search.value.clone()
3399 },
3400 if search.value.is_empty() {
3401 Style::default().fg(secondary).add_modifier(Modifier::DIM)
3402 } else {
3403 Style::default().fg(fg)
3404 },
3405 ),
3406 ]);
3407 let _ = prompt; let row_area = Rect {
3409 x: inner.left(),
3410 y: row,
3411 width: inner.width,
3412 height: 1,
3413 };
3414 frame.render_widget(Paragraph::new(line), row_area);
3415 row = row.saturating_add(1);
3416 }
3417
3418 for line_text in &overlay.lines {
3420 let row_area = Rect {
3421 x: inner.left(),
3422 y: row,
3423 width: inner.width,
3424 height: 1,
3425 };
3426 let line = Line::from(Span::styled(
3427 line_text.clone(),
3428 Style::default().fg(secondary),
3429 ));
3430 frame.render_widget(Paragraph::new(line), row_area);
3431 row = row.saturating_add(1);
3432 }
3433
3434 if filtered.is_empty() {
3436 let row_area = Rect {
3437 x: inner.left(),
3438 y: row,
3439 width: inner.width,
3440 height: 1,
3441 };
3442 let empty_text = if overlay.search.is_some() {
3443 " (no matches)"
3444 } else {
3445 " (no items)"
3446 };
3447 frame.render_widget(
3448 Paragraph::new(Line::from(Span::styled(
3449 empty_text,
3450 Style::default().fg(secondary).add_modifier(Modifier::DIM),
3451 ))),
3452 row_area,
3453 );
3454 } else {
3455 for (display_idx, &item_idx) in filtered.iter().take(visible_max).enumerate() {
3456 let item = &overlay.items[item_idx];
3457 let is_selected = display_idx == selected_filtered_pos;
3458 let marker = if is_selected { "\u{25b8} " } else { " " };
3459 let indent = " ".repeat(item.indent as usize);
3460 let item_style = if is_selected {
3461 Style::default().fg(primary).add_modifier(Modifier::BOLD)
3462 } else {
3463 Style::default().fg(fg)
3464 };
3465 let mut spans = vec![
3466 Span::styled(marker, item_style),
3467 Span::styled(indent, item_style),
3468 Span::styled(item.title.clone(), item_style),
3469 ];
3470 if let Some(badge) = &item.badge {
3471 spans.push(Span::raw(" "));
3472 spans.push(Span::styled(
3473 badge.clone(),
3474 Style::default().fg(secondary).add_modifier(Modifier::DIM),
3475 ));
3476 }
3477 if let Some(subtitle) = &item.subtitle {
3478 spans.push(Span::raw(" "));
3479 spans.push(Span::styled(
3480 subtitle.clone(),
3481 Style::default().fg(secondary),
3482 ));
3483 }
3484 let line = Line::from(spans);
3485 let row_area = Rect {
3486 x: inner.left(),
3487 y: row,
3488 width: inner.width,
3489 height: 1,
3490 };
3491 frame.render_widget(Paragraph::new(line), row_area);
3492 row = row.saturating_add(1);
3493 }
3494 }
3495}
3496
3497fn render_transcript(frame: &mut Frame<'_>, area: Rect, state: &RenderState, tick: u64) {
3498 if state.transcript.is_empty() {
3499 render_welcome(frame, area);
3500 return;
3501 }
3502 let styles = active_styles();
3503 let bg_color = color_from_anstyle(Some(styles.background));
3504
3505 let accent_w: u16 = 1;
3507 let scrollbar_w: u16 = 1;
3508 let content_area = Rect {
3509 x: area.x + accent_w,
3510 y: area.y,
3511 width: area.width.saturating_sub(accent_w + scrollbar_w),
3512 height: area.height,
3513 };
3514
3515 let search_set: std::collections::HashSet<usize> = state
3518 .search
3519 .as_ref()
3520 .map(|s| s.matches.iter().copied().collect())
3521 .unwrap_or_default();
3522 let current_match = state
3523 .search
3524 .as_ref()
3525 .and_then(|s| (!s.matches.is_empty()).then(|| s.matches[s.current]));
3526
3527 let mut display: Vec<(usize, InlineMessageKind, Line<'_>)> =
3528 Vec::with_capacity(state.transcript.len());
3529 const TRUNC_TAIL: usize = 3;
3534 let dim_style = Style::default()
3535 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
3536 .add_modifier(Modifier::DIM);
3537
3538 let mut blocks: Vec<(usize, Vec<(usize, &TranscriptLine)>)> = Vec::new();
3539 for (idx, tl) in state.transcript.iter().enumerate() {
3540 if blocks.last().is_some_and(|(id, _)| *id == tl.block_id) {
3541 blocks.last_mut().unwrap().1.push((idx, tl));
3542 } else {
3543 blocks.push((tl.block_id, vec![(idx, tl)]));
3544 }
3545 }
3546
3547 for (block_id, lines) in &blocks {
3548 let mode = state.block_mode(*block_id);
3549 let len = lines.len();
3550 match mode {
3551 BlockDisplayMode::Collapsed => {
3552 let &(idx, tl) = &lines[0];
3553 let is_match = search_set.contains(&idx);
3554 let line =
3555 transcript_line_marked(tl, &styles, true, is_match, current_match == Some(idx));
3556 display.push((idx, tl.kind, line));
3557 }
3558 BlockDisplayMode::Expanded => {
3559 for &(idx, tl) in lines {
3560 let is_match = search_set.contains(&idx);
3561 let line = transcript_line_marked(
3562 tl,
3563 &styles,
3564 false,
3565 is_match,
3566 current_match == Some(idx),
3567 );
3568 display.push((idx, tl.kind, line));
3569 }
3570 }
3571 BlockDisplayMode::Truncated => {
3572 if len <= TRUNC_TAIL + 1 {
3573 for &(idx, tl) in lines {
3575 let is_match = search_set.contains(&idx);
3576 let line = transcript_line_marked(
3577 tl,
3578 &styles,
3579 false,
3580 is_match,
3581 current_match == Some(idx),
3582 );
3583 display.push((idx, tl.kind, line));
3584 }
3585 } else {
3586 let &(hidx, htl) = &lines[0];
3588 let is_match = search_set.contains(&hidx);
3589 let line = transcript_line_marked(
3590 htl,
3591 &styles,
3592 false,
3593 is_match,
3594 current_match == Some(hidx),
3595 );
3596 display.push((hidx, htl.kind, line));
3597 let hidden = len - 1 - TRUNC_TAIL;
3599 let gap = Line::styled(format!(" \u{2026} +{hidden} lines"), dim_style);
3600 display.push((hidx, htl.kind, gap));
3601 for &(idx, tl) in lines.iter().rev().take(TRUNC_TAIL).rev() {
3603 let is_match = search_set.contains(&idx);
3604 let line = transcript_line_marked(
3605 tl,
3606 &styles,
3607 false,
3608 is_match,
3609 current_match == Some(idx),
3610 );
3611 display.push((idx, tl.kind, line));
3612 }
3613 }
3614 }
3615 }
3616 }
3617
3618 let total = display.len();
3620 let raw_start = if state.scroll_offset == usize::MAX {
3621 total.saturating_sub(content_area.height as usize)
3622 } else {
3623 display
3624 .iter()
3625 .position(|(orig_idx, _, _)| *orig_idx >= state.scroll_offset)
3626 .unwrap_or(total.saturating_sub(1))
3627 };
3628 let start = effective_scroll_offset(raw_start, total, content_area.height as usize);
3629
3630 let sticky_first: Option<usize> = display.get(start).and_then(|(orig_idx, _, _)| {
3634 let bid = state.transcript.get(*orig_idx)?.block_id;
3635 let first_idx = state.transcript.iter().position(|l| l.block_id == bid)?;
3636 (first_idx != *orig_idx).then_some(first_idx)
3637 });
3638 let sticky_h: u16 = if sticky_first.is_some() { 1 } else { 0 };
3639 let body_top = content_area.top() + sticky_h;
3640
3641 let running = state.reasoning_stage.is_some();
3643 const WAVE_ROWS: u16 = 32;
3644 const WAVE_SPEED: f64 = 0.15;
3645
3646 const FADE_ROWS: usize = 5;
3651 let sticky_opacity: f64 = if let Some(sidx) = sticky_first {
3652 let sticky_bid = state.transcript[sidx].block_id;
3653 let next_offset = display.iter().skip(start).position(|(orig_idx, _, _)| {
3656 state
3657 .transcript
3658 .get(*orig_idx)
3659 .map(|l| l.block_id != sticky_bid)
3660 .unwrap_or(false)
3661 });
3662 match next_offset {
3663 Some(off) if off <= FADE_ROWS => off as f64 / FADE_ROWS as f64,
3664 _ => 1.0,
3665 }
3666 } else {
3667 1.0
3668 };
3669
3670 if let Some(sidx) = sticky_first {
3673 let tl = &state.transcript[sidx];
3674 let accent_base = accent_color_for_kind(tl.kind, &styles);
3675 let rail_blend = 0.7 * sticky_opacity;
3676 let bg_blend = 0.1 * sticky_opacity;
3677 if sticky_opacity > 0.05
3678 && let Some(cell) = frame.buffer_mut().cell_mut((area.x, content_area.top()))
3679 {
3680 cell.set_char('\u{2503}');
3681 cell.set_style(Style::default().fg(blend_rgb(bg_color, accent_base, rail_blend)));
3682 }
3683 let line = transcript_line_marked(tl, &styles, false, false, false);
3684 let row = Rect {
3685 x: content_area.x,
3686 y: content_area.top(),
3687 width: content_area.width,
3688 height: 1,
3689 };
3690 if bg_blend > 0.01 {
3691 frame.buffer_mut().set_style(
3692 row,
3693 Style::default().bg(blend_rgb(bg_color, accent_base, bg_blend)),
3694 );
3695 }
3696 frame.render_widget(Paragraph::new(line), row);
3697 }
3698
3699 let mut y = body_top;
3701 let width = content_area.width.max(1) as usize;
3702 let mut visual_row: u16 = 0;
3703 for (_, kind, line) in display.into_iter().skip(start) {
3704 if y >= content_area.bottom() {
3705 break;
3706 }
3707 let text_w = line.width();
3708 let wrapped_h = if text_w == 0 {
3709 1
3710 } else {
3711 text_w.div_ceil(width).max(1) as u16
3712 };
3713
3714 let accent_base = accent_color_for_kind(kind, &styles);
3716 for row_offset in 0..wrapped_h {
3717 let paint_y = y + row_offset;
3718 if paint_y >= content_area.bottom() {
3719 break;
3720 }
3721 let brightness = if running {
3722 0.4 + 0.6 * wave_brightness(tick, visual_row + row_offset, WAVE_ROWS, WAVE_SPEED)
3723 } else {
3724 0.7
3725 };
3726 let rail_color = blend_rgb(bg_color, accent_base, brightness);
3727 if let Some(cell) = frame.buffer_mut().cell_mut((area.x, paint_y)) {
3728 cell.set_char('\u{2503}'); cell.set_style(Style::default().fg(rail_color));
3730 }
3731 }
3732
3733 let row = Rect {
3734 x: content_area.x,
3735 y,
3736 width: content_area.width,
3737 height: wrapped_h.min(content_area.bottom().saturating_sub(y)),
3738 };
3739 frame.render_widget(Paragraph::new(line).wrap(Wrap { trim: false }), row);
3740 y += wrapped_h;
3741 visual_row += wrapped_h;
3742 }
3743
3744 let body_viewport = (content_area.height as usize).saturating_sub(sticky_h as usize);
3747 if total > body_viewport {
3748 let follow = state.scroll_offset == usize::MAX;
3749 render_scrollbar(
3750 frame,
3751 area.right().saturating_sub(1),
3752 area.top(),
3753 area.height,
3754 total,
3755 body_viewport,
3756 start,
3757 follow,
3758 &styles,
3759 bg_color,
3760 );
3761 }
3762}
3763
3764#[allow(clippy::too_many_arguments)]
3769fn render_scrollbar(
3770 frame: &mut Frame,
3771 x: u16,
3772 top: u16,
3773 height: u16,
3774 total: usize,
3775 viewport: usize,
3776 start: usize,
3777 follow: bool,
3778 styles: &ThemeStyles,
3779 bg: Color,
3780) {
3781 if height == 0 {
3782 return;
3783 }
3784 let ratio = (start as f64 / total.max(1) as f64).clamp(0.0, 1.0);
3785 let thumb_h = (((viewport as f64 / total.max(1) as f64) * height as f64).ceil() as u16)
3786 .max(1)
3787 .min(height);
3788 let track_h = height.saturating_sub(thumb_h);
3789 let thumb_y = (ratio * track_h as f64).round() as u16;
3790
3791 let accent = color_from_anstyle(styles.primary.get_fg_color());
3792 let thumb_color = if follow {
3794 blend_rgb(bg, accent, 0.35)
3795 } else {
3796 accent
3797 };
3798 let rail_color = blend_rgb(bg, accent, 0.1);
3799
3800 for row in 0..height {
3801 let y = top + row;
3802 let is_thumb = row >= thumb_y && row < thumb_y + thumb_h;
3803 let (ch, color) = if is_thumb {
3804 ('\u{2588}', thumb_color) } else {
3806 ('\u{2502}', rail_color) };
3808 if let Some(cell) = frame.buffer_mut().cell_mut((x, y)) {
3809 cell.set_char(ch);
3810 cell.set_style(Style::default().fg(color));
3811 }
3812 }
3813}
3814
3815fn transcript_line_marked<'a>(
3818 line: &'a TranscriptLine,
3819 styles: &'a ThemeStyles,
3820 folded: bool,
3821 is_match: bool,
3822 is_current: bool,
3823) -> Line<'a> {
3824 let (kind_style, marker) = match line.kind {
3825 InlineMessageKind::Agent => (
3826 Style::default().fg(color_from_anstyle(styles.response.get_fg_color())),
3827 "\u{25cf}", ),
3829 InlineMessageKind::User => (
3830 Style::default().fg(color_from_anstyle(styles.primary.get_fg_color())),
3831 "\u{276f}", ),
3833 InlineMessageKind::Tool => (
3834 Style::default().fg(color_from_anstyle(styles.tool.get_fg_color())),
3835 "\u{2699}", ),
3837 InlineMessageKind::Error => (
3838 Style::default().fg(color_from_anstyle(styles.error.get_fg_color())),
3839 "\u{2717}", ),
3841 InlineMessageKind::Warning => (
3842 Style::default().fg(color_from_anstyle(styles.status.get_fg_color())),
3843 "\u{26a0}", ),
3845 InlineMessageKind::Info => (
3846 Style::default().fg(color_from_anstyle(styles.info.get_fg_color())),
3847 "\u{2139}", ),
3849 InlineMessageKind::Policy => (
3850 Style::default().fg(color_from_anstyle(styles.mcp.get_fg_color())),
3851 "\u{25c6}", ),
3853 InlineMessageKind::Pty => (
3854 Style::default().fg(color_from_anstyle(styles.pty_output.get_fg_color())),
3855 "\u{258c}", ),
3857 };
3858
3859 let prefix = if folded {
3861 format!("\u{25b8} {} ", marker) } else {
3863 format!("{} ", marker)
3864 };
3865
3866 let highlight = if is_current {
3868 Some(Style::default().reversed())
3869 } else if is_match {
3870 Some(Style::default().add_modifier(Modifier::UNDERLINED))
3871 } else {
3872 None
3873 };
3874
3875 let mut spans = Vec::with_capacity(line.segments.len() + 1);
3876 spans.push(Span::styled(prefix, kind_style));
3877 for segment in &line.segments {
3878 let mut style = segment_style(segment, kind_style, styles);
3879 if let Some(h) = highlight {
3880 style = style.patch(h);
3881 }
3882 spans.push(Span::styled(segment.text.clone(), style));
3883 }
3884 Line::from(spans)
3885}
3886
3887fn segment_style(segment: &InlineSegment, fallback: Style, styles: &ThemeStyles) -> Style {
3888 let mut style = fallback;
3889 let inline = segment.style.as_ref();
3890 if let Some(color) = inline.color {
3891 style = style.fg(color_from_anstyle(Some(color)));
3892 } else {
3893 style = style.fg(color_from_anstyle(styles.response.get_fg_color()));
3897 }
3898 if inline.effects.contains(anstyle::Effects::BOLD) {
3899 style = style.add_modifier(Modifier::BOLD);
3900 }
3901 if inline.effects.contains(anstyle::Effects::ITALIC) {
3902 style = style.add_modifier(Modifier::ITALIC);
3903 }
3904 if inline.effects.contains(anstyle::Effects::UNDERLINE) {
3905 style = style.add_modifier(Modifier::UNDERLINED);
3906 }
3907 if inline.effects.contains(anstyle::Effects::DIMMED) {
3908 style = style.add_modifier(Modifier::DIM);
3909 }
3910 style
3911}
3912
3913fn render_composer(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
3914 let styles = active_styles();
3915 let prefix_style = Style::default()
3916 .fg(color_from_anstyle(styles.primary.get_fg_color()))
3917 .bold();
3918 let text_style = Style::default().fg(color_from_anstyle(Some(styles.foreground)));
3919
3920 let prefix = state.prompt_prefix.clone();
3921 let body = state.input_buffer.clone();
3922 let placeholder = state.placeholder.clone();
3923
3924 let mut line_spans = Vec::new();
3925 if let Some(label) = state.vim_state.status_label() {
3926 line_spans.push(Span::styled(
3927 format!("[{label}] "),
3928 Style::default()
3929 .fg(color_from_anstyle(styles.tool.get_fg_color()))
3930 .add_modifier(Modifier::BOLD),
3931 ));
3932 }
3933 if state.autonomy_mode.is_auto() {
3934 line_spans.push(Span::styled(
3935 "[auto] ",
3936 Style::default()
3937 .fg(Color::Yellow)
3938 .add_modifier(Modifier::BOLD),
3939 ));
3940 }
3941 line_spans.push(Span::styled(prefix, prefix_style));
3942 if state.shell_mode {
3943 line_spans.push(Span::styled(
3944 "! ",
3945 Style::default()
3946 .fg(Color::Yellow)
3947 .add_modifier(Modifier::BOLD),
3948 ));
3949 }
3950 if body.is_empty()
3951 && let Some(ph) = placeholder
3952 {
3953 line_spans.push(Span::styled(
3954 ph,
3955 Style::default()
3956 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
3957 .dim(),
3958 ));
3959 } else {
3960 line_spans.push(Span::styled(body, text_style));
3961 }
3962 let block = Block::default()
3963 .borders(Borders::ALL)
3964 .border_type(BorderType::Rounded)
3965 .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())));
3966 let paragraph = Paragraph::new(Line::from(line_spans))
3967 .block(block)
3968 .wrap(Wrap { trim: false });
3969 frame.render_widget(paragraph, area);
3970
3971 if state.input_enabled {
3974 let vim_off = state
3975 .vim_state
3976 .status_label()
3977 .map(|l| format!("[{l}] ").chars().count() as u16)
3978 .unwrap_or(0);
3979 let shell_off = if state.shell_mode { 2 } else { 0 };
3980 let mode_off = if state.autonomy_mode.is_auto() { 7 } else { 0 };
3981 let cursor_x = area.left()
3982 + 1
3983 + vim_off
3984 + mode_off
3985 + shell_off
3986 + state.prompt_prefix.chars().count() as u16
3987 + state.input_cursor as u16;
3988 let cursor_y = area.top() + 1;
3989 frame.set_cursor_position(ratatui::layout::Position::new(cursor_x, cursor_y));
3990 }
3991}
3992
3993fn render_welcome(frame: &mut Frame<'_>, area: Rect) {
3996 let styles = active_styles();
3997 let primary = color_from_anstyle(styles.primary.get_fg_color());
3998 let fg = color_from_anstyle(Some(styles.foreground));
3999 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
4000
4001 if area.width >= 90 {
4004 use oxicode_vtui::design::layout::WelcomeLayout;
4005 let layout = WelcomeLayout::compute(area, 3, 0, 0, 1, 0, false);
4006 let logo_area = if layout.has_hero_box() {
4007 layout.hero_logo
4008 } else {
4009 layout.logo
4010 };
4011 if logo_area.height > 0 {
4012 frame.render_widget(
4013 Paragraph::new(Line::from(Span::styled(
4014 "\u{25cf} oxicode",
4015 Style::default().fg(primary).add_modifier(Modifier::BOLD),
4016 )))
4017 .alignment(Alignment::Center),
4018 logo_area,
4019 );
4020 }
4021 if layout.tip.height > 0 {
4022 frame.render_widget(
4023 Paragraph::new(Line::from(Span::styled(
4024 "Type a message to begin, or press / for commands.",
4025 Style::default().fg(fg),
4026 )))
4027 .alignment(Alignment::Center),
4028 layout.tip,
4029 );
4030 }
4031 if layout.version.height > 0 {
4032 frame.render_widget(
4033 Paragraph::new(Line::from(Span::styled(
4034 format!("v{}", env!("CARGO_PKG_VERSION")),
4035 Style::default().fg(secondary).add_modifier(Modifier::DIM),
4036 )))
4037 .alignment(Alignment::Center),
4038 layout.version,
4039 );
4040 }
4041 return;
4042 }
4043
4044 let version = env!("CARGO_PKG_VERSION");
4046 let text = vec![
4047 Line::from(""),
4048 Line::from(""),
4049 Line::from(Span::styled(
4050 "\u{25cf} oxicode",
4051 Style::default().fg(primary).add_modifier(Modifier::BOLD),
4052 )),
4053 Line::from(""),
4054 Line::from(Span::styled(
4055 "Type a message to begin, or press / for commands.",
4056 Style::default().fg(fg),
4057 )),
4058 Line::from(Span::styled(
4059 format!("v{version} \u{2014} /help for commands"),
4060 Style::default().fg(secondary),
4061 )),
4062 ];
4063 frame.render_widget(Paragraph::new(text).alignment(Alignment::Center), area);
4064}
4065
4066fn render_reasoning_indicator(frame: &mut Frame<'_>, composer_area: Rect, stage: &str) {
4068 let styles = active_styles();
4069 let indicator_area = Rect {
4070 x: composer_area.x,
4071 y: composer_area.top().saturating_sub(1),
4072 width: composer_area.width,
4073 height: 1,
4074 };
4075 let spinner = "\u{25cc}"; let line = Line::from(vec![
4077 Span::styled(
4078 format!("{spinner} "),
4079 Style::default().fg(color_from_anstyle(styles.tool.get_fg_color())),
4080 ),
4081 Span::styled(
4082 stage.to_string(),
4083 Style::default()
4084 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
4085 .add_modifier(Modifier::DIM),
4086 ),
4087 ]);
4088 frame.render_widget(Paragraph::new(line), indicator_area);
4089}
4090
4091fn render_queue_pane(frame: &mut Frame<'_>, scrollback: Rect, state: &RenderState) {
4093 let styles = active_styles();
4094 let entries = &state.queued_inputs;
4095 let interactive = state.queue_panel_open;
4096 let selected = state.queue_selected.min(entries.len().saturating_sub(1));
4097 let height = entries.len() as u16 + 1;
4098 let area = Rect {
4099 x: scrollback.x,
4100 y: scrollback.y,
4101 width: scrollback.width,
4102 height,
4103 };
4104 let info = color_from_anstyle(styles.info.get_fg_color());
4105 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
4106 let primary = color_from_anstyle(styles.primary.get_fg_color());
4107 let items: Vec<Line<'_>> = entries
4108 .iter()
4109 .enumerate()
4110 .map(|(i, e)| {
4111 let prefix = if interactive {
4112 format!("#{} ", i + 1)
4113 } else {
4114 "\u{2261} ".to_string()
4115 };
4116 let prefix_style = if interactive && i == selected {
4117 Style::default().fg(primary).add_modifier(Modifier::BOLD)
4118 } else {
4119 Style::default().fg(info)
4120 };
4121 let text_style = if interactive && i == selected {
4122 Style::default().fg(primary).add_modifier(Modifier::BOLD)
4123 } else {
4124 Style::default().fg(secondary)
4125 };
4126 let marker = if interactive && i == selected {
4127 "\u{25b8} " } else {
4129 " "
4130 };
4131 Line::from(vec![
4132 Span::styled(prefix, prefix_style),
4133 Span::styled(marker, prefix_style),
4134 Span::styled(e.clone(), text_style),
4135 ])
4136 })
4137 .collect();
4138 frame.render_widget(
4139 Paragraph::new(items).block(Block::default().borders(Borders::TOP).border_style(
4140 Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
4141 )),
4142 area,
4143 );
4144}
4145
4146fn flatten_todo_items(
4148 phases: &[oxicode_agent::tools::todo::TodoPhase],
4149) -> Vec<(String, TodoStatus)> {
4150 phases
4151 .iter()
4152 .flat_map(|p| p.tasks.iter().map(|t| (t.content.clone(), t.status)))
4153 .collect()
4154}
4155
4156fn render_todo_pane(frame: &mut Frame<'_>, scrollback: Rect, items: &[(String, TodoStatus)]) {
4158 let styles = active_styles();
4159 let height = items.len() as u16 + 1;
4160 let area = Rect {
4161 x: scrollback.x,
4162 y: scrollback.y,
4163 width: scrollback.width,
4164 height,
4165 };
4166 let lines: Vec<Line<'_>> = items
4167 .iter()
4168 .map(|(text, status)| {
4169 let (marker, color) = match status {
4173 TodoStatus::Completed => ("\u{2611}", Some(styles.foreground)), TodoStatus::InProgress => ("\u{25B6}", styles.primary.get_fg_color()), TodoStatus::Blocked => ("\u{23F8}", styles.info.get_fg_color()), TodoStatus::Abandoned => ("\u{2717}", styles.error.get_fg_color()), TodoStatus::Pending => ("\u{2610}", styles.secondary.get_fg_color()), };
4179 let text_style = if *status == TodoStatus::Completed {
4180 Style::default()
4181 .fg(color_from_anstyle(Some(styles.foreground)))
4182 .add_modifier(Modifier::CROSSED_OUT)
4183 } else {
4184 Style::default().fg(color_from_anstyle(Some(styles.foreground)))
4185 };
4186 Line::from(vec![
4187 Span::styled(
4188 format!("{marker} "),
4189 Style::default().fg(color_from_anstyle(color)),
4190 ),
4191 Span::styled(text.clone(), text_style),
4192 ])
4193 })
4194 .collect();
4195 frame.render_widget(Paragraph::new(lines), area);
4196}
4197
4198fn render_follow_ups(frame: &mut Frame<'_>, composer_area: Rect, chips: &[String]) {
4200 let styles = active_styles();
4201 let area = Rect {
4202 x: composer_area.x,
4203 y: composer_area.top().saturating_sub(1),
4204 width: composer_area.width,
4205 height: 1,
4206 };
4207 let mut spans = vec![Span::styled(
4208 "Suggestions: ",
4209 Style::default()
4210 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
4211 .add_modifier(Modifier::DIM),
4212 )];
4213 for (i, chip) in chips.iter().enumerate() {
4214 if i > 0 {
4215 spans.push(Span::raw(" "));
4216 }
4217 spans.push(Span::styled(
4218 format!("\u{25b8} {chip}"),
4219 Style::default().fg(color_from_anstyle(styles.primary.get_fg_color())),
4220 ));
4221 }
4222 frame.render_widget(Paragraph::new(Line::from(spans)), area);
4223}
4224
4225fn tip_is_visible(tip: &EphemeralTip, now_tick: u64) -> bool {
4227 now_tick.saturating_sub(tip.born_tick) < tip.ttl_ticks
4228}
4229
4230fn render_tip(frame: &mut Frame, composer_area: Rect, text: &str) {
4232 let styles = active_styles();
4233 let area = Rect {
4234 x: composer_area.x,
4235 y: composer_area.top().saturating_sub(1),
4236 width: composer_area.width,
4237 height: 1,
4238 };
4239 let line = Line::styled(
4240 format!(" \u{2139} {text}"),
4241 Style::default()
4242 .fg(color_from_anstyle(styles.info.get_fg_color()))
4243 .add_modifier(Modifier::DIM),
4244 );
4245 frame.render_widget(Paragraph::new(line), area);
4246}
4247
4248fn render_slash_popup(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
4251 let styles = active_styles();
4252 let items = &state.slash_popup.items;
4253 if items.is_empty() {
4254 return;
4255 }
4256
4257 let max_visible = 8usize;
4258 let visible = items.len().min(max_visible);
4259 let popup_h = visible as u16 + 2; let width = composer_area.width.min(64);
4261 let popup_area = Rect {
4262 x: composer_area.left(),
4263 y: composer_area.top().saturating_sub(popup_h),
4264 width,
4265 height: popup_h,
4266 };
4267 frame.render_widget(Clear, popup_area);
4268
4269 let border_color = color_from_anstyle(styles.secondary.get_fg_color());
4270 let title = Line::from(Span::styled(
4271 " Commands ",
4272 Style::default()
4273 .fg(color_from_anstyle(styles.primary.get_fg_color()))
4274 .add_modifier(Modifier::BOLD),
4275 ));
4276 let block = Block::default()
4277 .borders(Borders::ALL)
4278 .border_type(BorderType::Rounded)
4279 .border_style(Style::default().fg(border_color))
4280 .title(title);
4281 let inner = block.inner(popup_area);
4282 frame.render_widget(&block, popup_area);
4283
4284 let max_label = items
4286 .iter()
4287 .take(visible)
4288 .map(|i| i.label.chars().count())
4289 .max()
4290 .unwrap_or(0);
4291
4292 let primary = color_from_anstyle(styles.primary.get_fg_color());
4293 let fg = color_from_anstyle(Some(styles.foreground));
4294 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
4295
4296 for (i, item) in items.iter().take(visible).enumerate() {
4297 let is_selected = i == state.slash_popup.selected;
4298 let y = inner.top() + i as u16;
4299 let row_area = Rect {
4300 x: inner.left(),
4301 y,
4302 width: inner.width,
4303 height: 1,
4304 };
4305
4306 let marker = if is_selected { "\u{25b8} " } else { " " }; let label_style = if is_selected {
4308 Style::default().fg(primary).add_modifier(Modifier::BOLD)
4309 } else {
4310 Style::default().fg(fg)
4311 };
4312 let label_padded = format!("{:<width$}", item.label, width = max_label);
4313 let line = Line::from(vec![
4314 Span::styled(marker, label_style),
4315 Span::styled(label_padded, label_style),
4316 Span::raw(" "),
4317 Span::styled(&item.description, Style::default().fg(secondary)),
4318 ]);
4319 frame.render_widget(Paragraph::new(line), row_area);
4320 }
4321}
4322
4323fn render_file_search_dropdown(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
4327 let styles = active_styles();
4328 let Some(fs) = &state.file_search else {
4329 return;
4330 };
4331 let items = &fs.results;
4332 if items.is_empty() {
4333 return;
4334 }
4335
4336 let max_visible = 10usize;
4337 let visible = items.len().min(max_visible);
4338 let popup_h = visible as u16 + 2; let width = composer_area.width.min(72);
4340 let popup_area = Rect {
4341 x: composer_area.left(),
4342 y: composer_area.top().saturating_sub(popup_h),
4343 width,
4344 height: popup_h,
4345 };
4346 frame.render_widget(Clear, popup_area);
4347
4348 let border_color = color_from_anstyle(styles.secondary.get_fg_color());
4349 let title_str = if fs.hidden_mode {
4350 " Files (hidden) "
4351 } else {
4352 " Files "
4353 };
4354 let title = Line::from(Span::styled(
4355 title_str,
4356 Style::default()
4357 .fg(color_from_anstyle(styles.primary.get_fg_color()))
4358 .add_modifier(Modifier::BOLD),
4359 ));
4360 let block = Block::default()
4361 .borders(Borders::ALL)
4362 .border_type(BorderType::Rounded)
4363 .border_style(Style::default().fg(border_color))
4364 .title(title);
4365 let inner = block.inner(popup_area);
4366 frame.render_widget(&block, popup_area);
4367
4368 let primary = color_from_anstyle(styles.primary.get_fg_color());
4369 let fg = color_from_anstyle(Some(styles.foreground));
4370 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
4371
4372 for (i, result) in items.iter().take(visible).enumerate() {
4373 let is_selected = i == fs.selected;
4374 let y = inner.top() + i as u16;
4375 let row_area = Rect {
4376 x: inner.left(),
4377 y,
4378 width: inner.width,
4379 height: 1,
4380 };
4381
4382 let marker = if is_selected { "\u{25b8} " } else { " " }; let path_style = if is_selected {
4384 Style::default().fg(primary).add_modifier(Modifier::BOLD)
4385 } else {
4386 Style::default().fg(fg)
4387 };
4388 let line = Line::from(vec![
4389 Span::styled(marker, path_style),
4390 Span::styled(&result.path, path_style),
4391 ]);
4392 frame.render_widget(Paragraph::new(line), row_area);
4393 }
4394
4395 if popup_h >= 4 {
4397 let hint_y = inner.bottom();
4398 let hint_area = Rect {
4399 x: inner.left(),
4400 y: hint_y,
4401 width: inner.width,
4402 height: 1,
4403 };
4404 let count = items.len();
4405 let hint = format!("{count} files \u{00b7} Tab accept Esc cancel");
4406 let _ = secondary; frame.render_widget(
4408 Paragraph::new(Line::from(Span::styled(
4409 hint,
4410 Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
4411 )))
4412 .style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color()))),
4413 hint_area,
4414 );
4415 }
4416}
4417
4418struct InputEditor<'a> {
4424 buffer: &'a mut String,
4425 cursor: &'a mut usize,
4426}
4427
4428impl<'a> oxicode_vtui::vim::Editor for InputEditor<'a> {
4429 fn content(&self) -> &str {
4430 self.buffer
4431 }
4432 fn cursor(&self) -> usize {
4433 *self.cursor
4434 }
4435 fn set_cursor(&mut self, pos: usize) {
4436 *self.cursor = pos.min(self.buffer.len());
4437 }
4438 fn move_left(&mut self) {
4439 *self.cursor = self.cursor.saturating_sub(1);
4440 }
4441 fn move_right(&mut self) {
4442 let len = self.buffer.len();
4443 *self.cursor = (*self.cursor + 1).min(len);
4444 }
4445 fn delete_char_forward(&mut self) {
4446 let cursor = *self.cursor;
4447 if cursor < self.buffer.len() {
4448 let next = self.buffer[cursor..]
4449 .char_indices()
4450 .nth(1)
4451 .map(|(i, _)| cursor + i)
4452 .unwrap_or(self.buffer.len());
4453 self.buffer.replace_range(cursor..next, "");
4454 }
4455 }
4456 fn insert_text(&mut self, text: &str) {
4457 let cursor = *self.cursor;
4458 self.buffer.insert_str(cursor, text);
4459 *self.cursor = cursor + text.len();
4460 }
4461 fn replace(&mut self, content: String, cursor: usize) {
4462 *self.buffer = content;
4463 *self.cursor = cursor.min(self.buffer.len());
4464 }
4465}
4466
4467pub(crate) fn plain_segment(text: impl Into<String>) -> InlineSegment {
4472 InlineSegment {
4473 text: text.into(),
4474 style: Arc::new(InlineTextStyle::default()),
4475 }
4476}
4477
4478pub(super) fn effective_scroll_offset(offset: usize, total: usize, viewport: usize) -> usize {
4479 if offset == usize::MAX {
4480 return total.saturating_sub(viewport);
4481 }
4482 let max_start = total.saturating_sub(viewport);
4484 offset.min(max_start)
4485}
4486
4487fn insert_char_into_secure_input(value: &str, cursor: usize, ch: char) -> (String, usize) {
4498 let mut s = String::with_capacity(value.len() + ch.len_utf8());
4499 s.push_str(&value[..cursor]);
4500 s.push(ch);
4501 s.push_str(&value[cursor..]);
4502 (s, cursor + ch.len_utf8())
4503}
4504
4505fn backspace_secure_input(value: &str, cursor: usize) -> (String, usize) {
4509 if cursor == 0 {
4510 return (value.to_string(), 0);
4511 }
4512 let mut prev = cursor - 1;
4514 while !value.is_char_boundary(prev) {
4515 prev -= 1;
4516 }
4517 let mut s = String::with_capacity(value.len() - (cursor - prev));
4518 s.push_str(&value[..prev]);
4519 s.push_str(&value[cursor..]);
4520 (s, prev)
4521}
4522
4523fn insert_paste_into_secure_input(value: &str, cursor: usize, paste: &str) -> (String, usize) {
4528 let trimmed = paste.trim_end_matches('\n');
4529 let filtered: String = trimmed
4530 .chars()
4531 .filter(|c| c.is_ascii_graphic() || *c == ' ')
4532 .collect();
4533 let mut s = String::with_capacity(value.len() + filtered.len());
4534 s.push_str(&value[..cursor]);
4535 s.push_str(&filtered);
4536 s.push_str(&value[cursor..]);
4537 (s, cursor + filtered.len())
4538}
4539
4540fn slash_filter(token: &str, file_commands: &[FileCommand]) -> Vec<SlashPopupItem> {
4553 let builtins = SlashRegistry::builtin_commands();
4554 let builtin_names: std::collections::HashSet<&str> =
4555 builtins.iter().map(|(n, _, _)| *n).collect();
4556
4557 let mut items: Vec<SlashPopupItem> = builtins
4558 .into_iter()
4559 .filter(|(name, _, aliases)| {
4560 token.is_empty()
4561 || name.starts_with(token)
4562 || aliases.iter().any(|a| a.starts_with(token))
4563 })
4564 .map(|(name, desc, aliases)| {
4565 let mut label = format!("/{name}");
4566 for a in &aliases {
4567 label.push_str(&format!(", /{a}"));
4568 }
4569 SlashPopupItem {
4570 label,
4571 description: desc.to_string(),
4572 name: name.to_string(),
4573 }
4574 })
4575 .collect();
4576
4577 for fc in file_commands {
4579 if builtin_names.contains(fc.name.as_str())
4580 || fc
4581 .aliases
4582 .iter()
4583 .any(|alias| builtin_names.contains(alias.as_str()))
4584 {
4585 continue;
4586 }
4587 if token.is_empty()
4588 || fc.name.starts_with(token)
4589 || fc.aliases.iter().any(|a| a.starts_with(token))
4590 {
4591 let mut label = format!("/{}", fc.name);
4592 for a in &fc.aliases {
4593 label.push_str(&format!(", /{a}"));
4594 }
4595 items.push(SlashPopupItem {
4596 label,
4597 description: fc.description.clone(),
4598 name: fc.name.clone(),
4599 });
4600 }
4601 }
4602
4603 items
4604}
4605
4606fn refresh_slash_popup(state: &mut RenderState) {
4611 let buf = state.input_buffer.clone();
4612 let active = buf.starts_with('/') && !buf[1..].contains(' ');
4613 if !active {
4614 state.slash_popup.open = false;
4615 state.slash_popup.items.clear();
4616 state.slash_popup.selected = 0;
4617 return;
4618 }
4619 let token = &buf[1..];
4620 let items = slash_filter(token, &state.file_commands);
4621 state.slash_popup.open = !items.is_empty();
4622 if items.is_empty() {
4623 state.slash_popup.selected = 0;
4624 } else {
4625 state.slash_popup.selected = state.slash_popup.selected.min(items.len() - 1);
4626 }
4627 state.slash_popup.items = items;
4628}
4629fn refresh_input_popups(state: &mut RenderState) {
4633 refresh_slash_popup(state);
4634 refresh_file_search(state);
4635}
4636
4637fn refresh_file_search(state: &mut RenderState) {
4643 use crate::tui_vt::file_search;
4644 if state.slash_popup.open {
4646 state.file_search = None;
4647 return;
4648 }
4649 match file_search::parse_at_cursor(&state.input_buffer, state.input_cursor) {
4650 Some(token) => match &mut state.file_search {
4651 None => {
4652 let cwd = state.cwd.clone();
4653 state.file_search = Some(file_search::open(&cwd, token.at_offset, false));
4654 }
4655 Some(fs) => {
4656 if fs.query != token.path_query {
4657 fs.refresh(&token.path_query);
4658 }
4659 }
4660 },
4661 None => state.file_search = None,
4662 }
4663}
4664
4665fn accept_file_search(state: &mut RenderState, line_mode: bool) -> bool {
4670 use crate::tui_vt::file_search;
4671 let Some(fs) = &state.file_search else {
4672 return false;
4673 };
4674 let Some(result) = fs.selected_result().cloned() else {
4675 return false;
4676 };
4677 let at_offset = fs.at_offset;
4678 let text = file_search::insertion_text(&result.path, None, line_mode);
4679 let cursor_end = state.input_cursor;
4680 state
4682 .input_buffer
4683 .replace_range(at_offset..cursor_end.min(state.input_buffer.len()), &text);
4684 state.input_cursor = at_offset + text.len();
4685 state.file_search = None;
4686 true
4687}
4688
4689fn preview_tool_result(content: &str) -> String {
4690 const MAX: usize = 500;
4691 if content.chars().count() <= MAX {
4692 return content.to_string();
4693 }
4694 let truncated: String = content.chars().take(MAX).collect();
4695 format!("{truncated}\u{2026}")
4696}
4697
4698fn try_render_diff(content: &str, handle: &InlineHandle) -> bool {
4702 let lines: Vec<&str> = content.lines().collect();
4703 if !lines.iter().any(|l| l.starts_with("@@")) {
4707 return false;
4708 }
4709 let additions = lines
4710 .iter()
4711 .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
4712 .count();
4713 let deletions = lines
4714 .iter()
4715 .filter(|l| l.starts_with('-') && !l.starts_with("---"))
4716 .count();
4717 if additions + deletions < 2 {
4718 return false;
4719 }
4720
4721 let styles = active_styles();
4722 let green = styles.secondary.get_fg_color();
4723 let red = styles.error.get_fg_color();
4724 const MAX_DIFF_LINES: usize = 30;
4725
4726 let mut hdr_style = InlineTextStyle::default();
4728 hdr_style.effects |= anstyle::Effects::DIMMED;
4729 handle.append_line(
4730 InlineMessageKind::Tool,
4731 vec![InlineSegment {
4732 text: format!("\u{2713} diff (+{additions} \u{2212}{deletions})"),
4733 style: Arc::new(hdr_style),
4734 }],
4735 );
4736
4737 for line in lines.iter().take(MAX_DIFF_LINES) {
4739 let mut style = InlineTextStyle::default();
4740 if line.starts_with('+') && !line.starts_with("+++") {
4741 style.color = green;
4742 } else if line.starts_with('-') && !line.starts_with("---") {
4743 style.color = red;
4744 } else {
4745 style.effects |= anstyle::Effects::DIMMED;
4746 }
4747 handle.append_line(
4748 InlineMessageKind::Tool,
4749 vec![InlineSegment {
4750 text: format!(" {line}"),
4751 style: Arc::new(style),
4752 }],
4753 );
4754 }
4755
4756 if lines.len() > MAX_DIFF_LINES {
4757 let mut more_style = InlineTextStyle::default();
4758 more_style.effects |= anstyle::Effects::DIMMED;
4759 handle.append_line(
4760 InlineMessageKind::Tool,
4761 vec![InlineSegment {
4762 text: format!(" \u{2026} {} more lines", lines.len() - MAX_DIFF_LINES),
4763 style: Arc::new(more_style),
4764 }],
4765 );
4766 }
4767
4768 true
4769}
4770
4771fn color_from_anstyle(color: Option<anstyle::Color>) -> Color {
4772 match color {
4773 Some(anstyle::Color::Ansi(a)) => ansi_to_ratatui(a),
4774 Some(anstyle::Color::Ansi256(idx)) => Color::Indexed(idx.0),
4775 Some(anstyle::Color::Rgb(rgb)) => Color::Rgb(rgb.0, rgb.1, rgb.2),
4776 None => Color::Reset,
4777 }
4778}
4779fn ansi_to_ratatui(color: anstyle::AnsiColor) -> Color {
4780 use anstyle::AnsiColor as A;
4781 match color {
4782 A::Black => Color::Black,
4783 A::Red => Color::Red,
4784 A::Green => Color::Green,
4785 A::Yellow => Color::Yellow,
4786 A::Blue => Color::Blue,
4787 A::Magenta => Color::Magenta,
4788 A::Cyan => Color::Cyan,
4789 A::White => Color::Gray,
4790 A::BrightBlack => Color::DarkGray,
4791 A::BrightRed => Color::LightRed,
4792 A::BrightGreen => Color::LightGreen,
4793 A::BrightYellow => Color::LightYellow,
4794 A::BrightBlue => Color::LightBlue,
4795 A::BrightMagenta => Color::LightMagenta,
4796 A::BrightCyan => Color::LightCyan,
4797 A::BrightWhite => Color::White,
4798 }
4799}
4800
4801#[allow(dead_code, clippy::declare_interior_mutable_const)]
4804const _ATOMIC_REFS: (AtomicBool, Ordering) = (AtomicBool::new(false), Ordering::SeqCst);
4805
4806#[cfg(test)]
4807mod slash_popup_tests {
4808 use super::*;
4809
4810 #[test]
4811 fn empty_token_lists_all_commands() {
4812 let items = slash_filter("", &[]);
4813 assert!(items.len() >= 7);
4815 assert!(items.iter().any(|i| i.name == "quit"));
4816 assert!(items.iter().any(|i| i.name == "clear"));
4817 assert!(items.iter().any(|i| i.name == "model"));
4818 }
4819
4820 #[test]
4821 fn prefix_filter_matches_name() {
4822 let items = slash_filter("qu", &[]);
4823 assert_eq!(items.len(), 1);
4824 assert_eq!(items[0].name, "quit");
4825 assert!(items[0].label.contains("/quit"));
4826 }
4827
4828 #[test]
4829 fn prefix_filter_matches_alias() {
4830 let items = slash_filter("cl", &[]);
4832 let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
4833 assert!(names.contains(&"clear"));
4834 }
4835
4836 #[test]
4837 fn file_commands_appear_in_filter() {
4838 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
4839 "review",
4840 "---\ndescription: proj cmd\naliases: cr\n---\nbody",
4841 );
4842 let items = slash_filter("", &[fc]);
4843 assert!(items.iter().any(|i| i.name == "review"));
4844 assert!(items.iter().any(|i| i.name == "quit")); }
4846
4847 #[test]
4848 fn file_commands_filtered_by_prefix() {
4849 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
4850 "review",
4851 "---\ndescription: x\n---\nbody",
4852 );
4853 let items = slash_filter("rev", &[fc]);
4854 assert!(items.iter().any(|i| i.name == "review"));
4855 }
4856
4857 #[test]
4858 fn file_commands_shadowed_by_builtins_are_dropped() {
4859 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
4864 "quit",
4865 "---\ndescription: hijack\n---\nbody",
4866 );
4867 let items = slash_filter("", &[fc]);
4868 let quit_count = items.iter().filter(|i| i.name == "quit").count();
4869 assert_eq!(quit_count, 1, "shadowed file command must not appear");
4870 assert!(
4872 items
4873 .iter()
4874 .any(|i| i.name == "quit" && !i.description.contains("hijack"))
4875 );
4876 }
4877
4878 #[test]
4879 fn file_commands_with_builtin_aliases_are_dropped() {
4880 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
4881 "review",
4882 "---\ndescription: hijack\naliases: quit\n---\nbody",
4883 );
4884 let items = slash_filter("", &[fc]);
4885 assert!(!items.iter().any(|item| item.name == "review"));
4886 }
4887
4888 #[test]
4889 fn popup_opens_on_slash() {
4890 let mut state = RenderState::default();
4891 state.input_buffer = "/".to_string();
4892 refresh_input_popups(&mut state);
4893 assert!(state.slash_popup.open);
4894 assert!(!state.slash_popup.items.is_empty());
4895 }
4896
4897 #[test]
4898 fn popup_closes_on_space() {
4899 let mut state = RenderState::default();
4900 state.input_buffer = "/quit ".to_string();
4901 refresh_input_popups(&mut state);
4902 assert!(!state.slash_popup.open);
4903 }
4904
4905 #[test]
4906 fn popup_closes_on_non_slash() {
4907 let mut state = RenderState::default();
4908 state.input_buffer = "hello".to_string();
4909 refresh_input_popups(&mut state);
4910 assert!(!state.slash_popup.open);
4911 }
4912
4913 #[test]
4914 fn popup_filters_as_user_types() {
4915 let mut state = RenderState::default();
4916 state.input_buffer = "/m".to_string();
4917 refresh_input_popups(&mut state);
4918 assert!(state.slash_popup.open);
4919 assert!(
4922 state
4923 .slash_popup
4924 .items
4925 .iter()
4926 .all(|i| i.name.starts_with('m'))
4927 );
4928 }
4929
4930 #[test]
4931 fn popup_selection_clamps_on_shrink() {
4932 let mut state = RenderState::default();
4933 state.input_buffer = "/".to_string();
4934 refresh_input_popups(&mut state);
4935 let full_count = state.slash_popup.items.len();
4936 state.slash_popup.selected = full_count - 1;
4937 state.input_buffer = "/qu".to_string();
4939 refresh_input_popups(&mut state);
4940 assert!(state.slash_popup.selected < state.slash_popup.items.len());
4941 }
4942}
4943
4944#[cfg(test)]
4945mod render_tests {
4946 use super::*;
4947 use oxicode_vtui::tui::core::{InlineHandle, OverlayEvent};
4948 use ratatui::{Terminal, backend::TestBackend};
4949 use tokio::sync::mpsc;
4950
4951 fn render_frame_to_string(state: &RenderState) -> String {
4956 let backend = TestBackend::new(80, 24);
4957 let mut terminal = Terminal::new(backend).expect("backend");
4958 let (tx, _rx) = mpsc::unbounded_channel();
4959 let handle = InlineHandle::new_for_tests(tx);
4960 terminal
4961 .draw(|f| render_frame(f, state, &handle))
4962 .expect("draw");
4963 let buf = terminal.backend().buffer();
4964 let area = buf.area();
4965 let mut out = String::new();
4966 for y in 0..area.height {
4967 for x in 0..area.width {
4968 if let Some(cell) = buf.cell((x, y)) {
4969 out.push_str(cell.symbol());
4970 }
4971 }
4972 out.push('\n');
4973 }
4974 out
4975 }
4976
4977 #[test]
4978 fn welcome_screen_shown_when_transcript_empty() {
4979 let state = RenderState::default();
4980 let rendered = render_frame_to_string(&state);
4981 assert!(
4982 rendered.contains("oxicode"),
4983 "welcome banner must appear when transcript is empty"
4984 );
4985 }
4986
4987 #[test]
4988 fn composer_is_painted() {
4989 let mut state = RenderState::default();
4993 state.input_enabled = true;
4994 state.prompt_prefix = "> ".to_string();
4995 let rendered = render_frame_to_string(&state);
4996 assert!(
4997 rendered.contains('>'),
4998 "composer prompt prefix must be painted"
4999 );
5000 }
5001
5002 #[test]
5003 fn slash_popup_renders_command_list() {
5004 let mut state = RenderState::default();
5005 state.slash_popup.open = true;
5006 state.slash_popup.items = slash_filter("", &[]);
5007 let rendered = render_frame_to_string(&state);
5008 assert!(rendered.contains("Commands"), "popup title must render");
5009 assert!(rendered.contains("/quit"), "popup must list /quit");
5010 }
5011
5012 #[test]
5013 fn composer_and_popup_render_together() {
5014 let mut state = RenderState::default();
5015 state.prompt_prefix = "> ".to_string();
5016 state.input_buffer = "/qu".to_string();
5017 state.slash_popup.open = true;
5018 state.slash_popup.items = slash_filter("qu", &[]);
5019 let rendered = render_frame_to_string(&state);
5020 assert!(rendered.contains("Commands"), "popup must render");
5021 assert!(rendered.contains("/quit"), "popup must list /quit");
5022 assert!(rendered.contains('>'), "composer must still render");
5023 }
5024
5025 #[test]
5026 fn transcript_wraps_long_lines() {
5027 let mut state = RenderState::default();
5029 state.transcript.push(TranscriptLine {
5030 kind: InlineMessageKind::Agent,
5031 segments: vec![plain_segment(
5032 "This is a very long agent response line that should wrap across multiple terminal rows when rendered at a narrow width.".to_string()
5033 )],
5034 block_id: 0,
5035 });
5036 let backend = TestBackend::new(40, 24);
5037 let mut terminal = Terminal::new(backend).expect("backend");
5038 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5039 let handle = InlineHandle::new_for_tests(tx);
5040 terminal
5041 .draw(|f| render_frame(f, &state, &handle))
5042 .expect("draw");
5043 let buf = terminal.backend().buffer();
5044 let mut full = String::new();
5047 for y in 0..buf.area.height {
5048 for x in 0..buf.area.width {
5049 if let Some(cell) = buf.cell((x, y)) {
5050 full.push_str(cell.symbol());
5051 }
5052 }
5053 full.push('\n');
5054 }
5055 assert!(
5056 full.contains("wrap"),
5057 "long line must wrap, not clip — text should be visible past col 40"
5058 );
5059 }
5060
5061 fn sample_overlay_items() -> Vec<OverlayListItem> {
5064 vec![
5065 OverlayListItem {
5066 title: "model-a".to_string(),
5067 subtitle: Some("first".to_string()),
5068 badge: Some("ready".to_string()),
5069 indent: 0,
5070 search_value: None,
5071 selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(0)),
5072 },
5073 OverlayListItem {
5074 title: "model-b".to_string(),
5075 subtitle: None,
5076 badge: None,
5077 indent: 0,
5078 search_value: None,
5079 selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(1)),
5080 },
5081 OverlayListItem {
5082 title: "model-c".to_string(),
5083 subtitle: None,
5084 badge: None,
5085 indent: 0,
5086 search_value: None,
5087 selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(2)),
5088 },
5089 ]
5090 }
5091
5092 #[test]
5093 fn overlay_renders_title_and_items() {
5094 let mut state = RenderState::default();
5095 state.overlay = Some(OverlayState {
5096 title: "Select model".to_string(),
5097 lines: vec!["Pick one".to_string()],
5098 items: sample_overlay_items(),
5099 selected: 0,
5100 search: None,
5101 secure_input: None,
5102 });
5103 let rendered = render_frame_to_string(&state);
5104 assert!(
5105 rendered.contains("Select model"),
5106 "overlay title must render"
5107 );
5108 assert!(rendered.contains("model-a"), "first item must render");
5109 assert!(rendered.contains("model-b"), "second item must render");
5110 assert!(rendered.contains("model-c"), "third item must render");
5111 assert!(
5112 rendered.contains("Pick one"),
5113 "descriptive line must render"
5114 );
5115 }
5116
5117 #[test]
5118 fn overlay_search_filters_items() {
5119 let mut state = RenderState::default();
5120 state.overlay = Some(OverlayState {
5121 title: "Select".to_string(),
5122 lines: Vec::new(),
5123 items: sample_overlay_items(),
5124 selected: 0,
5125 search: Some(OverlaySearchState {
5126 label: "filter".to_string(),
5127 placeholder: Some("type".to_string()),
5128 value: "model-b".to_string(),
5129 }),
5130 secure_input: None,
5131 });
5132 let rendered = render_frame_to_string(&state);
5133 assert!(rendered.contains("model-b"), "matching item must render");
5134 assert!(
5135 !rendered.contains("model-a"),
5136 "non-matching item must not render (got: {})",
5137 rendered
5138 );
5139 assert!(
5140 !rendered.contains("model-c"),
5141 "non-matching item must not render"
5142 );
5143 }
5144
5145 #[test]
5146 fn overlay_keyboard_nav_moves_selection() {
5147 let mut state = RenderState::default();
5148 state.overlay = Some(OverlayState {
5149 title: "Select".to_string(),
5150 lines: Vec::new(),
5151 items: sample_overlay_items(),
5152 selected: 0,
5153 search: None,
5154 secure_input: None,
5155 });
5156 let state_arc = Arc::new(parking_lot::Mutex::new(state));
5157 let (tx, mut _rx) = mpsc::unbounded_channel();
5158
5159 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 0);
5161
5162 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
5164 assert!(consumed, "Down must be consumed while overlay is open");
5165 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 1);
5166
5167 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
5169 assert!(consumed);
5170 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 2);
5171
5172 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
5174 assert!(consumed);
5175 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 0);
5176
5177 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Up);
5179 assert!(consumed);
5180 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 2);
5181
5182 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
5184 assert!(consumed);
5185 assert!(
5186 state_arc.lock().overlay.is_none(),
5187 "overlay must be cleared after Enter"
5188 );
5189 let evt = _rx.try_recv().expect("submit event must arrive");
5190 match evt {
5191 InlineEvent::Overlay(OverlayEvent::Submitted(_)) => {}
5192 other => panic!("expected Submitted overlay event, got {other:?}"),
5193 }
5194 }
5195
5196 #[test]
5197 fn overlay_esc_closes_and_emits_cancelled() {
5198 let mut state = RenderState::default();
5199 state.overlay = Some(OverlayState {
5200 title: "Select".to_string(),
5201 lines: Vec::new(),
5202 items: sample_overlay_items(),
5203 selected: 0,
5204 search: None,
5205 secure_input: None,
5206 });
5207 let state_arc = Arc::new(parking_lot::Mutex::new(state));
5208 let (tx, mut rx) = mpsc::unbounded_channel();
5209
5210 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Esc);
5211 assert!(consumed);
5212 assert!(
5213 state_arc.lock().overlay.is_none(),
5214 "overlay must be cleared after Esc"
5215 );
5216 let evt = rx.try_recv().expect("cancel event must arrive");
5217 assert!(
5218 matches!(evt, InlineEvent::Overlay(OverlayEvent::Cancelled)),
5219 "expected Cancelled overlay event"
5220 );
5221 }
5222
5223 #[test]
5224 fn overlay_enter_on_readonly_item_is_noop() {
5225 let mut state = RenderState::default();
5229 state.overlay = Some(OverlayState {
5230 title: "Tools".to_string(),
5231 lines: Vec::new(),
5232 items: vec![OverlayListItem {
5233 title: "read".to_string(),
5234 subtitle: Some("Read a file".to_string()),
5235 badge: None,
5236 indent: 0,
5237 search_value: None,
5238 selection: None,
5239 }],
5240 selected: 0,
5241 search: None,
5242 secure_input: None,
5243 });
5244 let state_arc = Arc::new(parking_lot::Mutex::new(state));
5245 let (tx, mut rx) = mpsc::unbounded_channel();
5246
5247 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
5248 assert!(consumed, "Enter must be consumed even on read-only items");
5249 assert!(
5250 state_arc.lock().overlay.is_some(),
5251 "overlay must stay open when Enter hits a read-only item"
5252 );
5253 assert!(
5254 rx.try_recv().is_err(),
5255 "no overlay event must be emitted for a read-only Enter"
5256 );
5257 }
5258
5259 #[test]
5260 fn overlay_chars_route_to_search_field() {
5261 let mut state = RenderState::default();
5262 state.overlay = Some(OverlayState {
5263 title: "Select".to_string(),
5264 lines: Vec::new(),
5265 items: sample_overlay_items(),
5266 selected: 0,
5267 search: Some(OverlaySearchState {
5268 label: "filter".to_string(),
5269 placeholder: None,
5270 value: String::new(),
5271 }),
5272 secure_input: None,
5273 });
5274 let state_arc = Arc::new(parking_lot::Mutex::new(state));
5275 let (tx, _rx) = mpsc::unbounded_channel();
5276
5277 handle_overlay_key(&state_arc, &tx, KeyCode::Char('m'));
5278 handle_overlay_key(&state_arc, &tx, KeyCode::Char('o'));
5279 handle_overlay_key(&state_arc, &tx, KeyCode::Backspace);
5280 let value = state_arc
5281 .lock()
5282 .overlay
5283 .as_ref()
5284 .unwrap()
5285 .search
5286 .as_ref()
5287 .unwrap()
5288 .value
5289 .clone();
5290 assert_eq!(value, "m", "Backspace should drop last char");
5291 }
5292
5293 #[test]
5294 fn overlay_key_no_op_when_no_overlay_open() {
5295 let state = RenderState::default();
5296 let state_arc = Arc::new(parking_lot::Mutex::new(state));
5297 let (tx, _rx) = mpsc::unbounded_channel();
5298 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
5299 assert!(
5300 !consumed,
5301 "handle_overlay_key must return false when no overlay is open"
5302 );
5303 }
5304
5305 #[test]
5306 fn apply_command_show_overlay_populates_state() {
5307 use oxicode_vtui::tui::core::{InlineListItem, ListOverlayRequest};
5308 let mut state = RenderState::default();
5309 let items = vec![
5310 InlineListItem {
5311 title: "alpha".to_string(),
5312 subtitle: None,
5313 badge: None,
5314 indent: 0,
5315 selection: None,
5316 search_value: None,
5317 },
5318 InlineListItem {
5319 title: "beta".to_string(),
5320 subtitle: None,
5321 badge: None,
5322 indent: 0,
5323 selection: None,
5324 search_value: None,
5325 },
5326 ];
5327 let request = OverlayRequest::List(ListOverlayRequest {
5328 title: "Pick".to_string(),
5329 lines: vec!["desc".to_string()],
5330 footer_hint: None,
5331 items,
5332 selected: None,
5333 search: None,
5334 hotkeys: Vec::new(),
5335 });
5336 let shutdown = apply_command(
5337 &mut state,
5338 InlineCommand::ShowOverlay {
5339 request: Box::new(request),
5340 },
5341 );
5342 assert!(!shutdown, "ShowOverlay must not request shutdown");
5343 let overlay = state.overlay.as_ref().expect("overlay must be Some");
5344 assert_eq!(overlay.title, "Pick");
5345 assert_eq!(overlay.items.len(), 2);
5346 assert_eq!(overlay.items[0].title, "alpha");
5347 assert_eq!(overlay.items[1].title, "beta");
5348 assert_eq!(overlay.lines.len(), 1);
5349
5350 apply_command(&mut state, InlineCommand::CloseOverlay);
5352 assert!(state.overlay.is_none(), "CloseOverlay must clear state");
5353 }
5354
5355 #[test]
5356 fn materialize_overlay_modal_with_secure_prompt_populates_secure_input() {
5357 use oxicode_vtui::tui::core::{ModalOverlayRequest, SecurePromptConfig};
5358 let request = OverlayRequest::Modal(ModalOverlayRequest {
5359 title: "API key".into(),
5360 lines: vec!["Paste your key".into()],
5361 secure_prompt: Some(SecurePromptConfig {
5362 label: "Key".into(),
5363 placeholder: Some("sk-...".into()),
5364 mask_input: true,
5365 }),
5366 });
5367 let state = materialize_overlay(request);
5368 let secure = state
5369 .secure_input
5370 .expect("secure_input must be Some when secure_prompt is Some");
5371 assert_eq!(secure.config.label, "Key");
5372 assert!(secure.config.mask_input);
5373 assert_eq!(secure.value, "");
5374 assert_eq!(secure.cursor, 0);
5375 }
5376
5377 #[test]
5378 fn materialize_overlay_modal_without_secure_prompt_has_none_secure_input() {
5379 use oxicode_vtui::tui::core::ModalOverlayRequest;
5380 let request = OverlayRequest::Modal(ModalOverlayRequest {
5381 title: "Confirm".into(),
5382 lines: vec!["y/n".into()],
5383 secure_prompt: None,
5384 });
5385 let state = materialize_overlay(request);
5386 assert!(
5387 state.secure_input.is_none(),
5388 "secure_input must be None when secure_prompt is None"
5389 );
5390 }
5391
5392 fn three_block_transcript() -> Vec<TranscriptLine> {
5395 vec![
5397 TranscriptLine {
5398 kind: InlineMessageKind::User,
5399 segments: vec![plain_segment("hi")],
5400 block_id: 0,
5401 },
5402 TranscriptLine {
5403 kind: InlineMessageKind::Agent,
5404 segments: vec![plain_segment("hello")],
5405 block_id: 1,
5406 },
5407 TranscriptLine {
5408 kind: InlineMessageKind::Agent,
5409 segments: vec![plain_segment("world")],
5410 block_id: 1,
5411 },
5412 TranscriptLine {
5413 kind: InlineMessageKind::User,
5414 segments: vec![plain_segment("bye")],
5415 block_id: 2,
5416 },
5417 ]
5418 }
5419
5420 #[test]
5421 fn default_block_mode_is_truncated() {
5422 let state = RenderState::default();
5423 assert_eq!(state.block_mode(0), BlockDisplayMode::Truncated);
5424 assert!(state.block_display.is_empty(), "default needs no map entry");
5425 }
5426
5427 #[test]
5428 fn fold_all_collapses_every_block() {
5429 let mut state = RenderState::default();
5430 state.transcript = three_block_transcript();
5431 state.fold_all();
5432 assert_eq!(state.block_display.len(), 3, "3 distinct block ids");
5433 assert_eq!(state.block_mode(0), BlockDisplayMode::Collapsed);
5434 assert_eq!(state.block_mode(1), BlockDisplayMode::Collapsed);
5435 assert_eq!(state.block_mode(2), BlockDisplayMode::Collapsed);
5436 }
5437
5438 #[test]
5439 fn expand_all_after_fold_all_shows_expanded() {
5440 let mut state = RenderState::default();
5441 state.transcript = three_block_transcript();
5442 state.fold_all();
5443 state.expand_all();
5444 assert_eq!(state.block_display.len(), 3);
5445 assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
5446 assert_eq!(state.block_mode(2), BlockDisplayMode::Expanded);
5447 }
5448
5449 #[test]
5450 fn truncate_all_resets_to_default() {
5451 let mut state = RenderState::default();
5452 state.transcript = three_block_transcript();
5453 state.fold_all();
5454 state.truncate_all();
5455 assert!(state.block_display.is_empty());
5456 assert_eq!(state.block_mode(1), BlockDisplayMode::Truncated);
5457 }
5458
5459 #[test]
5460 fn fold_all_on_empty_transcript_is_noop() {
5461 let mut state = RenderState::default();
5462 state.fold_all();
5463 assert!(state.block_display.is_empty());
5464 }
5465
5466 #[test]
5467 fn cycle_block_advances_through_three_states() {
5468 let mut state = RenderState::default();
5469 state.transcript = three_block_transcript();
5470 state.scroll_offset = 0; state.cycle_block_at_view();
5473 assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
5474 state.cycle_block_at_view();
5476 assert_eq!(state.block_mode(0), BlockDisplayMode::Collapsed);
5477 state.cycle_block_at_view();
5479 assert_eq!(state.block_mode(0), BlockDisplayMode::Truncated);
5480 assert!(!state.block_display.contains_key(&0));
5481 }
5482
5483 #[test]
5484 fn cancel_grace_field_defaults_none() {
5485 let state = RenderState::default();
5486 assert!(
5487 state.cancel_grace_until.is_none(),
5488 "cancel_grace_until must default to None"
5489 );
5490 }
5491
5492 #[test]
5493 fn cancel_routes_to_interrupt_when_streaming() {
5494 assert_eq!(
5495 route_cancel(true),
5496 CancelRoute::Interrupt,
5497 "Esc while streaming must route through the interrupt path"
5498 );
5499 }
5500
5501 #[test]
5502 fn cancel_routes_to_exit_when_idle() {
5503 assert_eq!(
5504 route_cancel(false),
5505 CancelRoute::Exit,
5506 "Esc while idle must exit immediately (one-press quit)"
5507 );
5508 }
5509 #[test]
5510 fn scrollbar_paints_thumb_when_content_overflows() {
5511 let mut state = RenderState::default();
5514 for i in 0..40u32 {
5515 state.transcript.push(TranscriptLine {
5516 kind: InlineMessageKind::Agent,
5517 segments: vec![plain_segment(format!("line {i}"))],
5518 block_id: i as usize,
5519 });
5520 }
5521 let rendered = render_frame_to_string(&state);
5522 assert!(
5523 rendered.contains('\u{2588}'),
5524 "scrollbar thumb (█) must render when transcript overflows the viewport"
5525 );
5526 }
5527
5528 #[test]
5529 fn scrollbar_absent_when_content_fits_viewport() {
5530 let mut state = RenderState::default();
5532 state.transcript.push(TranscriptLine {
5533 kind: InlineMessageKind::Agent,
5534 segments: vec![plain_segment("hi")],
5535 block_id: 0,
5536 });
5537 let rendered = render_frame_to_string(&state);
5538 assert!(
5539 !rendered.contains('\u{2588}'),
5540 "no scrollbar thumb when content fits the viewport"
5541 );
5542 }
5543
5544 #[test]
5547 fn confirmation_modal_renders_title() {
5548 let mut state = RenderState::default();
5549 state.confirmation = Some(quit_confirmation());
5550 let rendered = render_frame_to_string(&state);
5551 assert!(
5552 rendered.contains("Quit oxicode?"),
5553 "confirmation title must render"
5554 );
5555 }
5556
5557 #[test]
5558 fn confirmation_yes_sends_exit_and_closes() {
5559 let mut state = RenderState::default();
5560 state.confirmation = Some(quit_confirmation());
5561 let state_arc = Arc::new(parking_lot::Mutex::new(state));
5562 let (tx, mut rx) = mpsc::unbounded_channel();
5563 handle_confirmation_key(&state_arc, &tx, KeyCode::Char('y'));
5564 assert!(
5565 state_arc.lock().confirmation.is_none(),
5566 "yes must close the modal"
5567 );
5568 let ev = rx.try_recv().expect("yes must send an event");
5569 assert!(matches!(ev, InlineEvent::Exit), "yes must send Exit");
5570 }
5571
5572 #[test]
5573 fn confirmation_no_closes_without_event() {
5574 let mut state = RenderState::default();
5575 state.confirmation = Some(quit_confirmation());
5576 let state_arc = Arc::new(parking_lot::Mutex::new(state));
5577 let (tx, mut rx) = mpsc::unbounded_channel();
5578 handle_confirmation_key(&state_arc, &tx, KeyCode::Char('n'));
5579 assert!(
5580 state_arc.lock().confirmation.is_none(),
5581 "no must close the modal"
5582 );
5583 assert!(rx.try_recv().is_err(), "no must not send an event");
5584 }
5585 #[test]
5588 fn tip_banner_renders_when_active() {
5589 let mut state = RenderState::default();
5590 let now_tick = FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed);
5591 state.tip = Some(EphemeralTip {
5592 text: "hello-tip-marker".to_string(),
5593 born_tick: now_tick,
5594 ttl_ticks: 100,
5595 key: "test",
5596 ambient: false,
5597 });
5598 let rendered = render_frame_to_string(&state);
5599 assert!(
5600 rendered.contains("hello-tip-marker"),
5601 "active tip must render above the composer"
5602 );
5603 }
5604
5605 #[test]
5606 fn tip_visible_within_ttl_window() {
5607 let tip = EphemeralTip {
5608 text: "x".to_string(),
5609 born_tick: 10,
5610 ttl_ticks: 5,
5611 key: "test",
5612 ambient: false,
5613 };
5614 assert!(tip_is_visible(&tip, 12), "within TTL must be visible");
5615 assert!(
5616 !tip_is_visible(&tip, 15),
5617 "at TTL boundary (born + ttl) must expire"
5618 );
5619 assert!(!tip_is_visible(&tip, 99), "past TTL must expire");
5620 }
5621
5622 #[test]
5625 fn sticky_header_pins_block_head_when_scrolled_into_body() {
5626 let mut state = RenderState::default();
5629 for i in 0..40u32 {
5630 state.transcript.push(TranscriptLine {
5631 kind: InlineMessageKind::Agent,
5632 segments: vec![plain_segment(format!("body-line-{i:02}"))],
5633 block_id: 0,
5634 });
5635 }
5636 state.scroll_offset = 10;
5637 let rendered = render_frame_to_string(&state);
5638 assert!(
5639 rendered.contains("body-line-00"),
5640 "sticky header must pin the block head when scrolled into the body"
5641 );
5642 }
5643
5644 #[test]
5645 fn sticky_header_absent_when_viewport_at_block_head() {
5646 let mut state = RenderState::default();
5648 for i in 0..40u32 {
5649 state.transcript.push(TranscriptLine {
5650 kind: InlineMessageKind::Agent,
5651 segments: vec![plain_segment(format!("head-line-{i:02}"))],
5652 block_id: 0,
5653 });
5654 }
5655 state.scroll_offset = 0;
5656 let rendered = render_frame_to_string(&state);
5657 assert!(rendered.contains("head-line-00"));
5660 }
5661
5662 #[test]
5665 fn turn_end_drains_queue_head() {
5666 let mut state = RenderState::default();
5667 state.queued_inputs = vec!["queued-1".into(), "queued-2".into()];
5668 state.drain_queue_head();
5669 assert_eq!(
5670 state.queued_inputs.len(),
5671 1,
5672 "drain_queue_head must drop the head (now running)"
5673 );
5674 assert_eq!(state.queued_inputs[0], "queued-2");
5675 }
5676
5677 #[test]
5680 fn render_frame_paints_transcript_content() {
5681 let mut state = RenderState::default();
5685 state.transcript.push(TranscriptLine {
5686 kind: InlineMessageKind::Agent,
5687 segments: vec![plain_segment("frame-content-marker-xyz")],
5688 block_id: 0,
5689 });
5690 let rendered = render_frame_to_string(&state);
5691 assert!(
5692 rendered.contains("frame-content-marker-xyz"),
5693 "render_frame must paint transcript content"
5694 );
5695 }
5696
5697 #[test]
5698 fn file_search_dropdown_renders_results() {
5699 use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
5700 let mut state = RenderState::default();
5701 state.input_enabled = true;
5702 state.file_search = Some(FileSearchState {
5703 query: "main".into(),
5704 at_offset: 0,
5705 hidden_mode: false,
5706 results: vec![
5707 FileSearchResult {
5708 path: "src/main.rs".into(),
5709 score: 100,
5710 },
5711 FileSearchResult {
5712 path: "tests/main.rs".into(),
5713 score: 50,
5714 },
5715 ],
5716 selected: 0,
5717 index: vec![],
5718 line_mode: false,
5719 });
5720 let rendered = render_frame_to_string(&state);
5721 assert!(rendered.contains("Files"), "dropdown title must render");
5722 assert!(
5723 rendered.contains("src/main.rs"),
5724 "dropdown must show file paths"
5725 );
5726 }
5727
5728 #[test]
5729 fn file_search_dropdown_hidden_mode_title() {
5730 use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
5731 let mut state = RenderState::default();
5732 state.input_enabled = true;
5733 state.file_search = Some(FileSearchState {
5734 query: "".into(),
5735 at_offset: 0,
5736 hidden_mode: true,
5737 results: vec![FileSearchResult {
5738 path: ".env".into(),
5739 score: 0,
5740 }],
5741 selected: 0,
5742 index: vec![],
5743 line_mode: false,
5744 });
5745 let rendered = render_frame_to_string(&state);
5746 assert!(
5747 rendered.contains("hidden"),
5748 "hidden mode must be indicated in title"
5749 );
5750 }
5751
5752 #[test]
5753 fn file_search_and_composer_render_together() {
5754 use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
5755 let mut state = RenderState::default();
5756 state.input_enabled = true;
5757 state.prompt_prefix = "> ".into();
5758 state.input_buffer = "@main".into();
5759 state.input_cursor = 5;
5760 state.file_search = Some(FileSearchState {
5761 query: "main".into(),
5762 at_offset: 0,
5763 hidden_mode: false,
5764 results: vec![FileSearchResult {
5765 path: "src/main.rs".into(),
5766 score: 100,
5767 }],
5768 selected: 0,
5769 index: vec![],
5770 line_mode: false,
5771 });
5772 let rendered = render_frame_to_string(&state);
5773 assert!(rendered.contains('>'), "composer must still render");
5775 assert!(
5776 rendered.contains("src/main.rs"),
5777 "dropdown must render alongside composer"
5778 );
5779 }
5780
5781 #[test]
5782 fn flatten_todo_items_preserves_order_and_status() {
5783 use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
5784 let phases = vec![
5785 TodoPhase {
5786 name: "A".into(),
5787 tasks: vec![
5788 TodoItem {
5789 content: "write code".into(),
5790 status: TodoStatus::InProgress,
5791 notes: None,
5792 block_reason: None,
5793 },
5794 TodoItem {
5795 content: "write tests".into(),
5796 status: TodoStatus::Pending,
5797 notes: None,
5798 block_reason: None,
5799 },
5800 ],
5801 },
5802 TodoPhase {
5803 name: "B".into(),
5804 tasks: vec![TodoItem {
5805 content: "waiting on review".into(),
5806 status: TodoStatus::Blocked,
5807 notes: None,
5808 block_reason: None,
5809 }],
5810 },
5811 ];
5812 let flat = flatten_todo_items(&phases);
5813 assert_eq!(flat.len(), 3);
5814 assert_eq!(flat[0], ("write code".to_string(), TodoStatus::InProgress));
5815 assert_eq!(flat[1], ("write tests".to_string(), TodoStatus::Pending));
5816 assert_eq!(
5817 flat[2],
5818 ("waiting on review".to_string(), TodoStatus::Blocked)
5819 );
5820 }
5821
5822 #[test]
5823 fn todo_pane_renders_when_items_present() {
5824 let mut state = RenderState::default();
5827 state.todo_items = vec![
5828 ("active task".to_string(), TodoStatus::InProgress),
5829 ("open task".to_string(), TodoStatus::Pending),
5830 ];
5831 let rendered = render_frame_to_string(&state);
5832 assert!(
5833 rendered.contains("active task"),
5834 "in-progress task must render"
5835 );
5836 assert!(rendered.contains("open task"), "pending task must render");
5837 assert!(
5839 rendered.contains('\u{25B6}'),
5840 "in-progress glyph must render"
5841 );
5842 }
5843
5844 #[test]
5845 fn todo_pane_hidden_when_empty() {
5846 let state = RenderState::default();
5847 let rendered = render_frame_to_string(&state);
5848 assert!(!rendered.contains('\u{2611}'), "no checkmark when empty");
5850 }
5851
5852 #[test]
5853 fn render_overlay_secure_input_shows_label_mask_value_and_placeholder() {
5854 use ratatui::{Terminal, backend::TestBackend};
5855 let backend = TestBackend::new(80, 24);
5856 let mut terminal = Terminal::new(backend).unwrap();
5857 let overlay = OverlayState {
5858 title: "OpenAI key".into(),
5859 lines: vec!["Paste your API key".into()],
5860 items: Vec::new(),
5861 selected: 0,
5862 search: None,
5863 secure_input: Some(OverlaySecureInput {
5864 config: SecurePromptConfig {
5865 label: "Key".into(),
5866 placeholder: Some("sk-...".into()),
5867 mask_input: true,
5868 },
5869 value: "sk-abc".into(),
5870 cursor: 6,
5871 }),
5872 };
5873 terminal
5874 .draw(|f| render_overlay(f, f.area(), &overlay))
5875 .unwrap();
5876 let buf = terminal.backend().buffer().clone();
5877 let text: String = buf
5879 .content()
5880 .iter()
5881 .map(|c| c.symbol())
5882 .collect::<Vec<_>>()
5883 .join("");
5884 assert!(text.contains("Key:"));
5885 assert!(text.contains("******"));
5886 assert!(!text.contains("sk-abc"));
5887 }
5888
5889 #[test]
5890 fn render_overlay_secure_input_placeholder_when_empty() {
5891 use ratatui::{Terminal, backend::TestBackend};
5892 let backend = TestBackend::new(80, 24);
5893 let mut terminal = Terminal::new(backend).unwrap();
5894 let overlay = OverlayState {
5895 title: "OpenAI key".into(),
5896 lines: vec!["Paste your API key".into()],
5897 items: Vec::new(),
5898 selected: 0,
5899 search: None,
5900 secure_input: Some(OverlaySecureInput {
5901 config: SecurePromptConfig {
5902 label: "Key".into(),
5903 placeholder: Some("sk-...".into()),
5904 mask_input: true,
5905 },
5906 value: String::new(),
5907 cursor: 0,
5908 }),
5909 };
5910 terminal
5911 .draw(|f| render_overlay(f, f.area(), &overlay))
5912 .unwrap();
5913 let buf = terminal.backend().buffer().clone();
5914 let text: String = buf
5915 .content()
5916 .iter()
5917 .map(|c| c.symbol())
5918 .collect::<Vec<_>>()
5919 .join("");
5920 assert!(text.contains("sk-..."));
5921 }
5922}
5923
5924#[cfg(test)]
5925mod secure_input_tests {
5926 use super::*;
5927 use oxicode_vtui::tui::core::OverlaySubmission;
5928
5929 #[test]
5930 fn overlay_submission_secure_input_is_routed_to_host() {
5931 let _ = OverlaySubmission::SecureInput("sk-test".into());
5934 let serialized = format!("{:?}", OverlaySubmission::SecureInput("x".into()));
5935 assert!(serialized.contains("SecureInput"));
5936 }
5937
5938 #[test]
5939 fn providers_action_matrix_branches_correctly() {
5940 assert_eq!(
5944 next_provider_actions(true, true),
5945 vec![
5946 AuthAction::SetApiKey,
5947 AuthAction::StartOAuth,
5948 AuthAction::RemoveKey,
5949 ],
5950 "has key + oauth-capable: replace, oauth, remove"
5951 );
5952 assert_eq!(
5953 next_provider_actions(true, false),
5954 vec![AuthAction::RemoveKey],
5955 "has key, key-only provider: remove only"
5956 );
5957 assert_eq!(
5958 next_provider_actions(false, true),
5959 vec![AuthAction::SetApiKey, AuthAction::StartOAuth],
5960 "no key + oauth-capable: set key, oauth"
5961 );
5962 assert_eq!(
5963 next_provider_actions(false, false),
5964 vec![AuthAction::SetApiKey],
5965 "no key + key-only provider: set key only"
5966 );
5967 }
5968
5969 #[test]
5970 fn insert_char_at_middle() {
5971 let (s, c) = insert_char_into_secure_input("abcd", 2, 'X');
5972 assert_eq!(s, "abXcd");
5973 assert_eq!(c, 3);
5974 }
5975
5976 #[test]
5977 fn backspace_at_start_is_noop() {
5978 let (s, c) = backspace_secure_input("abc", 0);
5979 assert_eq!(s, "abc");
5980 assert_eq!(c, 0);
5981 }
5982
5983 #[test]
5984 fn backspace_at_middle() {
5985 let (s, c) = backspace_secure_input("abcd", 2);
5986 assert_eq!(s, "acd");
5987 assert_eq!(c, 1);
5988 }
5989
5990 #[test]
5991 fn paste_strips_trailing_newline_and_drops_non_ascii() {
5992 let (s, c) = insert_paste_into_secure_input("ab", 2, "sk-xyz\nABC\u{1F600}");
5993 assert_eq!(s, "absk-xyzABC");
5994 assert_eq!(c, 11);
5995 }
5996
5997 #[test]
5998 fn insert_at_end_appends() {
5999 let (s, c) = insert_char_into_secure_input("hello", 5, '!');
6000 assert_eq!(s, "hello!");
6001 assert_eq!(c, 6);
6002 }
6003}