1#![allow(
2 clippy::field_reassign_with_default,
3 clippy::let_and_return,
4 clippy::borrow_interior_mutable_const,
5 clippy::derivable_impls
6)]
7use std::io::{self, Stdout, Write};
11use std::path::PathBuf;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use anyhow::Result;
16use crossterm::{
17 cursor::{Hide, Show},
18 event::{
19 self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEventKind,
20 KeyModifiers, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
21 PushKeyboardEnhancementFlags,
22 },
23 execute,
24 terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate, disable_raw_mode, enable_raw_mode},
25};
26use oxicode_agent::AgentEvent;
27use oxicode_agent::config::Mode;
28use oxicode_agent::tools::TodoStateProvider;
29use oxicode_agent::tools::todo::TodoStatus;
30use oxicode_vtui::theme::{ThemeStyles, active_styles};
31use oxicode_vtui::tui::core::{
32 InlineCommand, InlineEvent, InlineHandle, InlineHeaderContext, InlineHeaderStatusBadge,
33 InlineHeaderStatusTone, InlineListItem, InlineListSelection, InlineMessageKind, InlineSegment,
34 InlineTextStyle, OverlayRequest, OverlaySubmission,
35};
36use ratatui::{
37 Frame, Terminal,
38 backend::CrosstermBackend,
39 layout::{Alignment, Rect},
40 style::{Color, Modifier, Style},
41 text::{Line, Span},
42 widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph, Wrap},
43};
44
45use crate::App;
46use crate::app::agent_hub_registry::HubEntry;
47use crate::app::agent_session::SessionEvent;
48use crate::tui_vt::slash::file_commands::FileCommand;
49use crate::tui_vt::slash::registry::{SlashCtx, SlashOutcome, SlashRegistry};
50
51pub struct Tui {
62 terminal: Terminal<CrosstermBackend<Stdout>>,
63 tty_ok: bool,
64}
65
66impl Tui {
67 pub fn enter() -> Result<Self> {
70 Self::set_panic_hook();
71
72 let tty_ok = enable_raw_mode().is_ok();
73 let mut stdout = io::stdout();
74
75 if tty_ok {
76 let flags = if std::env::var("OXICODE_KITTY_KEYBOARD").as_deref() == Ok("1") {
80 KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
81 | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
82 | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
83 } else {
84 KeyboardEnhancementFlags::REPORT_EVENT_TYPES
85 };
86 let _ = execute!(
87 stdout,
88 Hide,
89 EnableBracketedPaste,
90 PushKeyboardEnhancementFlags(flags)
91 );
92 let _ = stdout.flush();
93 }
94
95 let backend = CrosstermBackend::new(stdout);
96 let mut terminal = Terminal::new(backend)?;
97 if tty_ok {
98 let _ = terminal.clear();
99 }
100
101 Ok(Self { terminal, tty_ok })
102 }
103
104 pub fn exit(&mut self) -> Result<()> {
108 if self.tty_ok {
109 let _ = execute!(
110 self.terminal.backend_mut(),
111 PopKeyboardEnhancementFlags,
112 DisableBracketedPaste
113 );
114 let _ = self.terminal.show_cursor();
115 disable_raw_mode()?;
117 self.tty_ok = false;
118 }
119 Ok(())
120 }
121
122 fn set_panic_hook() {
126 let original_hook = std::panic::take_hook();
127 std::panic::set_hook(Box::new(move |panic_info| {
128 let _ = execute!(io::stdout(), Show);
129 let _ = disable_raw_mode();
130 original_hook(panic_info);
131 }));
132 }
133}
134
135impl Drop for Tui {
136 fn drop(&mut self) {
137 let _ = self.exit();
138 }
139}
140
141#[derive(Default)]
148pub struct RenderState {
149 pub input_buffer: String,
151 pub input_cursor: usize,
153 pub transcript: Vec<TranscriptLine>,
155 pub scroll_offset: usize,
158 pub header_context: InlineHeaderContext,
160 pub input_enabled: bool,
162 pub footer_left: Option<String>,
164 pub footer_right: Option<String>,
165 pub prompt_prefix: String,
167 pub placeholder: Option<String>,
169 pub shutdown_requested: bool,
171 pub message_buffer: String,
173 pub agent_hub_open: bool,
175 pub hub_entries: Vec<(String, HubEntry)>,
177 pub pending_quit: bool,
179 pub slash_popup: SlashPopup,
181 pub reasoning_stage: Option<String>,
183 pub overlay: Option<OverlayState>,
185 pub overlay_model_ids: Vec<String>,
187 pub overlay_catalog_models: Vec<(String, String)>,
190 pub overlay_providers: Vec<String>,
192 pub catalog: Option<std::sync::Arc<dyn oxicode_sdk::ports::catalog::ModelCatalog>>,
195 pub queued_inputs: Vec<String>,
197 pub queue_panel_open: bool,
199 pub queue_selected: usize,
201 pub shell_mode: bool,
203 pub follow_ups: Vec<String>,
205 pub todo_items: Vec<(String, TodoStatus)>,
207 pub todo_provider: Option<Arc<dyn TodoStateProvider>>,
210 pub vim_state: oxicode_vtui::vim::VimState,
212 pub vim_clipboard: String,
214 pub search: Option<SearchState>,
216 pub block_display: std::collections::HashMap<usize, BlockDisplayMode>,
219 pub last_esc_at: Option<std::time::Instant>,
221 pub multiline_mode: bool,
223 pub autonomy_mode: Mode,
227 pub prompt_history: Vec<String>,
229 pub history_pos: Option<usize>,
231 pub next_block_id: usize,
233 pub cancel_grace_until: Option<std::time::Instant>,
236 pub confirmation: Option<ModalConfirmation>,
240 pub tip: Option<EphemeralTip>,
243 pub cwd: PathBuf,
245 pub file_search: Option<crate::tui_vt::file_search::FileSearchState>,
247 pub seen_tips: std::collections::HashMap<&'static str, u32>,
249 pub file_commands: Vec<FileCommand>,
252}
253
254#[derive(Debug, Clone)]
256pub struct TranscriptLine {
257 pub kind: InlineMessageKind,
258 pub segments: Vec<InlineSegment>,
259 pub block_id: usize,
262}
263
264#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
270pub enum BlockDisplayMode {
271 Collapsed,
273 #[default]
275 Truncated,
276 Expanded,
278}
279
280#[derive(Clone, Debug)]
282pub struct SearchState {
283 pub query: String,
284 pub matches: Vec<usize>,
286 pub current: usize,
288}
289
290#[derive(Clone)]
292pub struct SlashPopupItem {
293 pub label: String,
295 pub description: String,
297 pub name: String,
299}
300
301#[derive(Default, Clone)]
306pub struct SlashPopup {
307 pub open: bool,
308 pub items: Vec<SlashPopupItem>,
309 pub selected: usize,
310}
311
312#[derive(Clone, Debug)]
316pub struct OverlayListItem {
317 pub title: String,
318 pub subtitle: Option<String>,
319 pub badge: Option<String>,
320 pub indent: u8,
321 pub search_value: Option<String>,
322 pub selection: Option<oxicode_vtui::tui::core::InlineListSelection>,
324}
325
326#[derive(Clone, Debug)]
331pub struct OverlayState {
332 pub title: String,
333 pub lines: Vec<String>,
334 pub items: Vec<OverlayListItem>,
335 pub selected: usize,
336 pub search: Option<OverlaySearchState>,
337}
338
339#[derive(Clone, Debug)]
343pub struct ModalConfirmation {
344 pub title: String,
345 pub message: String,
346 pub action: ConfirmationAction,
349}
350
351#[derive(Clone, Debug, PartialEq, Eq)]
353pub enum ConfirmationAction {
354 Quit,
356 ClearConversation,
358 RemoveProviderKey(String),
360}
361
362#[derive(Clone, Debug)]
366pub struct EphemeralTip {
367 pub text: String,
368 pub born_tick: u64,
370 pub ttl_ticks: u64,
372 pub key: &'static str,
375 pub ambient: bool,
379}
380
381#[derive(Clone, Debug)]
383pub struct OverlaySearchState {
384 pub label: String,
385 pub placeholder: Option<String>,
386 pub value: String,
387}
388
389impl RenderState {
390 fn new_with_header(header: InlineHeaderContext) -> Self {
391 let mut s = Self::default();
392 s.header_context = header;
393 s.prompt_prefix = "> ".to_string();
394 s.input_enabled = true;
395 s
396 }
397
398 fn append_line(&mut self, kind: InlineMessageKind, segments: Vec<InlineSegment>) {
400 let block_id = self.block_id_for_kind(kind);
401 self.transcript.push(TranscriptLine {
402 kind,
403 segments,
404 block_id,
405 });
406 }
407
408 fn inline_segment(&mut self, kind: InlineMessageKind, segment: InlineSegment) {
412 if let Some(last) = self.transcript.last_mut()
413 && last.kind == kind
414 {
415 last.segments.push(segment);
416 return;
417 }
418 let block_id = self.block_id_for_kind(kind);
419 self.transcript.push(TranscriptLine {
420 kind,
421 segments: vec![segment],
422 block_id,
423 });
424 }
425
426 fn block_id_for_kind(&mut self, kind: InlineMessageKind) -> usize {
429 if let Some(last) = self.transcript.last()
430 && last.kind == kind
431 {
432 return last.block_id;
433 }
434 let id = self.next_block_id;
435 self.next_block_id += 1;
436 id
437 }
438
439 pub fn start_search(&mut self, query: &str) {
443 let needle = query.to_lowercase();
444 let matches: Vec<usize> = self
445 .transcript
446 .iter()
447 .enumerate()
448 .filter(|(_, line)| {
449 line.segments
450 .iter()
451 .any(|s| s.text.to_lowercase().contains(&needle))
452 })
453 .map(|(i, _)| i)
454 .collect();
455 self.search = Some(SearchState {
456 query: query.to_string(),
457 matches,
458 current: 0,
459 });
460 if let Some(s) = &self.search
462 && let Some(&first) = s.matches.first()
463 {
464 self.scroll_offset = first;
465 }
466 }
467
468 pub fn search_next(&mut self) {
470 if let Some(s) = &mut self.search
471 && !s.matches.is_empty()
472 {
473 s.current = (s.current + 1) % s.matches.len();
474 let line = s.matches[s.current];
475 self.scroll_offset = line;
476 }
477 }
478
479 pub fn search_prev(&mut self) {
481 if let Some(s) = &mut self.search
482 && !s.matches.is_empty()
483 {
484 if s.current == 0 {
485 s.current = s.matches.len() - 1;
486 } else {
487 s.current -= 1;
488 }
489 let line = s.matches[s.current];
490 self.scroll_offset = line;
491 }
492 }
493
494 pub fn block_mode(&self, block_id: usize) -> BlockDisplayMode {
498 self.block_display
499 .get(&block_id)
500 .copied()
501 .unwrap_or_default()
502 }
503
504 pub fn cycle_block_at_view(&mut self) {
507 let offset = self.effective_offset();
508 if let Some(line) = self.transcript.get(offset) {
509 let bid = line.block_id;
510 let next = match self.block_mode(bid) {
511 BlockDisplayMode::Collapsed => BlockDisplayMode::Truncated,
512 BlockDisplayMode::Truncated => BlockDisplayMode::Expanded,
513 BlockDisplayMode::Expanded => BlockDisplayMode::Collapsed,
514 };
515 if next == BlockDisplayMode::Truncated {
518 self.block_display.remove(&bid);
519 } else {
520 self.block_display.insert(bid, next);
521 }
522 }
523 }
524
525 pub fn expand_all(&mut self) {
527 for bid in self.all_block_ids() {
528 self.block_display.insert(bid, BlockDisplayMode::Expanded);
529 }
530 }
531
532 pub fn fold_all(&mut self) {
534 for bid in self.all_block_ids() {
535 self.block_display.insert(bid, BlockDisplayMode::Collapsed);
536 }
537 }
538
539 pub fn truncate_all(&mut self) {
541 self.block_display.clear();
542 }
543
544 fn all_block_ids(&self) -> Vec<usize> {
546 let mut ids = Vec::new();
547 let mut prev: Option<usize> = None;
548 for l in &self.transcript {
549 if prev != Some(l.block_id) {
550 ids.push(l.block_id);
551 prev = Some(l.block_id);
552 }
553 }
554 ids
555 }
556
557 pub fn jump_next_turn(&mut self) {
561 let offset = self.effective_offset();
562 let search_after = self
563 .transcript
564 .iter()
565 .enumerate()
566 .skip(offset + 1)
567 .find(|(_, l)| l.kind == InlineMessageKind::Agent || l.kind == InlineMessageKind::User);
568 if let Some((idx, _)) = search_after {
569 self.scroll_offset = idx;
570 }
571 }
572
573 pub fn jump_prev_turn(&mut self) {
575 let offset = self.effective_offset();
576 let search_before = self
577 .transcript
578 .iter()
579 .enumerate()
580 .take(offset)
581 .rev()
582 .find(|(_, l)| l.kind == InlineMessageKind::User);
583 if let Some((idx, _)) = search_before {
584 self.scroll_offset = idx;
585 }
586 }
587
588 fn effective_offset(&self) -> usize {
590 if self.scroll_offset == usize::MAX {
591 self.transcript.len().saturating_sub(1)
592 } else {
593 self.scroll_offset
594 }
595 }
596
597 pub fn drain_queue_head(&mut self) {
600 if !self.queued_inputs.is_empty() {
601 self.queued_inputs.remove(0);
602 }
603 }
604
605 pub fn show_tip(&mut self, key: &'static str, text: &str, ttl: u64, ambient: bool) {
608 let count = self.seen_tips.entry(key).or_insert(0);
609 if *count >= SEEN_CAP {
610 return;
611 }
612 *count += 1;
613 self.tip = Some(EphemeralTip {
614 text: text.to_string(),
615 born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
616 ttl_ticks: ttl,
617 key,
618 ambient,
619 });
620 }
621}
622
623const SEEN_CAP: u32 = 3;
625
626pub async fn run_tui(app: App) -> Result<()> {
633 let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
636 let git_branch = crate::util::git_utils::get_current_branch(&cwd);
637 super::host::activate_theme(app.settings());
638 let theme_id = oxicode_vtui::theme::active_theme_id();
640 let validation = oxicode_vtui::theme::validate_theme_contrast(&theme_id);
641 if validation.warnings.is_empty() {
642 tracing::debug!("theme '{theme_id}' passed contrast validation");
643 } else {
644 for w in &validation.warnings {
645 tracing::warn!("theme contrast: {w}");
646 }
647 }
648
649 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
652 let (evt_tx, mut evt_rx) = tokio::sync::mpsc::unbounded_channel::<InlineEvent>();
653 let handle = InlineHandle::new_for_tests(cmd_tx);
654
655 let session = build_agent_session(&app).await?;
659 let session_handle = session.clone_handle();
663
664 let (session_tx, mut session_rx) = tokio::sync::mpsc::unbounded_channel::<SessionEvent>();
672 let _sub_guard = session.subscribe(Box::new(move |event| {
673 let _ = session_tx.send(event.clone());
674 }));
675
676 let header = build_header_context(&app, &cwd, git_branch.as_deref());
678 handle.set_header_context(header.clone());
679
680 let mut tui = Tui::enter()?;
683
684 handle.set_prompt("> ".to_string(), InlineTextStyle::default());
688 handle.set_placeholder(Some("Describe what you want to build\u{2026}".to_string()));
689
690 let state = Arc::new(parking_lot::Mutex::new(RenderState::new_with_header(
693 header,
694 )));
695 state.lock().cwd = cwd.clone();
696 state.lock().catalog = Some(app.catalog());
697 state.lock().file_commands = crate::tui_vt::slash::file_commands::load_file_commands(&cwd);
698 state.lock().todo_provider = session_handle.todo_provider();
699 state.lock().tip = Some(EphemeralTip {
702 text: "Press ? for shortcuts \u{00b7} /help for commands".to_string(),
703 born_tick: 0,
704 ttl_ticks: 900,
705 key: "onboarding",
706 ambient: true,
707 });
708 if std::env::var("SSH_CONNECTION").is_ok() {
710 state.lock().show_tip(
711 "ssh_wrap",
712 "Over SSH? Consider tmux to keep sessions alive",
713 600,
714 true,
715 );
716 }
717 let mode_handle = app.ask_bridge().map(|b| {
721 let handle = b.mode_handle();
722 state.lock().autonomy_mode = Mode::load(&handle);
723 handle
724 });
725 spawn_input_thread(state.clone(), evt_tx.clone(), mode_handle);
726
727 let prompt_tx = spawn_agent_worker(session_handle.clone());
733
734 let result = run_event_loop(
735 &mut tui.terminal,
736 &mut cmd_rx,
737 &mut evt_rx,
738 &mut session_rx,
739 &handle,
740 &state,
741 &session_handle,
742 prompt_tx.clone(),
743 )
744 .await;
745
746 drop(prompt_tx);
749 handle.shutdown();
750 drop(tui);
752
753 result
754}
755
756#[allow(clippy::too_many_arguments)]
761async fn run_event_loop(
762 terminal: &mut Terminal<CrosstermBackend<Stdout>>,
763 cmd_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineCommand>,
764 evt_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineEvent>,
765 session_rx: &mut tokio::sync::mpsc::UnboundedReceiver<SessionEvent>,
766 handle: &InlineHandle,
767 state: &Arc<parking_lot::Mutex<RenderState>>,
768 session: &crate::app::agent_session::AgentSessionHandle,
769 prompt_tx: tokio::sync::mpsc::UnboundedSender<String>,
770) -> Result<()> {
771 while let Ok(cmd) = cmd_rx.try_recv() {
774 apply_command(&mut state.lock(), cmd);
775 }
776
777 {
782 let snapshot = state.lock();
783 let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
784 if let Err(err) = terminal.draw(|frame| render_frame(frame, &snapshot, handle)) {
785 tracing::warn!(?err, "initial tui draw failed");
786 }
787 let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
788 }
789
790 let mut render_tick = tokio::time::interval(std::time::Duration::from_millis(50));
796 render_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
797
798 loop {
799 tokio::select! {
800 biased;
803
804 Some(cmd) = cmd_rx.recv() => {
806 let shutdown = {
807 let mut s = state.lock();
808 apply_command(&mut s, cmd)
809 };
810 if shutdown {
811 break;
812 }
813 }
814
815 Some(event) = session_rx.recv() => {
817 handle_session_event(&mut state.lock(), handle, &event);
818 }
819
820 Some(evt) = evt_rx.recv() => {
822 let outcome = handle_inline_event(
823 &mut state.lock(),
824 handle,
825 session,
826 &prompt_tx,
827 evt,
828 );
829 if outcome == LoopOutcome::Exit {
830 break;
831 }
832 }
833
834 _ = tokio::signal::ctrl_c() => {
838 let outcome = {
839 let mut s = state.lock();
840 handle_interrupt(&mut s, session, handle)
841 };
842 if outcome == LoopOutcome::Exit {
843 break;
844 }
845 }
846
847 _ = render_tick.tick() => {}
850 }
851
852 if let Ok(size) = terminal.size()
854 && size.width < 40
855 {
856 let mut s = state.lock();
857 if s.tip.is_none() {
858 s.show_tip(
859 "small_screen",
860 "Terminal too narrow \u{2014} resize for full UI",
861 300,
862 true,
863 );
864 }
865 }
866 let mut snapshot = state.lock();
869 if let Some(provider) = snapshot.todo_provider.as_ref() {
872 snapshot.todo_items = flatten_todo_items(&provider.get_phases());
873 }
874 let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
875 let draw_err = terminal
876 .draw(|frame| render_frame(frame, &snapshot, handle))
877 .err();
878 let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
879 if let Some(err) = draw_err {
880 tracing::warn!(?err, "tui draw failed");
881 break;
882 }
883 }
884
885 Ok(())
886}
887
888#[derive(PartialEq, Eq)]
889enum LoopOutcome {
890 Continue,
891 Exit,
892}
893
894#[derive(PartialEq, Eq, Debug)]
899enum CancelRoute {
900 Interrupt,
903 Exit,
905}
906
907fn route_cancel(is_streaming: bool) -> CancelRoute {
910 if is_streaming {
911 CancelRoute::Interrupt
912 } else {
913 CancelRoute::Exit
914 }
915}
916
917fn apply_command(state: &mut RenderState, cmd: InlineCommand) -> bool {
924 match cmd {
925 InlineCommand::AppendLine { kind, segments } => {
926 state.append_line(kind, segments);
927 }
928 InlineCommand::Inline { kind, segment } => {
929 state.inline_segment(kind, segment);
930 }
931 InlineCommand::ReplaceLast {
932 count, kind, lines, ..
933 } => {
934 let drop = count.min(state.transcript.len());
936 for _ in 0..drop {
937 state.transcript.pop();
938 }
939 for line in lines {
940 state.append_line(kind, line);
941 }
942 }
943 InlineCommand::AppendPastedMessage { kind, text, .. } => {
944 state.append_line(kind, vec![plain_segment(text)]);
945 }
946 InlineCommand::SetPrompt { prefix, .. } => {
947 state.prompt_prefix = prefix;
948 }
949 InlineCommand::SetPlaceholder { hint, .. } => {
950 state.placeholder = hint;
951 }
952 InlineCommand::SetHeaderContext { context } => {
953 state.header_context = *context;
954 }
955 InlineCommand::SetInputStatus { left, right } => {
956 state.footer_left = left;
957 state.footer_right = right;
958 }
959 InlineCommand::SetInputEnabled(enabled) => {
960 state.input_enabled = enabled;
961 }
962 InlineCommand::SetCursorVisible(_) | InlineCommand::ForceRedraw => {}
963 InlineCommand::SetReasoningStage(stage) => {
964 state.reasoning_stage = stage;
965 }
966 InlineCommand::SetVimModeEnabled(enabled) => {
967 state.vim_state.set_enabled(enabled);
968 }
969 InlineCommand::SetQueuedInputs { entries } => {
970 state.queued_inputs = entries;
971 }
972 InlineCommand::ShowOverlay { request } => {
973 state.overlay = Some(materialize_overlay(*request));
974 }
975 InlineCommand::CloseOverlay => {
976 state.overlay = None;
977 }
978 InlineCommand::Shutdown => {
979 state.shutdown_requested = true;
980 return true;
981 }
982 _ => {
983 tracing::trace!("unhandled InlineCommand (not rendered)");
986 }
987 }
988 false
989}
990
991fn materialize_overlay(request: OverlayRequest) -> OverlayState {
996 match request {
997 OverlayRequest::Modal(req) => OverlayState {
998 title: req.title,
999 lines: req.lines,
1000 items: Vec::new(),
1001 selected: 0,
1002 search: None,
1003 },
1004 OverlayRequest::List(req) => {
1005 let search = req.search.map(|cfg| OverlaySearchState {
1006 label: cfg.label,
1007 placeholder: cfg.placeholder,
1008 value: String::new(),
1009 });
1010 OverlayState {
1011 title: req.title,
1012 lines: req.lines,
1013 items: req.items.into_iter().map(overlay_item_from).collect(),
1014 selected: 0,
1015 search,
1016 }
1017 }
1018 OverlayRequest::Wizard(req) => {
1019 let step_items = req
1023 .steps
1024 .first()
1025 .map(|s| {
1026 s.items
1027 .iter()
1028 .map(|it| overlay_item_from(it.clone()))
1029 .collect()
1030 })
1031 .unwrap_or_default();
1032 let search = req.search.map(|cfg| OverlaySearchState {
1033 label: cfg.label,
1034 placeholder: cfg.placeholder,
1035 value: String::new(),
1036 });
1037 OverlayState {
1038 title: req.title,
1039 lines: Vec::new(),
1040 items: step_items,
1041 selected: 0,
1042 search,
1043 }
1044 }
1045 }
1046}
1047fn overlay_item_from(item: InlineListItem) -> OverlayListItem {
1048 OverlayListItem {
1049 title: item.title,
1050 subtitle: item.subtitle,
1051 badge: item.badge,
1052 indent: item.indent,
1053 search_value: item.search_value,
1054 selection: item.selection,
1055 }
1056}
1057
1058fn handle_session_event(state: &mut RenderState, handle: &InlineHandle, event: &SessionEvent) {
1062 match event {
1063 SessionEvent::Agent(boxed) => {
1064 map_agent_event(handle, *boxed.clone(), state);
1065 }
1066 SessionEvent::CompactionStart { .. } => {
1067 handle.set_reasoning_stage(Some("Compacting\u{2026}".to_string()));
1068 }
1069 SessionEvent::CompactionEnd { error_message, .. } => {
1070 handle.set_reasoning_stage(None);
1071 if let Some(msg) = error_message {
1072 handle.append_line(
1073 InlineMessageKind::Error,
1074 vec![plain_segment(format!("Compaction failed: {msg}"))],
1075 );
1076 }
1077 }
1078 SessionEvent::ThinkingLevelChanged { .. } => {
1079 }
1082 SessionEvent::QueueUpdate { .. } => {
1083 let pending = state.transcript.len();
1087 handle.set_input_status(
1088 None,
1089 Some(if pending == 0 {
1090 "ready".to_string()
1091 } else {
1092 "queued".to_string()
1093 }),
1094 );
1095 }
1096 SessionEvent::Advisor { body, .. } => {
1097 handle.append_line(InlineMessageKind::Info, vec![plain_segment(body.clone())]);
1098 }
1099 SessionEvent::SessionInfoChanged => {
1100 }
1103 }
1104}
1105
1106fn map_agent_event(handle: &InlineHandle, event: AgentEvent, state: &mut RenderState) {
1108 match event {
1109 AgentEvent::TextChunk { text } => {
1110 state.message_buffer.push_str(&text);
1111 handle.inline(InlineMessageKind::Agent, plain_segment(text));
1112 }
1113 AgentEvent::MessageStart { .. } => {
1114 state.message_buffer.clear();
1115 }
1116 AgentEvent::MessageUpdate { delta, .. } => match &delta {
1117 oxicode_sdk::StreamDelta::Text(text) => {
1118 state.message_buffer.push_str(text);
1119 handle.inline(InlineMessageKind::Agent, plain_segment(text.clone()));
1120 }
1121 oxicode_sdk::StreamDelta::Thinking(text) => {
1122 let mut style = InlineTextStyle::default();
1125 style.effects |= anstyle::Effects::DIMMED;
1126 let seg = InlineSegment {
1127 text: format!("\u{2733} {text}"),
1128 style: Arc::new(style),
1129 };
1130 handle.inline(InlineMessageKind::Info, seg);
1131 }
1132 oxicode_sdk::StreamDelta::Sync => {
1133 if !state.message_buffer.is_empty() {
1135 let lines =
1136 oxicode_vtui::tui::ui::markdown::render_markdown(&state.message_buffer);
1137 let count = lines.len();
1138 if count > 0 {
1139 handle.replace_last(count, InlineMessageKind::Agent, lines);
1140 }
1141 state.message_buffer.clear();
1142 }
1143 }
1144 },
1145 AgentEvent::MessageEnd { .. } => {
1146 if !state.message_buffer.is_empty() {
1148 let lines = oxicode_vtui::tui::ui::markdown::render_markdown(&state.message_buffer);
1149 let count = lines.len();
1150 if count > 0 {
1151 handle.replace_last(count, InlineMessageKind::Agent, lines);
1152 }
1153 state.message_buffer.clear();
1154 }
1155 }
1156 AgentEvent::ToolStart { tool_name, .. } => {
1157 handle.append_line(
1158 InlineMessageKind::Tool,
1159 vec![plain_segment(format!("\u{2699} {tool_name}"))],
1160 );
1161 handle.set_reasoning_stage(Some(format!("tool: {tool_name}")));
1162 }
1163 AgentEvent::ToolComplete { result } => {
1164 if !try_render_diff(&result.content, handle) {
1166 let preview = preview_tool_result(&result.content);
1167 let mut style = InlineTextStyle::default();
1168 style.effects |= anstyle::Effects::DIMMED;
1169 handle.append_line(
1170 InlineMessageKind::Tool,
1171 vec![InlineSegment {
1172 text: format!("\u{2713} {preview}"),
1173 style: Arc::new(style),
1174 }],
1175 );
1176 }
1177 handle.set_reasoning_stage(None);
1178 handle.set_input_enabled(true);
1179 }
1180 AgentEvent::ToolError { error, .. } => {
1181 handle.append_line(
1182 InlineMessageKind::Error,
1183 vec![plain_segment(format!("\u{2717} {error}"))],
1184 );
1185 handle.set_reasoning_stage(None);
1186 handle.set_input_enabled(true);
1187 }
1188 AgentEvent::Error { message, .. } => {
1189 handle.append_line(InlineMessageKind::Error, vec![plain_segment(message)]);
1190 handle.set_input_enabled(true);
1191 handle.set_input_status(None, None);
1192 }
1193 AgentEvent::Compaction { .. } => {
1194 }
1197 AgentEvent::Cancelled => {
1198 handle.set_input_enabled(true);
1199 handle.set_input_status(None, Some("cancelled".to_string()));
1200 }
1201 AgentEvent::AutoRetryStart {
1202 attempt,
1203 max_attempts,
1204 ..
1205 } => {
1206 handle.set_input_status(None, Some(format!("retry {attempt}/{max_attempts}")));
1207 }
1208 AgentEvent::TurnEnd { .. } => {
1209 crate::tui_vt::notifications::emit_notification("oxicode", "Response complete");
1213 state.drain_queue_head();
1217 handle.set_reasoning_stage(None);
1218 }
1219 _ => {
1220 tracing::debug!(?event, "ignored AgentEvent variant");
1224 }
1225 }
1226}
1227
1228fn handle_inline_event(
1230 state: &mut RenderState,
1231 handle: &InlineHandle,
1232 session: &crate::app::agent_session::AgentSessionHandle,
1233 prompt_tx: &tokio::sync::mpsc::UnboundedSender<String>,
1234 evt: InlineEvent,
1235) -> LoopOutcome {
1236 match evt {
1237 InlineEvent::Submit(text) => {
1238 let prompt = text.to_string();
1242 state.input_buffer.clear();
1243 state.input_cursor = 0;
1244 if prompt.is_empty() {
1245 return LoopOutcome::Continue;
1246 }
1247 state.pending_quit = false;
1248 if prompt.trim_start().starts_with('/') {
1252 state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
1253 let mut ctx = SlashCtx {
1254 session,
1255 handle,
1256 state,
1257 };
1258 return match SlashRegistry::builtins().dispatch(&prompt, &mut ctx) {
1259 SlashOutcome::Quit => LoopOutcome::Exit,
1260 SlashOutcome::Handled => LoopOutcome::Continue,
1261 SlashOutcome::NotHandled => {
1262 if let Some(expanded) = crate::tui_vt::slash::file_commands::try_expand(
1264 &ctx.state.file_commands,
1265 &prompt,
1266 ) {
1267 let _ = prompt_tx.send(expanded);
1270 LoopOutcome::Continue
1271 } else {
1272 ctx.reply(
1273 InlineMessageKind::Error,
1274 format!("Unknown command: {}", prompt.trim()),
1275 );
1276 LoopOutcome::Continue
1277 }
1278 }
1279 };
1280 }
1281 state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
1282 if session.is_streaming() {
1286 state.queued_inputs.push(prompt.clone());
1287 state.show_tip(
1288 "send_now",
1289 "Ctrl+Enter sends now \u{00b7} Ctrl+; manages queue",
1290 240,
1291 true,
1292 );
1293 }
1294 let _ = prompt_tx.send(prompt);
1297 }
1298 InlineEvent::Cancel => {
1299 return match route_cancel(session.is_streaming()) {
1305 CancelRoute::Interrupt => handle_interrupt(state, session, handle),
1306 CancelRoute::Exit => LoopOutcome::Exit,
1307 };
1308 }
1309 InlineEvent::Exit => {
1310 return LoopOutcome::Exit;
1311 }
1312 InlineEvent::Interrupt => {
1313 return handle_interrupt(state, session, handle);
1314 }
1315 InlineEvent::ScrollLineUp => {
1316 state.scroll_offset = state.scroll_offset.saturating_add(1);
1317 }
1318 InlineEvent::ScrollLineDown => {
1319 state.scroll_offset = state.scroll_offset.saturating_sub(1);
1320 }
1321 InlineEvent::ScrollPageUp => {
1322 state.scroll_offset = state.scroll_offset.saturating_add(10);
1323 }
1324 InlineEvent::ScrollPageDown => {
1325 state.scroll_offset = state.scroll_offset.saturating_sub(10);
1326 }
1327 InlineEvent::CyclePrimaryAgent => {
1328 let _ = session.cycle_model();
1329 }
1330 InlineEvent::CyclePrimaryAgentPrevious => {
1331 let _ = session.cycle_model();
1334 }
1335 InlineEvent::Overlay(overlay_evt) => {
1336 use oxicode_vtui::tui::core::OverlayEvent;
1337 match overlay_evt {
1338 OverlayEvent::Submitted(sub) => {
1339 if let OverlaySubmission::Selection(InlineListSelection::Model(idx)) = &sub
1341 && idx < &state.overlay_model_ids.len()
1342 {
1343 let model_id = state.overlay_model_ids[*idx].clone();
1344 match session.set_model(&model_id) {
1345 Ok(()) => handle.append_line(
1346 InlineMessageKind::Info,
1347 vec![plain_segment(format!("Switched to {model_id}"))],
1348 ),
1349 Err(e) => handle.append_line(
1350 InlineMessageKind::Error,
1351 vec![plain_segment(format!("Failed to set model: {e}"))],
1352 ),
1353 }
1354 }
1355 if let OverlaySubmission::Selection(InlineListSelection::Theme(theme_id)) = &sub
1357 {
1358 match oxicode_vtui::theme::set_active_theme(theme_id) {
1359 Ok(()) => {
1360 let label = oxicode_vtui::theme::theme_label(theme_id)
1361 .unwrap_or(theme_id.as_ref())
1362 .to_string();
1363 handle.append_line(
1364 InlineMessageKind::Info,
1365 vec![plain_segment(format!("Theme: {label}"))],
1366 );
1367 }
1368 Err(e) => handle.append_line(
1369 InlineMessageKind::Error,
1370 vec![plain_segment(format!("Unknown theme: {e}"))],
1371 ),
1372 }
1373 }
1374 if let OverlaySubmission::Selection(InlineListSelection::SlashCommand(name)) =
1376 &sub
1377 {
1378 state.input_buffer = format!("/{name} ");
1379 state.input_cursor = state.input_buffer.len();
1380 }
1381 if let OverlaySubmission::Selection(InlineListSelection::ConfigAction(key)) =
1383 &sub
1384 {
1385 match key.as_str() {
1386 "thinking_level" => {
1387 if let Some(level) = session.cycle_thinking_level() {
1388 handle.append_line(
1389 InlineMessageKind::Info,
1390 vec![plain_segment(format!("Thinking: {level:?}"))],
1391 );
1392 }
1393 }
1394 "auto_compaction" => {
1395 let enabled = !session.auto_compaction_enabled();
1396 session.set_auto_compaction(enabled);
1397 handle.append_line(
1398 InlineMessageKind::Info,
1399 vec![plain_segment(format!(
1400 "Auto-compaction: {}",
1401 if enabled { "on" } else { "off" }
1402 ))],
1403 );
1404 }
1405 "auto_retry" => {
1406 let enabled = !session.auto_retry_enabled();
1407 session.set_auto_retry(enabled);
1408 handle.append_line(
1409 InlineMessageKind::Info,
1410 vec![plain_segment(format!(
1411 "Auto-retry: {}",
1412 if enabled { "on" } else { "off" }
1413 ))],
1414 );
1415 }
1416 "advisor" => match session.toggle_advisor() {
1417 Ok(enabled) => handle.append_line(
1418 InlineMessageKind::Info,
1419 vec![plain_segment(format!(
1420 "Advisor: {}",
1421 if enabled { "on" } else { "off" }
1422 ))],
1423 ),
1424 Err(e) => handle.append_line(
1425 InlineMessageKind::Error,
1426 vec![plain_segment(format!("Failed to toggle advisor: {e}"))],
1427 ),
1428 },
1429 _ => {}
1430 }
1431 }
1432 if let OverlaySubmission::Selection(InlineListSelection::Session(id)) = &sub {
1435 state.input_buffer = format!("/resume {id}");
1436 state.input_cursor = state.input_buffer.len();
1437 }
1438 if let OverlaySubmission::Selection(InlineListSelection::CatalogModel(idx)) =
1440 &sub
1441 && idx < &state.overlay_catalog_models.len()
1442 {
1443 let (provider, model_id) = &state.overlay_catalog_models[*idx];
1444 let full = format!("{provider}/{model_id}");
1445 match session.set_model(&full) {
1446 Ok(()) => handle.append_line(
1447 InlineMessageKind::Info,
1448 vec![plain_segment(format!("Switched to {full}"))],
1449 ),
1450 Err(e) => handle.append_line(
1451 InlineMessageKind::Error,
1452 vec![plain_segment(format!("Failed to set model: {e}"))],
1453 ),
1454 }
1455 }
1456 if let OverlaySubmission::Selection(InlineListSelection::ProviderRow(idx)) =
1459 &sub
1460 && idx < &state.overlay_providers.len()
1461 {
1462 let name = state.overlay_providers[*idx].clone();
1463 let auth = crate::store::auth_storage::shared_auth_storage();
1464 if auth.has(&name) {
1465 state.confirmation = Some(ModalConfirmation {
1466 title: format!("Remove key for {name}?"),
1467 message: " y \u{2014} remove key n / x \u{2014} cancel".into(),
1468 action: ConfirmationAction::RemoveProviderKey(name),
1469 });
1470 } else {
1471 let env_hint = state
1472 .catalog
1473 .as_ref()
1474 .and_then(|c| c.get_provider_sync(&name))
1475 .and_then(|p| p.env_key);
1476 let msg = match env_hint {
1477 Some(env) => format!(
1478 "No key for '{name}'. Set {env} or run `oxicode setup`."
1479 ),
1480 None => {
1481 format!("No key for '{name}'. Run `oxicode setup` to add one.")
1482 }
1483 };
1484 handle.append_line(InlineMessageKind::Info, vec![plain_segment(msg)]);
1485 }
1486 }
1487 state.overlay_catalog_models.clear();
1488 state.overlay_providers.clear();
1489 state.overlay_model_ids.clear();
1490 handle.close_overlay();
1491 }
1492 OverlayEvent::Cancelled => {
1493 handle.close_overlay();
1494 }
1495 OverlayEvent::SelectionChanged(_) => {}
1496 }
1497 }
1498 _ => {
1499 }
1503 }
1504 LoopOutcome::Continue
1505}
1506
1507struct StreamingGuard<'a>(&'a std::sync::atomic::AtomicBool);
1514
1515impl Drop for StreamingGuard<'_> {
1516 fn drop(&mut self) {
1517 use std::sync::atomic::Ordering;
1518 self.0.store(false, Ordering::SeqCst);
1519 }
1520}
1521
1522fn handle_interrupt(
1535 state: &mut RenderState,
1536 session: &crate::app::agent_session::AgentSessionHandle,
1537 _handle: &InlineHandle,
1538) -> LoopOutcome {
1539 if state.confirmation.is_some() {
1541 return LoopOutcome::Exit;
1542 }
1543 if state.pending_quit {
1546 state.confirmation = Some(quit_confirmation());
1547 state.pending_quit = false;
1548 return LoopOutcome::Continue;
1549 }
1550 if session.is_streaming() {
1554 let s = session.clone();
1555 tokio::spawn(async move {
1556 s.abort().await;
1557 });
1558 state.footer_left = Some("Stopping\u{2026} press Ctrl+C again to confirm quit".to_string());
1559 state.pending_quit = true;
1560 } else {
1561 state.footer_left = None;
1562 state.confirmation = Some(quit_confirmation());
1563 }
1564 LoopOutcome::Continue
1565}
1566
1567fn quit_confirmation() -> ModalConfirmation {
1569 ModalConfirmation {
1570 title: "Quit oxicode?".into(),
1571 message: " y \u{2014} quit now n / x \u{2014} stay".into(),
1572 action: ConfirmationAction::Quit,
1573 }
1574}
1575
1576pub(super) fn clear_confirmation() -> ModalConfirmation {
1578 ModalConfirmation {
1579 title: "Clear conversation?".into(),
1580 message: " y \u{2014} clear all n / x \u{2014} cancel".into(),
1581 action: ConfirmationAction::ClearConversation,
1582 }
1583}
1584
1585fn spawn_input_thread(
1591 state: Arc<parking_lot::Mutex<RenderState>>,
1592 evt_tx: tokio::sync::mpsc::UnboundedSender<InlineEvent>,
1593 mode_handle: Option<std::sync::Arc<std::sync::atomic::AtomicU8>>,
1594) -> std::thread::JoinHandle<()> {
1595 std::thread::spawn(move || {
1596 loop {
1604 match event::poll(std::time::Duration::from_millis(50)) {
1605 Ok(true) => {}
1606 Ok(false) => continue,
1607 Err(_) => break,
1608 }
1609 let event = match event::read() {
1610 Ok(ev) => ev,
1611 Err(_) => continue,
1612 };
1613
1614 let mut pasted = String::new();
1617 let mut key_event = None;
1618 match event {
1619 Event::Key(k) if k.kind == KeyEventKind::Press => key_event = Some(k),
1620 Event::Paste(p) => pasted = p,
1621 _ => {}
1622 }
1623
1624 if !pasted.is_empty() {
1625 let mut s = state.lock();
1626 let cursor = s.input_cursor;
1627 s.input_buffer.insert_str(cursor, &pasted);
1628 s.input_cursor = cursor + pasted.len();
1629 continue;
1630 }
1631
1632 let Some(key) = key_event else { continue };
1633
1634 if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
1638 let _ = evt_tx.send(InlineEvent::Interrupt);
1639 continue;
1640 }
1641
1642 if key.code == KeyCode::Char('m') && key.modifiers.contains(KeyModifiers::CONTROL) {
1644 let mut s = state.lock();
1645 s.multiline_mode = !s.multiline_mode;
1646 continue;
1647 }
1648
1649 if key.code == KeyCode::Char('p') && key.modifiers.contains(KeyModifiers::CONTROL) {
1651 let mut s = state.lock();
1652 s.overlay = Some(build_command_palette());
1653 continue;
1654 }
1655
1656 if key.code == KeyCode::Char(';') && key.modifiers.contains(KeyModifiers::CONTROL) {
1658 let mut s = state.lock();
1659 s.queue_panel_open = !s.queue_panel_open;
1660 if s.queue_panel_open {
1661 s.queue_selected = 0;
1662 }
1663 continue;
1664 }
1665
1666 if key.code == KeyCode::Char('e') && key.modifiers.contains(KeyModifiers::CONTROL) {
1668 let mut s = state.lock();
1669 s.fold_all();
1670 continue;
1671 }
1672
1673 if key.code == KeyCode::Enter && key.modifiers.contains(KeyModifiers::CONTROL) {
1676 let submitted = {
1677 let mut s = state.lock();
1678 let buf = if s.slash_popup.open && !s.slash_popup.items.is_empty() {
1679 format!("/{}", s.slash_popup.items[s.slash_popup.selected].name)
1680 } else {
1681 std::mem::take(&mut s.input_buffer)
1682 };
1683 s.input_cursor = 0;
1684 s.slash_popup = SlashPopup::default();
1685 s.history_pos = None;
1686 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 if !submitted.is_empty() {
1693 let _ = evt_tx.send(InlineEvent::Interrupt);
1694 let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
1695 }
1696 continue;
1697 }
1698
1699 {
1702 let s = state.lock();
1703 if s.confirmation.is_some() {
1704 drop(s);
1705 handle_confirmation_key(&state, &evt_tx, key.code);
1706 continue;
1707 }
1708 }
1709
1710 {
1716 let s = state.lock();
1717 if s.overlay.is_some() {
1718 drop(s);
1719 if handle_overlay_key(&state, &evt_tx, key.code) {
1720 continue;
1721 }
1722 }
1723 }
1724
1725 {
1729 let s = state.lock();
1730 if s.file_search.is_some() {
1731 drop(s);
1732 if handle_file_search_key(&state, &evt_tx, key.code) {
1733 continue;
1734 }
1735 }
1736 }
1737
1738 match key.code {
1739 KeyCode::BackTab => {
1741 if let Some(h) = &mode_handle {
1742 let new_mode = Mode::load(h).toggle();
1743 h.store(new_mode.as_u8(), std::sync::atomic::Ordering::SeqCst);
1744 let label = new_mode.label();
1745 let detail = if new_mode.is_auto() {
1746 "autonomous — no questions, runs to completion"
1747 } else {
1748 "interactive — may ask questions"
1749 };
1750 let mut s = state.lock();
1751 s.autonomy_mode = new_mode;
1752 s.tip = Some(EphemeralTip {
1753 text: format!("Mode: {label} — {detail}"),
1754 born_tick: 0,
1755 ttl_ticks: 240,
1756 key: "mode_toggle",
1757 ambient: false,
1758 });
1759 }
1760 continue;
1761 }
1762 KeyCode::Enter => {
1763 let send = !state.lock().multiline_mode
1766 || key
1767 .modifiers
1768 .contains(crossterm::event::KeyModifiers::SHIFT);
1769
1770 if !send {
1771 let mut s = state.lock();
1772 let cursor = s.input_cursor;
1773 s.input_buffer.insert(cursor, '\n');
1774 s.input_cursor = cursor + 1;
1775 continue;
1776 }
1777
1778 let shell_cmd = state.lock().shell_mode;
1780 if shell_cmd {
1781 let submitted = {
1782 let mut s = state.lock();
1783 let buf = std::mem::take(&mut s.input_buffer);
1784 s.input_cursor = 0;
1785 s.shell_mode = false;
1786 s.history_pos = None;
1787 if !buf.is_empty() {
1788 s.prompt_history.insert(0, buf.clone());
1789 s.prompt_history.truncate(100);
1790 }
1791 buf
1792 };
1793 if !submitted.is_empty() {
1794 let prompt = format!("Run this shell command: `{submitted}`");
1795 let _ = evt_tx.send(InlineEvent::Submit(prompt.into()));
1796 }
1797 continue;
1798 }
1799
1800 let submitted = {
1801 let mut s = state.lock();
1802 let buf = if s.slash_popup.open && !s.slash_popup.items.is_empty() {
1803 let item = &s.slash_popup.items[s.slash_popup.selected];
1804 format!("/{}", item.name)
1805 } else {
1806 std::mem::take(&mut s.input_buffer)
1807 };
1808 s.input_cursor = 0;
1809 s.slash_popup = SlashPopup::default();
1810 s.history_pos = None;
1811 if !buf.is_empty() && !buf.starts_with('/') {
1813 s.prompt_history.insert(0, buf.clone());
1814 s.prompt_history.truncate(100);
1815 }
1816 buf
1817 };
1818 let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
1819 }
1820 KeyCode::Esc => {
1821 let mut s = state.lock();
1828 if s.shell_mode {
1829 s.shell_mode = false;
1830 s.input_buffer.clear();
1831 s.input_cursor = 0;
1832 } else if s.slash_popup.open {
1833 s.slash_popup = SlashPopup::default();
1834 } else if !s.input_buffer.is_empty() {
1835 let now = std::time::Instant::now();
1836 let is_double = s
1837 .last_esc_at
1838 .map(|t| now.duration_since(t).as_millis() < 800)
1839 .unwrap_or(false);
1840 if is_double {
1841 s.input_buffer.clear();
1842 s.input_cursor = 0;
1843 s.last_esc_at = None;
1844 } else {
1845 s.last_esc_at = Some(now);
1846 s.tip = Some(EphemeralTip {
1849 text: "Press Esc again to clear input".to_string(),
1850 born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
1851 ttl_ticks: 120,
1852 key: "esc_clear",
1853 ambient: false,
1854 });
1855 }
1856 } else {
1857 let now = std::time::Instant::now();
1858 let in_grace = s.cancel_grace_until.map(|t| t > now).unwrap_or(false);
1859 if in_grace {
1860 } else {
1862 s.cancel_grace_until = Some(now + std::time::Duration::from_secs(1));
1863 s.last_esc_at = None;
1864 drop(s);
1865 let _ = evt_tx.send(InlineEvent::Cancel);
1866 }
1867 }
1868 }
1869 KeyCode::Tab => {
1870 let mut s = state.lock();
1873 if s.slash_popup.open && !s.slash_popup.items.is_empty() {
1874 let name = s.slash_popup.items[s.slash_popup.selected].name.clone();
1875 s.input_buffer = format!("/{} ", name);
1876 s.input_cursor = s.input_buffer.len();
1877 refresh_input_popups(&mut s);
1878 }
1879 }
1880 KeyCode::Backspace => {
1881 let mut s = state.lock();
1882 if s.input_cursor > 0 {
1883 let cursor = s.input_cursor;
1884 let prev = s
1887 .input_buffer
1888 .char_indices()
1889 .take_while(|(i, _)| *i < cursor)
1890 .last()
1891 .map(|(i, _)| i)
1892 .unwrap_or(0);
1893 s.input_buffer.replace_range(prev..cursor, "");
1894 s.input_cursor = prev;
1895 }
1896 refresh_input_popups(&mut s);
1897 }
1898 KeyCode::Delete => {
1899 let mut s = state.lock();
1900 if s.input_cursor < s.input_buffer.len() {
1901 let cursor = s.input_cursor;
1902 let next = s.input_buffer[cursor..]
1903 .char_indices()
1904 .nth(1)
1905 .map(|(i, _)| cursor + i)
1906 .unwrap_or(s.input_buffer.len());
1907 s.input_buffer.replace_range(cursor..next, "");
1908 }
1909 refresh_input_popups(&mut s);
1910 }
1911 KeyCode::Left => {
1912 let mut s = state.lock();
1913 s.input_cursor = s.input_cursor.saturating_sub(1);
1914 }
1915 KeyCode::Right => {
1916 let mut s = state.lock();
1917 let len = s.input_buffer.len();
1918 s.input_cursor = (s.input_cursor + 1).min(len);
1919 }
1920 KeyCode::Up => {
1921 let mut s = state.lock();
1922 if s.slash_popup.open && !s.slash_popup.items.is_empty() {
1923 let len = s.slash_popup.items.len();
1924 s.slash_popup.selected = if s.slash_popup.selected == 0 {
1925 len - 1
1926 } else {
1927 s.slash_popup.selected - 1
1928 };
1929 } else if s.queue_panel_open
1930 && !s.queued_inputs.is_empty()
1931 && s.input_buffer.is_empty()
1932 {
1933 s.queue_selected = if s.queue_selected == 0 {
1934 s.queued_inputs.len() - 1
1935 } else {
1936 s.queue_selected - 1
1937 };
1938 } else if s.input_buffer.is_empty() && !s.prompt_history.is_empty() {
1939 let pos = s.history_pos.unwrap_or(0);
1941 let next = (pos + 1).min(s.prompt_history.len() - 1);
1942 s.history_pos = Some(next);
1943 s.input_buffer = s.prompt_history[next].clone();
1944 s.input_cursor = s.input_buffer.len();
1945 } else {
1946 drop(s);
1947 let _ = evt_tx.send(InlineEvent::ScrollLineUp);
1948 }
1949 }
1950 KeyCode::Down => {
1951 let mut s = state.lock();
1952 if s.slash_popup.open && !s.slash_popup.items.is_empty() {
1953 let len = s.slash_popup.items.len();
1954 s.slash_popup.selected = if s.slash_popup.selected + 1 >= len {
1955 0
1956 } else {
1957 s.slash_popup.selected + 1
1958 };
1959 } else if s.queue_panel_open
1960 && !s.queued_inputs.is_empty()
1961 && s.input_buffer.is_empty()
1962 {
1963 s.queue_selected = if s.queue_selected + 1 >= s.queued_inputs.len() {
1964 0
1965 } else {
1966 s.queue_selected + 1
1967 };
1968 } else {
1969 drop(s);
1970 let _ = evt_tx.send(InlineEvent::ScrollLineDown);
1971 }
1972 }
1973 KeyCode::PageUp => {
1974 let _ = evt_tx.send(InlineEvent::ScrollPageUp);
1975 }
1976 KeyCode::PageDown => {
1977 let _ = evt_tx.send(InlineEvent::ScrollPageDown);
1978 }
1979 KeyCode::Char(ch) => {
1980 let mut s = state.lock();
1981 if s.file_search.is_some()
1985 && ch == '!'
1986 && s.input_buffer[..s.input_cursor].ends_with('@')
1987 {
1988 let cwd = s.cwd.clone();
1989 if let Some(fs) = s.file_search.as_mut() {
1990 fs.toggle_hidden(&cwd);
1991 }
1992 continue;
1993 }
1994 if s.agent_hub_open && ch == 'q' {
1995 s.agent_hub_open = false;
1996 } else if s.vim_state.enabled() && !s.slash_popup.open {
1997 let s = &mut *s;
2000 let vkey =
2001 crossterm::event::KeyEvent::new(KeyCode::Char(ch), key.modifiers);
2002 let mut editor = InputEditor {
2003 buffer: &mut s.input_buffer,
2004 cursor: &mut s.input_cursor,
2005 };
2006 let outcome = oxicode_vtui::vim::handle_key(
2007 &mut s.vim_state,
2008 &mut editor,
2009 &mut s.vim_clipboard,
2010 &vkey,
2011 );
2012 if outcome.handled {
2013 refresh_input_popups(s);
2014 } else {
2015 let cursor = s.input_cursor;
2016 s.input_buffer.insert(cursor, ch);
2017 s.input_cursor = cursor + ch.len_utf8();
2018 refresh_input_popups(s);
2019 }
2020 } else if s.input_buffer.is_empty() && !s.slash_popup.open {
2021 if ch == '!' && !s.shell_mode {
2023 s.shell_mode = true;
2024 continue;
2025 }
2026 if s.queue_panel_open && !s.queued_inputs.is_empty() {
2030 let idx = s.queue_selected.min(s.queued_inputs.len() - 1);
2031 match ch {
2032 'x' | 'X' => {
2033 s.queued_inputs.remove(idx);
2034 if s.queue_selected >= s.queued_inputs.len()
2035 && !s.queued_inputs.is_empty()
2036 {
2037 s.queue_selected = s.queued_inputs.len() - 1;
2038 }
2039 continue;
2040 }
2041 'e' => {
2042 let entry = s.queued_inputs.remove(idx);
2043 s.input_buffer = entry;
2044 s.input_cursor = s.input_buffer.len();
2045 s.queue_panel_open = false;
2046 continue;
2047 }
2048 'J' => {
2049 if idx + 1 < s.queued_inputs.len() {
2050 s.queued_inputs.swap(idx, idx + 1);
2051 s.queue_selected = idx + 1;
2052 }
2053 continue;
2054 }
2055 'K' => {
2056 if idx > 0 {
2057 s.queued_inputs.swap(idx, idx - 1);
2058 s.queue_selected = idx - 1;
2059 }
2060 continue;
2061 }
2062 _ => {} }
2064 }
2065 match ch {
2070 '?' => {
2071 s.overlay = Some(OverlayState {
2072 title: "Keyboard Shortcuts".into(),
2073 lines: cheatsheet_lines(),
2074 items: vec![],
2075 selected: 0,
2076 search: None,
2077 });
2078 }
2079 'e' => s.cycle_block_at_view(),
2080 'E' => s.expand_all(),
2081 'J' => s.jump_next_turn(),
2082 'K' => s.jump_prev_turn(),
2083 'n' if s.search.is_some() => s.search_next(),
2084 'N' if s.search.is_some() => s.search_prev(),
2085 _ => {
2086 let cursor = s.input_cursor;
2087 s.input_buffer.insert(cursor, ch);
2088 s.input_cursor = cursor + ch.len_utf8();
2089 refresh_input_popups(&mut s);
2090 }
2091 }
2092 } else {
2093 let cursor = s.input_cursor;
2094 s.input_buffer.insert(cursor, ch);
2095 s.input_cursor = cursor + ch.len_utf8();
2096 refresh_input_popups(&mut s);
2097 }
2098 if s.tip.is_none() && s.input_buffer.to_lowercase().contains("plan") {
2100 s.show_tip(
2101 "plan_nudge",
2102 "Try /compact to summarize and plan ahead",
2103 180,
2104 true,
2105 );
2106 }
2107 }
2108 _ => {}
2109 }
2110 }
2111 })
2112}
2113
2114fn handle_confirmation_key(
2118 state: &Arc<parking_lot::Mutex<RenderState>>,
2119 evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
2120 code: KeyCode,
2121) {
2122 let mut s = state.lock();
2123 let Some(confirm) = s.confirmation.clone() else {
2124 return;
2125 };
2126 match code {
2127 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
2128 s.confirmation = None;
2129 drop(s);
2130 match confirm.action {
2131 ConfirmationAction::Quit => {
2132 let _ = evt_tx.send(InlineEvent::Exit);
2133 }
2134 ConfirmationAction::ClearConversation => {
2135 let _ = evt_tx.send(InlineEvent::Submit("/clear --yes".into()));
2139 }
2140 ConfirmationAction::RemoveProviderKey(name) => {
2141 let _ = evt_tx.send(InlineEvent::Submit(
2145 format!("/providers remove {name} --yes").into(),
2146 ));
2147 }
2148 }
2149 }
2150 KeyCode::Char('n')
2151 | KeyCode::Char('N')
2152 | KeyCode::Char('x')
2153 | KeyCode::Char('X')
2154 | KeyCode::Esc => {
2155 s.confirmation = None;
2156 }
2157 _ => {}
2158 }
2159}
2160
2161fn handle_overlay_key(
2166 state: &Arc<parking_lot::Mutex<RenderState>>,
2167 evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
2168 code: KeyCode,
2169) -> bool {
2170 use oxicode_vtui::tui::core::{OverlayEvent, OverlaySubmission};
2171
2172 let mut s = state.lock();
2173 let Some(overlay) = s.overlay.as_mut() else {
2174 return false;
2175 };
2176
2177 match code {
2178 KeyCode::Esc => {
2179 drop(s);
2181 state.lock().overlay = None;
2182 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
2183 }
2184 KeyCode::Enter => {
2185 let submission = if let Some(item) = overlay.items.get(overlay.selected) {
2188 match item.selection.clone() {
2189 Some(sel) => sel,
2190 None => {
2191 return true;
2197 }
2198 }
2199 } else {
2200 drop(s);
2201 state.lock().overlay = None;
2202 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
2203 return true;
2204 };
2205 let title = overlay.title.clone();
2206 let selected = overlay.selected;
2207 drop(s);
2208 state.lock().overlay = None;
2209 tracing::debug!(
2210 overlay = %title,
2211 selected,
2212 "overlay submitted"
2213 );
2214 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Submitted(
2215 OverlaySubmission::Selection(submission),
2216 )));
2217 }
2218 KeyCode::Up => {
2219 let len = overlay_filtered_indices(overlay).len();
2220 if len == 0 {
2221 return true;
2222 }
2223 let pos = overlay_filtered_indices(overlay)
2224 .iter()
2225 .position(|&i| i == overlay.selected)
2226 .unwrap_or(0);
2227 let new_pos = if pos == 0 { len - 1 } else { pos - 1 };
2228 overlay.selected = overlay_filtered_indices(overlay)[new_pos];
2229 }
2230 KeyCode::Down => {
2231 let filtered = overlay_filtered_indices(overlay);
2232 let len = filtered.len();
2233 if len == 0 {
2234 return true;
2235 }
2236 let pos = filtered
2237 .iter()
2238 .position(|&i| i == overlay.selected)
2239 .unwrap_or(0);
2240 let new_pos = if pos + 1 >= len { 0 } else { pos + 1 };
2241 overlay.selected = filtered[new_pos];
2242 }
2243 KeyCode::Backspace => {
2244 if let Some(search) = overlay.search.as_mut() {
2245 search.value.pop();
2246 overlay.selected = 0;
2247 }
2248 }
2249 KeyCode::Char(ch) => {
2250 if let Some(search) = overlay.search.as_mut() {
2251 search.value.push(ch);
2252 overlay.selected = 0;
2253 }
2254 }
2255 _ => {
2256 }
2258 }
2259 true
2260}
2261
2262fn overlay_filtered_indices(overlay: &OverlayState) -> Vec<usize> {
2267 let needle = overlay
2268 .search
2269 .as_ref()
2270 .map(|s| s.value.to_lowercase())
2271 .unwrap_or_default();
2272 if needle.is_empty() {
2273 return (0..overlay.items.len()).collect();
2274 }
2275 overlay
2276 .items
2277 .iter()
2278 .enumerate()
2279 .filter_map(|(idx, item)| {
2280 let title_hit = item.title.to_lowercase().contains(&needle);
2281 let sv_hit = item
2282 .search_value
2283 .as_deref()
2284 .map(|v| v.to_lowercase().contains(&needle))
2285 .unwrap_or(false);
2286 if title_hit || sv_hit { Some(idx) } else { None }
2287 })
2288 .collect()
2289}
2290
2291fn handle_file_search_key(
2297 state: &Arc<parking_lot::Mutex<RenderState>>,
2298 _evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
2299 code: KeyCode,
2300) -> bool {
2301 match code {
2302 KeyCode::Up => {
2303 let mut s = state.lock();
2304 if let Some(fs) = s.file_search.as_mut() {
2305 fs.up();
2306 true
2307 } else {
2308 false
2309 }
2310 }
2311 KeyCode::Down => {
2312 let mut s = state.lock();
2313 if let Some(fs) = s.file_search.as_mut() {
2314 fs.down();
2315 true
2316 } else {
2317 false
2318 }
2319 }
2320 KeyCode::Tab | KeyCode::Enter => {
2321 let mut s = state.lock();
2322 if s.file_search
2323 .as_ref()
2324 .and_then(|fs| fs.selected_result())
2325 .is_some()
2326 {
2327 accept_file_search(&mut s, false);
2328 true
2329 } else {
2330 s.file_search = None;
2332 false
2333 }
2334 }
2335 KeyCode::Esc => {
2336 let mut s = state.lock();
2337 s.file_search = None;
2338 true
2339 }
2340 _ => false,
2341 }
2342}
2343
2344fn spawn_agent_worker(
2350 session: crate::app::agent_session::AgentSessionHandle,
2351) -> tokio::sync::mpsc::UnboundedSender<String> {
2352 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
2353
2354 std::thread::spawn(move || {
2355 let runtime = match tokio::runtime::Builder::new_current_thread()
2356 .enable_all()
2357 .build()
2358 {
2359 Ok(rt) => rt,
2360 Err(err) => {
2361 tracing::error!(?err, "failed to build agent worker runtime");
2362 return;
2363 }
2364 };
2365
2366 runtime.block_on(async move {
2367 let local = tokio::task::LocalSet::new();
2368 local
2369 .run_until(async move {
2370 while let Some(prompt) = prompt_rx.recv().await {
2371 run_one_prompt(&session, prompt).await;
2372 }
2373 })
2374 .await;
2375 });
2376 });
2377
2378 prompt_tx
2379}
2380
2381async fn run_one_prompt(session: &crate::app::agent_session::AgentSessionHandle, prompt: String) {
2382 let session_for_forward = session.clone();
2383 let (event_tx, event_rx) = std::sync::mpsc::channel::<AgentEvent>();
2384
2385 let forwarder = std::thread::spawn(move || {
2389 while let Ok(event) = event_rx.recv() {
2390 session_for_forward.forward_event_to_extensions(&event);
2391 }
2392 });
2393
2394 use std::sync::atomic::Ordering;
2398 session.reset_should_stop();
2399 let streaming = session.streaming_flag();
2400 streaming.store(true, Ordering::SeqCst);
2401 let _stream_guard = StreamingGuard(&streaming);
2402
2403 let agent = session.agent_ref();
2404 let local = tokio::task::LocalSet::new();
2405 let result = local
2406 .run_until(agent.run_with_channel(prompt, event_tx))
2407 .await;
2408
2409 let _ = forwarder.join();
2412 if let Err(err) = result {
2413 tracing::warn!(?err, "agent run failed");
2414 }
2415}
2416
2417fn build_header_context(
2426 app: &App,
2427 cwd: &std::path::Path,
2428 git_branch: Option<&str>,
2429) -> InlineHeaderContext {
2430 let workspace_name = cwd
2431 .file_name()
2432 .map(|n| n.to_string_lossy().into_owned())
2433 .unwrap_or_else(|| "oxicode".to_string());
2434 let model_id = app.model_id();
2435 let provider = model_id
2436 .split_once('/')
2437 .map(|(p, _)| p.to_string())
2438 .unwrap_or_else(|| "Provider".to_string());
2439 let branch = git_branch.unwrap_or("\u{2014}").to_string();
2440 let mut ctx = InlineHeaderContext::default();
2441 ctx.app_name = "oxicode".to_string();
2442 ctx.provider = provider;
2443 ctx.model = model_id.clone();
2444 ctx.git = format!("git: {workspace_name}@{branch}");
2445 ctx.tools = "Tools: ready".to_string();
2446 ctx.search_tools = Some(InlineHeaderStatusBadge {
2447 text: workspace_name,
2448 tone: InlineHeaderStatusTone::Ready,
2449 });
2450 ctx.persistent_memory = Some(InlineHeaderStatusBadge {
2451 text: branch,
2452 tone: InlineHeaderStatusTone::Ready,
2453 });
2454 ctx.editor_context = Some(model_id);
2455 ctx
2456}
2457
2458async fn build_agent_session(app: &App) -> Result<crate::app::agent_session::AgentSession> {
2461 use crate::app::agent_session_runtime::{
2462 CreateAgentSessionFromServicesOptions, CreateAgentSessionServicesOptions,
2463 create_agent_session_from_services, create_agent_session_services,
2464 };
2465 use crate::store::session::SessionManager;
2466
2467 let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
2468 let hook_runner = Arc::clone(&app.oxicode().ports().hooks);
2469 let services = create_agent_session_services(
2470 CreateAgentSessionServicesOptions::new(cwd.clone()),
2471 Some(hook_runner),
2472 )?;
2473 let services = Arc::new(services);
2474
2475 let model_id = app.model_id();
2476 let tools = app.agent_tools();
2477
2478 let session_manager = SessionManager::create(&cwd.to_string_lossy(), None);
2479
2480 let result = create_agent_session_from_services(CreateAgentSessionFromServicesOptions {
2481 services,
2482 session_manager,
2483 model_id: if model_id.is_empty() {
2484 None
2485 } else {
2486 Some(model_id)
2487 },
2488 thinking_level: None,
2489 scoped_models: Vec::new(),
2490 tool_registry: Some(tools),
2491 session_state: Some(app.session_state().clone()),
2494 })
2495 .await?;
2496
2497 if let Some(msg) = result.model_fallback_message {
2498 tracing::warn!(message = %msg, "agent session model fallback");
2499 }
2500 Ok(result.session)
2501}
2502
2503fn cheatsheet_lines() -> Vec<String> {
2509 vec![
2510 "".into(),
2511 " Navigation".into(),
2512 " j / ↓ Scroll down".into(),
2513 " k / ↑ Scroll up".into(),
2514 " J (Shift+j) Next turn".into(),
2515 " K (Shift+k) Previous turn".into(),
2516 " PgDn / PgUp Page scroll".into(),
2517 " g / G Top / bottom".into(),
2518 "".into(),
2519 " Blocks".into(),
2520 " e Cycle block (collapse/truncate/expand)".into(),
2521 " E Expand all blocks".into(),
2522 " Ctrl+E Collapse all blocks".into(),
2523 "".into(),
2524 " Search".into(),
2525 " /find <q> Search transcript".into(),
2526 " n / N Next / previous match".into(),
2527 "".into(),
2528 " Commands".into(),
2529 " /theme Cycle color theme".into(),
2530 " /model Pick a model".into(),
2531 " /vim Toggle vim mode".into(),
2532 " /compact Compact context".into(),
2533 " /clear Clear conversation".into(),
2534 " Ctrl+C Cancel run (then y to quit)".into(),
2535 " Ctrl+Enter Send now (abort + submit)".into(),
2536 " Ctrl+M Toggle multiline input".into(),
2537 " Shift+Tab Toggle Auto mode (no questions, runs to end)".into(),
2538 " Ctrl+; Toggle queue panel".into(),
2539 "".into(),
2540 " Special Input".into(),
2541 " @ File picker (fuzzy search)".into(),
2542 " @! Toggle hidden files in picker".into(),
2543 " ! Shell mode (bash command)".into(),
2544 ]
2545}
2546
2547fn build_command_palette() -> OverlayState {
2550 use oxicode_vtui::tui::core::{InlineListItem, InlineListSelection};
2551
2552 let catalog = SlashRegistry::builtin_commands();
2553 let mut items: Vec<InlineListItem> = catalog
2554 .iter()
2555 .map(|(name, desc, aliases)| {
2556 let title = if aliases.is_empty() {
2557 format!("/{name}")
2558 } else {
2559 format!(
2560 "/{name} ({})",
2561 aliases
2562 .iter()
2563 .map(|a| format!("/{a}"))
2564 .collect::<Vec<_>>()
2565 .join(", ")
2566 )
2567 };
2568 InlineListItem {
2569 title,
2570 subtitle: Some(desc.to_string()),
2571 badge: None,
2572 indent: 0,
2573 selection: Some(InlineListSelection::SlashCommand(name.to_string())),
2574 search_value: Some(format!("{name} {desc}")),
2575 }
2576 })
2577 .collect();
2578 items.sort_by(|a, b| a.title.cmp(&b.title));
2579
2580 OverlayState {
2581 title: "Command Palette".into(),
2582 lines: vec!["Type to filter, Enter to select".into()],
2583 items: items
2584 .into_iter()
2585 .map(|item| OverlayListItem {
2586 title: item.title,
2587 subtitle: item.subtitle,
2588 badge: item.badge,
2589 indent: item.indent,
2590 search_value: item.search_value,
2591 selection: item.selection,
2592 })
2593 .collect(),
2594 selected: 0,
2595 search: Some(OverlaySearchState {
2596 label: "search".into(),
2597 placeholder: Some("filter commands\u{2026}".into()),
2598 value: String::new(),
2599 }),
2600 }
2601}
2602
2603static FRAME_TICK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2605static TITLE_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
2607const TITLE_SPINNER: &[&str] = &[
2609 "\u{2807}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}", "\u{2827}",
2610];
2611
2612fn wave_brightness(tick: u64, row: u16, wave_rows: u16, speed: f64) -> f64 {
2615 let phase =
2616 (tick as f64 * speed) + (row as f64 / wave_rows.max(1) as f64) * std::f64::consts::TAU;
2617 let s = phase.sin();
2618 s * s
2619}
2620
2621fn blend_rgb(base: Color, target: Color, ratio: f64) -> Color {
2623 match (base, target) {
2624 (Color::Rgb(br, bg, bb), Color::Rgb(tr, tg, tb)) => {
2625 let r = (br as f64 + (tr as f64 - br as f64) * ratio).round() as u8;
2626 let g = (bg as f64 + (tg as f64 - bg as f64) * ratio).round() as u8;
2627 let b = (bb as f64 + (tb as f64 - bb as f64) * ratio).round() as u8;
2628 Color::Rgb(r, g, b)
2629 }
2630 _ => base,
2631 }
2632}
2633
2634fn accent_color_for_kind(kind: InlineMessageKind, styles: &ThemeStyles) -> Color {
2636 match kind {
2637 InlineMessageKind::User => color_from_anstyle(styles.primary.get_fg_color()),
2638 InlineMessageKind::Agent => color_from_anstyle(styles.response.get_fg_color()),
2639 InlineMessageKind::Tool => color_from_anstyle(styles.tool.get_fg_color()),
2640 InlineMessageKind::Error => color_from_anstyle(styles.error.get_fg_color()),
2641 InlineMessageKind::Warning => color_from_anstyle(styles.status.get_fg_color()),
2642 InlineMessageKind::Info => color_from_anstyle(styles.info.get_fg_color()),
2643 InlineMessageKind::Policy => color_from_anstyle(styles.mcp.get_fg_color()),
2644 InlineMessageKind::Pty => color_from_anstyle(styles.pty_output.get_fg_color()),
2645 }
2646}
2647
2648fn render_frame(frame: &mut Frame<'_>, state: &RenderState, _handle: &InlineHandle) {
2654 let area = frame.area();
2655 let bg = active_styles().background;
2660 frame
2661 .buffer_mut()
2662 .set_style(area, Style::default().bg(color_from_anstyle(Some(bg))));
2663 let layout = super::frame_layout::render_chrome(frame, area, state);
2664 let tick = FRAME_TICK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2665 {
2667 let running = state.reasoning_stage.is_some();
2668 let was_running = TITLE_RUNNING.swap(running, std::sync::atomic::Ordering::Relaxed);
2669 if running || was_running {
2670 let title = if running {
2671 let spin = TITLE_SPINNER[(tick as usize) % TITLE_SPINNER.len()];
2672 let model = state
2673 .header_context
2674 .editor_context
2675 .as_deref()
2676 .unwrap_or("oxicode");
2677 format!("{spin} oxicode \u{2014} {model}")
2678 } else {
2679 "oxicode".to_string()
2680 };
2681 use std::io::Write;
2682 let _ = write!(std::io::stderr(), "\x1b]2;{}\x07", title);
2683 let _ = std::io::stderr().flush();
2684 }
2685 }
2686 render_transcript(frame, layout.scrollback, state, tick);
2687 if !state.queued_inputs.is_empty() {
2688 render_queue_pane(frame, layout.scrollback, state);
2689 }
2690 if !state.todo_items.is_empty() {
2691 render_todo_pane(frame, layout.scrollback, &state.todo_items);
2692 }
2693 if !state.follow_ups.is_empty() {
2694 render_follow_ups(frame, layout.prompt, &state.follow_ups);
2695 }
2696 if let Some(stage) = &state.reasoning_stage {
2697 render_reasoning_indicator(frame, layout.prompt, stage);
2698 }
2699 render_composer(frame, layout.prompt, state);
2700 let occluded = state.overlay.is_some() || state.confirmation.is_some();
2702 if let Some(tip) = &state.tip
2703 && tip_is_visible(tip, tick)
2704 && !(tip.ambient && occluded)
2705 {
2706 render_tip(frame, layout.prompt, &tip.text);
2707 }
2708 if state.slash_popup.open {
2709 render_slash_popup(frame, layout.prompt, state);
2710 }
2711 if state.file_search.is_some() {
2712 render_file_search_dropdown(frame, layout.prompt, state);
2713 }
2714 if state.agent_hub_open {
2715 render_agent_hub(frame, area, state);
2716 }
2717 if let Some(overlay) = &state.overlay {
2718 render_overlay(frame, area, overlay);
2719 }
2720 if let Some(confirm) = &state.confirmation {
2721 render_confirmation(frame, area, confirm);
2722 }
2723}
2724
2725fn render_confirmation(frame: &mut Frame, area: Rect, confirm: &ModalConfirmation) {
2727 let styles = active_styles();
2728 let accent = color_from_anstyle(styles.error.get_fg_color());
2729 let inner_w = confirm
2730 .title
2731 .chars()
2732 .count()
2733 .max(confirm.message.chars().count())
2734 .max(36) as u16;
2735 let width = inner_w + 4;
2736 let height = 5;
2737 let x = area.x + area.width.saturating_sub(width) / 2;
2738 let y = area.y + area.height.saturating_sub(height) / 2;
2739 let popup_area = Rect {
2740 x,
2741 y,
2742 width,
2743 height,
2744 };
2745 let block = Block::default()
2746 .borders(Borders::ALL)
2747 .border_type(BorderType::Rounded)
2748 .title(Span::styled(
2749 format!(" {} ", confirm.title),
2750 Style::default().fg(accent).bold(),
2751 ))
2752 .border_style(Style::default().fg(accent));
2753 let msg = Line::styled(
2754 confirm.message.clone(),
2755 Style::default().fg(color_from_anstyle(Some(styles.foreground))),
2756 );
2757 frame.render_widget(
2758 Paragraph::new(vec![Line::default(), msg]).block(block),
2759 popup_area,
2760 );
2761}
2762
2763fn render_agent_hub(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
2767 let rows = state.hub_entries.len() as u16;
2768 let height = rows.saturating_add(4).min(area.height.saturating_sub(1));
2769 let width = area.width.clamp(30, 80);
2770 let rect = Rect {
2771 x: area.x + (area.width.saturating_sub(width)) / 2,
2772 y: area.y + (area.height.saturating_sub(height)) / 2,
2773 width,
2774 height,
2775 };
2776 frame.render_widget(Clear, rect);
2777
2778 let title = Line::from(Span::styled(
2779 " Agent Hub ",
2780 Style::default().add_modifier(Modifier::BOLD),
2781 ));
2782 let block = Block::default().borders(Borders::ALL).title(title);
2783
2784 let items: Vec<ListItem<'_>> = if state.hub_entries.is_empty() {
2785 vec![ListItem::new(Line::from(Span::raw(
2786 "No agents registered.",
2787 )))]
2788 } else {
2789 state
2790 .hub_entries
2791 .iter()
2792 .map(|(id, e)| {
2793 ListItem::new(Line::from(vec![
2794 Span::raw(format!("{:?} ", e.kind)),
2795 Span::raw(e.display_name.clone()),
2796 Span::raw(format!(" — {:?} ({})", e.status, id)),
2797 ]))
2798 })
2799 .collect()
2800 };
2801 frame.render_widget(List::new(items).block(block), rect);
2802}
2803
2804fn render_overlay(frame: &mut Frame<'_>, area: Rect, overlay: &OverlayState) {
2809 let styles = active_styles();
2810 let visible_max = (area.height as usize).saturating_sub(6).max(3);
2811
2812 let filtered: Vec<usize> = match &overlay.search {
2814 Some(search) if !search.value.is_empty() => {
2815 let needle = search.value.to_lowercase();
2816 overlay
2817 .items
2818 .iter()
2819 .enumerate()
2820 .filter_map(|(idx, item)| {
2821 let title_match = item.title.to_lowercase().contains(&needle);
2822 let sv_match = item
2823 .search_value
2824 .as_deref()
2825 .map(|v| v.to_lowercase().contains(&needle))
2826 .unwrap_or(false);
2827 if title_match || sv_match {
2828 Some(idx)
2829 } else {
2830 None
2831 }
2832 })
2833 .collect()
2834 }
2835 _ => (0..overlay.items.len()).collect(),
2836 };
2837
2838 let has_search = overlay.search.is_some();
2839 let lines_count = overlay.lines.len();
2840 let items_count = filtered.len().min(visible_max);
2841 let height_inner = (lines_count + items_count + if has_search { 1 } else { 0 }) as u16;
2842 let desired_h = height_inner.saturating_add(2); let height = desired_h.min(area.height.saturating_sub(2));
2844 let width = area.width.clamp(30, 80);
2845 let rect = Rect {
2846 x: area.x + (area.width.saturating_sub(width)) / 2,
2847 y: area.y + (area.height.saturating_sub(height)) / 2,
2848 width,
2849 height,
2850 };
2851 frame.render_widget(Clear, rect);
2852
2853 let title = Line::from(Span::styled(
2854 format!(" {} ", overlay.title),
2855 Style::default()
2856 .fg(color_from_anstyle(styles.primary.get_fg_color()))
2857 .add_modifier(Modifier::BOLD),
2858 ));
2859 let block = Block::default()
2860 .borders(Borders::ALL)
2861 .border_type(BorderType::Rounded)
2862 .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
2863 .title(title);
2864 let inner = block.inner(rect);
2865 frame.render_widget(&block, rect);
2866
2867 let primary = color_from_anstyle(styles.primary.get_fg_color());
2868 let fg = color_from_anstyle(Some(styles.foreground));
2869 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
2870
2871 let selected_filtered_pos = filtered
2873 .iter()
2874 .position(|&idx| idx == overlay.selected)
2875 .unwrap_or(0);
2876
2877 let mut row = inner.top();
2878 if let Some(search) = &overlay.search {
2880 let prompt = format!("{}: {}", search.label, search.value);
2881 let line = Line::from(vec![
2882 Span::styled(
2883 format!("{}: ", search.label),
2884 Style::default().fg(secondary),
2885 ),
2886 Span::styled(
2887 if search.value.is_empty() {
2888 search
2889 .placeholder
2890 .clone()
2891 .unwrap_or_else(|| "type to filter\u{2026}".to_string())
2892 } else {
2893 search.value.clone()
2894 },
2895 if search.value.is_empty() {
2896 Style::default().fg(secondary).add_modifier(Modifier::DIM)
2897 } else {
2898 Style::default().fg(fg)
2899 },
2900 ),
2901 ]);
2902 let _ = prompt; let row_area = Rect {
2904 x: inner.left(),
2905 y: row,
2906 width: inner.width,
2907 height: 1,
2908 };
2909 frame.render_widget(Paragraph::new(line), row_area);
2910 row = row.saturating_add(1);
2911 }
2912
2913 for line_text in &overlay.lines {
2915 let row_area = Rect {
2916 x: inner.left(),
2917 y: row,
2918 width: inner.width,
2919 height: 1,
2920 };
2921 let line = Line::from(Span::styled(
2922 line_text.clone(),
2923 Style::default().fg(secondary),
2924 ));
2925 frame.render_widget(Paragraph::new(line), row_area);
2926 row = row.saturating_add(1);
2927 }
2928
2929 if filtered.is_empty() {
2931 let row_area = Rect {
2932 x: inner.left(),
2933 y: row,
2934 width: inner.width,
2935 height: 1,
2936 };
2937 let empty_text = if overlay.search.is_some() {
2938 " (no matches)"
2939 } else {
2940 " (no items)"
2941 };
2942 frame.render_widget(
2943 Paragraph::new(Line::from(Span::styled(
2944 empty_text,
2945 Style::default().fg(secondary).add_modifier(Modifier::DIM),
2946 ))),
2947 row_area,
2948 );
2949 } else {
2950 for (display_idx, &item_idx) in filtered.iter().take(visible_max).enumerate() {
2951 let item = &overlay.items[item_idx];
2952 let is_selected = display_idx == selected_filtered_pos;
2953 let marker = if is_selected { "\u{25b8} " } else { " " };
2954 let indent = " ".repeat(item.indent as usize);
2955 let item_style = if is_selected {
2956 Style::default().fg(primary).add_modifier(Modifier::BOLD)
2957 } else {
2958 Style::default().fg(fg)
2959 };
2960 let mut spans = vec![
2961 Span::styled(marker, item_style),
2962 Span::styled(indent, item_style),
2963 Span::styled(item.title.clone(), item_style),
2964 ];
2965 if let Some(badge) = &item.badge {
2966 spans.push(Span::raw(" "));
2967 spans.push(Span::styled(
2968 badge.clone(),
2969 Style::default().fg(secondary).add_modifier(Modifier::DIM),
2970 ));
2971 }
2972 if let Some(subtitle) = &item.subtitle {
2973 spans.push(Span::raw(" "));
2974 spans.push(Span::styled(
2975 subtitle.clone(),
2976 Style::default().fg(secondary),
2977 ));
2978 }
2979 let line = Line::from(spans);
2980 let row_area = Rect {
2981 x: inner.left(),
2982 y: row,
2983 width: inner.width,
2984 height: 1,
2985 };
2986 frame.render_widget(Paragraph::new(line), row_area);
2987 row = row.saturating_add(1);
2988 }
2989 }
2990}
2991
2992fn render_transcript(frame: &mut Frame<'_>, area: Rect, state: &RenderState, tick: u64) {
2993 if state.transcript.is_empty() {
2994 render_welcome(frame, area);
2995 return;
2996 }
2997 let styles = active_styles();
2998 let bg_color = color_from_anstyle(Some(styles.background));
2999
3000 let accent_w: u16 = 1;
3002 let scrollbar_w: u16 = 1;
3003 let content_area = Rect {
3004 x: area.x + accent_w,
3005 y: area.y,
3006 width: area.width.saturating_sub(accent_w + scrollbar_w),
3007 height: area.height,
3008 };
3009
3010 let search_set: std::collections::HashSet<usize> = state
3013 .search
3014 .as_ref()
3015 .map(|s| s.matches.iter().copied().collect())
3016 .unwrap_or_default();
3017 let current_match = state
3018 .search
3019 .as_ref()
3020 .and_then(|s| (!s.matches.is_empty()).then(|| s.matches[s.current]));
3021
3022 let mut display: Vec<(usize, InlineMessageKind, Line<'_>)> =
3023 Vec::with_capacity(state.transcript.len());
3024 const TRUNC_TAIL: usize = 3;
3029 let dim_style = Style::default()
3030 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
3031 .add_modifier(Modifier::DIM);
3032
3033 let mut blocks: Vec<(usize, Vec<(usize, &TranscriptLine)>)> = Vec::new();
3034 for (idx, tl) in state.transcript.iter().enumerate() {
3035 if blocks.last().is_some_and(|(id, _)| *id == tl.block_id) {
3036 blocks.last_mut().unwrap().1.push((idx, tl));
3037 } else {
3038 blocks.push((tl.block_id, vec![(idx, tl)]));
3039 }
3040 }
3041
3042 for (block_id, lines) in &blocks {
3043 let mode = state.block_mode(*block_id);
3044 let len = lines.len();
3045 match mode {
3046 BlockDisplayMode::Collapsed => {
3047 let &(idx, tl) = &lines[0];
3048 let is_match = search_set.contains(&idx);
3049 let line =
3050 transcript_line_marked(tl, &styles, true, is_match, current_match == Some(idx));
3051 display.push((idx, tl.kind, line));
3052 }
3053 BlockDisplayMode::Expanded => {
3054 for &(idx, tl) in lines {
3055 let is_match = search_set.contains(&idx);
3056 let line = transcript_line_marked(
3057 tl,
3058 &styles,
3059 false,
3060 is_match,
3061 current_match == Some(idx),
3062 );
3063 display.push((idx, tl.kind, line));
3064 }
3065 }
3066 BlockDisplayMode::Truncated => {
3067 if len <= TRUNC_TAIL + 1 {
3068 for &(idx, tl) in lines {
3070 let is_match = search_set.contains(&idx);
3071 let line = transcript_line_marked(
3072 tl,
3073 &styles,
3074 false,
3075 is_match,
3076 current_match == Some(idx),
3077 );
3078 display.push((idx, tl.kind, line));
3079 }
3080 } else {
3081 let &(hidx, htl) = &lines[0];
3083 let is_match = search_set.contains(&hidx);
3084 let line = transcript_line_marked(
3085 htl,
3086 &styles,
3087 false,
3088 is_match,
3089 current_match == Some(hidx),
3090 );
3091 display.push((hidx, htl.kind, line));
3092 let hidden = len - 1 - TRUNC_TAIL;
3094 let gap = Line::styled(format!(" \u{2026} +{hidden} lines"), dim_style);
3095 display.push((hidx, htl.kind, gap));
3096 for &(idx, tl) in lines.iter().rev().take(TRUNC_TAIL).rev() {
3098 let is_match = search_set.contains(&idx);
3099 let line = transcript_line_marked(
3100 tl,
3101 &styles,
3102 false,
3103 is_match,
3104 current_match == Some(idx),
3105 );
3106 display.push((idx, tl.kind, line));
3107 }
3108 }
3109 }
3110 }
3111 }
3112
3113 let total = display.len();
3115 let raw_start = if state.scroll_offset == usize::MAX {
3116 total.saturating_sub(content_area.height as usize)
3117 } else {
3118 display
3119 .iter()
3120 .position(|(orig_idx, _, _)| *orig_idx >= state.scroll_offset)
3121 .unwrap_or(total.saturating_sub(1))
3122 };
3123 let start = effective_scroll_offset(raw_start, total, content_area.height as usize);
3124
3125 let sticky_first: Option<usize> = display.get(start).and_then(|(orig_idx, _, _)| {
3129 let bid = state.transcript.get(*orig_idx)?.block_id;
3130 let first_idx = state.transcript.iter().position(|l| l.block_id == bid)?;
3131 (first_idx != *orig_idx).then_some(first_idx)
3132 });
3133 let sticky_h: u16 = if sticky_first.is_some() { 1 } else { 0 };
3134 let body_top = content_area.top() + sticky_h;
3135
3136 let running = state.reasoning_stage.is_some();
3138 const WAVE_ROWS: u16 = 32;
3139 const WAVE_SPEED: f64 = 0.15;
3140
3141 const FADE_ROWS: usize = 5;
3146 let sticky_opacity: f64 = if let Some(sidx) = sticky_first {
3147 let sticky_bid = state.transcript[sidx].block_id;
3148 let next_offset = display.iter().skip(start).position(|(orig_idx, _, _)| {
3151 state
3152 .transcript
3153 .get(*orig_idx)
3154 .map(|l| l.block_id != sticky_bid)
3155 .unwrap_or(false)
3156 });
3157 match next_offset {
3158 Some(off) if off <= FADE_ROWS => off as f64 / FADE_ROWS as f64,
3159 _ => 1.0,
3160 }
3161 } else {
3162 1.0
3163 };
3164
3165 if let Some(sidx) = sticky_first {
3168 let tl = &state.transcript[sidx];
3169 let accent_base = accent_color_for_kind(tl.kind, &styles);
3170 let rail_blend = 0.7 * sticky_opacity;
3171 let bg_blend = 0.1 * sticky_opacity;
3172 if sticky_opacity > 0.05
3173 && let Some(cell) = frame.buffer_mut().cell_mut((area.x, content_area.top()))
3174 {
3175 cell.set_char('\u{2503}');
3176 cell.set_style(Style::default().fg(blend_rgb(bg_color, accent_base, rail_blend)));
3177 }
3178 let line = transcript_line_marked(tl, &styles, false, false, false);
3179 let row = Rect {
3180 x: content_area.x,
3181 y: content_area.top(),
3182 width: content_area.width,
3183 height: 1,
3184 };
3185 if bg_blend > 0.01 {
3186 frame.buffer_mut().set_style(
3187 row,
3188 Style::default().bg(blend_rgb(bg_color, accent_base, bg_blend)),
3189 );
3190 }
3191 frame.render_widget(Paragraph::new(line), row);
3192 }
3193
3194 let mut y = body_top;
3196 let width = content_area.width.max(1) as usize;
3197 let mut visual_row: u16 = 0;
3198 for (_, kind, line) in display.into_iter().skip(start) {
3199 if y >= content_area.bottom() {
3200 break;
3201 }
3202 let text_w = line.width();
3203 let wrapped_h = if text_w == 0 {
3204 1
3205 } else {
3206 text_w.div_ceil(width).max(1) as u16
3207 };
3208
3209 let accent_base = accent_color_for_kind(kind, &styles);
3211 for row_offset in 0..wrapped_h {
3212 let paint_y = y + row_offset;
3213 if paint_y >= content_area.bottom() {
3214 break;
3215 }
3216 let brightness = if running {
3217 0.4 + 0.6 * wave_brightness(tick, visual_row + row_offset, WAVE_ROWS, WAVE_SPEED)
3218 } else {
3219 0.7
3220 };
3221 let rail_color = blend_rgb(bg_color, accent_base, brightness);
3222 if let Some(cell) = frame.buffer_mut().cell_mut((area.x, paint_y)) {
3223 cell.set_char('\u{2503}'); cell.set_style(Style::default().fg(rail_color));
3225 }
3226 }
3227
3228 let row = Rect {
3229 x: content_area.x,
3230 y,
3231 width: content_area.width,
3232 height: wrapped_h.min(content_area.bottom().saturating_sub(y)),
3233 };
3234 frame.render_widget(Paragraph::new(line).wrap(Wrap { trim: false }), row);
3235 y += wrapped_h;
3236 visual_row += wrapped_h;
3237 }
3238
3239 let body_viewport = (content_area.height as usize).saturating_sub(sticky_h as usize);
3242 if total > body_viewport {
3243 let follow = state.scroll_offset == usize::MAX;
3244 render_scrollbar(
3245 frame,
3246 area.right().saturating_sub(1),
3247 area.top(),
3248 area.height,
3249 total,
3250 body_viewport,
3251 start,
3252 follow,
3253 &styles,
3254 bg_color,
3255 );
3256 }
3257}
3258
3259#[allow(clippy::too_many_arguments)]
3264fn render_scrollbar(
3265 frame: &mut Frame,
3266 x: u16,
3267 top: u16,
3268 height: u16,
3269 total: usize,
3270 viewport: usize,
3271 start: usize,
3272 follow: bool,
3273 styles: &ThemeStyles,
3274 bg: Color,
3275) {
3276 if height == 0 {
3277 return;
3278 }
3279 let ratio = (start as f64 / total.max(1) as f64).clamp(0.0, 1.0);
3280 let thumb_h = (((viewport as f64 / total.max(1) as f64) * height as f64).ceil() as u16)
3281 .max(1)
3282 .min(height);
3283 let track_h = height.saturating_sub(thumb_h);
3284 let thumb_y = (ratio * track_h as f64).round() as u16;
3285
3286 let accent = color_from_anstyle(styles.primary.get_fg_color());
3287 let thumb_color = if follow {
3289 blend_rgb(bg, accent, 0.35)
3290 } else {
3291 accent
3292 };
3293 let rail_color = blend_rgb(bg, accent, 0.1);
3294
3295 for row in 0..height {
3296 let y = top + row;
3297 let is_thumb = row >= thumb_y && row < thumb_y + thumb_h;
3298 let (ch, color) = if is_thumb {
3299 ('\u{2588}', thumb_color) } else {
3301 ('\u{2502}', rail_color) };
3303 if let Some(cell) = frame.buffer_mut().cell_mut((x, y)) {
3304 cell.set_char(ch);
3305 cell.set_style(Style::default().fg(color));
3306 }
3307 }
3308}
3309
3310fn transcript_line_marked<'a>(
3313 line: &'a TranscriptLine,
3314 styles: &'a ThemeStyles,
3315 folded: bool,
3316 is_match: bool,
3317 is_current: bool,
3318) -> Line<'a> {
3319 let (kind_style, marker) = match line.kind {
3320 InlineMessageKind::Agent => (
3321 Style::default().fg(color_from_anstyle(styles.response.get_fg_color())),
3322 "\u{25cf}", ),
3324 InlineMessageKind::User => (
3325 Style::default().fg(color_from_anstyle(styles.primary.get_fg_color())),
3326 "\u{276f}", ),
3328 InlineMessageKind::Tool => (
3329 Style::default().fg(color_from_anstyle(styles.tool.get_fg_color())),
3330 "\u{2699}", ),
3332 InlineMessageKind::Error => (
3333 Style::default().fg(color_from_anstyle(styles.error.get_fg_color())),
3334 "\u{2717}", ),
3336 InlineMessageKind::Warning => (
3337 Style::default().fg(color_from_anstyle(styles.status.get_fg_color())),
3338 "\u{26a0}", ),
3340 InlineMessageKind::Info => (
3341 Style::default().fg(color_from_anstyle(styles.info.get_fg_color())),
3342 "\u{2139}", ),
3344 InlineMessageKind::Policy => (
3345 Style::default().fg(color_from_anstyle(styles.mcp.get_fg_color())),
3346 "\u{25c6}", ),
3348 InlineMessageKind::Pty => (
3349 Style::default().fg(color_from_anstyle(styles.pty_output.get_fg_color())),
3350 "\u{258c}", ),
3352 };
3353
3354 let prefix = if folded {
3356 format!("\u{25b8} {} ", marker) } else {
3358 format!("{} ", marker)
3359 };
3360
3361 let highlight = if is_current {
3363 Some(Style::default().reversed())
3364 } else if is_match {
3365 Some(Style::default().add_modifier(Modifier::UNDERLINED))
3366 } else {
3367 None
3368 };
3369
3370 let mut spans = Vec::with_capacity(line.segments.len() + 1);
3371 spans.push(Span::styled(prefix, kind_style));
3372 for segment in &line.segments {
3373 let mut style = segment_style(segment, kind_style, styles);
3374 if let Some(h) = highlight {
3375 style = style.patch(h);
3376 }
3377 spans.push(Span::styled(segment.text.clone(), style));
3378 }
3379 Line::from(spans)
3380}
3381
3382fn segment_style(segment: &InlineSegment, fallback: Style, styles: &ThemeStyles) -> Style {
3383 let mut style = fallback;
3384 let inline = segment.style.as_ref();
3385 if let Some(color) = inline.color {
3386 style = style.fg(color_from_anstyle(Some(color)));
3387 } else {
3388 style = style.fg(color_from_anstyle(styles.response.get_fg_color()));
3392 }
3393 if inline.effects.contains(anstyle::Effects::BOLD) {
3394 style = style.add_modifier(Modifier::BOLD);
3395 }
3396 if inline.effects.contains(anstyle::Effects::ITALIC) {
3397 style = style.add_modifier(Modifier::ITALIC);
3398 }
3399 if inline.effects.contains(anstyle::Effects::UNDERLINE) {
3400 style = style.add_modifier(Modifier::UNDERLINED);
3401 }
3402 if inline.effects.contains(anstyle::Effects::DIMMED) {
3403 style = style.add_modifier(Modifier::DIM);
3404 }
3405 style
3406}
3407
3408fn render_composer(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
3409 let styles = active_styles();
3410 let prefix_style = Style::default()
3411 .fg(color_from_anstyle(styles.primary.get_fg_color()))
3412 .bold();
3413 let text_style = Style::default().fg(color_from_anstyle(Some(styles.foreground)));
3414
3415 let prefix = state.prompt_prefix.clone();
3416 let body = state.input_buffer.clone();
3417 let placeholder = state.placeholder.clone();
3418
3419 let mut line_spans = Vec::new();
3420 if let Some(label) = state.vim_state.status_label() {
3421 line_spans.push(Span::styled(
3422 format!("[{label}] "),
3423 Style::default()
3424 .fg(color_from_anstyle(styles.tool.get_fg_color()))
3425 .add_modifier(Modifier::BOLD),
3426 ));
3427 }
3428 if state.autonomy_mode.is_auto() {
3429 line_spans.push(Span::styled(
3430 "[auto] ",
3431 Style::default()
3432 .fg(Color::Yellow)
3433 .add_modifier(Modifier::BOLD),
3434 ));
3435 }
3436 line_spans.push(Span::styled(prefix, prefix_style));
3437 if state.shell_mode {
3438 line_spans.push(Span::styled(
3439 "! ",
3440 Style::default()
3441 .fg(Color::Yellow)
3442 .add_modifier(Modifier::BOLD),
3443 ));
3444 }
3445 if body.is_empty()
3446 && let Some(ph) = placeholder
3447 {
3448 line_spans.push(Span::styled(
3449 ph,
3450 Style::default()
3451 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
3452 .dim(),
3453 ));
3454 } else {
3455 line_spans.push(Span::styled(body, text_style));
3456 }
3457 let block = Block::default()
3458 .borders(Borders::ALL)
3459 .border_type(BorderType::Rounded)
3460 .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())));
3461 let paragraph = Paragraph::new(Line::from(line_spans))
3462 .block(block)
3463 .wrap(Wrap { trim: false });
3464 frame.render_widget(paragraph, area);
3465
3466 if state.input_enabled {
3469 let vim_off = state
3470 .vim_state
3471 .status_label()
3472 .map(|l| format!("[{l}] ").chars().count() as u16)
3473 .unwrap_or(0);
3474 let shell_off = if state.shell_mode { 2 } else { 0 };
3475 let mode_off = if state.autonomy_mode.is_auto() { 7 } else { 0 };
3476 let cursor_x = area.left()
3477 + 1
3478 + vim_off
3479 + mode_off
3480 + shell_off
3481 + state.prompt_prefix.chars().count() as u16
3482 + state.input_cursor as u16;
3483 let cursor_y = area.top() + 1;
3484 frame.set_cursor_position(ratatui::layout::Position::new(cursor_x, cursor_y));
3485 }
3486}
3487
3488fn render_welcome(frame: &mut Frame<'_>, area: Rect) {
3491 let styles = active_styles();
3492 let primary = color_from_anstyle(styles.primary.get_fg_color());
3493 let fg = color_from_anstyle(Some(styles.foreground));
3494 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
3495
3496 if area.width >= 90 {
3499 use oxicode_vtui::design::layout::WelcomeLayout;
3500 let layout = WelcomeLayout::compute(area, 3, 0, 0, 1, 0, false);
3501 let logo_area = if layout.has_hero_box() {
3502 layout.hero_logo
3503 } else {
3504 layout.logo
3505 };
3506 if logo_area.height > 0 {
3507 frame.render_widget(
3508 Paragraph::new(Line::from(Span::styled(
3509 "\u{25cf} oxicode",
3510 Style::default().fg(primary).add_modifier(Modifier::BOLD),
3511 )))
3512 .alignment(Alignment::Center),
3513 logo_area,
3514 );
3515 }
3516 if layout.tip.height > 0 {
3517 frame.render_widget(
3518 Paragraph::new(Line::from(Span::styled(
3519 "Type a message to begin, or press / for commands.",
3520 Style::default().fg(fg),
3521 )))
3522 .alignment(Alignment::Center),
3523 layout.tip,
3524 );
3525 }
3526 if layout.version.height > 0 {
3527 frame.render_widget(
3528 Paragraph::new(Line::from(Span::styled(
3529 format!("v{}", env!("CARGO_PKG_VERSION")),
3530 Style::default().fg(secondary).add_modifier(Modifier::DIM),
3531 )))
3532 .alignment(Alignment::Center),
3533 layout.version,
3534 );
3535 }
3536 return;
3537 }
3538
3539 let version = env!("CARGO_PKG_VERSION");
3541 let text = vec![
3542 Line::from(""),
3543 Line::from(""),
3544 Line::from(Span::styled(
3545 "\u{25cf} oxicode",
3546 Style::default().fg(primary).add_modifier(Modifier::BOLD),
3547 )),
3548 Line::from(""),
3549 Line::from(Span::styled(
3550 "Type a message to begin, or press / for commands.",
3551 Style::default().fg(fg),
3552 )),
3553 Line::from(Span::styled(
3554 format!("v{version} \u{2014} /help for commands"),
3555 Style::default().fg(secondary),
3556 )),
3557 ];
3558 frame.render_widget(Paragraph::new(text).alignment(Alignment::Center), area);
3559}
3560
3561fn render_reasoning_indicator(frame: &mut Frame<'_>, composer_area: Rect, stage: &str) {
3563 let styles = active_styles();
3564 let indicator_area = Rect {
3565 x: composer_area.x,
3566 y: composer_area.top().saturating_sub(1),
3567 width: composer_area.width,
3568 height: 1,
3569 };
3570 let spinner = "\u{25cc}"; let line = Line::from(vec![
3572 Span::styled(
3573 format!("{spinner} "),
3574 Style::default().fg(color_from_anstyle(styles.tool.get_fg_color())),
3575 ),
3576 Span::styled(
3577 stage.to_string(),
3578 Style::default()
3579 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
3580 .add_modifier(Modifier::DIM),
3581 ),
3582 ]);
3583 frame.render_widget(Paragraph::new(line), indicator_area);
3584}
3585
3586fn render_queue_pane(frame: &mut Frame<'_>, scrollback: Rect, state: &RenderState) {
3588 let styles = active_styles();
3589 let entries = &state.queued_inputs;
3590 let interactive = state.queue_panel_open;
3591 let selected = state.queue_selected.min(entries.len().saturating_sub(1));
3592 let height = entries.len() as u16 + 1;
3593 let area = Rect {
3594 x: scrollback.x,
3595 y: scrollback.y,
3596 width: scrollback.width,
3597 height,
3598 };
3599 let info = color_from_anstyle(styles.info.get_fg_color());
3600 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
3601 let primary = color_from_anstyle(styles.primary.get_fg_color());
3602 let items: Vec<Line<'_>> = entries
3603 .iter()
3604 .enumerate()
3605 .map(|(i, e)| {
3606 let prefix = if interactive {
3607 format!("#{} ", i + 1)
3608 } else {
3609 "\u{2261} ".to_string()
3610 };
3611 let prefix_style = if interactive && i == selected {
3612 Style::default().fg(primary).add_modifier(Modifier::BOLD)
3613 } else {
3614 Style::default().fg(info)
3615 };
3616 let text_style = if interactive && i == selected {
3617 Style::default().fg(primary).add_modifier(Modifier::BOLD)
3618 } else {
3619 Style::default().fg(secondary)
3620 };
3621 let marker = if interactive && i == selected {
3622 "\u{25b8} " } else {
3624 " "
3625 };
3626 Line::from(vec![
3627 Span::styled(prefix, prefix_style),
3628 Span::styled(marker, prefix_style),
3629 Span::styled(e.clone(), text_style),
3630 ])
3631 })
3632 .collect();
3633 frame.render_widget(
3634 Paragraph::new(items).block(Block::default().borders(Borders::TOP).border_style(
3635 Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
3636 )),
3637 area,
3638 );
3639}
3640
3641fn flatten_todo_items(
3643 phases: &[oxicode_agent::tools::todo::TodoPhase],
3644) -> Vec<(String, TodoStatus)> {
3645 phases
3646 .iter()
3647 .flat_map(|p| p.tasks.iter().map(|t| (t.content.clone(), t.status)))
3648 .collect()
3649}
3650
3651fn render_todo_pane(frame: &mut Frame<'_>, scrollback: Rect, items: &[(String, TodoStatus)]) {
3653 let styles = active_styles();
3654 let height = items.len() as u16 + 1;
3655 let area = Rect {
3656 x: scrollback.x,
3657 y: scrollback.y,
3658 width: scrollback.width,
3659 height,
3660 };
3661 let lines: Vec<Line<'_>> = items
3662 .iter()
3663 .map(|(text, status)| {
3664 let (marker, color) = match status {
3668 TodoStatus::Completed => ("\u{2611}", Some(styles.foreground)), TodoStatus::InProgress => ("\u{25B6}", styles.primary.get_fg_color()), TodoStatus::Blocked => ("\u{23F8}", styles.info.get_fg_color()), TodoStatus::Abandoned => ("\u{2717}", styles.error.get_fg_color()), TodoStatus::Pending => ("\u{2610}", styles.secondary.get_fg_color()), };
3674 let text_style = if *status == TodoStatus::Completed {
3675 Style::default()
3676 .fg(color_from_anstyle(Some(styles.foreground)))
3677 .add_modifier(Modifier::CROSSED_OUT)
3678 } else {
3679 Style::default().fg(color_from_anstyle(Some(styles.foreground)))
3680 };
3681 Line::from(vec![
3682 Span::styled(
3683 format!("{marker} "),
3684 Style::default().fg(color_from_anstyle(color)),
3685 ),
3686 Span::styled(text.clone(), text_style),
3687 ])
3688 })
3689 .collect();
3690 frame.render_widget(Paragraph::new(lines), area);
3691}
3692
3693fn render_follow_ups(frame: &mut Frame<'_>, composer_area: Rect, chips: &[String]) {
3695 let styles = active_styles();
3696 let area = Rect {
3697 x: composer_area.x,
3698 y: composer_area.top().saturating_sub(1),
3699 width: composer_area.width,
3700 height: 1,
3701 };
3702 let mut spans = vec![Span::styled(
3703 "Suggestions: ",
3704 Style::default()
3705 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
3706 .add_modifier(Modifier::DIM),
3707 )];
3708 for (i, chip) in chips.iter().enumerate() {
3709 if i > 0 {
3710 spans.push(Span::raw(" "));
3711 }
3712 spans.push(Span::styled(
3713 format!("\u{25b8} {chip}"),
3714 Style::default().fg(color_from_anstyle(styles.primary.get_fg_color())),
3715 ));
3716 }
3717 frame.render_widget(Paragraph::new(Line::from(spans)), area);
3718}
3719
3720fn tip_is_visible(tip: &EphemeralTip, now_tick: u64) -> bool {
3722 now_tick.saturating_sub(tip.born_tick) < tip.ttl_ticks
3723}
3724
3725fn render_tip(frame: &mut Frame, composer_area: Rect, text: &str) {
3727 let styles = active_styles();
3728 let area = Rect {
3729 x: composer_area.x,
3730 y: composer_area.top().saturating_sub(1),
3731 width: composer_area.width,
3732 height: 1,
3733 };
3734 let line = Line::styled(
3735 format!(" \u{2139} {text}"),
3736 Style::default()
3737 .fg(color_from_anstyle(styles.info.get_fg_color()))
3738 .add_modifier(Modifier::DIM),
3739 );
3740 frame.render_widget(Paragraph::new(line), area);
3741}
3742
3743fn render_slash_popup(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
3746 let styles = active_styles();
3747 let items = &state.slash_popup.items;
3748 if items.is_empty() {
3749 return;
3750 }
3751
3752 let max_visible = 8usize;
3753 let visible = items.len().min(max_visible);
3754 let popup_h = visible as u16 + 2; let width = composer_area.width.min(64);
3756 let popup_area = Rect {
3757 x: composer_area.left(),
3758 y: composer_area.top().saturating_sub(popup_h),
3759 width,
3760 height: popup_h,
3761 };
3762 frame.render_widget(Clear, popup_area);
3763
3764 let border_color = color_from_anstyle(styles.secondary.get_fg_color());
3765 let title = Line::from(Span::styled(
3766 " Commands ",
3767 Style::default()
3768 .fg(color_from_anstyle(styles.primary.get_fg_color()))
3769 .add_modifier(Modifier::BOLD),
3770 ));
3771 let block = Block::default()
3772 .borders(Borders::ALL)
3773 .border_type(BorderType::Rounded)
3774 .border_style(Style::default().fg(border_color))
3775 .title(title);
3776 let inner = block.inner(popup_area);
3777 frame.render_widget(&block, popup_area);
3778
3779 let max_label = items
3781 .iter()
3782 .take(visible)
3783 .map(|i| i.label.chars().count())
3784 .max()
3785 .unwrap_or(0);
3786
3787 let primary = color_from_anstyle(styles.primary.get_fg_color());
3788 let fg = color_from_anstyle(Some(styles.foreground));
3789 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
3790
3791 for (i, item) in items.iter().take(visible).enumerate() {
3792 let is_selected = i == state.slash_popup.selected;
3793 let y = inner.top() + i as u16;
3794 let row_area = Rect {
3795 x: inner.left(),
3796 y,
3797 width: inner.width,
3798 height: 1,
3799 };
3800
3801 let marker = if is_selected { "\u{25b8} " } else { " " }; let label_style = if is_selected {
3803 Style::default().fg(primary).add_modifier(Modifier::BOLD)
3804 } else {
3805 Style::default().fg(fg)
3806 };
3807 let label_padded = format!("{:<width$}", item.label, width = max_label);
3808 let line = Line::from(vec![
3809 Span::styled(marker, label_style),
3810 Span::styled(label_padded, label_style),
3811 Span::raw(" "),
3812 Span::styled(&item.description, Style::default().fg(secondary)),
3813 ]);
3814 frame.render_widget(Paragraph::new(line), row_area);
3815 }
3816}
3817
3818fn render_file_search_dropdown(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
3822 let styles = active_styles();
3823 let Some(fs) = &state.file_search else {
3824 return;
3825 };
3826 let items = &fs.results;
3827 if items.is_empty() {
3828 return;
3829 }
3830
3831 let max_visible = 10usize;
3832 let visible = items.len().min(max_visible);
3833 let popup_h = visible as u16 + 2; let width = composer_area.width.min(72);
3835 let popup_area = Rect {
3836 x: composer_area.left(),
3837 y: composer_area.top().saturating_sub(popup_h),
3838 width,
3839 height: popup_h,
3840 };
3841 frame.render_widget(Clear, popup_area);
3842
3843 let border_color = color_from_anstyle(styles.secondary.get_fg_color());
3844 let title_str = if fs.hidden_mode {
3845 " Files (hidden) "
3846 } else {
3847 " Files "
3848 };
3849 let title = Line::from(Span::styled(
3850 title_str,
3851 Style::default()
3852 .fg(color_from_anstyle(styles.primary.get_fg_color()))
3853 .add_modifier(Modifier::BOLD),
3854 ));
3855 let block = Block::default()
3856 .borders(Borders::ALL)
3857 .border_type(BorderType::Rounded)
3858 .border_style(Style::default().fg(border_color))
3859 .title(title);
3860 let inner = block.inner(popup_area);
3861 frame.render_widget(&block, popup_area);
3862
3863 let primary = color_from_anstyle(styles.primary.get_fg_color());
3864 let fg = color_from_anstyle(Some(styles.foreground));
3865 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
3866
3867 for (i, result) in items.iter().take(visible).enumerate() {
3868 let is_selected = i == fs.selected;
3869 let y = inner.top() + i as u16;
3870 let row_area = Rect {
3871 x: inner.left(),
3872 y,
3873 width: inner.width,
3874 height: 1,
3875 };
3876
3877 let marker = if is_selected { "\u{25b8} " } else { " " }; let path_style = if is_selected {
3879 Style::default().fg(primary).add_modifier(Modifier::BOLD)
3880 } else {
3881 Style::default().fg(fg)
3882 };
3883 let line = Line::from(vec![
3884 Span::styled(marker, path_style),
3885 Span::styled(&result.path, path_style),
3886 ]);
3887 frame.render_widget(Paragraph::new(line), row_area);
3888 }
3889
3890 if popup_h >= 4 {
3892 let hint_y = inner.bottom();
3893 let hint_area = Rect {
3894 x: inner.left(),
3895 y: hint_y,
3896 width: inner.width,
3897 height: 1,
3898 };
3899 let count = items.len();
3900 let hint = format!("{count} files \u{00b7} Tab accept Esc cancel");
3901 let _ = secondary; frame.render_widget(
3903 Paragraph::new(Line::from(Span::styled(
3904 hint,
3905 Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
3906 )))
3907 .style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color()))),
3908 hint_area,
3909 );
3910 }
3911}
3912
3913struct InputEditor<'a> {
3919 buffer: &'a mut String,
3920 cursor: &'a mut usize,
3921}
3922
3923impl<'a> oxicode_vtui::vim::Editor for InputEditor<'a> {
3924 fn content(&self) -> &str {
3925 self.buffer
3926 }
3927 fn cursor(&self) -> usize {
3928 *self.cursor
3929 }
3930 fn set_cursor(&mut self, pos: usize) {
3931 *self.cursor = pos.min(self.buffer.len());
3932 }
3933 fn move_left(&mut self) {
3934 *self.cursor = self.cursor.saturating_sub(1);
3935 }
3936 fn move_right(&mut self) {
3937 let len = self.buffer.len();
3938 *self.cursor = (*self.cursor + 1).min(len);
3939 }
3940 fn delete_char_forward(&mut self) {
3941 let cursor = *self.cursor;
3942 if cursor < self.buffer.len() {
3943 let next = self.buffer[cursor..]
3944 .char_indices()
3945 .nth(1)
3946 .map(|(i, _)| cursor + i)
3947 .unwrap_or(self.buffer.len());
3948 self.buffer.replace_range(cursor..next, "");
3949 }
3950 }
3951 fn insert_text(&mut self, text: &str) {
3952 let cursor = *self.cursor;
3953 self.buffer.insert_str(cursor, text);
3954 *self.cursor = cursor + text.len();
3955 }
3956 fn replace(&mut self, content: String, cursor: usize) {
3957 *self.buffer = content;
3958 *self.cursor = cursor.min(self.buffer.len());
3959 }
3960}
3961
3962pub(crate) fn plain_segment(text: impl Into<String>) -> InlineSegment {
3967 InlineSegment {
3968 text: text.into(),
3969 style: Arc::new(InlineTextStyle::default()),
3970 }
3971}
3972
3973pub(super) fn effective_scroll_offset(offset: usize, total: usize, viewport: usize) -> usize {
3974 if offset == usize::MAX {
3975 return total.saturating_sub(viewport);
3976 }
3977 let max_start = total.saturating_sub(viewport);
3979 offset.min(max_start)
3980}
3981
3982fn slash_filter(token: &str, file_commands: &[FileCommand]) -> Vec<SlashPopupItem> {
3995 let builtins = SlashRegistry::builtin_commands();
3996 let builtin_names: std::collections::HashSet<&str> =
3997 builtins.iter().map(|(n, _, _)| *n).collect();
3998
3999 let mut items: Vec<SlashPopupItem> = builtins
4000 .into_iter()
4001 .filter(|(name, _, aliases)| {
4002 token.is_empty()
4003 || name.starts_with(token)
4004 || aliases.iter().any(|a| a.starts_with(token))
4005 })
4006 .map(|(name, desc, aliases)| {
4007 let mut label = format!("/{name}");
4008 for a in &aliases {
4009 label.push_str(&format!(", /{a}"));
4010 }
4011 SlashPopupItem {
4012 label,
4013 description: desc.to_string(),
4014 name: name.to_string(),
4015 }
4016 })
4017 .collect();
4018
4019 for fc in file_commands {
4021 if builtin_names.contains(fc.name.as_str())
4022 || fc
4023 .aliases
4024 .iter()
4025 .any(|alias| builtin_names.contains(alias.as_str()))
4026 {
4027 continue;
4028 }
4029 if token.is_empty()
4030 || fc.name.starts_with(token)
4031 || fc.aliases.iter().any(|a| a.starts_with(token))
4032 {
4033 let mut label = format!("/{}", fc.name);
4034 for a in &fc.aliases {
4035 label.push_str(&format!(", /{a}"));
4036 }
4037 items.push(SlashPopupItem {
4038 label,
4039 description: fc.description.clone(),
4040 name: fc.name.clone(),
4041 });
4042 }
4043 }
4044
4045 items
4046}
4047
4048fn refresh_slash_popup(state: &mut RenderState) {
4053 let buf = state.input_buffer.clone();
4054 let active = buf.starts_with('/') && !buf[1..].contains(' ');
4055 if !active {
4056 state.slash_popup.open = false;
4057 state.slash_popup.items.clear();
4058 state.slash_popup.selected = 0;
4059 return;
4060 }
4061 let token = &buf[1..];
4062 let items = slash_filter(token, &state.file_commands);
4063 state.slash_popup.open = !items.is_empty();
4064 if items.is_empty() {
4065 state.slash_popup.selected = 0;
4066 } else {
4067 state.slash_popup.selected = state.slash_popup.selected.min(items.len() - 1);
4068 }
4069 state.slash_popup.items = items;
4070}
4071fn refresh_input_popups(state: &mut RenderState) {
4075 refresh_slash_popup(state);
4076 refresh_file_search(state);
4077}
4078
4079fn refresh_file_search(state: &mut RenderState) {
4085 use crate::tui_vt::file_search;
4086 if state.slash_popup.open {
4088 state.file_search = None;
4089 return;
4090 }
4091 match file_search::parse_at_cursor(&state.input_buffer, state.input_cursor) {
4092 Some(token) => match &mut state.file_search {
4093 None => {
4094 let cwd = state.cwd.clone();
4095 state.file_search = Some(file_search::open(&cwd, token.at_offset, false));
4096 }
4097 Some(fs) => {
4098 if fs.query != token.path_query {
4099 fs.refresh(&token.path_query);
4100 }
4101 }
4102 },
4103 None => state.file_search = None,
4104 }
4105}
4106
4107fn accept_file_search(state: &mut RenderState, line_mode: bool) -> bool {
4112 use crate::tui_vt::file_search;
4113 let Some(fs) = &state.file_search else {
4114 return false;
4115 };
4116 let Some(result) = fs.selected_result().cloned() else {
4117 return false;
4118 };
4119 let at_offset = fs.at_offset;
4120 let text = file_search::insertion_text(&result.path, None, line_mode);
4121 let cursor_end = state.input_cursor;
4122 state
4124 .input_buffer
4125 .replace_range(at_offset..cursor_end.min(state.input_buffer.len()), &text);
4126 state.input_cursor = at_offset + text.len();
4127 state.file_search = None;
4128 true
4129}
4130
4131fn preview_tool_result(content: &str) -> String {
4132 const MAX: usize = 500;
4133 if content.chars().count() <= MAX {
4134 return content.to_string();
4135 }
4136 let truncated: String = content.chars().take(MAX).collect();
4137 format!("{truncated}\u{2026}")
4138}
4139
4140fn try_render_diff(content: &str, handle: &InlineHandle) -> bool {
4144 let lines: Vec<&str> = content.lines().collect();
4145 if !lines.iter().any(|l| l.starts_with("@@")) {
4149 return false;
4150 }
4151 let additions = lines
4152 .iter()
4153 .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
4154 .count();
4155 let deletions = lines
4156 .iter()
4157 .filter(|l| l.starts_with('-') && !l.starts_with("---"))
4158 .count();
4159 if additions + deletions < 2 {
4160 return false;
4161 }
4162
4163 let styles = active_styles();
4164 let green = styles.secondary.get_fg_color();
4165 let red = styles.error.get_fg_color();
4166 const MAX_DIFF_LINES: usize = 30;
4167
4168 let mut hdr_style = InlineTextStyle::default();
4170 hdr_style.effects |= anstyle::Effects::DIMMED;
4171 handle.append_line(
4172 InlineMessageKind::Tool,
4173 vec![InlineSegment {
4174 text: format!("\u{2713} diff (+{additions} \u{2212}{deletions})"),
4175 style: Arc::new(hdr_style),
4176 }],
4177 );
4178
4179 for line in lines.iter().take(MAX_DIFF_LINES) {
4181 let mut style = InlineTextStyle::default();
4182 if line.starts_with('+') && !line.starts_with("+++") {
4183 style.color = green;
4184 } else if line.starts_with('-') && !line.starts_with("---") {
4185 style.color = red;
4186 } else {
4187 style.effects |= anstyle::Effects::DIMMED;
4188 }
4189 handle.append_line(
4190 InlineMessageKind::Tool,
4191 vec![InlineSegment {
4192 text: format!(" {line}"),
4193 style: Arc::new(style),
4194 }],
4195 );
4196 }
4197
4198 if lines.len() > MAX_DIFF_LINES {
4199 let mut more_style = InlineTextStyle::default();
4200 more_style.effects |= anstyle::Effects::DIMMED;
4201 handle.append_line(
4202 InlineMessageKind::Tool,
4203 vec![InlineSegment {
4204 text: format!(" \u{2026} {} more lines", lines.len() - MAX_DIFF_LINES),
4205 style: Arc::new(more_style),
4206 }],
4207 );
4208 }
4209
4210 true
4211}
4212
4213fn color_from_anstyle(color: Option<anstyle::Color>) -> Color {
4214 match color {
4215 Some(anstyle::Color::Ansi(a)) => ansi_to_ratatui(a),
4216 Some(anstyle::Color::Ansi256(idx)) => Color::Indexed(idx.0),
4217 Some(anstyle::Color::Rgb(rgb)) => Color::Rgb(rgb.0, rgb.1, rgb.2),
4218 None => Color::Reset,
4219 }
4220}
4221fn ansi_to_ratatui(color: anstyle::AnsiColor) -> Color {
4222 use anstyle::AnsiColor as A;
4223 match color {
4224 A::Black => Color::Black,
4225 A::Red => Color::Red,
4226 A::Green => Color::Green,
4227 A::Yellow => Color::Yellow,
4228 A::Blue => Color::Blue,
4229 A::Magenta => Color::Magenta,
4230 A::Cyan => Color::Cyan,
4231 A::White => Color::Gray,
4232 A::BrightBlack => Color::DarkGray,
4233 A::BrightRed => Color::LightRed,
4234 A::BrightGreen => Color::LightGreen,
4235 A::BrightYellow => Color::LightYellow,
4236 A::BrightBlue => Color::LightBlue,
4237 A::BrightMagenta => Color::LightMagenta,
4238 A::BrightCyan => Color::LightCyan,
4239 A::BrightWhite => Color::White,
4240 }
4241}
4242
4243#[allow(dead_code, clippy::declare_interior_mutable_const)]
4246const _ATOMIC_REFS: (AtomicBool, Ordering) = (AtomicBool::new(false), Ordering::SeqCst);
4247
4248#[cfg(test)]
4249mod slash_popup_tests {
4250 use super::*;
4251
4252 #[test]
4253 fn empty_token_lists_all_commands() {
4254 let items = slash_filter("", &[]);
4255 assert!(items.len() >= 7);
4257 assert!(items.iter().any(|i| i.name == "quit"));
4258 assert!(items.iter().any(|i| i.name == "clear"));
4259 assert!(items.iter().any(|i| i.name == "model"));
4260 }
4261
4262 #[test]
4263 fn prefix_filter_matches_name() {
4264 let items = slash_filter("qu", &[]);
4265 assert_eq!(items.len(), 1);
4266 assert_eq!(items[0].name, "quit");
4267 assert!(items[0].label.contains("/quit"));
4268 }
4269
4270 #[test]
4271 fn prefix_filter_matches_alias() {
4272 let items = slash_filter("cl", &[]);
4274 let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
4275 assert!(names.contains(&"clear"));
4276 }
4277
4278 #[test]
4279 fn file_commands_appear_in_filter() {
4280 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
4281 "review",
4282 "---\ndescription: proj cmd\naliases: cr\n---\nbody",
4283 );
4284 let items = slash_filter("", &[fc]);
4285 assert!(items.iter().any(|i| i.name == "review"));
4286 assert!(items.iter().any(|i| i.name == "quit")); }
4288
4289 #[test]
4290 fn file_commands_filtered_by_prefix() {
4291 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
4292 "review",
4293 "---\ndescription: x\n---\nbody",
4294 );
4295 let items = slash_filter("rev", &[fc]);
4296 assert!(items.iter().any(|i| i.name == "review"));
4297 }
4298
4299 #[test]
4300 fn file_commands_shadowed_by_builtins_are_dropped() {
4301 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
4306 "quit",
4307 "---\ndescription: hijack\n---\nbody",
4308 );
4309 let items = slash_filter("", &[fc]);
4310 let quit_count = items.iter().filter(|i| i.name == "quit").count();
4311 assert_eq!(quit_count, 1, "shadowed file command must not appear");
4312 assert!(
4314 items
4315 .iter()
4316 .any(|i| i.name == "quit" && !i.description.contains("hijack"))
4317 );
4318 }
4319
4320 #[test]
4321 fn file_commands_with_builtin_aliases_are_dropped() {
4322 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
4323 "review",
4324 "---\ndescription: hijack\naliases: quit\n---\nbody",
4325 );
4326 let items = slash_filter("", &[fc]);
4327 assert!(!items.iter().any(|item| item.name == "review"));
4328 }
4329
4330 #[test]
4331 fn popup_opens_on_slash() {
4332 let mut state = RenderState::default();
4333 state.input_buffer = "/".to_string();
4334 refresh_input_popups(&mut state);
4335 assert!(state.slash_popup.open);
4336 assert!(!state.slash_popup.items.is_empty());
4337 }
4338
4339 #[test]
4340 fn popup_closes_on_space() {
4341 let mut state = RenderState::default();
4342 state.input_buffer = "/quit ".to_string();
4343 refresh_input_popups(&mut state);
4344 assert!(!state.slash_popup.open);
4345 }
4346
4347 #[test]
4348 fn popup_closes_on_non_slash() {
4349 let mut state = RenderState::default();
4350 state.input_buffer = "hello".to_string();
4351 refresh_input_popups(&mut state);
4352 assert!(!state.slash_popup.open);
4353 }
4354
4355 #[test]
4356 fn popup_filters_as_user_types() {
4357 let mut state = RenderState::default();
4358 state.input_buffer = "/m".to_string();
4359 refresh_input_popups(&mut state);
4360 assert!(state.slash_popup.open);
4361 assert!(
4364 state
4365 .slash_popup
4366 .items
4367 .iter()
4368 .all(|i| i.name.starts_with('m'))
4369 );
4370 }
4371
4372 #[test]
4373 fn popup_selection_clamps_on_shrink() {
4374 let mut state = RenderState::default();
4375 state.input_buffer = "/".to_string();
4376 refresh_input_popups(&mut state);
4377 let full_count = state.slash_popup.items.len();
4378 state.slash_popup.selected = full_count - 1;
4379 state.input_buffer = "/qu".to_string();
4381 refresh_input_popups(&mut state);
4382 assert!(state.slash_popup.selected < state.slash_popup.items.len());
4383 }
4384}
4385
4386#[cfg(test)]
4387mod render_tests {
4388 use super::*;
4389 use oxicode_vtui::tui::core::{InlineHandle, OverlayEvent};
4390 use ratatui::{Terminal, backend::TestBackend};
4391 use tokio::sync::mpsc;
4392
4393 fn render_frame_to_string(state: &RenderState) -> String {
4398 let backend = TestBackend::new(80, 24);
4399 let mut terminal = Terminal::new(backend).expect("backend");
4400 let (tx, _rx) = mpsc::unbounded_channel();
4401 let handle = InlineHandle::new_for_tests(tx);
4402 terminal
4403 .draw(|f| render_frame(f, state, &handle))
4404 .expect("draw");
4405 let buf = terminal.backend().buffer();
4406 let area = buf.area();
4407 let mut out = String::new();
4408 for y in 0..area.height {
4409 for x in 0..area.width {
4410 if let Some(cell) = buf.cell((x, y)) {
4411 out.push_str(cell.symbol());
4412 }
4413 }
4414 out.push('\n');
4415 }
4416 out
4417 }
4418
4419 #[test]
4420 fn welcome_screen_shown_when_transcript_empty() {
4421 let state = RenderState::default();
4422 let rendered = render_frame_to_string(&state);
4423 assert!(
4424 rendered.contains("oxicode"),
4425 "welcome banner must appear when transcript is empty"
4426 );
4427 }
4428
4429 #[test]
4430 fn composer_is_painted() {
4431 let mut state = RenderState::default();
4435 state.input_enabled = true;
4436 state.prompt_prefix = "> ".to_string();
4437 let rendered = render_frame_to_string(&state);
4438 assert!(
4439 rendered.contains('>'),
4440 "composer prompt prefix must be painted"
4441 );
4442 }
4443
4444 #[test]
4445 fn slash_popup_renders_command_list() {
4446 let mut state = RenderState::default();
4447 state.slash_popup.open = true;
4448 state.slash_popup.items = slash_filter("", &[]);
4449 let rendered = render_frame_to_string(&state);
4450 assert!(rendered.contains("Commands"), "popup title must render");
4451 assert!(rendered.contains("/quit"), "popup must list /quit");
4452 }
4453
4454 #[test]
4455 fn composer_and_popup_render_together() {
4456 let mut state = RenderState::default();
4457 state.prompt_prefix = "> ".to_string();
4458 state.input_buffer = "/qu".to_string();
4459 state.slash_popup.open = true;
4460 state.slash_popup.items = slash_filter("qu", &[]);
4461 let rendered = render_frame_to_string(&state);
4462 assert!(rendered.contains("Commands"), "popup must render");
4463 assert!(rendered.contains("/quit"), "popup must list /quit");
4464 assert!(rendered.contains('>'), "composer must still render");
4465 }
4466
4467 #[test]
4468 fn transcript_wraps_long_lines() {
4469 let mut state = RenderState::default();
4471 state.transcript.push(TranscriptLine {
4472 kind: InlineMessageKind::Agent,
4473 segments: vec![plain_segment(
4474 "This is a very long agent response line that should wrap across multiple terminal rows when rendered at a narrow width.".to_string()
4475 )],
4476 block_id: 0,
4477 });
4478 let backend = TestBackend::new(40, 24);
4479 let mut terminal = Terminal::new(backend).expect("backend");
4480 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4481 let handle = InlineHandle::new_for_tests(tx);
4482 terminal
4483 .draw(|f| render_frame(f, &state, &handle))
4484 .expect("draw");
4485 let buf = terminal.backend().buffer();
4486 let mut full = String::new();
4489 for y in 0..buf.area.height {
4490 for x in 0..buf.area.width {
4491 if let Some(cell) = buf.cell((x, y)) {
4492 full.push_str(cell.symbol());
4493 }
4494 }
4495 full.push('\n');
4496 }
4497 assert!(
4498 full.contains("wrap"),
4499 "long line must wrap, not clip — text should be visible past col 40"
4500 );
4501 }
4502
4503 fn sample_overlay_items() -> Vec<OverlayListItem> {
4506 vec![
4507 OverlayListItem {
4508 title: "model-a".to_string(),
4509 subtitle: Some("first".to_string()),
4510 badge: Some("ready".to_string()),
4511 indent: 0,
4512 search_value: None,
4513 selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(0)),
4514 },
4515 OverlayListItem {
4516 title: "model-b".to_string(),
4517 subtitle: None,
4518 badge: None,
4519 indent: 0,
4520 search_value: None,
4521 selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(1)),
4522 },
4523 OverlayListItem {
4524 title: "model-c".to_string(),
4525 subtitle: None,
4526 badge: None,
4527 indent: 0,
4528 search_value: None,
4529 selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(2)),
4530 },
4531 ]
4532 }
4533
4534 #[test]
4535 fn overlay_renders_title_and_items() {
4536 let mut state = RenderState::default();
4537 state.overlay = Some(OverlayState {
4538 title: "Select model".to_string(),
4539 lines: vec!["Pick one".to_string()],
4540 items: sample_overlay_items(),
4541 selected: 0,
4542 search: None,
4543 });
4544 let rendered = render_frame_to_string(&state);
4545 assert!(
4546 rendered.contains("Select model"),
4547 "overlay title must render"
4548 );
4549 assert!(rendered.contains("model-a"), "first item must render");
4550 assert!(rendered.contains("model-b"), "second item must render");
4551 assert!(rendered.contains("model-c"), "third item must render");
4552 assert!(
4553 rendered.contains("Pick one"),
4554 "descriptive line must render"
4555 );
4556 }
4557
4558 #[test]
4559 fn overlay_search_filters_items() {
4560 let mut state = RenderState::default();
4561 state.overlay = Some(OverlayState {
4562 title: "Select".to_string(),
4563 lines: Vec::new(),
4564 items: sample_overlay_items(),
4565 selected: 0,
4566 search: Some(OverlaySearchState {
4567 label: "filter".to_string(),
4568 placeholder: Some("type".to_string()),
4569 value: "model-b".to_string(),
4570 }),
4571 });
4572 let rendered = render_frame_to_string(&state);
4573 assert!(rendered.contains("model-b"), "matching item must render");
4574 assert!(
4575 !rendered.contains("model-a"),
4576 "non-matching item must not render (got: {})",
4577 rendered
4578 );
4579 assert!(
4580 !rendered.contains("model-c"),
4581 "non-matching item must not render"
4582 );
4583 }
4584
4585 #[test]
4586 fn overlay_keyboard_nav_moves_selection() {
4587 let mut state = RenderState::default();
4588 state.overlay = Some(OverlayState {
4589 title: "Select".to_string(),
4590 lines: Vec::new(),
4591 items: sample_overlay_items(),
4592 selected: 0,
4593 search: None,
4594 });
4595 let state_arc = Arc::new(parking_lot::Mutex::new(state));
4596 let (tx, mut _rx) = mpsc::unbounded_channel();
4597
4598 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 0);
4600
4601 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
4603 assert!(consumed, "Down must be consumed while overlay is open");
4604 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 1);
4605
4606 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
4608 assert!(consumed);
4609 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 2);
4610
4611 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
4613 assert!(consumed);
4614 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 0);
4615
4616 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Up);
4618 assert!(consumed);
4619 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 2);
4620
4621 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
4623 assert!(consumed);
4624 assert!(
4625 state_arc.lock().overlay.is_none(),
4626 "overlay must be cleared after Enter"
4627 );
4628 let evt = _rx.try_recv().expect("submit event must arrive");
4629 match evt {
4630 InlineEvent::Overlay(OverlayEvent::Submitted(_)) => {}
4631 other => panic!("expected Submitted overlay event, got {other:?}"),
4632 }
4633 }
4634
4635 #[test]
4636 fn overlay_esc_closes_and_emits_cancelled() {
4637 let mut state = RenderState::default();
4638 state.overlay = Some(OverlayState {
4639 title: "Select".to_string(),
4640 lines: Vec::new(),
4641 items: sample_overlay_items(),
4642 selected: 0,
4643 search: None,
4644 });
4645 let state_arc = Arc::new(parking_lot::Mutex::new(state));
4646 let (tx, mut rx) = mpsc::unbounded_channel();
4647
4648 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Esc);
4649 assert!(consumed);
4650 assert!(
4651 state_arc.lock().overlay.is_none(),
4652 "overlay must be cleared after Esc"
4653 );
4654 let evt = rx.try_recv().expect("cancel event must arrive");
4655 assert!(
4656 matches!(evt, InlineEvent::Overlay(OverlayEvent::Cancelled)),
4657 "expected Cancelled overlay event"
4658 );
4659 }
4660
4661 #[test]
4662 fn overlay_enter_on_readonly_item_is_noop() {
4663 let mut state = RenderState::default();
4667 state.overlay = Some(OverlayState {
4668 title: "Tools".to_string(),
4669 lines: Vec::new(),
4670 items: vec![OverlayListItem {
4671 title: "read".to_string(),
4672 subtitle: Some("Read a file".to_string()),
4673 badge: None,
4674 indent: 0,
4675 search_value: None,
4676 selection: None,
4677 }],
4678 selected: 0,
4679 search: None,
4680 });
4681 let state_arc = Arc::new(parking_lot::Mutex::new(state));
4682 let (tx, mut rx) = mpsc::unbounded_channel();
4683
4684 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
4685 assert!(consumed, "Enter must be consumed even on read-only items");
4686 assert!(
4687 state_arc.lock().overlay.is_some(),
4688 "overlay must stay open when Enter hits a read-only item"
4689 );
4690 assert!(
4691 rx.try_recv().is_err(),
4692 "no overlay event must be emitted for a read-only Enter"
4693 );
4694 }
4695
4696 #[test]
4697 fn overlay_chars_route_to_search_field() {
4698 let mut state = RenderState::default();
4699 state.overlay = Some(OverlayState {
4700 title: "Select".to_string(),
4701 lines: Vec::new(),
4702 items: sample_overlay_items(),
4703 selected: 0,
4704 search: Some(OverlaySearchState {
4705 label: "filter".to_string(),
4706 placeholder: None,
4707 value: String::new(),
4708 }),
4709 });
4710 let state_arc = Arc::new(parking_lot::Mutex::new(state));
4711 let (tx, _rx) = mpsc::unbounded_channel();
4712
4713 handle_overlay_key(&state_arc, &tx, KeyCode::Char('m'));
4714 handle_overlay_key(&state_arc, &tx, KeyCode::Char('o'));
4715 handle_overlay_key(&state_arc, &tx, KeyCode::Backspace);
4716 let value = state_arc
4717 .lock()
4718 .overlay
4719 .as_ref()
4720 .unwrap()
4721 .search
4722 .as_ref()
4723 .unwrap()
4724 .value
4725 .clone();
4726 assert_eq!(value, "m", "Backspace should drop last char");
4727 }
4728
4729 #[test]
4730 fn overlay_key_no_op_when_no_overlay_open() {
4731 let state = RenderState::default();
4732 let state_arc = Arc::new(parking_lot::Mutex::new(state));
4733 let (tx, _rx) = mpsc::unbounded_channel();
4734 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
4735 assert!(
4736 !consumed,
4737 "handle_overlay_key must return false when no overlay is open"
4738 );
4739 }
4740
4741 #[test]
4742 fn apply_command_show_overlay_populates_state() {
4743 use oxicode_vtui::tui::core::{InlineListItem, ListOverlayRequest};
4744 let mut state = RenderState::default();
4745 let items = vec![
4746 InlineListItem {
4747 title: "alpha".to_string(),
4748 subtitle: None,
4749 badge: None,
4750 indent: 0,
4751 selection: None,
4752 search_value: None,
4753 },
4754 InlineListItem {
4755 title: "beta".to_string(),
4756 subtitle: None,
4757 badge: None,
4758 indent: 0,
4759 selection: None,
4760 search_value: None,
4761 },
4762 ];
4763 let request = OverlayRequest::List(ListOverlayRequest {
4764 title: "Pick".to_string(),
4765 lines: vec!["desc".to_string()],
4766 footer_hint: None,
4767 items,
4768 selected: None,
4769 search: None,
4770 hotkeys: Vec::new(),
4771 });
4772 let shutdown = apply_command(
4773 &mut state,
4774 InlineCommand::ShowOverlay {
4775 request: Box::new(request),
4776 },
4777 );
4778 assert!(!shutdown, "ShowOverlay must not request shutdown");
4779 let overlay = state.overlay.as_ref().expect("overlay must be Some");
4780 assert_eq!(overlay.title, "Pick");
4781 assert_eq!(overlay.items.len(), 2);
4782 assert_eq!(overlay.items[0].title, "alpha");
4783 assert_eq!(overlay.items[1].title, "beta");
4784 assert_eq!(overlay.lines.len(), 1);
4785
4786 apply_command(&mut state, InlineCommand::CloseOverlay);
4788 assert!(state.overlay.is_none(), "CloseOverlay must clear state");
4789 }
4790
4791 fn three_block_transcript() -> Vec<TranscriptLine> {
4794 vec![
4796 TranscriptLine {
4797 kind: InlineMessageKind::User,
4798 segments: vec![plain_segment("hi")],
4799 block_id: 0,
4800 },
4801 TranscriptLine {
4802 kind: InlineMessageKind::Agent,
4803 segments: vec![plain_segment("hello")],
4804 block_id: 1,
4805 },
4806 TranscriptLine {
4807 kind: InlineMessageKind::Agent,
4808 segments: vec![plain_segment("world")],
4809 block_id: 1,
4810 },
4811 TranscriptLine {
4812 kind: InlineMessageKind::User,
4813 segments: vec![plain_segment("bye")],
4814 block_id: 2,
4815 },
4816 ]
4817 }
4818
4819 #[test]
4820 fn default_block_mode_is_truncated() {
4821 let state = RenderState::default();
4822 assert_eq!(state.block_mode(0), BlockDisplayMode::Truncated);
4823 assert!(state.block_display.is_empty(), "default needs no map entry");
4824 }
4825
4826 #[test]
4827 fn fold_all_collapses_every_block() {
4828 let mut state = RenderState::default();
4829 state.transcript = three_block_transcript();
4830 state.fold_all();
4831 assert_eq!(state.block_display.len(), 3, "3 distinct block ids");
4832 assert_eq!(state.block_mode(0), BlockDisplayMode::Collapsed);
4833 assert_eq!(state.block_mode(1), BlockDisplayMode::Collapsed);
4834 assert_eq!(state.block_mode(2), BlockDisplayMode::Collapsed);
4835 }
4836
4837 #[test]
4838 fn expand_all_after_fold_all_shows_expanded() {
4839 let mut state = RenderState::default();
4840 state.transcript = three_block_transcript();
4841 state.fold_all();
4842 state.expand_all();
4843 assert_eq!(state.block_display.len(), 3);
4844 assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
4845 assert_eq!(state.block_mode(2), BlockDisplayMode::Expanded);
4846 }
4847
4848 #[test]
4849 fn truncate_all_resets_to_default() {
4850 let mut state = RenderState::default();
4851 state.transcript = three_block_transcript();
4852 state.fold_all();
4853 state.truncate_all();
4854 assert!(state.block_display.is_empty());
4855 assert_eq!(state.block_mode(1), BlockDisplayMode::Truncated);
4856 }
4857
4858 #[test]
4859 fn fold_all_on_empty_transcript_is_noop() {
4860 let mut state = RenderState::default();
4861 state.fold_all();
4862 assert!(state.block_display.is_empty());
4863 }
4864
4865 #[test]
4866 fn cycle_block_advances_through_three_states() {
4867 let mut state = RenderState::default();
4868 state.transcript = three_block_transcript();
4869 state.scroll_offset = 0; state.cycle_block_at_view();
4872 assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
4873 state.cycle_block_at_view();
4875 assert_eq!(state.block_mode(0), BlockDisplayMode::Collapsed);
4876 state.cycle_block_at_view();
4878 assert_eq!(state.block_mode(0), BlockDisplayMode::Truncated);
4879 assert!(!state.block_display.contains_key(&0));
4880 }
4881
4882 #[test]
4883 fn cancel_grace_field_defaults_none() {
4884 let state = RenderState::default();
4885 assert!(
4886 state.cancel_grace_until.is_none(),
4887 "cancel_grace_until must default to None"
4888 );
4889 }
4890
4891 #[test]
4892 fn cancel_routes_to_interrupt_when_streaming() {
4893 assert_eq!(
4894 route_cancel(true),
4895 CancelRoute::Interrupt,
4896 "Esc while streaming must route through the interrupt path"
4897 );
4898 }
4899
4900 #[test]
4901 fn cancel_routes_to_exit_when_idle() {
4902 assert_eq!(
4903 route_cancel(false),
4904 CancelRoute::Exit,
4905 "Esc while idle must exit immediately (one-press quit)"
4906 );
4907 }
4908 #[test]
4909 fn scrollbar_paints_thumb_when_content_overflows() {
4910 let mut state = RenderState::default();
4913 for i in 0..40u32 {
4914 state.transcript.push(TranscriptLine {
4915 kind: InlineMessageKind::Agent,
4916 segments: vec![plain_segment(format!("line {i}"))],
4917 block_id: i as usize,
4918 });
4919 }
4920 let rendered = render_frame_to_string(&state);
4921 assert!(
4922 rendered.contains('\u{2588}'),
4923 "scrollbar thumb (█) must render when transcript overflows the viewport"
4924 );
4925 }
4926
4927 #[test]
4928 fn scrollbar_absent_when_content_fits_viewport() {
4929 let mut state = RenderState::default();
4931 state.transcript.push(TranscriptLine {
4932 kind: InlineMessageKind::Agent,
4933 segments: vec![plain_segment("hi")],
4934 block_id: 0,
4935 });
4936 let rendered = render_frame_to_string(&state);
4937 assert!(
4938 !rendered.contains('\u{2588}'),
4939 "no scrollbar thumb when content fits the viewport"
4940 );
4941 }
4942
4943 #[test]
4946 fn confirmation_modal_renders_title() {
4947 let mut state = RenderState::default();
4948 state.confirmation = Some(quit_confirmation());
4949 let rendered = render_frame_to_string(&state);
4950 assert!(
4951 rendered.contains("Quit oxicode?"),
4952 "confirmation title must render"
4953 );
4954 }
4955
4956 #[test]
4957 fn confirmation_yes_sends_exit_and_closes() {
4958 let mut state = RenderState::default();
4959 state.confirmation = Some(quit_confirmation());
4960 let state_arc = Arc::new(parking_lot::Mutex::new(state));
4961 let (tx, mut rx) = mpsc::unbounded_channel();
4962 handle_confirmation_key(&state_arc, &tx, KeyCode::Char('y'));
4963 assert!(
4964 state_arc.lock().confirmation.is_none(),
4965 "yes must close the modal"
4966 );
4967 let ev = rx.try_recv().expect("yes must send an event");
4968 assert!(matches!(ev, InlineEvent::Exit), "yes must send Exit");
4969 }
4970
4971 #[test]
4972 fn confirmation_no_closes_without_event() {
4973 let mut state = RenderState::default();
4974 state.confirmation = Some(quit_confirmation());
4975 let state_arc = Arc::new(parking_lot::Mutex::new(state));
4976 let (tx, mut rx) = mpsc::unbounded_channel();
4977 handle_confirmation_key(&state_arc, &tx, KeyCode::Char('n'));
4978 assert!(
4979 state_arc.lock().confirmation.is_none(),
4980 "no must close the modal"
4981 );
4982 assert!(rx.try_recv().is_err(), "no must not send an event");
4983 }
4984 #[test]
4987 fn tip_banner_renders_when_active() {
4988 let mut state = RenderState::default();
4989 let now_tick = FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed);
4990 state.tip = Some(EphemeralTip {
4991 text: "hello-tip-marker".to_string(),
4992 born_tick: now_tick,
4993 ttl_ticks: 100,
4994 key: "test",
4995 ambient: false,
4996 });
4997 let rendered = render_frame_to_string(&state);
4998 assert!(
4999 rendered.contains("hello-tip-marker"),
5000 "active tip must render above the composer"
5001 );
5002 }
5003
5004 #[test]
5005 fn tip_visible_within_ttl_window() {
5006 let tip = EphemeralTip {
5007 text: "x".to_string(),
5008 born_tick: 10,
5009 ttl_ticks: 5,
5010 key: "test",
5011 ambient: false,
5012 };
5013 assert!(tip_is_visible(&tip, 12), "within TTL must be visible");
5014 assert!(
5015 !tip_is_visible(&tip, 15),
5016 "at TTL boundary (born + ttl) must expire"
5017 );
5018 assert!(!tip_is_visible(&tip, 99), "past TTL must expire");
5019 }
5020
5021 #[test]
5024 fn sticky_header_pins_block_head_when_scrolled_into_body() {
5025 let mut state = RenderState::default();
5028 for i in 0..40u32 {
5029 state.transcript.push(TranscriptLine {
5030 kind: InlineMessageKind::Agent,
5031 segments: vec![plain_segment(format!("body-line-{i:02}"))],
5032 block_id: 0,
5033 });
5034 }
5035 state.scroll_offset = 10;
5036 let rendered = render_frame_to_string(&state);
5037 assert!(
5038 rendered.contains("body-line-00"),
5039 "sticky header must pin the block head when scrolled into the body"
5040 );
5041 }
5042
5043 #[test]
5044 fn sticky_header_absent_when_viewport_at_block_head() {
5045 let mut state = RenderState::default();
5047 for i in 0..40u32 {
5048 state.transcript.push(TranscriptLine {
5049 kind: InlineMessageKind::Agent,
5050 segments: vec![plain_segment(format!("head-line-{i:02}"))],
5051 block_id: 0,
5052 });
5053 }
5054 state.scroll_offset = 0;
5055 let rendered = render_frame_to_string(&state);
5056 assert!(rendered.contains("head-line-00"));
5059 }
5060
5061 #[test]
5064 fn turn_end_drains_queue_head() {
5065 let mut state = RenderState::default();
5066 state.queued_inputs = vec!["queued-1".into(), "queued-2".into()];
5067 state.drain_queue_head();
5068 assert_eq!(
5069 state.queued_inputs.len(),
5070 1,
5071 "drain_queue_head must drop the head (now running)"
5072 );
5073 assert_eq!(state.queued_inputs[0], "queued-2");
5074 }
5075
5076 #[test]
5079 fn render_frame_paints_transcript_content() {
5080 let mut state = RenderState::default();
5084 state.transcript.push(TranscriptLine {
5085 kind: InlineMessageKind::Agent,
5086 segments: vec![plain_segment("frame-content-marker-xyz")],
5087 block_id: 0,
5088 });
5089 let rendered = render_frame_to_string(&state);
5090 assert!(
5091 rendered.contains("frame-content-marker-xyz"),
5092 "render_frame must paint transcript content"
5093 );
5094 }
5095
5096 #[test]
5097 fn file_search_dropdown_renders_results() {
5098 use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
5099 let mut state = RenderState::default();
5100 state.input_enabled = true;
5101 state.file_search = Some(FileSearchState {
5102 query: "main".into(),
5103 at_offset: 0,
5104 hidden_mode: false,
5105 results: vec![
5106 FileSearchResult {
5107 path: "src/main.rs".into(),
5108 score: 100,
5109 },
5110 FileSearchResult {
5111 path: "tests/main.rs".into(),
5112 score: 50,
5113 },
5114 ],
5115 selected: 0,
5116 index: vec![],
5117 line_mode: false,
5118 });
5119 let rendered = render_frame_to_string(&state);
5120 assert!(rendered.contains("Files"), "dropdown title must render");
5121 assert!(
5122 rendered.contains("src/main.rs"),
5123 "dropdown must show file paths"
5124 );
5125 }
5126
5127 #[test]
5128 fn file_search_dropdown_hidden_mode_title() {
5129 use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
5130 let mut state = RenderState::default();
5131 state.input_enabled = true;
5132 state.file_search = Some(FileSearchState {
5133 query: "".into(),
5134 at_offset: 0,
5135 hidden_mode: true,
5136 results: vec![FileSearchResult {
5137 path: ".env".into(),
5138 score: 0,
5139 }],
5140 selected: 0,
5141 index: vec![],
5142 line_mode: false,
5143 });
5144 let rendered = render_frame_to_string(&state);
5145 assert!(
5146 rendered.contains("hidden"),
5147 "hidden mode must be indicated in title"
5148 );
5149 }
5150
5151 #[test]
5152 fn file_search_and_composer_render_together() {
5153 use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
5154 let mut state = RenderState::default();
5155 state.input_enabled = true;
5156 state.prompt_prefix = "> ".into();
5157 state.input_buffer = "@main".into();
5158 state.input_cursor = 5;
5159 state.file_search = Some(FileSearchState {
5160 query: "main".into(),
5161 at_offset: 0,
5162 hidden_mode: false,
5163 results: vec![FileSearchResult {
5164 path: "src/main.rs".into(),
5165 score: 100,
5166 }],
5167 selected: 0,
5168 index: vec![],
5169 line_mode: false,
5170 });
5171 let rendered = render_frame_to_string(&state);
5172 assert!(rendered.contains('>'), "composer must still render");
5174 assert!(
5175 rendered.contains("src/main.rs"),
5176 "dropdown must render alongside composer"
5177 );
5178 }
5179
5180 #[test]
5181 fn flatten_todo_items_preserves_order_and_status() {
5182 use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
5183 let phases = vec![
5184 TodoPhase {
5185 name: "A".into(),
5186 tasks: vec![
5187 TodoItem {
5188 content: "write code".into(),
5189 status: TodoStatus::InProgress,
5190 notes: None,
5191 block_reason: None,
5192 },
5193 TodoItem {
5194 content: "write tests".into(),
5195 status: TodoStatus::Pending,
5196 notes: None,
5197 block_reason: None,
5198 },
5199 ],
5200 },
5201 TodoPhase {
5202 name: "B".into(),
5203 tasks: vec![TodoItem {
5204 content: "waiting on review".into(),
5205 status: TodoStatus::Blocked,
5206 notes: None,
5207 block_reason: None,
5208 }],
5209 },
5210 ];
5211 let flat = flatten_todo_items(&phases);
5212 assert_eq!(flat.len(), 3);
5213 assert_eq!(flat[0], ("write code".to_string(), TodoStatus::InProgress));
5214 assert_eq!(flat[1], ("write tests".to_string(), TodoStatus::Pending));
5215 assert_eq!(
5216 flat[2],
5217 ("waiting on review".to_string(), TodoStatus::Blocked)
5218 );
5219 }
5220
5221 #[test]
5222 fn todo_pane_renders_when_items_present() {
5223 let mut state = RenderState::default();
5226 state.todo_items = vec![
5227 ("active task".to_string(), TodoStatus::InProgress),
5228 ("open task".to_string(), TodoStatus::Pending),
5229 ];
5230 let rendered = render_frame_to_string(&state);
5231 assert!(
5232 rendered.contains("active task"),
5233 "in-progress task must render"
5234 );
5235 assert!(rendered.contains("open task"), "pending task must render");
5236 assert!(
5238 rendered.contains('\u{25B6}'),
5239 "in-progress glyph must render"
5240 );
5241 }
5242
5243 #[test]
5244 fn todo_pane_hidden_when_empty() {
5245 let state = RenderState::default();
5246 let rendered = render_frame_to_string(&state);
5247 assert!(!rendered.contains('\u{2611}'), "no checkmark when empty");
5249 }
5250}