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_vtui::theme::{ThemeStyles, active_styles};
28use oxicode_vtui::tui::core::{
29 InlineCommand, InlineEvent, InlineHandle, InlineHeaderContext, InlineHeaderStatusBadge,
30 InlineHeaderStatusTone, InlineListItem, InlineListSelection, InlineMessageKind, InlineSegment,
31 InlineTextStyle, OverlayRequest, OverlaySubmission,
32};
33use ratatui::{
34 Frame, Terminal,
35 backend::CrosstermBackend,
36 layout::{Alignment, Rect},
37 style::{Color, Modifier, Style},
38 text::{Line, Span},
39 widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph, Wrap},
40};
41
42use crate::App;
43use crate::app::agent_hub_registry::HubEntry;
44use crate::app::agent_session::SessionEvent;
45use crate::tui_vt::slash::registry::{SlashCtx, SlashOutcome, SlashRegistry};
46
47pub struct Tui {
58 terminal: Terminal<CrosstermBackend<Stdout>>,
59 tty_ok: bool,
60}
61
62impl Tui {
63 pub fn enter() -> Result<Self> {
66 Self::set_panic_hook();
67
68 let tty_ok = enable_raw_mode().is_ok();
69 let mut stdout = io::stdout();
70
71 if tty_ok {
72 let flags = if std::env::var("OXICODE_KITTY_KEYBOARD").as_deref() == Ok("1") {
76 KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
77 | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
78 | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
79 } else {
80 KeyboardEnhancementFlags::REPORT_EVENT_TYPES
81 };
82 let _ = execute!(
83 stdout,
84 Hide,
85 EnableBracketedPaste,
86 PushKeyboardEnhancementFlags(flags)
87 );
88 let _ = stdout.flush();
89 }
90
91 let backend = CrosstermBackend::new(stdout);
92 let mut terminal = Terminal::new(backend)?;
93 if tty_ok {
94 let _ = terminal.clear();
95 }
96
97 Ok(Self { terminal, tty_ok })
98 }
99
100 pub fn exit(&mut self) -> Result<()> {
104 if self.tty_ok {
105 let _ = execute!(
106 self.terminal.backend_mut(),
107 PopKeyboardEnhancementFlags,
108 DisableBracketedPaste
109 );
110 let _ = self.terminal.show_cursor();
111 disable_raw_mode()?;
113 self.tty_ok = false;
114 }
115 Ok(())
116 }
117
118 fn set_panic_hook() {
122 let original_hook = std::panic::take_hook();
123 std::panic::set_hook(Box::new(move |panic_info| {
124 let _ = execute!(io::stdout(), Show);
125 let _ = disable_raw_mode();
126 original_hook(panic_info);
127 }));
128 }
129}
130
131impl Drop for Tui {
132 fn drop(&mut self) {
133 let _ = self.exit();
134 }
135}
136
137#[derive(Default)]
144pub struct RenderState {
145 pub input_buffer: String,
147 pub input_cursor: usize,
149 pub transcript: Vec<TranscriptLine>,
151 pub scroll_offset: usize,
154 pub header_context: InlineHeaderContext,
156 pub input_enabled: bool,
158 pub footer_left: Option<String>,
160 pub footer_right: Option<String>,
161 pub prompt_prefix: String,
163 pub placeholder: Option<String>,
165 pub shutdown_requested: bool,
167 pub message_buffer: String,
169 pub agent_hub_open: bool,
171 pub hub_entries: Vec<(String, HubEntry)>,
173 pub pending_quit: bool,
175 pub slash_popup: SlashPopup,
177 pub reasoning_stage: Option<String>,
179 pub overlay: Option<OverlayState>,
181 pub overlay_model_ids: Vec<String>,
183 pub queued_inputs: Vec<String>,
185 pub queue_panel_open: bool,
187 pub queue_selected: usize,
189 pub shell_mode: bool,
191 pub follow_ups: Vec<String>,
193 pub todo_items: Vec<(String, bool)>,
195 pub vim_state: oxicode_vtui::vim::VimState,
197 pub vim_clipboard: String,
199 pub search: Option<SearchState>,
201 pub block_display: std::collections::HashMap<usize, BlockDisplayMode>,
204 pub last_esc_at: Option<std::time::Instant>,
206 pub multiline_mode: bool,
208 pub prompt_history: Vec<String>,
210 pub history_pos: Option<usize>,
212 pub next_block_id: usize,
214 pub cancel_grace_until: Option<std::time::Instant>,
217 pub confirmation: Option<ModalConfirmation>,
221 pub tip: Option<EphemeralTip>,
224 pub cwd: PathBuf,
226 pub file_search: Option<crate::tui_vt::file_search::FileSearchState>,
228 pub seen_tips: std::collections::HashMap<&'static str, u32>,
230}
231
232#[derive(Debug, Clone)]
234pub struct TranscriptLine {
235 pub kind: InlineMessageKind,
236 pub segments: Vec<InlineSegment>,
237 pub block_id: usize,
240}
241
242#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
248pub enum BlockDisplayMode {
249 Collapsed,
251 #[default]
253 Truncated,
254 Expanded,
256}
257
258#[derive(Clone, Debug)]
260pub struct SearchState {
261 pub query: String,
262 pub matches: Vec<usize>,
264 pub current: usize,
266}
267
268#[derive(Clone)]
270pub struct SlashPopupItem {
271 pub label: String,
273 pub description: String,
275 pub name: String,
277}
278
279#[derive(Default, Clone)]
284pub struct SlashPopup {
285 pub open: bool,
286 pub items: Vec<SlashPopupItem>,
287 pub selected: usize,
288}
289
290#[derive(Clone, Debug)]
294pub struct OverlayListItem {
295 pub title: String,
296 pub subtitle: Option<String>,
297 pub badge: Option<String>,
298 pub indent: u8,
299 pub search_value: Option<String>,
300 pub selection: Option<oxicode_vtui::tui::core::InlineListSelection>,
302}
303
304#[derive(Clone, Debug)]
309pub struct OverlayState {
310 pub title: String,
311 pub lines: Vec<String>,
312 pub items: Vec<OverlayListItem>,
313 pub selected: usize,
314 pub search: Option<OverlaySearchState>,
315}
316
317#[derive(Clone, Debug)]
321pub struct ModalConfirmation {
322 pub title: String,
323 pub message: String,
324 pub action: ConfirmationAction,
327}
328
329#[derive(Clone, Debug, PartialEq, Eq)]
331pub enum ConfirmationAction {
332 Quit,
334 ClearConversation,
336}
337
338#[derive(Clone, Debug)]
342pub struct EphemeralTip {
343 pub text: String,
344 pub born_tick: u64,
346 pub ttl_ticks: u64,
348 pub key: &'static str,
351 pub ambient: bool,
355}
356
357#[derive(Clone, Debug)]
359pub struct OverlaySearchState {
360 pub label: String,
361 pub placeholder: Option<String>,
362 pub value: String,
363}
364
365impl RenderState {
366 fn new_with_header(header: InlineHeaderContext) -> Self {
367 let mut s = Self::default();
368 s.header_context = header;
369 s.prompt_prefix = "> ".to_string();
370 s.input_enabled = true;
371 s
372 }
373
374 fn append_line(&mut self, kind: InlineMessageKind, segments: Vec<InlineSegment>) {
376 let block_id = self.block_id_for_kind(kind);
377 self.transcript.push(TranscriptLine {
378 kind,
379 segments,
380 block_id,
381 });
382 }
383
384 fn inline_segment(&mut self, kind: InlineMessageKind, segment: InlineSegment) {
388 if let Some(last) = self.transcript.last_mut()
389 && last.kind == kind
390 {
391 last.segments.push(segment);
392 return;
393 }
394 let block_id = self.block_id_for_kind(kind);
395 self.transcript.push(TranscriptLine {
396 kind,
397 segments: vec![segment],
398 block_id,
399 });
400 }
401
402 fn block_id_for_kind(&mut self, kind: InlineMessageKind) -> usize {
405 if let Some(last) = self.transcript.last()
406 && last.kind == kind
407 {
408 return last.block_id;
409 }
410 let id = self.next_block_id;
411 self.next_block_id += 1;
412 id
413 }
414
415 pub fn start_search(&mut self, query: &str) {
419 let needle = query.to_lowercase();
420 let matches: Vec<usize> = self
421 .transcript
422 .iter()
423 .enumerate()
424 .filter(|(_, line)| {
425 line.segments
426 .iter()
427 .any(|s| s.text.to_lowercase().contains(&needle))
428 })
429 .map(|(i, _)| i)
430 .collect();
431 self.search = Some(SearchState {
432 query: query.to_string(),
433 matches,
434 current: 0,
435 });
436 if let Some(s) = &self.search
438 && let Some(&first) = s.matches.first()
439 {
440 self.scroll_offset = first;
441 }
442 }
443
444 pub fn search_next(&mut self) {
446 if let Some(s) = &mut self.search
447 && !s.matches.is_empty()
448 {
449 s.current = (s.current + 1) % s.matches.len();
450 let line = s.matches[s.current];
451 self.scroll_offset = line;
452 }
453 }
454
455 pub fn search_prev(&mut self) {
457 if let Some(s) = &mut self.search
458 && !s.matches.is_empty()
459 {
460 if s.current == 0 {
461 s.current = s.matches.len() - 1;
462 } else {
463 s.current -= 1;
464 }
465 let line = s.matches[s.current];
466 self.scroll_offset = line;
467 }
468 }
469
470 pub fn block_mode(&self, block_id: usize) -> BlockDisplayMode {
474 self.block_display
475 .get(&block_id)
476 .copied()
477 .unwrap_or_default()
478 }
479
480 pub fn cycle_block_at_view(&mut self) {
483 let offset = self.effective_offset();
484 if let Some(line) = self.transcript.get(offset) {
485 let bid = line.block_id;
486 let next = match self.block_mode(bid) {
487 BlockDisplayMode::Collapsed => BlockDisplayMode::Truncated,
488 BlockDisplayMode::Truncated => BlockDisplayMode::Expanded,
489 BlockDisplayMode::Expanded => BlockDisplayMode::Collapsed,
490 };
491 if next == BlockDisplayMode::Truncated {
494 self.block_display.remove(&bid);
495 } else {
496 self.block_display.insert(bid, next);
497 }
498 }
499 }
500
501 pub fn expand_all(&mut self) {
503 for bid in self.all_block_ids() {
504 self.block_display.insert(bid, BlockDisplayMode::Expanded);
505 }
506 }
507
508 pub fn fold_all(&mut self) {
510 for bid in self.all_block_ids() {
511 self.block_display.insert(bid, BlockDisplayMode::Collapsed);
512 }
513 }
514
515 pub fn truncate_all(&mut self) {
517 self.block_display.clear();
518 }
519
520 fn all_block_ids(&self) -> Vec<usize> {
522 let mut ids = Vec::new();
523 let mut prev: Option<usize> = None;
524 for l in &self.transcript {
525 if prev != Some(l.block_id) {
526 ids.push(l.block_id);
527 prev = Some(l.block_id);
528 }
529 }
530 ids
531 }
532
533 pub fn jump_next_turn(&mut self) {
537 let offset = self.effective_offset();
538 let search_after = self
539 .transcript
540 .iter()
541 .enumerate()
542 .skip(offset + 1)
543 .find(|(_, l)| l.kind == InlineMessageKind::Agent || l.kind == InlineMessageKind::User);
544 if let Some((idx, _)) = search_after {
545 self.scroll_offset = idx;
546 }
547 }
548
549 pub fn jump_prev_turn(&mut self) {
551 let offset = self.effective_offset();
552 let search_before = self
553 .transcript
554 .iter()
555 .enumerate()
556 .take(offset)
557 .rev()
558 .find(|(_, l)| l.kind == InlineMessageKind::User);
559 if let Some((idx, _)) = search_before {
560 self.scroll_offset = idx;
561 }
562 }
563
564 fn effective_offset(&self) -> usize {
566 if self.scroll_offset == usize::MAX {
567 self.transcript.len().saturating_sub(1)
568 } else {
569 self.scroll_offset
570 }
571 }
572
573 pub fn drain_queue_head(&mut self) {
576 if !self.queued_inputs.is_empty() {
577 self.queued_inputs.remove(0);
578 }
579 }
580
581 pub fn show_tip(&mut self, key: &'static str, text: &str, ttl: u64, ambient: bool) {
584 let count = self.seen_tips.entry(key).or_insert(0);
585 if *count >= SEEN_CAP {
586 return;
587 }
588 *count += 1;
589 self.tip = Some(EphemeralTip {
590 text: text.to_string(),
591 born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
592 ttl_ticks: ttl,
593 key,
594 ambient,
595 });
596 }
597}
598
599const SEEN_CAP: u32 = 3;
601
602pub async fn run_tui(app: App) -> Result<()> {
609 let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
612 let git_branch = crate::util::git_utils::get_current_branch(&cwd);
613 super::host::activate_theme(app.settings());
614 let theme_id = oxicode_vtui::theme::active_theme_id();
616 let validation = oxicode_vtui::theme::validate_theme_contrast(&theme_id);
617 if validation.warnings.is_empty() {
618 tracing::debug!("theme '{theme_id}' passed contrast validation");
619 } else {
620 for w in &validation.warnings {
621 tracing::warn!("theme contrast: {w}");
622 }
623 }
624
625 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
628 let (evt_tx, mut evt_rx) = tokio::sync::mpsc::unbounded_channel::<InlineEvent>();
629 let handle = InlineHandle::new_for_tests(cmd_tx);
630
631 let session = build_agent_session(&app).await?;
635 let session_handle = session.clone_handle();
639
640 let (session_tx, mut session_rx) = tokio::sync::mpsc::unbounded_channel::<SessionEvent>();
648 let _sub_guard = session.subscribe(Box::new(move |event| {
649 let _ = session_tx.send(event.clone());
650 }));
651
652 let header = build_header_context(&app, &cwd, git_branch.as_deref());
654 handle.set_header_context(header.clone());
655
656 let mut tui = Tui::enter()?;
659
660 handle.set_prompt("> ".to_string(), InlineTextStyle::default());
664 handle.set_placeholder(Some("Describe what you want to build\u{2026}".to_string()));
665
666 let state = Arc::new(parking_lot::Mutex::new(RenderState::new_with_header(
669 header,
670 )));
671 state.lock().cwd = cwd.clone();
672 state.lock().tip = Some(EphemeralTip {
675 text: "Press ? for shortcuts \u{00b7} /help for commands".to_string(),
676 born_tick: 0,
677 ttl_ticks: 900,
678 key: "onboarding",
679 ambient: true,
680 });
681 if std::env::var("SSH_CONNECTION").is_ok() {
683 state.lock().show_tip(
684 "ssh_wrap",
685 "Over SSH? Consider tmux to keep sessions alive",
686 600,
687 true,
688 );
689 }
690 spawn_input_thread(state.clone(), evt_tx.clone());
691
692 let prompt_tx = spawn_agent_worker(session_handle.clone());
698
699 let result = run_event_loop(
700 &mut tui.terminal,
701 &mut cmd_rx,
702 &mut evt_rx,
703 &mut session_rx,
704 &handle,
705 &state,
706 &session_handle,
707 prompt_tx.clone(),
708 )
709 .await;
710
711 drop(prompt_tx);
714 handle.shutdown();
715 drop(tui);
717
718 result
719}
720
721#[allow(clippy::too_many_arguments)]
726async fn run_event_loop(
727 terminal: &mut Terminal<CrosstermBackend<Stdout>>,
728 cmd_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineCommand>,
729 evt_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineEvent>,
730 session_rx: &mut tokio::sync::mpsc::UnboundedReceiver<SessionEvent>,
731 handle: &InlineHandle,
732 state: &Arc<parking_lot::Mutex<RenderState>>,
733 session: &crate::app::agent_session::AgentSessionHandle,
734 prompt_tx: tokio::sync::mpsc::UnboundedSender<String>,
735) -> Result<()> {
736 while let Ok(cmd) = cmd_rx.try_recv() {
739 apply_command(&mut state.lock(), cmd);
740 }
741
742 {
747 let snapshot = state.lock();
748 let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
749 if let Err(err) = terminal.draw(|frame| render_frame(frame, &snapshot, handle)) {
750 tracing::warn!(?err, "initial tui draw failed");
751 }
752 let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
753 }
754
755 let mut render_tick = tokio::time::interval(std::time::Duration::from_millis(50));
761 render_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
762
763 loop {
764 tokio::select! {
765 biased;
768
769 Some(cmd) = cmd_rx.recv() => {
771 let shutdown = {
772 let mut s = state.lock();
773 apply_command(&mut s, cmd)
774 };
775 if shutdown {
776 break;
777 }
778 }
779
780 Some(event) = session_rx.recv() => {
782 handle_session_event(&mut state.lock(), handle, &event);
783 }
784
785 Some(evt) = evt_rx.recv() => {
787 let outcome = handle_inline_event(
788 &mut state.lock(),
789 handle,
790 session,
791 &prompt_tx,
792 evt,
793 );
794 if outcome == LoopOutcome::Exit {
795 break;
796 }
797 }
798
799 _ = tokio::signal::ctrl_c() => {
803 let outcome = {
804 let mut s = state.lock();
805 handle_interrupt(&mut s, session, handle)
806 };
807 if outcome == LoopOutcome::Exit {
808 break;
809 }
810 }
811
812 _ = render_tick.tick() => {}
815 }
816
817 if let Ok(size) = terminal.size()
819 && size.width < 40
820 {
821 let mut s = state.lock();
822 if s.tip.is_none() {
823 s.show_tip(
824 "small_screen",
825 "Terminal too narrow \u{2014} resize for full UI",
826 300,
827 true,
828 );
829 }
830 }
831 let snapshot = state.lock();
834 let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
835 let draw_err = terminal
836 .draw(|frame| render_frame(frame, &snapshot, handle))
837 .err();
838 let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
839 if let Some(err) = draw_err {
840 tracing::warn!(?err, "tui draw failed");
841 break;
842 }
843 }
844
845 Ok(())
846}
847
848#[derive(PartialEq, Eq)]
849enum LoopOutcome {
850 Continue,
851 Exit,
852}
853
854#[derive(PartialEq, Eq, Debug)]
859enum CancelRoute {
860 Interrupt,
863 Exit,
865}
866
867fn route_cancel(is_streaming: bool) -> CancelRoute {
870 if is_streaming {
871 CancelRoute::Interrupt
872 } else {
873 CancelRoute::Exit
874 }
875}
876
877fn apply_command(state: &mut RenderState, cmd: InlineCommand) -> bool {
884 match cmd {
885 InlineCommand::AppendLine { kind, segments } => {
886 state.append_line(kind, segments);
887 }
888 InlineCommand::Inline { kind, segment } => {
889 state.inline_segment(kind, segment);
890 }
891 InlineCommand::ReplaceLast {
892 count, kind, lines, ..
893 } => {
894 let drop = count.min(state.transcript.len());
896 for _ in 0..drop {
897 state.transcript.pop();
898 }
899 for line in lines {
900 state.append_line(kind, line);
901 }
902 }
903 InlineCommand::AppendPastedMessage { kind, text, .. } => {
904 state.append_line(kind, vec![plain_segment(text)]);
905 }
906 InlineCommand::SetPrompt { prefix, .. } => {
907 state.prompt_prefix = prefix;
908 }
909 InlineCommand::SetPlaceholder { hint, .. } => {
910 state.placeholder = hint;
911 }
912 InlineCommand::SetHeaderContext { context } => {
913 state.header_context = *context;
914 }
915 InlineCommand::SetInputStatus { left, right } => {
916 state.footer_left = left;
917 state.footer_right = right;
918 }
919 InlineCommand::SetInputEnabled(enabled) => {
920 state.input_enabled = enabled;
921 }
922 InlineCommand::SetCursorVisible(_) | InlineCommand::ForceRedraw => {}
923 InlineCommand::SetReasoningStage(stage) => {
924 state.reasoning_stage = stage;
925 }
926 InlineCommand::SetVimModeEnabled(enabled) => {
927 state.vim_state.set_enabled(enabled);
928 }
929 InlineCommand::SetQueuedInputs { entries } => {
930 state.queued_inputs = entries;
931 }
932 InlineCommand::ShowOverlay { request } => {
933 state.overlay = Some(materialize_overlay(*request));
934 }
935 InlineCommand::CloseOverlay => {
936 state.overlay = None;
937 }
938 InlineCommand::Shutdown => {
939 state.shutdown_requested = true;
940 return true;
941 }
942 _ => {
943 tracing::trace!("unhandled InlineCommand (not rendered)");
946 }
947 }
948 false
949}
950
951fn materialize_overlay(request: OverlayRequest) -> OverlayState {
956 match request {
957 OverlayRequest::Modal(req) => OverlayState {
958 title: req.title,
959 lines: req.lines,
960 items: Vec::new(),
961 selected: 0,
962 search: None,
963 },
964 OverlayRequest::List(req) => {
965 let search = req.search.map(|cfg| OverlaySearchState {
966 label: cfg.label,
967 placeholder: cfg.placeholder,
968 value: String::new(),
969 });
970 OverlayState {
971 title: req.title,
972 lines: req.lines,
973 items: req.items.into_iter().map(overlay_item_from).collect(),
974 selected: 0,
975 search,
976 }
977 }
978 OverlayRequest::Wizard(req) => {
979 let step_items = req
983 .steps
984 .first()
985 .map(|s| {
986 s.items
987 .iter()
988 .map(|it| overlay_item_from(it.clone()))
989 .collect()
990 })
991 .unwrap_or_default();
992 let search = req.search.map(|cfg| OverlaySearchState {
993 label: cfg.label,
994 placeholder: cfg.placeholder,
995 value: String::new(),
996 });
997 OverlayState {
998 title: req.title,
999 lines: Vec::new(),
1000 items: step_items,
1001 selected: 0,
1002 search,
1003 }
1004 }
1005 }
1006}
1007fn overlay_item_from(item: InlineListItem) -> OverlayListItem {
1008 OverlayListItem {
1009 title: item.title,
1010 subtitle: item.subtitle,
1011 badge: item.badge,
1012 indent: item.indent,
1013 search_value: item.search_value,
1014 selection: item.selection,
1015 }
1016}
1017
1018fn handle_session_event(state: &mut RenderState, handle: &InlineHandle, event: &SessionEvent) {
1022 match event {
1023 SessionEvent::Agent(boxed) => {
1024 map_agent_event(handle, *boxed.clone(), state);
1025 }
1026 SessionEvent::CompactionStart { .. } => {
1027 handle.set_reasoning_stage(Some("Compacting\u{2026}".to_string()));
1028 }
1029 SessionEvent::CompactionEnd { error_message, .. } => {
1030 handle.set_reasoning_stage(None);
1031 if let Some(msg) = error_message {
1032 handle.append_line(
1033 InlineMessageKind::Error,
1034 vec![plain_segment(format!("Compaction failed: {msg}"))],
1035 );
1036 }
1037 }
1038 SessionEvent::ThinkingLevelChanged { .. } => {
1039 }
1042 SessionEvent::QueueUpdate { .. } => {
1043 let pending = state.transcript.len();
1047 handle.set_input_status(
1048 None,
1049 Some(if pending == 0 {
1050 "ready".to_string()
1051 } else {
1052 "queued".to_string()
1053 }),
1054 );
1055 }
1056 SessionEvent::Advisor { body, .. } => {
1057 handle.append_line(InlineMessageKind::Info, vec![plain_segment(body.clone())]);
1058 }
1059 SessionEvent::SessionInfoChanged => {
1060 }
1063 }
1064}
1065
1066fn map_agent_event(handle: &InlineHandle, event: AgentEvent, state: &mut RenderState) {
1068 match event {
1069 AgentEvent::TextChunk { text } => {
1070 state.message_buffer.push_str(&text);
1071 handle.inline(InlineMessageKind::Agent, plain_segment(text));
1072 }
1073 AgentEvent::MessageStart { .. } => {
1074 state.message_buffer.clear();
1075 }
1076 AgentEvent::MessageUpdate { delta, .. } => match &delta {
1077 oxicode_sdk::StreamDelta::Text(text) => {
1078 state.message_buffer.push_str(text);
1079 handle.inline(InlineMessageKind::Agent, plain_segment(text.clone()));
1080 }
1081 oxicode_sdk::StreamDelta::Thinking(text) => {
1082 let mut style = InlineTextStyle::default();
1085 style.effects |= anstyle::Effects::DIMMED;
1086 let seg = InlineSegment {
1087 text: format!("\u{2733} {text}"),
1088 style: Arc::new(style),
1089 };
1090 handle.inline(InlineMessageKind::Info, seg);
1091 }
1092 oxicode_sdk::StreamDelta::Sync => {
1093 if !state.message_buffer.is_empty() {
1095 let lines =
1096 oxicode_vtui::tui::ui::markdown::render_markdown(&state.message_buffer);
1097 let count = lines.len();
1098 if count > 0 {
1099 handle.replace_last(count, InlineMessageKind::Agent, lines);
1100 }
1101 state.message_buffer.clear();
1102 }
1103 }
1104 },
1105 AgentEvent::MessageEnd { .. } => {
1106 if !state.message_buffer.is_empty() {
1108 let lines = oxicode_vtui::tui::ui::markdown::render_markdown(&state.message_buffer);
1109 let count = lines.len();
1110 if count > 0 {
1111 handle.replace_last(count, InlineMessageKind::Agent, lines);
1112 }
1113 state.message_buffer.clear();
1114 }
1115 }
1116 AgentEvent::ToolStart { tool_name, .. } => {
1117 handle.append_line(
1118 InlineMessageKind::Tool,
1119 vec![plain_segment(format!("\u{2699} {tool_name}"))],
1120 );
1121 handle.set_reasoning_stage(Some(format!("tool: {tool_name}")));
1122 }
1123 AgentEvent::ToolComplete { result } => {
1124 if !try_render_diff(&result.content, handle) {
1126 let preview = preview_tool_result(&result.content);
1127 let mut style = InlineTextStyle::default();
1128 style.effects |= anstyle::Effects::DIMMED;
1129 handle.append_line(
1130 InlineMessageKind::Tool,
1131 vec![InlineSegment {
1132 text: format!("\u{2713} {preview}"),
1133 style: Arc::new(style),
1134 }],
1135 );
1136 }
1137 handle.set_reasoning_stage(None);
1138 handle.set_input_enabled(true);
1139 }
1140 AgentEvent::ToolError { error, .. } => {
1141 handle.append_line(
1142 InlineMessageKind::Error,
1143 vec![plain_segment(format!("\u{2717} {error}"))],
1144 );
1145 handle.set_reasoning_stage(None);
1146 handle.set_input_enabled(true);
1147 }
1148 AgentEvent::Error { message, .. } => {
1149 handle.append_line(InlineMessageKind::Error, vec![plain_segment(message)]);
1150 handle.set_input_enabled(true);
1151 handle.set_input_status(None, None);
1152 }
1153 AgentEvent::Compaction { .. } => {
1154 }
1157 AgentEvent::Cancelled => {
1158 handle.set_input_enabled(true);
1159 handle.set_input_status(None, Some("cancelled".to_string()));
1160 }
1161 AgentEvent::AutoRetryStart {
1162 attempt,
1163 max_attempts,
1164 ..
1165 } => {
1166 handle.set_input_status(None, Some(format!("retry {attempt}/{max_attempts}")));
1167 }
1168 AgentEvent::TurnEnd { .. } => {
1169 crate::tui_vt::notifications::emit_notification("oxicode", "Response complete");
1173 state.drain_queue_head();
1177 handle.set_reasoning_stage(None);
1178 }
1179 _ => {
1180 tracing::debug!(?event, "ignored AgentEvent variant");
1184 }
1185 }
1186}
1187
1188fn handle_inline_event(
1190 state: &mut RenderState,
1191 handle: &InlineHandle,
1192 session: &crate::app::agent_session::AgentSessionHandle,
1193 prompt_tx: &tokio::sync::mpsc::UnboundedSender<String>,
1194 evt: InlineEvent,
1195) -> LoopOutcome {
1196 match evt {
1197 InlineEvent::Submit(text) => {
1198 let prompt = text.to_string();
1202 state.input_buffer.clear();
1203 state.input_cursor = 0;
1204 if prompt.is_empty() {
1205 return LoopOutcome::Continue;
1206 }
1207 state.pending_quit = false;
1208 if prompt.trim_start().starts_with('/') {
1212 state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
1213 let mut ctx = SlashCtx {
1214 session,
1215 handle,
1216 state,
1217 };
1218 return match SlashRegistry::builtins().dispatch(&prompt, &mut ctx) {
1219 SlashOutcome::Quit => LoopOutcome::Exit,
1220 SlashOutcome::Handled => LoopOutcome::Continue,
1221 SlashOutcome::NotHandled => {
1222 ctx.reply(
1223 InlineMessageKind::Error,
1224 format!("Unknown command: {}", prompt.trim()),
1225 );
1226 LoopOutcome::Continue
1227 }
1228 };
1229 }
1230 state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
1231 if session.is_streaming() {
1235 state.queued_inputs.push(prompt.clone());
1236 state.show_tip(
1237 "send_now",
1238 "Ctrl+Enter sends now \u{00b7} Ctrl+; manages queue",
1239 240,
1240 true,
1241 );
1242 }
1243 let _ = prompt_tx.send(prompt);
1246 }
1247 InlineEvent::Cancel => {
1248 return match route_cancel(session.is_streaming()) {
1254 CancelRoute::Interrupt => handle_interrupt(state, session, handle),
1255 CancelRoute::Exit => LoopOutcome::Exit,
1256 };
1257 }
1258 InlineEvent::Exit => {
1259 return LoopOutcome::Exit;
1260 }
1261 InlineEvent::Interrupt => {
1262 return handle_interrupt(state, session, handle);
1263 }
1264 InlineEvent::ScrollLineUp => {
1265 state.scroll_offset = state.scroll_offset.saturating_add(1);
1266 }
1267 InlineEvent::ScrollLineDown => {
1268 state.scroll_offset = state.scroll_offset.saturating_sub(1);
1269 }
1270 InlineEvent::ScrollPageUp => {
1271 state.scroll_offset = state.scroll_offset.saturating_add(10);
1272 }
1273 InlineEvent::ScrollPageDown => {
1274 state.scroll_offset = state.scroll_offset.saturating_sub(10);
1275 }
1276 InlineEvent::CyclePrimaryAgent => {
1277 let _ = session.cycle_model();
1278 }
1279 InlineEvent::CyclePrimaryAgentPrevious => {
1280 let _ = session.cycle_model();
1283 }
1284 InlineEvent::Overlay(overlay_evt) => {
1285 use oxicode_vtui::tui::core::OverlayEvent;
1286 match overlay_evt {
1287 OverlayEvent::Submitted(sub) => {
1288 if let OverlaySubmission::Selection(InlineListSelection::Model(idx)) = &sub
1290 && idx < &state.overlay_model_ids.len()
1291 {
1292 let model_id = state.overlay_model_ids[*idx].clone();
1293 match session.set_model(&model_id) {
1294 Ok(()) => handle.append_line(
1295 InlineMessageKind::Info,
1296 vec![plain_segment(format!("Switched to {model_id}"))],
1297 ),
1298 Err(e) => handle.append_line(
1299 InlineMessageKind::Error,
1300 vec![plain_segment(format!("Failed to set model: {e}"))],
1301 ),
1302 }
1303 }
1304 if let OverlaySubmission::Selection(InlineListSelection::Theme(theme_id)) = &sub
1306 {
1307 match oxicode_vtui::theme::set_active_theme(theme_id) {
1308 Ok(()) => {
1309 let label = oxicode_vtui::theme::theme_label(theme_id)
1310 .unwrap_or(theme_id.as_ref())
1311 .to_string();
1312 handle.append_line(
1313 InlineMessageKind::Info,
1314 vec![plain_segment(format!("Theme: {label}"))],
1315 );
1316 }
1317 Err(e) => handle.append_line(
1318 InlineMessageKind::Error,
1319 vec![plain_segment(format!("Unknown theme: {e}"))],
1320 ),
1321 }
1322 }
1323 if let OverlaySubmission::Selection(InlineListSelection::SlashCommand(name)) =
1325 &sub
1326 {
1327 state.input_buffer = format!("/{name} ");
1328 state.input_cursor = state.input_buffer.len();
1329 }
1330 if let OverlaySubmission::Selection(InlineListSelection::ConfigAction(key)) =
1332 &sub
1333 {
1334 match key.as_str() {
1335 "thinking_level" => {
1336 if let Some(level) = session.cycle_thinking_level() {
1337 handle.append_line(
1338 InlineMessageKind::Info,
1339 vec![plain_segment(format!("Thinking: {level:?}"))],
1340 );
1341 }
1342 }
1343 "auto_compaction" => {
1344 let enabled = !session.auto_compaction_enabled();
1345 session.set_auto_compaction(enabled);
1346 handle.append_line(
1347 InlineMessageKind::Info,
1348 vec![plain_segment(format!(
1349 "Auto-compaction: {}",
1350 if enabled { "on" } else { "off" }
1351 ))],
1352 );
1353 }
1354 "auto_retry" => {
1355 let enabled = !session.auto_retry_enabled();
1356 session.set_auto_retry(enabled);
1357 handle.append_line(
1358 InlineMessageKind::Info,
1359 vec![plain_segment(format!(
1360 "Auto-retry: {}",
1361 if enabled { "on" } else { "off" }
1362 ))],
1363 );
1364 }
1365 "advisor" => match session.toggle_advisor() {
1366 Ok(enabled) => handle.append_line(
1367 InlineMessageKind::Info,
1368 vec![plain_segment(format!(
1369 "Advisor: {}",
1370 if enabled { "on" } else { "off" }
1371 ))],
1372 ),
1373 Err(e) => handle.append_line(
1374 InlineMessageKind::Error,
1375 vec![plain_segment(format!("Failed to toggle advisor: {e}"))],
1376 ),
1377 },
1378 _ => {}
1379 }
1380 }
1381 if let OverlaySubmission::Selection(InlineListSelection::Session(id)) = &sub {
1384 state.input_buffer = format!("/resume {id}");
1385 state.input_cursor = state.input_buffer.len();
1386 }
1387 state.overlay_model_ids.clear();
1388 handle.close_overlay();
1389 }
1390 OverlayEvent::Cancelled => {
1391 handle.close_overlay();
1392 }
1393 OverlayEvent::SelectionChanged(_) => {}
1394 }
1395 }
1396 _ => {
1397 }
1401 }
1402 LoopOutcome::Continue
1403}
1404
1405struct StreamingGuard<'a>(&'a std::sync::atomic::AtomicBool);
1412
1413impl Drop for StreamingGuard<'_> {
1414 fn drop(&mut self) {
1415 use std::sync::atomic::Ordering;
1416 self.0.store(false, Ordering::SeqCst);
1417 }
1418}
1419
1420fn handle_interrupt(
1433 state: &mut RenderState,
1434 session: &crate::app::agent_session::AgentSessionHandle,
1435 _handle: &InlineHandle,
1436) -> LoopOutcome {
1437 if state.confirmation.is_some() {
1439 return LoopOutcome::Exit;
1440 }
1441 if state.pending_quit {
1444 state.confirmation = Some(quit_confirmation());
1445 state.pending_quit = false;
1446 return LoopOutcome::Continue;
1447 }
1448 if session.is_streaming() {
1452 let s = session.clone();
1453 tokio::spawn(async move {
1454 s.abort().await;
1455 });
1456 state.footer_left = Some("Stopping\u{2026} press Ctrl+C again to confirm quit".to_string());
1457 state.pending_quit = true;
1458 } else {
1459 state.footer_left = None;
1460 state.confirmation = Some(quit_confirmation());
1461 }
1462 LoopOutcome::Continue
1463}
1464
1465fn quit_confirmation() -> ModalConfirmation {
1467 ModalConfirmation {
1468 title: "Quit oxicode?".into(),
1469 message: " y \u{2014} quit now n / x \u{2014} stay".into(),
1470 action: ConfirmationAction::Quit,
1471 }
1472}
1473
1474pub(super) fn clear_confirmation() -> ModalConfirmation {
1476 ModalConfirmation {
1477 title: "Clear conversation?".into(),
1478 message: " y \u{2014} clear all n / x \u{2014} cancel".into(),
1479 action: ConfirmationAction::ClearConversation,
1480 }
1481}
1482
1483fn spawn_input_thread(
1489 state: Arc<parking_lot::Mutex<RenderState>>,
1490 evt_tx: tokio::sync::mpsc::UnboundedSender<InlineEvent>,
1491) -> std::thread::JoinHandle<()> {
1492 std::thread::spawn(move || {
1493 loop {
1501 match event::poll(std::time::Duration::from_millis(50)) {
1502 Ok(true) => {}
1503 Ok(false) => continue,
1504 Err(_) => break,
1505 }
1506 let event = match event::read() {
1507 Ok(ev) => ev,
1508 Err(_) => continue,
1509 };
1510
1511 let mut pasted = String::new();
1514 let mut key_event = None;
1515 match event {
1516 Event::Key(k) if k.kind == KeyEventKind::Press => key_event = Some(k),
1517 Event::Paste(p) => pasted = p,
1518 _ => {}
1519 }
1520
1521 if !pasted.is_empty() {
1522 let mut s = state.lock();
1523 let cursor = s.input_cursor;
1524 s.input_buffer.insert_str(cursor, &pasted);
1525 s.input_cursor = cursor + pasted.len();
1526 continue;
1527 }
1528
1529 let Some(key) = key_event else { continue };
1530
1531 if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
1535 let _ = evt_tx.send(InlineEvent::Interrupt);
1536 continue;
1537 }
1538
1539 if key.code == KeyCode::Char('m') && key.modifiers.contains(KeyModifiers::CONTROL) {
1541 let mut s = state.lock();
1542 s.multiline_mode = !s.multiline_mode;
1543 continue;
1544 }
1545
1546 if key.code == KeyCode::Char('p') && key.modifiers.contains(KeyModifiers::CONTROL) {
1548 let mut s = state.lock();
1549 s.overlay = Some(build_command_palette());
1550 continue;
1551 }
1552
1553 if key.code == KeyCode::Char(';') && key.modifiers.contains(KeyModifiers::CONTROL) {
1555 let mut s = state.lock();
1556 s.queue_panel_open = !s.queue_panel_open;
1557 if s.queue_panel_open {
1558 s.queue_selected = 0;
1559 }
1560 continue;
1561 }
1562
1563 if key.code == KeyCode::Char('e') && key.modifiers.contains(KeyModifiers::CONTROL) {
1565 let mut s = state.lock();
1566 s.fold_all();
1567 continue;
1568 }
1569
1570 if key.code == KeyCode::Enter && key.modifiers.contains(KeyModifiers::CONTROL) {
1573 let submitted = {
1574 let mut s = state.lock();
1575 let buf = if s.slash_popup.open && !s.slash_popup.items.is_empty() {
1576 format!("/{}", s.slash_popup.items[s.slash_popup.selected].name)
1577 } else {
1578 std::mem::take(&mut s.input_buffer)
1579 };
1580 s.input_cursor = 0;
1581 s.slash_popup = SlashPopup::default();
1582 s.history_pos = None;
1583 if !buf.is_empty() && !buf.starts_with('/') {
1584 s.prompt_history.insert(0, buf.clone());
1585 s.prompt_history.truncate(100);
1586 }
1587 buf
1588 };
1589 if !submitted.is_empty() {
1590 let _ = evt_tx.send(InlineEvent::Interrupt);
1591 let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
1592 }
1593 continue;
1594 }
1595
1596 {
1599 let s = state.lock();
1600 if s.confirmation.is_some() {
1601 drop(s);
1602 handle_confirmation_key(&state, &evt_tx, key.code);
1603 continue;
1604 }
1605 }
1606
1607 {
1613 let s = state.lock();
1614 if s.overlay.is_some() {
1615 drop(s);
1616 if handle_overlay_key(&state, &evt_tx, key.code) {
1617 continue;
1618 }
1619 }
1620 }
1621
1622 {
1626 let s = state.lock();
1627 if s.file_search.is_some() {
1628 drop(s);
1629 if handle_file_search_key(&state, &evt_tx, key.code) {
1630 continue;
1631 }
1632 }
1633 }
1634
1635 match key.code {
1636 KeyCode::Enter => {
1637 let send = !state.lock().multiline_mode
1640 || key
1641 .modifiers
1642 .contains(crossterm::event::KeyModifiers::SHIFT);
1643
1644 if !send {
1645 let mut s = state.lock();
1646 let cursor = s.input_cursor;
1647 s.input_buffer.insert(cursor, '\n');
1648 s.input_cursor = cursor + 1;
1649 continue;
1650 }
1651
1652 let shell_cmd = state.lock().shell_mode;
1654 if shell_cmd {
1655 let submitted = {
1656 let mut s = state.lock();
1657 let buf = std::mem::take(&mut s.input_buffer);
1658 s.input_cursor = 0;
1659 s.shell_mode = false;
1660 s.history_pos = None;
1661 if !buf.is_empty() {
1662 s.prompt_history.insert(0, buf.clone());
1663 s.prompt_history.truncate(100);
1664 }
1665 buf
1666 };
1667 if !submitted.is_empty() {
1668 let prompt = format!("Run this shell command: `{submitted}`");
1669 let _ = evt_tx.send(InlineEvent::Submit(prompt.into()));
1670 }
1671 continue;
1672 }
1673
1674 let submitted = {
1675 let mut s = state.lock();
1676 let buf = if s.slash_popup.open && !s.slash_popup.items.is_empty() {
1677 let item = &s.slash_popup.items[s.slash_popup.selected];
1678 format!("/{}", item.name)
1679 } else {
1680 std::mem::take(&mut s.input_buffer)
1681 };
1682 s.input_cursor = 0;
1683 s.slash_popup = SlashPopup::default();
1684 s.history_pos = None;
1685 if !buf.is_empty() && !buf.starts_with('/') {
1687 s.prompt_history.insert(0, buf.clone());
1688 s.prompt_history.truncate(100);
1689 }
1690 buf
1691 };
1692 let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
1693 }
1694 KeyCode::Esc => {
1695 let mut s = state.lock();
1702 if s.shell_mode {
1703 s.shell_mode = false;
1704 s.input_buffer.clear();
1705 s.input_cursor = 0;
1706 } else if s.slash_popup.open {
1707 s.slash_popup = SlashPopup::default();
1708 } else if !s.input_buffer.is_empty() {
1709 let now = std::time::Instant::now();
1710 let is_double = s
1711 .last_esc_at
1712 .map(|t| now.duration_since(t).as_millis() < 800)
1713 .unwrap_or(false);
1714 if is_double {
1715 s.input_buffer.clear();
1716 s.input_cursor = 0;
1717 s.last_esc_at = None;
1718 } else {
1719 s.last_esc_at = Some(now);
1720 s.tip = Some(EphemeralTip {
1723 text: "Press Esc again to clear input".to_string(),
1724 born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
1725 ttl_ticks: 120,
1726 key: "esc_clear",
1727 ambient: false,
1728 });
1729 }
1730 } else {
1731 let now = std::time::Instant::now();
1732 let in_grace = s.cancel_grace_until.map(|t| t > now).unwrap_or(false);
1733 if in_grace {
1734 } else {
1736 s.cancel_grace_until = Some(now + std::time::Duration::from_secs(1));
1737 s.last_esc_at = None;
1738 drop(s);
1739 let _ = evt_tx.send(InlineEvent::Cancel);
1740 }
1741 }
1742 }
1743 KeyCode::Tab => {
1744 let mut s = state.lock();
1747 if s.slash_popup.open && !s.slash_popup.items.is_empty() {
1748 let name = s.slash_popup.items[s.slash_popup.selected].name.clone();
1749 s.input_buffer = format!("/{} ", name);
1750 s.input_cursor = s.input_buffer.len();
1751 refresh_input_popups(&mut s);
1752 }
1753 }
1754 KeyCode::Backspace => {
1755 let mut s = state.lock();
1756 if s.input_cursor > 0 {
1757 let cursor = s.input_cursor;
1758 let prev = s
1761 .input_buffer
1762 .char_indices()
1763 .take_while(|(i, _)| *i < cursor)
1764 .last()
1765 .map(|(i, _)| i)
1766 .unwrap_or(0);
1767 s.input_buffer.replace_range(prev..cursor, "");
1768 s.input_cursor = prev;
1769 }
1770 refresh_input_popups(&mut s);
1771 }
1772 KeyCode::Delete => {
1773 let mut s = state.lock();
1774 if s.input_cursor < s.input_buffer.len() {
1775 let cursor = s.input_cursor;
1776 let next = s.input_buffer[cursor..]
1777 .char_indices()
1778 .nth(1)
1779 .map(|(i, _)| cursor + i)
1780 .unwrap_or(s.input_buffer.len());
1781 s.input_buffer.replace_range(cursor..next, "");
1782 }
1783 refresh_input_popups(&mut s);
1784 }
1785 KeyCode::Left => {
1786 let mut s = state.lock();
1787 s.input_cursor = s.input_cursor.saturating_sub(1);
1788 }
1789 KeyCode::Right => {
1790 let mut s = state.lock();
1791 let len = s.input_buffer.len();
1792 s.input_cursor = (s.input_cursor + 1).min(len);
1793 }
1794 KeyCode::Up => {
1795 let mut s = state.lock();
1796 if s.slash_popup.open && !s.slash_popup.items.is_empty() {
1797 let len = s.slash_popup.items.len();
1798 s.slash_popup.selected = if s.slash_popup.selected == 0 {
1799 len - 1
1800 } else {
1801 s.slash_popup.selected - 1
1802 };
1803 } else if s.queue_panel_open
1804 && !s.queued_inputs.is_empty()
1805 && s.input_buffer.is_empty()
1806 {
1807 s.queue_selected = if s.queue_selected == 0 {
1808 s.queued_inputs.len() - 1
1809 } else {
1810 s.queue_selected - 1
1811 };
1812 } else if s.input_buffer.is_empty() && !s.prompt_history.is_empty() {
1813 let pos = s.history_pos.unwrap_or(0);
1815 let next = (pos + 1).min(s.prompt_history.len() - 1);
1816 s.history_pos = Some(next);
1817 s.input_buffer = s.prompt_history[next].clone();
1818 s.input_cursor = s.input_buffer.len();
1819 } else {
1820 drop(s);
1821 let _ = evt_tx.send(InlineEvent::ScrollLineUp);
1822 }
1823 }
1824 KeyCode::Down => {
1825 let mut s = state.lock();
1826 if s.slash_popup.open && !s.slash_popup.items.is_empty() {
1827 let len = s.slash_popup.items.len();
1828 s.slash_popup.selected = if s.slash_popup.selected + 1 >= len {
1829 0
1830 } else {
1831 s.slash_popup.selected + 1
1832 };
1833 } else if s.queue_panel_open
1834 && !s.queued_inputs.is_empty()
1835 && s.input_buffer.is_empty()
1836 {
1837 s.queue_selected = if s.queue_selected + 1 >= s.queued_inputs.len() {
1838 0
1839 } else {
1840 s.queue_selected + 1
1841 };
1842 } else {
1843 drop(s);
1844 let _ = evt_tx.send(InlineEvent::ScrollLineDown);
1845 }
1846 }
1847 KeyCode::PageUp => {
1848 let _ = evt_tx.send(InlineEvent::ScrollPageUp);
1849 }
1850 KeyCode::PageDown => {
1851 let _ = evt_tx.send(InlineEvent::ScrollPageDown);
1852 }
1853 KeyCode::Char(ch) => {
1854 let mut s = state.lock();
1855 if s.file_search.is_some()
1859 && ch == '!'
1860 && s.input_buffer[..s.input_cursor].ends_with('@')
1861 {
1862 let cwd = s.cwd.clone();
1863 if let Some(fs) = s.file_search.as_mut() {
1864 fs.toggle_hidden(&cwd);
1865 }
1866 continue;
1867 }
1868 if s.agent_hub_open && ch == 'q' {
1869 s.agent_hub_open = false;
1870 } else if s.vim_state.enabled() && !s.slash_popup.open {
1871 let s = &mut *s;
1874 let vkey =
1875 crossterm::event::KeyEvent::new(KeyCode::Char(ch), key.modifiers);
1876 let mut editor = InputEditor {
1877 buffer: &mut s.input_buffer,
1878 cursor: &mut s.input_cursor,
1879 };
1880 let outcome = oxicode_vtui::vim::handle_key(
1881 &mut s.vim_state,
1882 &mut editor,
1883 &mut s.vim_clipboard,
1884 &vkey,
1885 );
1886 if outcome.handled {
1887 refresh_input_popups(s);
1888 } else {
1889 let cursor = s.input_cursor;
1890 s.input_buffer.insert(cursor, ch);
1891 s.input_cursor = cursor + ch.len_utf8();
1892 refresh_input_popups(s);
1893 }
1894 } else if s.input_buffer.is_empty() && !s.slash_popup.open {
1895 if ch == '!' && !s.shell_mode {
1897 s.shell_mode = true;
1898 continue;
1899 }
1900 if s.queue_panel_open && !s.queued_inputs.is_empty() {
1904 let idx = s.queue_selected.min(s.queued_inputs.len() - 1);
1905 match ch {
1906 'x' | 'X' => {
1907 s.queued_inputs.remove(idx);
1908 if s.queue_selected >= s.queued_inputs.len()
1909 && !s.queued_inputs.is_empty()
1910 {
1911 s.queue_selected = s.queued_inputs.len() - 1;
1912 }
1913 continue;
1914 }
1915 'e' => {
1916 let entry = s.queued_inputs.remove(idx);
1917 s.input_buffer = entry;
1918 s.input_cursor = s.input_buffer.len();
1919 s.queue_panel_open = false;
1920 continue;
1921 }
1922 'J' => {
1923 if idx + 1 < s.queued_inputs.len() {
1924 s.queued_inputs.swap(idx, idx + 1);
1925 s.queue_selected = idx + 1;
1926 }
1927 continue;
1928 }
1929 'K' => {
1930 if idx > 0 {
1931 s.queued_inputs.swap(idx, idx - 1);
1932 s.queue_selected = idx - 1;
1933 }
1934 continue;
1935 }
1936 _ => {} }
1938 }
1939 match ch {
1944 '?' => {
1945 s.overlay = Some(OverlayState {
1946 title: "Keyboard Shortcuts".into(),
1947 lines: cheatsheet_lines(),
1948 items: vec![],
1949 selected: 0,
1950 search: None,
1951 });
1952 }
1953 'e' => s.cycle_block_at_view(),
1954 'E' => s.expand_all(),
1955 'J' => s.jump_next_turn(),
1956 'K' => s.jump_prev_turn(),
1957 'n' if s.search.is_some() => s.search_next(),
1958 'N' if s.search.is_some() => s.search_prev(),
1959 _ => {
1960 let cursor = s.input_cursor;
1961 s.input_buffer.insert(cursor, ch);
1962 s.input_cursor = cursor + ch.len_utf8();
1963 refresh_input_popups(&mut s);
1964 }
1965 }
1966 } else {
1967 let cursor = s.input_cursor;
1968 s.input_buffer.insert(cursor, ch);
1969 s.input_cursor = cursor + ch.len_utf8();
1970 refresh_input_popups(&mut s);
1971 }
1972 if s.tip.is_none() && s.input_buffer.to_lowercase().contains("plan") {
1974 s.show_tip(
1975 "plan_nudge",
1976 "Try /compact to summarize and plan ahead",
1977 180,
1978 true,
1979 );
1980 }
1981 }
1982 _ => {}
1983 }
1984 }
1985 })
1986}
1987
1988fn handle_confirmation_key(
1992 state: &Arc<parking_lot::Mutex<RenderState>>,
1993 evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
1994 code: KeyCode,
1995) {
1996 let mut s = state.lock();
1997 let Some(confirm) = s.confirmation.clone() else {
1998 return;
1999 };
2000 match code {
2001 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
2002 s.confirmation = None;
2003 drop(s);
2004 match confirm.action {
2005 ConfirmationAction::Quit => {
2006 let _ = evt_tx.send(InlineEvent::Exit);
2007 }
2008 ConfirmationAction::ClearConversation => {
2009 let _ = evt_tx.send(InlineEvent::Submit("/clear --yes".into()));
2013 }
2014 }
2015 }
2016 KeyCode::Char('n')
2017 | KeyCode::Char('N')
2018 | KeyCode::Char('x')
2019 | KeyCode::Char('X')
2020 | KeyCode::Esc => {
2021 s.confirmation = None;
2022 }
2023 _ => {}
2024 }
2025}
2026
2027fn handle_overlay_key(
2032 state: &Arc<parking_lot::Mutex<RenderState>>,
2033 evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
2034 code: KeyCode,
2035) -> bool {
2036 use oxicode_vtui::tui::core::{InlineListSelection, OverlayEvent, OverlaySubmission};
2037
2038 let mut s = state.lock();
2039 let Some(overlay) = s.overlay.as_mut() else {
2040 return false;
2041 };
2042
2043 match code {
2044 KeyCode::Esc => {
2045 drop(s);
2047 state.lock().overlay = None;
2048 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
2049 }
2050 KeyCode::Enter => {
2051 let submission = if let Some(item) = overlay.items.get(overlay.selected) {
2054 item.selection.clone().unwrap_or_else(|| {
2055 InlineListSelection::SlashCommand(format!("overlay:{}", overlay.selected))
2060 })
2061 } else {
2062 drop(s);
2063 state.lock().overlay = None;
2064 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
2065 return true;
2066 };
2067 let title = overlay.title.clone();
2068 let selected = overlay.selected;
2069 drop(s);
2070 state.lock().overlay = None;
2071 tracing::debug!(
2072 overlay = %title,
2073 selected,
2074 "overlay submitted"
2075 );
2076 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Submitted(
2077 OverlaySubmission::Selection(submission),
2078 )));
2079 }
2080 KeyCode::Up => {
2081 let len = overlay_filtered_indices(overlay).len();
2082 if len == 0 {
2083 return true;
2084 }
2085 let pos = overlay_filtered_indices(overlay)
2086 .iter()
2087 .position(|&i| i == overlay.selected)
2088 .unwrap_or(0);
2089 let new_pos = if pos == 0 { len - 1 } else { pos - 1 };
2090 overlay.selected = overlay_filtered_indices(overlay)[new_pos];
2091 }
2092 KeyCode::Down => {
2093 let filtered = overlay_filtered_indices(overlay);
2094 let len = filtered.len();
2095 if len == 0 {
2096 return true;
2097 }
2098 let pos = filtered
2099 .iter()
2100 .position(|&i| i == overlay.selected)
2101 .unwrap_or(0);
2102 let new_pos = if pos + 1 >= len { 0 } else { pos + 1 };
2103 overlay.selected = filtered[new_pos];
2104 }
2105 KeyCode::Backspace => {
2106 if let Some(search) = overlay.search.as_mut() {
2107 search.value.pop();
2108 overlay.selected = 0;
2109 }
2110 }
2111 KeyCode::Char(ch) => {
2112 if let Some(search) = overlay.search.as_mut() {
2113 search.value.push(ch);
2114 overlay.selected = 0;
2115 }
2116 }
2117 _ => {
2118 }
2120 }
2121 true
2122}
2123
2124fn overlay_filtered_indices(overlay: &OverlayState) -> Vec<usize> {
2129 let needle = overlay
2130 .search
2131 .as_ref()
2132 .map(|s| s.value.to_lowercase())
2133 .unwrap_or_default();
2134 if needle.is_empty() {
2135 return (0..overlay.items.len()).collect();
2136 }
2137 overlay
2138 .items
2139 .iter()
2140 .enumerate()
2141 .filter_map(|(idx, item)| {
2142 let title_hit = item.title.to_lowercase().contains(&needle);
2143 let sv_hit = item
2144 .search_value
2145 .as_deref()
2146 .map(|v| v.to_lowercase().contains(&needle))
2147 .unwrap_or(false);
2148 if title_hit || sv_hit { Some(idx) } else { None }
2149 })
2150 .collect()
2151}
2152
2153fn handle_file_search_key(
2159 state: &Arc<parking_lot::Mutex<RenderState>>,
2160 _evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
2161 code: KeyCode,
2162) -> bool {
2163 match code {
2164 KeyCode::Up => {
2165 let mut s = state.lock();
2166 if let Some(fs) = s.file_search.as_mut() {
2167 fs.up();
2168 true
2169 } else {
2170 false
2171 }
2172 }
2173 KeyCode::Down => {
2174 let mut s = state.lock();
2175 if let Some(fs) = s.file_search.as_mut() {
2176 fs.down();
2177 true
2178 } else {
2179 false
2180 }
2181 }
2182 KeyCode::Tab | KeyCode::Enter => {
2183 let mut s = state.lock();
2184 if s.file_search
2185 .as_ref()
2186 .and_then(|fs| fs.selected_result())
2187 .is_some()
2188 {
2189 accept_file_search(&mut s, false);
2190 true
2191 } else {
2192 s.file_search = None;
2194 false
2195 }
2196 }
2197 KeyCode::Esc => {
2198 let mut s = state.lock();
2199 s.file_search = None;
2200 true
2201 }
2202 _ => false,
2203 }
2204}
2205
2206fn spawn_agent_worker(
2212 session: crate::app::agent_session::AgentSessionHandle,
2213) -> tokio::sync::mpsc::UnboundedSender<String> {
2214 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
2215
2216 std::thread::spawn(move || {
2217 let runtime = match tokio::runtime::Builder::new_current_thread()
2218 .enable_all()
2219 .build()
2220 {
2221 Ok(rt) => rt,
2222 Err(err) => {
2223 tracing::error!(?err, "failed to build agent worker runtime");
2224 return;
2225 }
2226 };
2227
2228 runtime.block_on(async move {
2229 let local = tokio::task::LocalSet::new();
2230 local
2231 .run_until(async move {
2232 while let Some(prompt) = prompt_rx.recv().await {
2233 run_one_prompt(&session, prompt).await;
2234 }
2235 })
2236 .await;
2237 });
2238 });
2239
2240 prompt_tx
2241}
2242
2243async fn run_one_prompt(session: &crate::app::agent_session::AgentSessionHandle, prompt: String) {
2244 let session_for_forward = session.clone();
2245 let (event_tx, event_rx) = std::sync::mpsc::channel::<AgentEvent>();
2246
2247 let forwarder = std::thread::spawn(move || {
2251 while let Ok(event) = event_rx.recv() {
2252 session_for_forward.forward_event_to_extensions(&event);
2253 }
2254 });
2255
2256 use std::sync::atomic::Ordering;
2260 session.reset_should_stop();
2261 let streaming = session.streaming_flag();
2262 streaming.store(true, Ordering::SeqCst);
2263 let _stream_guard = StreamingGuard(&streaming);
2264
2265 let agent = session.agent_ref();
2266 let local = tokio::task::LocalSet::new();
2267 let result = local
2268 .run_until(agent.run_with_channel(prompt, event_tx))
2269 .await;
2270
2271 let _ = forwarder.join();
2274 if let Err(err) = result {
2275 tracing::warn!(?err, "agent run failed");
2276 }
2277}
2278
2279fn build_header_context(
2288 app: &App,
2289 cwd: &std::path::Path,
2290 git_branch: Option<&str>,
2291) -> InlineHeaderContext {
2292 let workspace_name = cwd
2293 .file_name()
2294 .map(|n| n.to_string_lossy().into_owned())
2295 .unwrap_or_else(|| "oxicode".to_string());
2296 let model_id = app.model_id();
2297 let provider = model_id
2298 .split_once('/')
2299 .map(|(p, _)| p.to_string())
2300 .unwrap_or_else(|| "Provider".to_string());
2301 let branch = git_branch.unwrap_or("\u{2014}").to_string();
2302 let mut ctx = InlineHeaderContext::default();
2303 ctx.app_name = "oxicode".to_string();
2304 ctx.provider = provider;
2305 ctx.model = model_id.clone();
2306 ctx.git = format!("git: {workspace_name}@{branch}");
2307 ctx.tools = "Tools: ready".to_string();
2308 ctx.search_tools = Some(InlineHeaderStatusBadge {
2309 text: workspace_name,
2310 tone: InlineHeaderStatusTone::Ready,
2311 });
2312 ctx.persistent_memory = Some(InlineHeaderStatusBadge {
2313 text: branch,
2314 tone: InlineHeaderStatusTone::Ready,
2315 });
2316 ctx.editor_context = Some(model_id);
2317 ctx
2318}
2319
2320async fn build_agent_session(app: &App) -> Result<crate::app::agent_session::AgentSession> {
2323 use crate::app::agent_session_runtime::{
2324 CreateAgentSessionFromServicesOptions, CreateAgentSessionServicesOptions,
2325 create_agent_session_from_services, create_agent_session_services,
2326 };
2327 use crate::store::session::SessionManager;
2328
2329 let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
2330 let hook_runner = Arc::clone(&app.oxicode().ports().hooks);
2331 let services = create_agent_session_services(
2332 CreateAgentSessionServicesOptions::new(cwd.clone()),
2333 Some(hook_runner),
2334 )?;
2335 let services = Arc::new(services);
2336
2337 let model_id = app.model_id();
2338 let tools = app.agent_tools();
2339
2340 let session_manager = SessionManager::create(&cwd.to_string_lossy(), None);
2341
2342 let result = create_agent_session_from_services(CreateAgentSessionFromServicesOptions {
2343 services,
2344 session_manager,
2345 model_id: if model_id.is_empty() {
2346 None
2347 } else {
2348 Some(model_id)
2349 },
2350 thinking_level: None,
2351 scoped_models: Vec::new(),
2352 tool_registry: Some(tools),
2353 session_state: Some(app.session_state().clone()),
2356 })
2357 .await?;
2358
2359 if let Some(msg) = result.model_fallback_message {
2360 tracing::warn!(message = %msg, "agent session model fallback");
2361 }
2362 Ok(result.session)
2363}
2364
2365fn cheatsheet_lines() -> Vec<String> {
2371 vec![
2372 "".into(),
2373 " Navigation".into(),
2374 " j / ↓ Scroll down".into(),
2375 " k / ↑ Scroll up".into(),
2376 " J (Shift+j) Next turn".into(),
2377 " K (Shift+k) Previous turn".into(),
2378 " PgDn / PgUp Page scroll".into(),
2379 " g / G Top / bottom".into(),
2380 "".into(),
2381 " Blocks".into(),
2382 " e Cycle block (collapse/truncate/expand)".into(),
2383 " E Expand all blocks".into(),
2384 " Ctrl+E Collapse all blocks".into(),
2385 "".into(),
2386 " Search".into(),
2387 " /find <q> Search transcript".into(),
2388 " n / N Next / previous match".into(),
2389 "".into(),
2390 " Commands".into(),
2391 " /theme Cycle color theme".into(),
2392 " /model Pick a model".into(),
2393 " /vim Toggle vim mode".into(),
2394 " /compact Compact context".into(),
2395 " /clear Clear conversation".into(),
2396 " Ctrl+C Cancel run (then y to quit)".into(),
2397 " Ctrl+Enter Send now (abort + submit)".into(),
2398 " Ctrl+M Toggle multiline input".into(),
2399 " Ctrl+; Toggle queue panel".into(),
2400 "".into(),
2401 " Special Input".into(),
2402 " @ File picker (fuzzy search)".into(),
2403 " @! Toggle hidden files in picker".into(),
2404 " ! Shell mode (bash command)".into(),
2405 ]
2406}
2407
2408fn build_command_palette() -> OverlayState {
2411 use oxicode_vtui::tui::core::{InlineListItem, InlineListSelection};
2412
2413 let catalog = SlashRegistry::builtin_commands();
2414 let mut items: Vec<InlineListItem> = catalog
2415 .iter()
2416 .map(|(name, desc, aliases)| {
2417 let title = if aliases.is_empty() {
2418 format!("/{name}")
2419 } else {
2420 format!(
2421 "/{name} ({})",
2422 aliases
2423 .iter()
2424 .map(|a| format!("/{a}"))
2425 .collect::<Vec<_>>()
2426 .join(", ")
2427 )
2428 };
2429 InlineListItem {
2430 title,
2431 subtitle: Some(desc.to_string()),
2432 badge: None,
2433 indent: 0,
2434 selection: Some(InlineListSelection::SlashCommand(name.to_string())),
2435 search_value: Some(format!("{name} {desc}")),
2436 }
2437 })
2438 .collect();
2439 items.sort_by(|a, b| a.title.cmp(&b.title));
2440
2441 OverlayState {
2442 title: "Command Palette".into(),
2443 lines: vec!["Type to filter, Enter to select".into()],
2444 items: items
2445 .into_iter()
2446 .map(|item| OverlayListItem {
2447 title: item.title,
2448 subtitle: item.subtitle,
2449 badge: item.badge,
2450 indent: item.indent,
2451 search_value: item.search_value,
2452 selection: item.selection,
2453 })
2454 .collect(),
2455 selected: 0,
2456 search: Some(OverlaySearchState {
2457 label: "search".into(),
2458 placeholder: Some("filter commands\u{2026}".into()),
2459 value: String::new(),
2460 }),
2461 }
2462}
2463
2464static FRAME_TICK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2466static TITLE_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
2468const TITLE_SPINNER: &[&str] = &[
2470 "\u{2807}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}", "\u{2827}",
2471];
2472
2473fn wave_brightness(tick: u64, row: u16, wave_rows: u16, speed: f64) -> f64 {
2476 let phase =
2477 (tick as f64 * speed) + (row as f64 / wave_rows.max(1) as f64) * std::f64::consts::TAU;
2478 let s = phase.sin();
2479 s * s
2480}
2481
2482fn blend_rgb(base: Color, target: Color, ratio: f64) -> Color {
2484 match (base, target) {
2485 (Color::Rgb(br, bg, bb), Color::Rgb(tr, tg, tb)) => {
2486 let r = (br as f64 + (tr as f64 - br as f64) * ratio).round() as u8;
2487 let g = (bg as f64 + (tg as f64 - bg as f64) * ratio).round() as u8;
2488 let b = (bb as f64 + (tb as f64 - bb as f64) * ratio).round() as u8;
2489 Color::Rgb(r, g, b)
2490 }
2491 _ => base,
2492 }
2493}
2494
2495fn accent_color_for_kind(kind: InlineMessageKind, styles: &ThemeStyles) -> Color {
2497 match kind {
2498 InlineMessageKind::User => color_from_anstyle(styles.primary.get_fg_color()),
2499 InlineMessageKind::Agent => color_from_anstyle(styles.response.get_fg_color()),
2500 InlineMessageKind::Tool => color_from_anstyle(styles.tool.get_fg_color()),
2501 InlineMessageKind::Error => color_from_anstyle(styles.error.get_fg_color()),
2502 InlineMessageKind::Warning => color_from_anstyle(styles.status.get_fg_color()),
2503 InlineMessageKind::Info => color_from_anstyle(styles.info.get_fg_color()),
2504 InlineMessageKind::Policy => color_from_anstyle(styles.mcp.get_fg_color()),
2505 InlineMessageKind::Pty => color_from_anstyle(styles.pty_output.get_fg_color()),
2506 }
2507}
2508
2509fn render_frame(frame: &mut Frame<'_>, state: &RenderState, _handle: &InlineHandle) {
2515 let area = frame.area();
2516 let bg = active_styles().background;
2521 frame
2522 .buffer_mut()
2523 .set_style(area, Style::default().bg(color_from_anstyle(Some(bg))));
2524 let layout = super::frame_layout::render_chrome(frame, area, state);
2525 let tick = FRAME_TICK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2526 {
2528 let running = state.reasoning_stage.is_some();
2529 let was_running = TITLE_RUNNING.swap(running, std::sync::atomic::Ordering::Relaxed);
2530 if running || was_running {
2531 let title = if running {
2532 let spin = TITLE_SPINNER[(tick as usize) % TITLE_SPINNER.len()];
2533 let model = state
2534 .header_context
2535 .editor_context
2536 .as_deref()
2537 .unwrap_or("oxicode");
2538 format!("{spin} oxicode \u{2014} {model}")
2539 } else {
2540 "oxicode".to_string()
2541 };
2542 use std::io::Write;
2543 let _ = write!(std::io::stderr(), "\x1b]2;{}\x07", title);
2544 let _ = std::io::stderr().flush();
2545 }
2546 }
2547 render_transcript(frame, layout.scrollback, state, tick);
2548 if !state.queued_inputs.is_empty() {
2549 render_queue_pane(frame, layout.scrollback, state);
2550 }
2551 if !state.todo_items.is_empty() {
2552 render_todo_pane(frame, layout.scrollback, &state.todo_items);
2553 }
2554 if !state.follow_ups.is_empty() {
2555 render_follow_ups(frame, layout.prompt, &state.follow_ups);
2556 }
2557 if let Some(stage) = &state.reasoning_stage {
2558 render_reasoning_indicator(frame, layout.prompt, stage);
2559 }
2560 render_composer(frame, layout.prompt, state);
2561 let occluded = state.overlay.is_some() || state.confirmation.is_some();
2563 if let Some(tip) = &state.tip
2564 && tip_is_visible(tip, tick)
2565 && !(tip.ambient && occluded)
2566 {
2567 render_tip(frame, layout.prompt, &tip.text);
2568 }
2569 if state.slash_popup.open {
2570 render_slash_popup(frame, layout.prompt, state);
2571 }
2572 if state.file_search.is_some() {
2573 render_file_search_dropdown(frame, layout.prompt, state);
2574 }
2575 if state.agent_hub_open {
2576 render_agent_hub(frame, area, state);
2577 }
2578 if let Some(overlay) = &state.overlay {
2579 render_overlay(frame, area, overlay);
2580 }
2581 if let Some(confirm) = &state.confirmation {
2582 render_confirmation(frame, area, confirm);
2583 }
2584}
2585
2586fn render_confirmation(frame: &mut Frame, area: Rect, confirm: &ModalConfirmation) {
2588 let styles = active_styles();
2589 let accent = color_from_anstyle(styles.error.get_fg_color());
2590 let inner_w = confirm
2591 .title
2592 .chars()
2593 .count()
2594 .max(confirm.message.chars().count())
2595 .max(36) as u16;
2596 let width = inner_w + 4;
2597 let height = 5;
2598 let x = area.x + area.width.saturating_sub(width) / 2;
2599 let y = area.y + area.height.saturating_sub(height) / 2;
2600 let popup_area = Rect {
2601 x,
2602 y,
2603 width,
2604 height,
2605 };
2606 let block = Block::default()
2607 .borders(Borders::ALL)
2608 .border_type(BorderType::Rounded)
2609 .title(Span::styled(
2610 format!(" {} ", confirm.title),
2611 Style::default().fg(accent).bold(),
2612 ))
2613 .border_style(Style::default().fg(accent));
2614 let msg = Line::styled(
2615 confirm.message.clone(),
2616 Style::default().fg(color_from_anstyle(Some(styles.foreground))),
2617 );
2618 frame.render_widget(
2619 Paragraph::new(vec![Line::default(), msg]).block(block),
2620 popup_area,
2621 );
2622}
2623
2624fn render_agent_hub(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
2628 let rows = state.hub_entries.len() as u16;
2629 let height = rows.saturating_add(4).min(area.height.saturating_sub(1));
2630 let width = area.width.clamp(30, 80);
2631 let rect = Rect {
2632 x: area.x + (area.width.saturating_sub(width)) / 2,
2633 y: area.y + (area.height.saturating_sub(height)) / 2,
2634 width,
2635 height,
2636 };
2637 frame.render_widget(Clear, rect);
2638
2639 let title = Line::from(Span::styled(
2640 " Agent Hub ",
2641 Style::default().add_modifier(Modifier::BOLD),
2642 ));
2643 let block = Block::default().borders(Borders::ALL).title(title);
2644
2645 let items: Vec<ListItem<'_>> = if state.hub_entries.is_empty() {
2646 vec![ListItem::new(Line::from(Span::raw(
2647 "No agents registered.",
2648 )))]
2649 } else {
2650 state
2651 .hub_entries
2652 .iter()
2653 .map(|(id, e)| {
2654 ListItem::new(Line::from(vec![
2655 Span::raw(format!("{:?} ", e.kind)),
2656 Span::raw(e.display_name.clone()),
2657 Span::raw(format!(" — {:?} ({})", e.status, id)),
2658 ]))
2659 })
2660 .collect()
2661 };
2662 frame.render_widget(List::new(items).block(block), rect);
2663}
2664
2665fn render_overlay(frame: &mut Frame<'_>, area: Rect, overlay: &OverlayState) {
2670 let styles = active_styles();
2671 let visible_max = (area.height as usize).saturating_sub(6).max(3);
2672
2673 let filtered: Vec<usize> = match &overlay.search {
2675 Some(search) if !search.value.is_empty() => {
2676 let needle = search.value.to_lowercase();
2677 overlay
2678 .items
2679 .iter()
2680 .enumerate()
2681 .filter_map(|(idx, item)| {
2682 let title_match = item.title.to_lowercase().contains(&needle);
2683 let sv_match = item
2684 .search_value
2685 .as_deref()
2686 .map(|v| v.to_lowercase().contains(&needle))
2687 .unwrap_or(false);
2688 if title_match || sv_match {
2689 Some(idx)
2690 } else {
2691 None
2692 }
2693 })
2694 .collect()
2695 }
2696 _ => (0..overlay.items.len()).collect(),
2697 };
2698
2699 let has_search = overlay.search.is_some();
2700 let lines_count = overlay.lines.len();
2701 let items_count = filtered.len().min(visible_max);
2702 let height_inner = (lines_count + items_count + if has_search { 1 } else { 0 }) as u16;
2703 let desired_h = height_inner.saturating_add(2); let height = desired_h.min(area.height.saturating_sub(2));
2705 let width = area.width.clamp(30, 80);
2706 let rect = Rect {
2707 x: area.x + (area.width.saturating_sub(width)) / 2,
2708 y: area.y + (area.height.saturating_sub(height)) / 2,
2709 width,
2710 height,
2711 };
2712 frame.render_widget(Clear, rect);
2713
2714 let title = Line::from(Span::styled(
2715 format!(" {} ", overlay.title),
2716 Style::default()
2717 .fg(color_from_anstyle(styles.primary.get_fg_color()))
2718 .add_modifier(Modifier::BOLD),
2719 ));
2720 let block = Block::default()
2721 .borders(Borders::ALL)
2722 .border_type(BorderType::Rounded)
2723 .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
2724 .title(title);
2725 let inner = block.inner(rect);
2726 frame.render_widget(&block, rect);
2727
2728 let primary = color_from_anstyle(styles.primary.get_fg_color());
2729 let fg = color_from_anstyle(Some(styles.foreground));
2730 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
2731
2732 let selected_filtered_pos = filtered
2734 .iter()
2735 .position(|&idx| idx == overlay.selected)
2736 .unwrap_or(0);
2737
2738 let mut row = inner.top();
2739 if let Some(search) = &overlay.search {
2741 let prompt = format!("{}: {}", search.label, search.value);
2742 let line = Line::from(vec![
2743 Span::styled(
2744 format!("{}: ", search.label),
2745 Style::default().fg(secondary),
2746 ),
2747 Span::styled(
2748 if search.value.is_empty() {
2749 search
2750 .placeholder
2751 .clone()
2752 .unwrap_or_else(|| "type to filter\u{2026}".to_string())
2753 } else {
2754 search.value.clone()
2755 },
2756 if search.value.is_empty() {
2757 Style::default().fg(secondary).add_modifier(Modifier::DIM)
2758 } else {
2759 Style::default().fg(fg)
2760 },
2761 ),
2762 ]);
2763 let _ = prompt; let row_area = Rect {
2765 x: inner.left(),
2766 y: row,
2767 width: inner.width,
2768 height: 1,
2769 };
2770 frame.render_widget(Paragraph::new(line), row_area);
2771 row = row.saturating_add(1);
2772 }
2773
2774 for line_text in &overlay.lines {
2776 let row_area = Rect {
2777 x: inner.left(),
2778 y: row,
2779 width: inner.width,
2780 height: 1,
2781 };
2782 let line = Line::from(Span::styled(
2783 line_text.clone(),
2784 Style::default().fg(secondary),
2785 ));
2786 frame.render_widget(Paragraph::new(line), row_area);
2787 row = row.saturating_add(1);
2788 }
2789
2790 if filtered.is_empty() {
2792 let row_area = Rect {
2793 x: inner.left(),
2794 y: row,
2795 width: inner.width,
2796 height: 1,
2797 };
2798 let empty_text = if overlay.search.is_some() {
2799 " (no matches)"
2800 } else {
2801 " (no items)"
2802 };
2803 frame.render_widget(
2804 Paragraph::new(Line::from(Span::styled(
2805 empty_text,
2806 Style::default().fg(secondary).add_modifier(Modifier::DIM),
2807 ))),
2808 row_area,
2809 );
2810 } else {
2811 for (display_idx, &item_idx) in filtered.iter().take(visible_max).enumerate() {
2812 let item = &overlay.items[item_idx];
2813 let is_selected = display_idx == selected_filtered_pos;
2814 let marker = if is_selected { "\u{25b8} " } else { " " };
2815 let indent = " ".repeat(item.indent as usize);
2816 let item_style = if is_selected {
2817 Style::default().fg(primary).add_modifier(Modifier::BOLD)
2818 } else {
2819 Style::default().fg(fg)
2820 };
2821 let mut spans = vec![
2822 Span::styled(marker, item_style),
2823 Span::styled(indent, item_style),
2824 Span::styled(item.title.clone(), item_style),
2825 ];
2826 if let Some(badge) = &item.badge {
2827 spans.push(Span::raw(" "));
2828 spans.push(Span::styled(
2829 badge.clone(),
2830 Style::default().fg(secondary).add_modifier(Modifier::DIM),
2831 ));
2832 }
2833 if let Some(subtitle) = &item.subtitle {
2834 spans.push(Span::raw(" "));
2835 spans.push(Span::styled(
2836 subtitle.clone(),
2837 Style::default().fg(secondary),
2838 ));
2839 }
2840 let line = Line::from(spans);
2841 let row_area = Rect {
2842 x: inner.left(),
2843 y: row,
2844 width: inner.width,
2845 height: 1,
2846 };
2847 frame.render_widget(Paragraph::new(line), row_area);
2848 row = row.saturating_add(1);
2849 }
2850 }
2851}
2852
2853fn render_transcript(frame: &mut Frame<'_>, area: Rect, state: &RenderState, tick: u64) {
2854 if state.transcript.is_empty() {
2855 render_welcome(frame, area);
2856 return;
2857 }
2858 let styles = active_styles();
2859 let bg_color = color_from_anstyle(Some(styles.background));
2860
2861 let accent_w: u16 = 1;
2863 let scrollbar_w: u16 = 1;
2864 let content_area = Rect {
2865 x: area.x + accent_w,
2866 y: area.y,
2867 width: area.width.saturating_sub(accent_w + scrollbar_w),
2868 height: area.height,
2869 };
2870
2871 let search_set: std::collections::HashSet<usize> = state
2874 .search
2875 .as_ref()
2876 .map(|s| s.matches.iter().copied().collect())
2877 .unwrap_or_default();
2878 let current_match = state
2879 .search
2880 .as_ref()
2881 .and_then(|s| (!s.matches.is_empty()).then(|| s.matches[s.current]));
2882
2883 let mut display: Vec<(usize, InlineMessageKind, Line<'_>)> =
2884 Vec::with_capacity(state.transcript.len());
2885 const TRUNC_TAIL: usize = 3;
2890 let dim_style = Style::default()
2891 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
2892 .add_modifier(Modifier::DIM);
2893
2894 let mut blocks: Vec<(usize, Vec<(usize, &TranscriptLine)>)> = Vec::new();
2895 for (idx, tl) in state.transcript.iter().enumerate() {
2896 if blocks.last().is_some_and(|(id, _)| *id == tl.block_id) {
2897 blocks.last_mut().unwrap().1.push((idx, tl));
2898 } else {
2899 blocks.push((tl.block_id, vec![(idx, tl)]));
2900 }
2901 }
2902
2903 for (block_id, lines) in &blocks {
2904 let mode = state.block_mode(*block_id);
2905 let len = lines.len();
2906 match mode {
2907 BlockDisplayMode::Collapsed => {
2908 let &(idx, tl) = &lines[0];
2909 let is_match = search_set.contains(&idx);
2910 let line =
2911 transcript_line_marked(tl, &styles, true, is_match, current_match == Some(idx));
2912 display.push((idx, tl.kind, line));
2913 }
2914 BlockDisplayMode::Expanded => {
2915 for &(idx, tl) in lines {
2916 let is_match = search_set.contains(&idx);
2917 let line = transcript_line_marked(
2918 tl,
2919 &styles,
2920 false,
2921 is_match,
2922 current_match == Some(idx),
2923 );
2924 display.push((idx, tl.kind, line));
2925 }
2926 }
2927 BlockDisplayMode::Truncated => {
2928 if len <= TRUNC_TAIL + 1 {
2929 for &(idx, tl) in lines {
2931 let is_match = search_set.contains(&idx);
2932 let line = transcript_line_marked(
2933 tl,
2934 &styles,
2935 false,
2936 is_match,
2937 current_match == Some(idx),
2938 );
2939 display.push((idx, tl.kind, line));
2940 }
2941 } else {
2942 let &(hidx, htl) = &lines[0];
2944 let is_match = search_set.contains(&hidx);
2945 let line = transcript_line_marked(
2946 htl,
2947 &styles,
2948 false,
2949 is_match,
2950 current_match == Some(hidx),
2951 );
2952 display.push((hidx, htl.kind, line));
2953 let hidden = len - 1 - TRUNC_TAIL;
2955 let gap = Line::styled(format!(" \u{2026} +{hidden} lines"), dim_style);
2956 display.push((hidx, htl.kind, gap));
2957 for &(idx, tl) in lines.iter().rev().take(TRUNC_TAIL).rev() {
2959 let is_match = search_set.contains(&idx);
2960 let line = transcript_line_marked(
2961 tl,
2962 &styles,
2963 false,
2964 is_match,
2965 current_match == Some(idx),
2966 );
2967 display.push((idx, tl.kind, line));
2968 }
2969 }
2970 }
2971 }
2972 }
2973
2974 let total = display.len();
2976 let raw_start = if state.scroll_offset == usize::MAX {
2977 total.saturating_sub(content_area.height as usize)
2978 } else {
2979 display
2980 .iter()
2981 .position(|(orig_idx, _, _)| *orig_idx >= state.scroll_offset)
2982 .unwrap_or(total.saturating_sub(1))
2983 };
2984 let start = effective_scroll_offset(raw_start, total, content_area.height as usize);
2985
2986 let sticky_first: Option<usize> = display.get(start).and_then(|(orig_idx, _, _)| {
2990 let bid = state.transcript.get(*orig_idx)?.block_id;
2991 let first_idx = state.transcript.iter().position(|l| l.block_id == bid)?;
2992 (first_idx != *orig_idx).then_some(first_idx)
2993 });
2994 let sticky_h: u16 = if sticky_first.is_some() { 1 } else { 0 };
2995 let body_top = content_area.top() + sticky_h;
2996
2997 let running = state.reasoning_stage.is_some();
2999 const WAVE_ROWS: u16 = 32;
3000 const WAVE_SPEED: f64 = 0.15;
3001
3002 const FADE_ROWS: usize = 5;
3007 let sticky_opacity: f64 = if let Some(sidx) = sticky_first {
3008 let sticky_bid = state.transcript[sidx].block_id;
3009 let next_offset = display.iter().skip(start).position(|(orig_idx, _, _)| {
3012 state
3013 .transcript
3014 .get(*orig_idx)
3015 .map(|l| l.block_id != sticky_bid)
3016 .unwrap_or(false)
3017 });
3018 match next_offset {
3019 Some(off) if off <= FADE_ROWS => off as f64 / FADE_ROWS as f64,
3020 _ => 1.0,
3021 }
3022 } else {
3023 1.0
3024 };
3025
3026 if let Some(sidx) = sticky_first {
3029 let tl = &state.transcript[sidx];
3030 let accent_base = accent_color_for_kind(tl.kind, &styles);
3031 let rail_blend = 0.7 * sticky_opacity;
3032 let bg_blend = 0.1 * sticky_opacity;
3033 if sticky_opacity > 0.05
3034 && let Some(cell) = frame.buffer_mut().cell_mut((area.x, content_area.top()))
3035 {
3036 cell.set_char('\u{2503}');
3037 cell.set_style(Style::default().fg(blend_rgb(bg_color, accent_base, rail_blend)));
3038 }
3039 let line = transcript_line_marked(tl, &styles, false, false, false);
3040 let row = Rect {
3041 x: content_area.x,
3042 y: content_area.top(),
3043 width: content_area.width,
3044 height: 1,
3045 };
3046 if bg_blend > 0.01 {
3047 frame.buffer_mut().set_style(
3048 row,
3049 Style::default().bg(blend_rgb(bg_color, accent_base, bg_blend)),
3050 );
3051 }
3052 frame.render_widget(Paragraph::new(line), row);
3053 }
3054
3055 let mut y = body_top;
3057 let width = content_area.width.max(1) as usize;
3058 let mut visual_row: u16 = 0;
3059 for (_, kind, line) in display.into_iter().skip(start) {
3060 if y >= content_area.bottom() {
3061 break;
3062 }
3063 let text_w = line.width();
3064 let wrapped_h = if text_w == 0 {
3065 1
3066 } else {
3067 text_w.div_ceil(width).max(1) as u16
3068 };
3069
3070 let accent_base = accent_color_for_kind(kind, &styles);
3072 for row_offset in 0..wrapped_h {
3073 let paint_y = y + row_offset;
3074 if paint_y >= content_area.bottom() {
3075 break;
3076 }
3077 let brightness = if running {
3078 0.4 + 0.6 * wave_brightness(tick, visual_row + row_offset, WAVE_ROWS, WAVE_SPEED)
3079 } else {
3080 0.7
3081 };
3082 let rail_color = blend_rgb(bg_color, accent_base, brightness);
3083 if let Some(cell) = frame.buffer_mut().cell_mut((area.x, paint_y)) {
3084 cell.set_char('\u{2503}'); cell.set_style(Style::default().fg(rail_color));
3086 }
3087 }
3088
3089 let row = Rect {
3090 x: content_area.x,
3091 y,
3092 width: content_area.width,
3093 height: wrapped_h.min(content_area.bottom().saturating_sub(y)),
3094 };
3095 frame.render_widget(Paragraph::new(line).wrap(Wrap { trim: false }), row);
3096 y += wrapped_h;
3097 visual_row += wrapped_h;
3098 }
3099
3100 let body_viewport = (content_area.height as usize).saturating_sub(sticky_h as usize);
3103 if total > body_viewport {
3104 let follow = state.scroll_offset == usize::MAX;
3105 render_scrollbar(
3106 frame,
3107 area.right().saturating_sub(1),
3108 area.top(),
3109 area.height,
3110 total,
3111 body_viewport,
3112 start,
3113 follow,
3114 &styles,
3115 bg_color,
3116 );
3117 }
3118}
3119
3120#[allow(clippy::too_many_arguments)]
3125fn render_scrollbar(
3126 frame: &mut Frame,
3127 x: u16,
3128 top: u16,
3129 height: u16,
3130 total: usize,
3131 viewport: usize,
3132 start: usize,
3133 follow: bool,
3134 styles: &ThemeStyles,
3135 bg: Color,
3136) {
3137 if height == 0 {
3138 return;
3139 }
3140 let ratio = (start as f64 / total.max(1) as f64).clamp(0.0, 1.0);
3141 let thumb_h = (((viewport as f64 / total.max(1) as f64) * height as f64).ceil() as u16)
3142 .max(1)
3143 .min(height);
3144 let track_h = height.saturating_sub(thumb_h);
3145 let thumb_y = (ratio * track_h as f64).round() as u16;
3146
3147 let accent = color_from_anstyle(styles.primary.get_fg_color());
3148 let thumb_color = if follow {
3150 blend_rgb(bg, accent, 0.35)
3151 } else {
3152 accent
3153 };
3154 let rail_color = blend_rgb(bg, accent, 0.1);
3155
3156 for row in 0..height {
3157 let y = top + row;
3158 let is_thumb = row >= thumb_y && row < thumb_y + thumb_h;
3159 let (ch, color) = if is_thumb {
3160 ('\u{2588}', thumb_color) } else {
3162 ('\u{2502}', rail_color) };
3164 if let Some(cell) = frame.buffer_mut().cell_mut((x, y)) {
3165 cell.set_char(ch);
3166 cell.set_style(Style::default().fg(color));
3167 }
3168 }
3169}
3170
3171fn transcript_line_marked<'a>(
3174 line: &'a TranscriptLine,
3175 styles: &'a ThemeStyles,
3176 folded: bool,
3177 is_match: bool,
3178 is_current: bool,
3179) -> Line<'a> {
3180 let (kind_style, marker) = match line.kind {
3181 InlineMessageKind::Agent => (
3182 Style::default().fg(color_from_anstyle(styles.response.get_fg_color())),
3183 "\u{25cf}", ),
3185 InlineMessageKind::User => (
3186 Style::default().fg(color_from_anstyle(styles.primary.get_fg_color())),
3187 "\u{276f}", ),
3189 InlineMessageKind::Tool => (
3190 Style::default().fg(color_from_anstyle(styles.tool.get_fg_color())),
3191 "\u{2699}", ),
3193 InlineMessageKind::Error => (
3194 Style::default().fg(color_from_anstyle(styles.error.get_fg_color())),
3195 "\u{2717}", ),
3197 InlineMessageKind::Warning => (
3198 Style::default().fg(color_from_anstyle(styles.status.get_fg_color())),
3199 "\u{26a0}", ),
3201 InlineMessageKind::Info => (
3202 Style::default().fg(color_from_anstyle(styles.info.get_fg_color())),
3203 "\u{2139}", ),
3205 InlineMessageKind::Policy => (
3206 Style::default().fg(color_from_anstyle(styles.mcp.get_fg_color())),
3207 "\u{25c6}", ),
3209 InlineMessageKind::Pty => (
3210 Style::default().fg(color_from_anstyle(styles.pty_output.get_fg_color())),
3211 "\u{258c}", ),
3213 };
3214
3215 let prefix = if folded {
3217 format!("\u{25b8} {} ", marker) } else {
3219 format!("{} ", marker)
3220 };
3221
3222 let highlight = if is_current {
3224 Some(Style::default().reversed())
3225 } else if is_match {
3226 Some(Style::default().add_modifier(Modifier::UNDERLINED))
3227 } else {
3228 None
3229 };
3230
3231 let mut spans = Vec::with_capacity(line.segments.len() + 1);
3232 spans.push(Span::styled(prefix, kind_style));
3233 for segment in &line.segments {
3234 let mut style = segment_style(segment, kind_style, styles);
3235 if let Some(h) = highlight {
3236 style = style.patch(h);
3237 }
3238 spans.push(Span::styled(segment.text.clone(), style));
3239 }
3240 Line::from(spans)
3241}
3242
3243fn segment_style(segment: &InlineSegment, fallback: Style, styles: &ThemeStyles) -> Style {
3244 let mut style = fallback;
3245 let inline = segment.style.as_ref();
3246 if let Some(color) = inline.color {
3247 style = style.fg(color_from_anstyle(Some(color)));
3248 } else {
3249 style = style.fg(color_from_anstyle(styles.response.get_fg_color()));
3253 }
3254 if inline.effects.contains(anstyle::Effects::BOLD) {
3255 style = style.add_modifier(Modifier::BOLD);
3256 }
3257 if inline.effects.contains(anstyle::Effects::ITALIC) {
3258 style = style.add_modifier(Modifier::ITALIC);
3259 }
3260 if inline.effects.contains(anstyle::Effects::UNDERLINE) {
3261 style = style.add_modifier(Modifier::UNDERLINED);
3262 }
3263 if inline.effects.contains(anstyle::Effects::DIMMED) {
3264 style = style.add_modifier(Modifier::DIM);
3265 }
3266 style
3267}
3268
3269fn render_composer(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
3270 let styles = active_styles();
3271 let prefix_style = Style::default()
3272 .fg(color_from_anstyle(styles.primary.get_fg_color()))
3273 .bold();
3274 let text_style = Style::default().fg(color_from_anstyle(Some(styles.foreground)));
3275
3276 let prefix = state.prompt_prefix.clone();
3277 let body = state.input_buffer.clone();
3278 let placeholder = state.placeholder.clone();
3279
3280 let mut line_spans = Vec::new();
3281 if let Some(label) = state.vim_state.status_label() {
3282 line_spans.push(Span::styled(
3283 format!("[{label}] "),
3284 Style::default()
3285 .fg(color_from_anstyle(styles.tool.get_fg_color()))
3286 .add_modifier(Modifier::BOLD),
3287 ));
3288 }
3289 line_spans.push(Span::styled(prefix, prefix_style));
3290 if state.shell_mode {
3291 line_spans.push(Span::styled(
3292 "! ",
3293 Style::default()
3294 .fg(Color::Yellow)
3295 .add_modifier(Modifier::BOLD),
3296 ));
3297 }
3298 if body.is_empty()
3299 && let Some(ph) = placeholder
3300 {
3301 line_spans.push(Span::styled(
3302 ph,
3303 Style::default()
3304 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
3305 .dim(),
3306 ));
3307 } else {
3308 line_spans.push(Span::styled(body, text_style));
3309 }
3310 let block = Block::default()
3311 .borders(Borders::ALL)
3312 .border_type(BorderType::Rounded)
3313 .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())));
3314 let paragraph = Paragraph::new(Line::from(line_spans))
3315 .block(block)
3316 .wrap(Wrap { trim: false });
3317 frame.render_widget(paragraph, area);
3318
3319 if state.input_enabled {
3322 let vim_off = state
3323 .vim_state
3324 .status_label()
3325 .map(|l| format!("[{l}] ").chars().count() as u16)
3326 .unwrap_or(0);
3327 let shell_off = if state.shell_mode { 2 } else { 0 };
3328 let cursor_x = area.left()
3329 + 1
3330 + vim_off
3331 + shell_off
3332 + state.prompt_prefix.chars().count() as u16
3333 + state.input_cursor as u16;
3334 let cursor_y = area.top() + 1;
3335 frame.set_cursor_position(ratatui::layout::Position::new(cursor_x, cursor_y));
3336 }
3337}
3338
3339fn render_welcome(frame: &mut Frame<'_>, area: Rect) {
3342 let styles = active_styles();
3343 let primary = color_from_anstyle(styles.primary.get_fg_color());
3344 let fg = color_from_anstyle(Some(styles.foreground));
3345 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
3346
3347 if area.width >= 90 {
3350 use oxicode_vtui::design::layout::WelcomeLayout;
3351 let layout = WelcomeLayout::compute(area, 3, 0, 0, 1, 0, false);
3352 let logo_area = if layout.has_hero_box() {
3353 layout.hero_logo
3354 } else {
3355 layout.logo
3356 };
3357 if logo_area.height > 0 {
3358 frame.render_widget(
3359 Paragraph::new(Line::from(Span::styled(
3360 "\u{25cf} oxicode",
3361 Style::default().fg(primary).add_modifier(Modifier::BOLD),
3362 )))
3363 .alignment(Alignment::Center),
3364 logo_area,
3365 );
3366 }
3367 if layout.tip.height > 0 {
3368 frame.render_widget(
3369 Paragraph::new(Line::from(Span::styled(
3370 "Type a message to begin, or press / for commands.",
3371 Style::default().fg(fg),
3372 )))
3373 .alignment(Alignment::Center),
3374 layout.tip,
3375 );
3376 }
3377 if layout.version.height > 0 {
3378 frame.render_widget(
3379 Paragraph::new(Line::from(Span::styled(
3380 format!("v{}", env!("CARGO_PKG_VERSION")),
3381 Style::default().fg(secondary).add_modifier(Modifier::DIM),
3382 )))
3383 .alignment(Alignment::Center),
3384 layout.version,
3385 );
3386 }
3387 return;
3388 }
3389
3390 let version = env!("CARGO_PKG_VERSION");
3392 let text = vec![
3393 Line::from(""),
3394 Line::from(""),
3395 Line::from(Span::styled(
3396 "\u{25cf} oxicode",
3397 Style::default().fg(primary).add_modifier(Modifier::BOLD),
3398 )),
3399 Line::from(""),
3400 Line::from(Span::styled(
3401 "Type a message to begin, or press / for commands.",
3402 Style::default().fg(fg),
3403 )),
3404 Line::from(Span::styled(
3405 format!("v{version} \u{2014} /help for commands"),
3406 Style::default().fg(secondary),
3407 )),
3408 ];
3409 frame.render_widget(Paragraph::new(text).alignment(Alignment::Center), area);
3410}
3411
3412fn render_reasoning_indicator(frame: &mut Frame<'_>, composer_area: Rect, stage: &str) {
3414 let styles = active_styles();
3415 let indicator_area = Rect {
3416 x: composer_area.x,
3417 y: composer_area.top().saturating_sub(1),
3418 width: composer_area.width,
3419 height: 1,
3420 };
3421 let spinner = "\u{25cc}"; let line = Line::from(vec![
3423 Span::styled(
3424 format!("{spinner} "),
3425 Style::default().fg(color_from_anstyle(styles.tool.get_fg_color())),
3426 ),
3427 Span::styled(
3428 stage.to_string(),
3429 Style::default()
3430 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
3431 .add_modifier(Modifier::DIM),
3432 ),
3433 ]);
3434 frame.render_widget(Paragraph::new(line), indicator_area);
3435}
3436
3437fn render_queue_pane(frame: &mut Frame<'_>, scrollback: Rect, state: &RenderState) {
3439 let styles = active_styles();
3440 let entries = &state.queued_inputs;
3441 let interactive = state.queue_panel_open;
3442 let selected = state.queue_selected.min(entries.len().saturating_sub(1));
3443 let height = entries.len() as u16 + 1;
3444 let area = Rect {
3445 x: scrollback.x,
3446 y: scrollback.y,
3447 width: scrollback.width,
3448 height,
3449 };
3450 let info = color_from_anstyle(styles.info.get_fg_color());
3451 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
3452 let primary = color_from_anstyle(styles.primary.get_fg_color());
3453 let items: Vec<Line<'_>> = entries
3454 .iter()
3455 .enumerate()
3456 .map(|(i, e)| {
3457 let prefix = if interactive {
3458 format!("#{} ", i + 1)
3459 } else {
3460 "\u{2261} ".to_string()
3461 };
3462 let prefix_style = if interactive && i == selected {
3463 Style::default().fg(primary).add_modifier(Modifier::BOLD)
3464 } else {
3465 Style::default().fg(info)
3466 };
3467 let text_style = if interactive && i == selected {
3468 Style::default().fg(primary).add_modifier(Modifier::BOLD)
3469 } else {
3470 Style::default().fg(secondary)
3471 };
3472 let marker = if interactive && i == selected {
3473 "\u{25b8} " } else {
3475 " "
3476 };
3477 Line::from(vec![
3478 Span::styled(prefix, prefix_style),
3479 Span::styled(marker, prefix_style),
3480 Span::styled(e.clone(), text_style),
3481 ])
3482 })
3483 .collect();
3484 frame.render_widget(
3485 Paragraph::new(items).block(Block::default().borders(Borders::TOP).border_style(
3486 Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
3487 )),
3488 area,
3489 );
3490}
3491
3492fn render_todo_pane(frame: &mut Frame<'_>, scrollback: Rect, items: &[(String, bool)]) {
3494 let styles = active_styles();
3495 let height = items.len() as u16 + 1;
3496 let area = Rect {
3497 x: scrollback.x,
3498 y: scrollback.y,
3499 width: scrollback.width,
3500 height,
3501 };
3502 let lines: Vec<Line<'_>> = items
3503 .iter()
3504 .map(|(text, done)| {
3505 let (marker, color) = if *done {
3506 ("\u{2611}", styles.tool.get_fg_color()) } else {
3508 ("\u{2610}", styles.secondary.get_fg_color()) };
3510 Line::from(vec![
3511 Span::styled(
3512 format!("{marker} "),
3513 Style::default().fg(color_from_anstyle(color)),
3514 ),
3515 Span::styled(
3516 text.clone(),
3517 Style::default().fg(color_from_anstyle(Some(styles.foreground))),
3518 ),
3519 ])
3520 })
3521 .collect();
3522 frame.render_widget(Paragraph::new(lines), area);
3523}
3524
3525fn render_follow_ups(frame: &mut Frame<'_>, composer_area: Rect, chips: &[String]) {
3527 let styles = active_styles();
3528 let area = Rect {
3529 x: composer_area.x,
3530 y: composer_area.top().saturating_sub(1),
3531 width: composer_area.width,
3532 height: 1,
3533 };
3534 let mut spans = vec![Span::styled(
3535 "Suggestions: ",
3536 Style::default()
3537 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
3538 .add_modifier(Modifier::DIM),
3539 )];
3540 for (i, chip) in chips.iter().enumerate() {
3541 if i > 0 {
3542 spans.push(Span::raw(" "));
3543 }
3544 spans.push(Span::styled(
3545 format!("\u{25b8} {chip}"),
3546 Style::default().fg(color_from_anstyle(styles.primary.get_fg_color())),
3547 ));
3548 }
3549 frame.render_widget(Paragraph::new(Line::from(spans)), area);
3550}
3551
3552fn tip_is_visible(tip: &EphemeralTip, now_tick: u64) -> bool {
3554 now_tick.saturating_sub(tip.born_tick) < tip.ttl_ticks
3555}
3556
3557fn render_tip(frame: &mut Frame, composer_area: Rect, text: &str) {
3559 let styles = active_styles();
3560 let area = Rect {
3561 x: composer_area.x,
3562 y: composer_area.top().saturating_sub(1),
3563 width: composer_area.width,
3564 height: 1,
3565 };
3566 let line = Line::styled(
3567 format!(" \u{2139} {text}"),
3568 Style::default()
3569 .fg(color_from_anstyle(styles.info.get_fg_color()))
3570 .add_modifier(Modifier::DIM),
3571 );
3572 frame.render_widget(Paragraph::new(line), area);
3573}
3574
3575fn render_slash_popup(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
3578 let styles = active_styles();
3579 let items = &state.slash_popup.items;
3580 if items.is_empty() {
3581 return;
3582 }
3583
3584 let max_visible = 8usize;
3585 let visible = items.len().min(max_visible);
3586 let popup_h = visible as u16 + 2; let width = composer_area.width.min(64);
3588 let popup_area = Rect {
3589 x: composer_area.left(),
3590 y: composer_area.top().saturating_sub(popup_h),
3591 width,
3592 height: popup_h,
3593 };
3594 frame.render_widget(Clear, popup_area);
3595
3596 let border_color = color_from_anstyle(styles.secondary.get_fg_color());
3597 let title = Line::from(Span::styled(
3598 " Commands ",
3599 Style::default()
3600 .fg(color_from_anstyle(styles.primary.get_fg_color()))
3601 .add_modifier(Modifier::BOLD),
3602 ));
3603 let block = Block::default()
3604 .borders(Borders::ALL)
3605 .border_type(BorderType::Rounded)
3606 .border_style(Style::default().fg(border_color))
3607 .title(title);
3608 let inner = block.inner(popup_area);
3609 frame.render_widget(&block, popup_area);
3610
3611 let max_label = items
3613 .iter()
3614 .take(visible)
3615 .map(|i| i.label.chars().count())
3616 .max()
3617 .unwrap_or(0);
3618
3619 let primary = color_from_anstyle(styles.primary.get_fg_color());
3620 let fg = color_from_anstyle(Some(styles.foreground));
3621 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
3622
3623 for (i, item) in items.iter().take(visible).enumerate() {
3624 let is_selected = i == state.slash_popup.selected;
3625 let y = inner.top() + i as u16;
3626 let row_area = Rect {
3627 x: inner.left(),
3628 y,
3629 width: inner.width,
3630 height: 1,
3631 };
3632
3633 let marker = if is_selected { "\u{25b8} " } else { " " }; let label_style = if is_selected {
3635 Style::default().fg(primary).add_modifier(Modifier::BOLD)
3636 } else {
3637 Style::default().fg(fg)
3638 };
3639 let label_padded = format!("{:<width$}", item.label, width = max_label);
3640 let line = Line::from(vec![
3641 Span::styled(marker, label_style),
3642 Span::styled(label_padded, label_style),
3643 Span::raw(" "),
3644 Span::styled(&item.description, Style::default().fg(secondary)),
3645 ]);
3646 frame.render_widget(Paragraph::new(line), row_area);
3647 }
3648}
3649
3650fn render_file_search_dropdown(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
3654 let styles = active_styles();
3655 let Some(fs) = &state.file_search else {
3656 return;
3657 };
3658 let items = &fs.results;
3659 if items.is_empty() {
3660 return;
3661 }
3662
3663 let max_visible = 10usize;
3664 let visible = items.len().min(max_visible);
3665 let popup_h = visible as u16 + 2; let width = composer_area.width.min(72);
3667 let popup_area = Rect {
3668 x: composer_area.left(),
3669 y: composer_area.top().saturating_sub(popup_h),
3670 width,
3671 height: popup_h,
3672 };
3673 frame.render_widget(Clear, popup_area);
3674
3675 let border_color = color_from_anstyle(styles.secondary.get_fg_color());
3676 let title_str = if fs.hidden_mode {
3677 " Files (hidden) "
3678 } else {
3679 " Files "
3680 };
3681 let title = Line::from(Span::styled(
3682 title_str,
3683 Style::default()
3684 .fg(color_from_anstyle(styles.primary.get_fg_color()))
3685 .add_modifier(Modifier::BOLD),
3686 ));
3687 let block = Block::default()
3688 .borders(Borders::ALL)
3689 .border_type(BorderType::Rounded)
3690 .border_style(Style::default().fg(border_color))
3691 .title(title);
3692 let inner = block.inner(popup_area);
3693 frame.render_widget(&block, popup_area);
3694
3695 let primary = color_from_anstyle(styles.primary.get_fg_color());
3696 let fg = color_from_anstyle(Some(styles.foreground));
3697 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
3698
3699 for (i, result) in items.iter().take(visible).enumerate() {
3700 let is_selected = i == fs.selected;
3701 let y = inner.top() + i as u16;
3702 let row_area = Rect {
3703 x: inner.left(),
3704 y,
3705 width: inner.width,
3706 height: 1,
3707 };
3708
3709 let marker = if is_selected { "\u{25b8} " } else { " " }; let path_style = if is_selected {
3711 Style::default().fg(primary).add_modifier(Modifier::BOLD)
3712 } else {
3713 Style::default().fg(fg)
3714 };
3715 let line = Line::from(vec![
3716 Span::styled(marker, path_style),
3717 Span::styled(&result.path, path_style),
3718 ]);
3719 frame.render_widget(Paragraph::new(line), row_area);
3720 }
3721
3722 if popup_h >= 4 {
3724 let hint_y = inner.bottom();
3725 let hint_area = Rect {
3726 x: inner.left(),
3727 y: hint_y,
3728 width: inner.width,
3729 height: 1,
3730 };
3731 let count = items.len();
3732 let hint = format!("{count} files \u{00b7} Tab accept Esc cancel");
3733 let _ = secondary; frame.render_widget(
3735 Paragraph::new(Line::from(Span::styled(
3736 hint,
3737 Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
3738 )))
3739 .style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color()))),
3740 hint_area,
3741 );
3742 }
3743}
3744
3745struct InputEditor<'a> {
3751 buffer: &'a mut String,
3752 cursor: &'a mut usize,
3753}
3754
3755impl<'a> oxicode_vtui::vim::Editor for InputEditor<'a> {
3756 fn content(&self) -> &str {
3757 self.buffer
3758 }
3759 fn cursor(&self) -> usize {
3760 *self.cursor
3761 }
3762 fn set_cursor(&mut self, pos: usize) {
3763 *self.cursor = pos.min(self.buffer.len());
3764 }
3765 fn move_left(&mut self) {
3766 *self.cursor = self.cursor.saturating_sub(1);
3767 }
3768 fn move_right(&mut self) {
3769 let len = self.buffer.len();
3770 *self.cursor = (*self.cursor + 1).min(len);
3771 }
3772 fn delete_char_forward(&mut self) {
3773 let cursor = *self.cursor;
3774 if cursor < self.buffer.len() {
3775 let next = self.buffer[cursor..]
3776 .char_indices()
3777 .nth(1)
3778 .map(|(i, _)| cursor + i)
3779 .unwrap_or(self.buffer.len());
3780 self.buffer.replace_range(cursor..next, "");
3781 }
3782 }
3783 fn insert_text(&mut self, text: &str) {
3784 let cursor = *self.cursor;
3785 self.buffer.insert_str(cursor, text);
3786 *self.cursor = cursor + text.len();
3787 }
3788 fn replace(&mut self, content: String, cursor: usize) {
3789 *self.buffer = content;
3790 *self.cursor = cursor.min(self.buffer.len());
3791 }
3792}
3793
3794pub(crate) fn plain_segment(text: impl Into<String>) -> InlineSegment {
3799 InlineSegment {
3800 text: text.into(),
3801 style: Arc::new(InlineTextStyle::default()),
3802 }
3803}
3804
3805pub(super) fn effective_scroll_offset(offset: usize, total: usize, viewport: usize) -> usize {
3806 if offset == usize::MAX {
3807 return total.saturating_sub(viewport);
3808 }
3809 let max_start = total.saturating_sub(viewport);
3811 offset.min(max_start)
3812}
3813
3814fn slash_filter(token: &str) -> Vec<SlashPopupItem> {
3822 SlashRegistry::builtin_commands()
3823 .into_iter()
3824 .filter(|(name, _, aliases)| {
3825 token.is_empty()
3826 || name.starts_with(token)
3827 || aliases.iter().any(|a| a.starts_with(token))
3828 })
3829 .map(|(name, desc, aliases)| {
3830 let mut label = format!("/{name}");
3831 for a in &aliases {
3832 label.push_str(&format!(", /{a}"));
3833 }
3834 SlashPopupItem {
3835 label,
3836 description: desc.to_string(),
3837 name: name.to_string(),
3838 }
3839 })
3840 .collect()
3841}
3842
3843fn refresh_slash_popup(state: &mut RenderState) {
3848 let buf = state.input_buffer.clone();
3849 let active = buf.starts_with('/') && !buf[1..].contains(' ');
3850 if !active {
3851 state.slash_popup.open = false;
3852 state.slash_popup.items.clear();
3853 state.slash_popup.selected = 0;
3854 return;
3855 }
3856 let token = &buf[1..];
3857 let items = slash_filter(token);
3858 state.slash_popup.open = !items.is_empty();
3859 if items.is_empty() {
3860 state.slash_popup.selected = 0;
3861 } else {
3862 state.slash_popup.selected = state.slash_popup.selected.min(items.len() - 1);
3863 }
3864 state.slash_popup.items = items;
3865}
3866fn refresh_input_popups(state: &mut RenderState) {
3870 refresh_slash_popup(state);
3871 refresh_file_search(state);
3872}
3873
3874fn refresh_file_search(state: &mut RenderState) {
3880 use crate::tui_vt::file_search;
3881 if state.slash_popup.open {
3883 state.file_search = None;
3884 return;
3885 }
3886 match file_search::parse_at_cursor(&state.input_buffer, state.input_cursor) {
3887 Some(token) => match &mut state.file_search {
3888 None => {
3889 let cwd = state.cwd.clone();
3890 state.file_search = Some(file_search::open(&cwd, token.at_offset, false));
3891 }
3892 Some(fs) => {
3893 if fs.query != token.path_query {
3894 fs.refresh(&token.path_query);
3895 }
3896 }
3897 },
3898 None => state.file_search = None,
3899 }
3900}
3901
3902fn accept_file_search(state: &mut RenderState, line_mode: bool) -> bool {
3907 use crate::tui_vt::file_search;
3908 let Some(fs) = &state.file_search else {
3909 return false;
3910 };
3911 let Some(result) = fs.selected_result().cloned() else {
3912 return false;
3913 };
3914 let at_offset = fs.at_offset;
3915 let text = file_search::insertion_text(&result.path, None, line_mode);
3916 let cursor_end = state.input_cursor;
3917 state
3919 .input_buffer
3920 .replace_range(at_offset..cursor_end.min(state.input_buffer.len()), &text);
3921 state.input_cursor = at_offset + text.len();
3922 state.file_search = None;
3923 true
3924}
3925
3926fn preview_tool_result(content: &str) -> String {
3927 const MAX: usize = 500;
3928 if content.chars().count() <= MAX {
3929 return content.to_string();
3930 }
3931 let truncated: String = content.chars().take(MAX).collect();
3932 format!("{truncated}\u{2026}")
3933}
3934
3935fn try_render_diff(content: &str, handle: &InlineHandle) -> bool {
3939 let lines: Vec<&str> = content.lines().collect();
3940 if !lines.iter().any(|l| l.starts_with("@@")) {
3944 return false;
3945 }
3946 let additions = lines
3947 .iter()
3948 .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
3949 .count();
3950 let deletions = lines
3951 .iter()
3952 .filter(|l| l.starts_with('-') && !l.starts_with("---"))
3953 .count();
3954 if additions + deletions < 2 {
3955 return false;
3956 }
3957
3958 let styles = active_styles();
3959 let green = styles.secondary.get_fg_color();
3960 let red = styles.error.get_fg_color();
3961 const MAX_DIFF_LINES: usize = 30;
3962
3963 let mut hdr_style = InlineTextStyle::default();
3965 hdr_style.effects |= anstyle::Effects::DIMMED;
3966 handle.append_line(
3967 InlineMessageKind::Tool,
3968 vec![InlineSegment {
3969 text: format!("\u{2713} diff (+{additions} \u{2212}{deletions})"),
3970 style: Arc::new(hdr_style),
3971 }],
3972 );
3973
3974 for line in lines.iter().take(MAX_DIFF_LINES) {
3976 let mut style = InlineTextStyle::default();
3977 if line.starts_with('+') && !line.starts_with("+++") {
3978 style.color = green;
3979 } else if line.starts_with('-') && !line.starts_with("---") {
3980 style.color = red;
3981 } else {
3982 style.effects |= anstyle::Effects::DIMMED;
3983 }
3984 handle.append_line(
3985 InlineMessageKind::Tool,
3986 vec![InlineSegment {
3987 text: format!(" {line}"),
3988 style: Arc::new(style),
3989 }],
3990 );
3991 }
3992
3993 if lines.len() > MAX_DIFF_LINES {
3994 let mut more_style = InlineTextStyle::default();
3995 more_style.effects |= anstyle::Effects::DIMMED;
3996 handle.append_line(
3997 InlineMessageKind::Tool,
3998 vec![InlineSegment {
3999 text: format!(" \u{2026} {} more lines", lines.len() - MAX_DIFF_LINES),
4000 style: Arc::new(more_style),
4001 }],
4002 );
4003 }
4004
4005 true
4006}
4007
4008fn color_from_anstyle(color: Option<anstyle::Color>) -> Color {
4009 match color {
4010 Some(anstyle::Color::Ansi(a)) => ansi_to_ratatui(a),
4011 Some(anstyle::Color::Ansi256(idx)) => Color::Indexed(idx.0),
4012 Some(anstyle::Color::Rgb(rgb)) => Color::Rgb(rgb.0, rgb.1, rgb.2),
4013 None => Color::Reset,
4014 }
4015}
4016fn ansi_to_ratatui(color: anstyle::AnsiColor) -> Color {
4017 use anstyle::AnsiColor as A;
4018 match color {
4019 A::Black => Color::Black,
4020 A::Red => Color::Red,
4021 A::Green => Color::Green,
4022 A::Yellow => Color::Yellow,
4023 A::Blue => Color::Blue,
4024 A::Magenta => Color::Magenta,
4025 A::Cyan => Color::Cyan,
4026 A::White => Color::Gray,
4027 A::BrightBlack => Color::DarkGray,
4028 A::BrightRed => Color::LightRed,
4029 A::BrightGreen => Color::LightGreen,
4030 A::BrightYellow => Color::LightYellow,
4031 A::BrightBlue => Color::LightBlue,
4032 A::BrightMagenta => Color::LightMagenta,
4033 A::BrightCyan => Color::LightCyan,
4034 A::BrightWhite => Color::White,
4035 }
4036}
4037
4038#[allow(dead_code, clippy::declare_interior_mutable_const)]
4041const _ATOMIC_REFS: (AtomicBool, Ordering) = (AtomicBool::new(false), Ordering::SeqCst);
4042
4043#[cfg(test)]
4044mod slash_popup_tests {
4045 use super::*;
4046
4047 #[test]
4048 fn empty_token_lists_all_commands() {
4049 let items = slash_filter("");
4050 assert!(items.len() >= 7);
4052 assert!(items.iter().any(|i| i.name == "quit"));
4053 assert!(items.iter().any(|i| i.name == "clear"));
4054 assert!(items.iter().any(|i| i.name == "model"));
4055 }
4056
4057 #[test]
4058 fn prefix_filter_matches_name() {
4059 let items = slash_filter("qu");
4060 assert_eq!(items.len(), 1);
4061 assert_eq!(items[0].name, "quit");
4062 assert!(items[0].label.contains("/quit"));
4063 }
4064
4065 #[test]
4066 fn prefix_filter_matches_alias() {
4067 let items = slash_filter("cl");
4069 let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
4070 assert!(names.contains(&"clear"));
4071 }
4072
4073 #[test]
4074 fn popup_opens_on_slash() {
4075 let mut state = RenderState::default();
4076 state.input_buffer = "/".to_string();
4077 refresh_input_popups(&mut state);
4078 assert!(state.slash_popup.open);
4079 assert!(!state.slash_popup.items.is_empty());
4080 }
4081
4082 #[test]
4083 fn popup_closes_on_space() {
4084 let mut state = RenderState::default();
4085 state.input_buffer = "/quit ".to_string();
4086 refresh_input_popups(&mut state);
4087 assert!(!state.slash_popup.open);
4088 }
4089
4090 #[test]
4091 fn popup_closes_on_non_slash() {
4092 let mut state = RenderState::default();
4093 state.input_buffer = "hello".to_string();
4094 refresh_input_popups(&mut state);
4095 assert!(!state.slash_popup.open);
4096 }
4097
4098 #[test]
4099 fn popup_filters_as_user_types() {
4100 let mut state = RenderState::default();
4101 state.input_buffer = "/m".to_string();
4102 refresh_input_popups(&mut state);
4103 assert!(state.slash_popup.open);
4104 assert!(
4107 state
4108 .slash_popup
4109 .items
4110 .iter()
4111 .all(|i| i.name.starts_with('m'))
4112 );
4113 }
4114
4115 #[test]
4116 fn popup_selection_clamps_on_shrink() {
4117 let mut state = RenderState::default();
4118 state.input_buffer = "/".to_string();
4119 refresh_input_popups(&mut state);
4120 let full_count = state.slash_popup.items.len();
4121 state.slash_popup.selected = full_count - 1;
4122 state.input_buffer = "/qu".to_string();
4124 refresh_input_popups(&mut state);
4125 assert!(state.slash_popup.selected < state.slash_popup.items.len());
4126 }
4127}
4128
4129#[cfg(test)]
4130mod render_tests {
4131 use super::*;
4132 use oxicode_vtui::tui::core::{InlineHandle, OverlayEvent};
4133 use ratatui::{Terminal, backend::TestBackend};
4134 use tokio::sync::mpsc;
4135
4136 fn render_frame_to_string(state: &RenderState) -> String {
4141 let backend = TestBackend::new(80, 24);
4142 let mut terminal = Terminal::new(backend).expect("backend");
4143 let (tx, _rx) = mpsc::unbounded_channel();
4144 let handle = InlineHandle::new_for_tests(tx);
4145 terminal
4146 .draw(|f| render_frame(f, state, &handle))
4147 .expect("draw");
4148 let buf = terminal.backend().buffer();
4149 let area = buf.area();
4150 let mut out = String::new();
4151 for y in 0..area.height {
4152 for x in 0..area.width {
4153 if let Some(cell) = buf.cell((x, y)) {
4154 out.push_str(cell.symbol());
4155 }
4156 }
4157 out.push('\n');
4158 }
4159 out
4160 }
4161
4162 #[test]
4163 fn welcome_screen_shown_when_transcript_empty() {
4164 let state = RenderState::default();
4165 let rendered = render_frame_to_string(&state);
4166 assert!(
4167 rendered.contains("oxicode"),
4168 "welcome banner must appear when transcript is empty"
4169 );
4170 }
4171
4172 #[test]
4173 fn composer_is_painted() {
4174 let mut state = RenderState::default();
4178 state.input_enabled = true;
4179 state.prompt_prefix = "> ".to_string();
4180 let rendered = render_frame_to_string(&state);
4181 assert!(
4182 rendered.contains('>'),
4183 "composer prompt prefix must be painted"
4184 );
4185 }
4186
4187 #[test]
4188 fn slash_popup_renders_command_list() {
4189 let mut state = RenderState::default();
4190 state.slash_popup.open = true;
4191 state.slash_popup.items = slash_filter("");
4192 let rendered = render_frame_to_string(&state);
4193 assert!(rendered.contains("Commands"), "popup title must render");
4194 assert!(rendered.contains("/quit"), "popup must list /quit");
4195 }
4196
4197 #[test]
4198 fn composer_and_popup_render_together() {
4199 let mut state = RenderState::default();
4200 state.prompt_prefix = "> ".to_string();
4201 state.input_buffer = "/qu".to_string();
4202 state.slash_popup.open = true;
4203 state.slash_popup.items = slash_filter("qu");
4204 let rendered = render_frame_to_string(&state);
4205 assert!(rendered.contains("Commands"), "popup must render");
4206 assert!(rendered.contains("/quit"), "popup must list /quit");
4207 assert!(rendered.contains('>'), "composer must still render");
4208 }
4209
4210 #[test]
4211 fn transcript_wraps_long_lines() {
4212 let mut state = RenderState::default();
4214 state.transcript.push(TranscriptLine {
4215 kind: InlineMessageKind::Agent,
4216 segments: vec![plain_segment(
4217 "This is a very long agent response line that should wrap across multiple terminal rows when rendered at a narrow width.".to_string()
4218 )],
4219 block_id: 0,
4220 });
4221 let backend = TestBackend::new(40, 24);
4222 let mut terminal = Terminal::new(backend).expect("backend");
4223 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4224 let handle = InlineHandle::new_for_tests(tx);
4225 terminal
4226 .draw(|f| render_frame(f, &state, &handle))
4227 .expect("draw");
4228 let buf = terminal.backend().buffer();
4229 let mut full = String::new();
4232 for y in 0..buf.area.height {
4233 for x in 0..buf.area.width {
4234 if let Some(cell) = buf.cell((x, y)) {
4235 full.push_str(cell.symbol());
4236 }
4237 }
4238 full.push('\n');
4239 }
4240 assert!(
4241 full.contains("wrap"),
4242 "long line must wrap, not clip — text should be visible past col 40"
4243 );
4244 }
4245
4246 fn sample_overlay_items() -> Vec<OverlayListItem> {
4249 vec![
4250 OverlayListItem {
4251 title: "model-a".to_string(),
4252 subtitle: Some("first".to_string()),
4253 badge: Some("ready".to_string()),
4254 indent: 0,
4255 search_value: None,
4256 selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(0)),
4257 },
4258 OverlayListItem {
4259 title: "model-b".to_string(),
4260 subtitle: None,
4261 badge: None,
4262 indent: 0,
4263 search_value: None,
4264 selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(1)),
4265 },
4266 OverlayListItem {
4267 title: "model-c".to_string(),
4268 subtitle: None,
4269 badge: None,
4270 indent: 0,
4271 search_value: None,
4272 selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(2)),
4273 },
4274 ]
4275 }
4276
4277 #[test]
4278 fn overlay_renders_title_and_items() {
4279 let mut state = RenderState::default();
4280 state.overlay = Some(OverlayState {
4281 title: "Select model".to_string(),
4282 lines: vec!["Pick one".to_string()],
4283 items: sample_overlay_items(),
4284 selected: 0,
4285 search: None,
4286 });
4287 let rendered = render_frame_to_string(&state);
4288 assert!(
4289 rendered.contains("Select model"),
4290 "overlay title must render"
4291 );
4292 assert!(rendered.contains("model-a"), "first item must render");
4293 assert!(rendered.contains("model-b"), "second item must render");
4294 assert!(rendered.contains("model-c"), "third item must render");
4295 assert!(
4296 rendered.contains("Pick one"),
4297 "descriptive line must render"
4298 );
4299 }
4300
4301 #[test]
4302 fn overlay_search_filters_items() {
4303 let mut state = RenderState::default();
4304 state.overlay = Some(OverlayState {
4305 title: "Select".to_string(),
4306 lines: Vec::new(),
4307 items: sample_overlay_items(),
4308 selected: 0,
4309 search: Some(OverlaySearchState {
4310 label: "filter".to_string(),
4311 placeholder: Some("type".to_string()),
4312 value: "model-b".to_string(),
4313 }),
4314 });
4315 let rendered = render_frame_to_string(&state);
4316 assert!(rendered.contains("model-b"), "matching item must render");
4317 assert!(
4318 !rendered.contains("model-a"),
4319 "non-matching item must not render (got: {})",
4320 rendered
4321 );
4322 assert!(
4323 !rendered.contains("model-c"),
4324 "non-matching item must not render"
4325 );
4326 }
4327
4328 #[test]
4329 fn overlay_keyboard_nav_moves_selection() {
4330 let mut state = RenderState::default();
4331 state.overlay = Some(OverlayState {
4332 title: "Select".to_string(),
4333 lines: Vec::new(),
4334 items: sample_overlay_items(),
4335 selected: 0,
4336 search: None,
4337 });
4338 let state_arc = Arc::new(parking_lot::Mutex::new(state));
4339 let (tx, mut _rx) = mpsc::unbounded_channel();
4340
4341 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 0);
4343
4344 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
4346 assert!(consumed, "Down must be consumed while overlay is open");
4347 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 1);
4348
4349 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
4351 assert!(consumed);
4352 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 2);
4353
4354 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
4356 assert!(consumed);
4357 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 0);
4358
4359 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Up);
4361 assert!(consumed);
4362 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 2);
4363
4364 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
4366 assert!(consumed);
4367 assert!(
4368 state_arc.lock().overlay.is_none(),
4369 "overlay must be cleared after Enter"
4370 );
4371 let evt = _rx.try_recv().expect("submit event must arrive");
4372 match evt {
4373 InlineEvent::Overlay(OverlayEvent::Submitted(_)) => {}
4374 other => panic!("expected Submitted overlay event, got {other:?}"),
4375 }
4376 }
4377
4378 #[test]
4379 fn overlay_esc_closes_and_emits_cancelled() {
4380 let mut state = RenderState::default();
4381 state.overlay = Some(OverlayState {
4382 title: "Select".to_string(),
4383 lines: Vec::new(),
4384 items: sample_overlay_items(),
4385 selected: 0,
4386 search: None,
4387 });
4388 let state_arc = Arc::new(parking_lot::Mutex::new(state));
4389 let (tx, mut rx) = mpsc::unbounded_channel();
4390
4391 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Esc);
4392 assert!(consumed);
4393 assert!(
4394 state_arc.lock().overlay.is_none(),
4395 "overlay must be cleared after Esc"
4396 );
4397 let evt = rx.try_recv().expect("cancel event must arrive");
4398 assert!(
4399 matches!(evt, InlineEvent::Overlay(OverlayEvent::Cancelled)),
4400 "expected Cancelled overlay event"
4401 );
4402 }
4403
4404 #[test]
4405 fn overlay_chars_route_to_search_field() {
4406 let mut state = RenderState::default();
4407 state.overlay = Some(OverlayState {
4408 title: "Select".to_string(),
4409 lines: Vec::new(),
4410 items: sample_overlay_items(),
4411 selected: 0,
4412 search: Some(OverlaySearchState {
4413 label: "filter".to_string(),
4414 placeholder: None,
4415 value: String::new(),
4416 }),
4417 });
4418 let state_arc = Arc::new(parking_lot::Mutex::new(state));
4419 let (tx, _rx) = mpsc::unbounded_channel();
4420
4421 handle_overlay_key(&state_arc, &tx, KeyCode::Char('m'));
4422 handle_overlay_key(&state_arc, &tx, KeyCode::Char('o'));
4423 handle_overlay_key(&state_arc, &tx, KeyCode::Backspace);
4424 let value = state_arc
4425 .lock()
4426 .overlay
4427 .as_ref()
4428 .unwrap()
4429 .search
4430 .as_ref()
4431 .unwrap()
4432 .value
4433 .clone();
4434 assert_eq!(value, "m", "Backspace should drop last char");
4435 }
4436
4437 #[test]
4438 fn overlay_key_no_op_when_no_overlay_open() {
4439 let state = RenderState::default();
4440 let state_arc = Arc::new(parking_lot::Mutex::new(state));
4441 let (tx, _rx) = mpsc::unbounded_channel();
4442 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
4443 assert!(
4444 !consumed,
4445 "handle_overlay_key must return false when no overlay is open"
4446 );
4447 }
4448
4449 #[test]
4450 fn apply_command_show_overlay_populates_state() {
4451 use oxicode_vtui::tui::core::{InlineListItem, ListOverlayRequest};
4452 let mut state = RenderState::default();
4453 let items = vec![
4454 InlineListItem {
4455 title: "alpha".to_string(),
4456 subtitle: None,
4457 badge: None,
4458 indent: 0,
4459 selection: None,
4460 search_value: None,
4461 },
4462 InlineListItem {
4463 title: "beta".to_string(),
4464 subtitle: None,
4465 badge: None,
4466 indent: 0,
4467 selection: None,
4468 search_value: None,
4469 },
4470 ];
4471 let request = OverlayRequest::List(ListOverlayRequest {
4472 title: "Pick".to_string(),
4473 lines: vec!["desc".to_string()],
4474 footer_hint: None,
4475 items,
4476 selected: None,
4477 search: None,
4478 hotkeys: Vec::new(),
4479 });
4480 let shutdown = apply_command(
4481 &mut state,
4482 InlineCommand::ShowOverlay {
4483 request: Box::new(request),
4484 },
4485 );
4486 assert!(!shutdown, "ShowOverlay must not request shutdown");
4487 let overlay = state.overlay.as_ref().expect("overlay must be Some");
4488 assert_eq!(overlay.title, "Pick");
4489 assert_eq!(overlay.items.len(), 2);
4490 assert_eq!(overlay.items[0].title, "alpha");
4491 assert_eq!(overlay.items[1].title, "beta");
4492 assert_eq!(overlay.lines.len(), 1);
4493
4494 apply_command(&mut state, InlineCommand::CloseOverlay);
4496 assert!(state.overlay.is_none(), "CloseOverlay must clear state");
4497 }
4498
4499 fn three_block_transcript() -> Vec<TranscriptLine> {
4502 vec![
4504 TranscriptLine {
4505 kind: InlineMessageKind::User,
4506 segments: vec![plain_segment("hi")],
4507 block_id: 0,
4508 },
4509 TranscriptLine {
4510 kind: InlineMessageKind::Agent,
4511 segments: vec![plain_segment("hello")],
4512 block_id: 1,
4513 },
4514 TranscriptLine {
4515 kind: InlineMessageKind::Agent,
4516 segments: vec![plain_segment("world")],
4517 block_id: 1,
4518 },
4519 TranscriptLine {
4520 kind: InlineMessageKind::User,
4521 segments: vec![plain_segment("bye")],
4522 block_id: 2,
4523 },
4524 ]
4525 }
4526
4527 #[test]
4528 fn default_block_mode_is_truncated() {
4529 let state = RenderState::default();
4530 assert_eq!(state.block_mode(0), BlockDisplayMode::Truncated);
4531 assert!(state.block_display.is_empty(), "default needs no map entry");
4532 }
4533
4534 #[test]
4535 fn fold_all_collapses_every_block() {
4536 let mut state = RenderState::default();
4537 state.transcript = three_block_transcript();
4538 state.fold_all();
4539 assert_eq!(state.block_display.len(), 3, "3 distinct block ids");
4540 assert_eq!(state.block_mode(0), BlockDisplayMode::Collapsed);
4541 assert_eq!(state.block_mode(1), BlockDisplayMode::Collapsed);
4542 assert_eq!(state.block_mode(2), BlockDisplayMode::Collapsed);
4543 }
4544
4545 #[test]
4546 fn expand_all_after_fold_all_shows_expanded() {
4547 let mut state = RenderState::default();
4548 state.transcript = three_block_transcript();
4549 state.fold_all();
4550 state.expand_all();
4551 assert_eq!(state.block_display.len(), 3);
4552 assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
4553 assert_eq!(state.block_mode(2), BlockDisplayMode::Expanded);
4554 }
4555
4556 #[test]
4557 fn truncate_all_resets_to_default() {
4558 let mut state = RenderState::default();
4559 state.transcript = three_block_transcript();
4560 state.fold_all();
4561 state.truncate_all();
4562 assert!(state.block_display.is_empty());
4563 assert_eq!(state.block_mode(1), BlockDisplayMode::Truncated);
4564 }
4565
4566 #[test]
4567 fn fold_all_on_empty_transcript_is_noop() {
4568 let mut state = RenderState::default();
4569 state.fold_all();
4570 assert!(state.block_display.is_empty());
4571 }
4572
4573 #[test]
4574 fn cycle_block_advances_through_three_states() {
4575 let mut state = RenderState::default();
4576 state.transcript = three_block_transcript();
4577 state.scroll_offset = 0; state.cycle_block_at_view();
4580 assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
4581 state.cycle_block_at_view();
4583 assert_eq!(state.block_mode(0), BlockDisplayMode::Collapsed);
4584 state.cycle_block_at_view();
4586 assert_eq!(state.block_mode(0), BlockDisplayMode::Truncated);
4587 assert!(!state.block_display.contains_key(&0));
4588 }
4589
4590 #[test]
4591 fn cancel_grace_field_defaults_none() {
4592 let state = RenderState::default();
4593 assert!(
4594 state.cancel_grace_until.is_none(),
4595 "cancel_grace_until must default to None"
4596 );
4597 }
4598
4599 #[test]
4600 fn cancel_routes_to_interrupt_when_streaming() {
4601 assert_eq!(
4602 route_cancel(true),
4603 CancelRoute::Interrupt,
4604 "Esc while streaming must route through the interrupt path"
4605 );
4606 }
4607
4608 #[test]
4609 fn cancel_routes_to_exit_when_idle() {
4610 assert_eq!(
4611 route_cancel(false),
4612 CancelRoute::Exit,
4613 "Esc while idle must exit immediately (one-press quit)"
4614 );
4615 }
4616 #[test]
4617 fn scrollbar_paints_thumb_when_content_overflows() {
4618 let mut state = RenderState::default();
4621 for i in 0..40u32 {
4622 state.transcript.push(TranscriptLine {
4623 kind: InlineMessageKind::Agent,
4624 segments: vec![plain_segment(format!("line {i}"))],
4625 block_id: i as usize,
4626 });
4627 }
4628 let rendered = render_frame_to_string(&state);
4629 assert!(
4630 rendered.contains('\u{2588}'),
4631 "scrollbar thumb (█) must render when transcript overflows the viewport"
4632 );
4633 }
4634
4635 #[test]
4636 fn scrollbar_absent_when_content_fits_viewport() {
4637 let mut state = RenderState::default();
4639 state.transcript.push(TranscriptLine {
4640 kind: InlineMessageKind::Agent,
4641 segments: vec![plain_segment("hi")],
4642 block_id: 0,
4643 });
4644 let rendered = render_frame_to_string(&state);
4645 assert!(
4646 !rendered.contains('\u{2588}'),
4647 "no scrollbar thumb when content fits the viewport"
4648 );
4649 }
4650
4651 #[test]
4654 fn confirmation_modal_renders_title() {
4655 let mut state = RenderState::default();
4656 state.confirmation = Some(quit_confirmation());
4657 let rendered = render_frame_to_string(&state);
4658 assert!(
4659 rendered.contains("Quit oxicode?"),
4660 "confirmation title must render"
4661 );
4662 }
4663
4664 #[test]
4665 fn confirmation_yes_sends_exit_and_closes() {
4666 let mut state = RenderState::default();
4667 state.confirmation = Some(quit_confirmation());
4668 let state_arc = Arc::new(parking_lot::Mutex::new(state));
4669 let (tx, mut rx) = mpsc::unbounded_channel();
4670 handle_confirmation_key(&state_arc, &tx, KeyCode::Char('y'));
4671 assert!(
4672 state_arc.lock().confirmation.is_none(),
4673 "yes must close the modal"
4674 );
4675 let ev = rx.try_recv().expect("yes must send an event");
4676 assert!(matches!(ev, InlineEvent::Exit), "yes must send Exit");
4677 }
4678
4679 #[test]
4680 fn confirmation_no_closes_without_event() {
4681 let mut state = RenderState::default();
4682 state.confirmation = Some(quit_confirmation());
4683 let state_arc = Arc::new(parking_lot::Mutex::new(state));
4684 let (tx, mut rx) = mpsc::unbounded_channel();
4685 handle_confirmation_key(&state_arc, &tx, KeyCode::Char('n'));
4686 assert!(
4687 state_arc.lock().confirmation.is_none(),
4688 "no must close the modal"
4689 );
4690 assert!(rx.try_recv().is_err(), "no must not send an event");
4691 }
4692 #[test]
4695 fn tip_banner_renders_when_active() {
4696 let mut state = RenderState::default();
4697 let now_tick = FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed);
4698 state.tip = Some(EphemeralTip {
4699 text: "hello-tip-marker".to_string(),
4700 born_tick: now_tick,
4701 ttl_ticks: 100,
4702 key: "test",
4703 ambient: false,
4704 });
4705 let rendered = render_frame_to_string(&state);
4706 assert!(
4707 rendered.contains("hello-tip-marker"),
4708 "active tip must render above the composer"
4709 );
4710 }
4711
4712 #[test]
4713 fn tip_visible_within_ttl_window() {
4714 let tip = EphemeralTip {
4715 text: "x".to_string(),
4716 born_tick: 10,
4717 ttl_ticks: 5,
4718 key: "test",
4719 ambient: false,
4720 };
4721 assert!(tip_is_visible(&tip, 12), "within TTL must be visible");
4722 assert!(
4723 !tip_is_visible(&tip, 15),
4724 "at TTL boundary (born + ttl) must expire"
4725 );
4726 assert!(!tip_is_visible(&tip, 99), "past TTL must expire");
4727 }
4728
4729 #[test]
4732 fn sticky_header_pins_block_head_when_scrolled_into_body() {
4733 let mut state = RenderState::default();
4736 for i in 0..40u32 {
4737 state.transcript.push(TranscriptLine {
4738 kind: InlineMessageKind::Agent,
4739 segments: vec![plain_segment(format!("body-line-{i:02}"))],
4740 block_id: 0,
4741 });
4742 }
4743 state.scroll_offset = 10;
4744 let rendered = render_frame_to_string(&state);
4745 assert!(
4746 rendered.contains("body-line-00"),
4747 "sticky header must pin the block head when scrolled into the body"
4748 );
4749 }
4750
4751 #[test]
4752 fn sticky_header_absent_when_viewport_at_block_head() {
4753 let mut state = RenderState::default();
4755 for i in 0..40u32 {
4756 state.transcript.push(TranscriptLine {
4757 kind: InlineMessageKind::Agent,
4758 segments: vec![plain_segment(format!("head-line-{i:02}"))],
4759 block_id: 0,
4760 });
4761 }
4762 state.scroll_offset = 0;
4763 let rendered = render_frame_to_string(&state);
4764 assert!(rendered.contains("head-line-00"));
4767 }
4768
4769 #[test]
4772 fn turn_end_drains_queue_head() {
4773 let mut state = RenderState::default();
4774 state.queued_inputs = vec!["queued-1".into(), "queued-2".into()];
4775 state.drain_queue_head();
4776 assert_eq!(
4777 state.queued_inputs.len(),
4778 1,
4779 "drain_queue_head must drop the head (now running)"
4780 );
4781 assert_eq!(state.queued_inputs[0], "queued-2");
4782 }
4783
4784 #[test]
4787 fn render_frame_paints_transcript_content() {
4788 let mut state = RenderState::default();
4792 state.transcript.push(TranscriptLine {
4793 kind: InlineMessageKind::Agent,
4794 segments: vec![plain_segment("frame-content-marker-xyz")],
4795 block_id: 0,
4796 });
4797 let rendered = render_frame_to_string(&state);
4798 assert!(
4799 rendered.contains("frame-content-marker-xyz"),
4800 "render_frame must paint transcript content"
4801 );
4802 }
4803
4804 #[test]
4805 fn file_search_dropdown_renders_results() {
4806 use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
4807 let mut state = RenderState::default();
4808 state.input_enabled = true;
4809 state.file_search = Some(FileSearchState {
4810 query: "main".into(),
4811 at_offset: 0,
4812 hidden_mode: false,
4813 results: vec![
4814 FileSearchResult {
4815 path: "src/main.rs".into(),
4816 score: 100,
4817 },
4818 FileSearchResult {
4819 path: "tests/main.rs".into(),
4820 score: 50,
4821 },
4822 ],
4823 selected: 0,
4824 index: vec![],
4825 line_mode: false,
4826 });
4827 let rendered = render_frame_to_string(&state);
4828 assert!(rendered.contains("Files"), "dropdown title must render");
4829 assert!(
4830 rendered.contains("src/main.rs"),
4831 "dropdown must show file paths"
4832 );
4833 }
4834
4835 #[test]
4836 fn file_search_dropdown_hidden_mode_title() {
4837 use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
4838 let mut state = RenderState::default();
4839 state.input_enabled = true;
4840 state.file_search = Some(FileSearchState {
4841 query: "".into(),
4842 at_offset: 0,
4843 hidden_mode: true,
4844 results: vec![FileSearchResult {
4845 path: ".env".into(),
4846 score: 0,
4847 }],
4848 selected: 0,
4849 index: vec![],
4850 line_mode: false,
4851 });
4852 let rendered = render_frame_to_string(&state);
4853 assert!(
4854 rendered.contains("hidden"),
4855 "hidden mode must be indicated in title"
4856 );
4857 }
4858
4859 #[test]
4860 fn file_search_and_composer_render_together() {
4861 use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
4862 let mut state = RenderState::default();
4863 state.input_enabled = true;
4864 state.prompt_prefix = "> ".into();
4865 state.input_buffer = "@main".into();
4866 state.input_cursor = 5;
4867 state.file_search = Some(FileSearchState {
4868 query: "main".into(),
4869 at_offset: 0,
4870 hidden_mode: false,
4871 results: vec![FileSearchResult {
4872 path: "src/main.rs".into(),
4873 score: 100,
4874 }],
4875 selected: 0,
4876 index: vec![],
4877 line_mode: false,
4878 });
4879 let rendered = render_frame_to_string(&state);
4880 assert!(rendered.contains('>'), "composer must still render");
4882 assert!(
4883 rendered.contains("src/main.rs"),
4884 "dropdown must render alongside composer"
4885 );
4886 }
4887}