1#![allow(
2 clippy::field_reassign_with_default,
3 clippy::let_and_return,
4 clippy::borrow_interior_mutable_const,
5 clippy::derivable_impls
6)]
7use std::collections::VecDeque;
11use std::io::{self, Stdout, Write};
12use std::path::PathBuf;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicBool, Ordering};
15
16use anyhow::Result;
17use crossterm::{
18 cursor::{Hide, Show},
19 event::{
20 self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, KeyEventKind,
21 KeyModifiers, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
22 PushKeyboardEnhancementFlags,
23 },
24 execute,
25 terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate, disable_raw_mode, enable_raw_mode},
26};
27use oxicode_agent::AgentEvent;
28use oxicode_agent::config::Mode;
29use oxicode_agent::tools::TodoStateProvider;
30use oxicode_agent::tools::todo::{TodoItem, TodoPhase, TodoStatus};
31use oxicode_vtui::theme::{ThemeStyles, active_styles};
32use oxicode_vtui::tui::core::{
33 AuthAction, InlineCommand, InlineEvent, InlineHandle, InlineHeaderContext,
34 InlineHeaderStatusBadge, InlineHeaderStatusTone, InlineListItem, InlineListSelection,
35 InlineMessageKind, InlineSegment, InlineTextStyle, OverlayRequest, OverlaySubmission,
36 SecurePromptConfig,
37};
38use ratatui::{
39 Frame, Terminal, TerminalOptions, Viewport,
40 backend::CrosstermBackend,
41 buffer::Buffer,
42 layout::{Alignment, Margin, Position, Rect},
43 style::{Color, Modifier, Style},
44 text::{Line, Span},
45 widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph, Wrap},
46};
47use unicode_width::UnicodeWidthStr;
48
49use crate::App;
50use crate::app::agent_hub_registry::HubEntry;
51use crate::app::agent_session::SessionEvent;
52use crate::tui_vt::keymap::{GlobalAction, KeyCombo, Keymap};
53use crate::tui_vt::settings_defs::{
54 SETTING_DEFS, SettingKey, SettingWidget, SettingsMapRow, SettingsTab, defs_for_tab,
55 get_display_value,
56};
57use crate::tui_vt::slash::file_commands::FileCommand;
58use crate::tui_vt::slash::registry::{
59 SlashCtx, SlashOutcome, SlashRegistry, settings_overlay_items,
60};
61use oxicode_vtui::presentation::{
62 BlockAlloc, BlockDisplayMode, TranscriptLine, VisibleItem, allocate_rows, visible_items,
63};
64use oxicode_vtui::tui::ui::clamp_segments_to_width;
65
66use oxicode_textarea::{EditBuffer, ElementKind, TextArea, TextAreaState};
67
68use ratatui::widgets::FrameExt;
69const MASKED_ELEMENT_KIND: ElementKind = ElementKind(1);
74pub struct Tui {
84 terminal: Terminal<CrosstermBackend<Stdout>>,
85 tty_ok: bool,
86}
87
88impl Tui {
89 pub fn enter() -> Result<Self> {
97 Self::set_panic_hook();
98
99 let tty_ok = enable_raw_mode().is_ok();
100 let mut stdout = io::stdout();
101
102 if tty_ok {
103 let flags = if std::env::var("OXICODE_KITTY_KEYBOARD").as_deref() == Ok("1") {
107 KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
108 | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
109 | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
110 } else {
111 KeyboardEnhancementFlags::REPORT_EVENT_TYPES
112 };
113 let _ = execute!(
114 stdout,
115 Hide,
116 EnableBracketedPaste,
117 PushKeyboardEnhancementFlags(flags)
118 );
119 let _ = stdout.flush();
120 }
121
122 let mut terminal = None;
132 if tty_ok {
133 let height = crossterm::terminal::size().map(|s| s.1).unwrap_or(24);
134 let backend = CrosstermBackend::new(stdout);
135 terminal = Terminal::with_options(
136 backend,
137 TerminalOptions {
138 viewport: Viewport::Inline(height),
139 },
140 )
141 .ok();
142 }
143 let mut terminal = match terminal {
144 Some(t) => t,
145 None => Terminal::new(CrosstermBackend::new(io::stdout()))?,
146 };
147 if tty_ok {
148 let _ = terminal.clear();
149 }
150
151 Ok(Self { terminal, tty_ok })
152 }
153 pub fn exit(&mut self) -> Result<()> {
157 if self.tty_ok {
158 let _ = execute!(
159 self.terminal.backend_mut(),
160 PopKeyboardEnhancementFlags,
161 DisableBracketedPaste
162 );
163 let _ = self.terminal.show_cursor();
164 disable_raw_mode()?;
166 self.tty_ok = false;
167 }
168 Ok(())
169 }
170
171 fn set_panic_hook() {
175 let original_hook = std::panic::take_hook();
176 std::panic::set_hook(Box::new(move |panic_info| {
177 let _ = execute!(io::stdout(), Show);
178 let _ = disable_raw_mode();
179 original_hook(panic_info);
180 }));
181 }
182}
183
184impl Drop for Tui {
185 fn drop(&mut self) {
186 let _ = self.exit();
187 }
188}
189
190#[derive(Default)]
198struct PromptQueue {
199 pending: parking_lot::Mutex<VecDeque<String>>,
200 wake: tokio::sync::Notify,
201}
202
203impl PromptQueue {
204 fn enqueue(&self, prompt: String) {
205 self.pending.lock().push_back(prompt);
206 self.wake.notify_one();
207 }
208
209 fn remove(&self, index: usize) -> Option<String> {
210 self.pending.lock().remove(index)
211 }
212
213 fn move_by(&self, index: usize, delta: isize) -> bool {
214 let mut pending = self.pending.lock();
215 let Some(target) = index.checked_add_signed(delta) else {
216 return false;
217 };
218 if index >= pending.len() || target >= pending.len() {
219 return false;
220 }
221 pending.swap(index, target);
222 true
223 }
224
225 async fn next(&self) -> String {
226 loop {
227 let notified = self.wake.notified();
228 if let Some(prompt) = self.pending.lock().pop_front() {
229 return prompt;
230 }
231 notified.await;
232 }
233 }
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
248pub(crate) enum BrainChip {
249 #[default]
251 Off,
252 Down,
254 Degraded,
256 Ok,
258}
259
260impl BrainChip {
261 pub(crate) fn chip_label(self) -> Option<(&'static str, bool)> {
263 match self {
264 BrainChip::Off => None,
265 BrainChip::Ok => Some(("brain·ok", true)),
266 BrainChip::Degraded | BrainChip::Down => Some(("brain·down", false)),
267 }
268 }
269}
270
271#[derive(Debug, Clone)]
278pub(crate) struct RunState {
279 pub started_at: std::time::Instant,
281 pub turn: u32,
283 pub tool_calls: u32,
285}
286
287impl Default for RunState {
288 fn default() -> Self {
289 Self {
290 started_at: std::time::Instant::now(),
291 turn: 0,
292 tool_calls: 0,
293 }
294 }
295}
296
297pub struct RenderState {
298 pub composer: oxicode_textarea::TextArea,
300 pub transcript: Vec<TranscriptLine>,
302 pub scroll_offset: usize,
305 pub committed_entries: usize,
310 pub viewport_width: u16,
315 pub last_viewport_height: u16,
319 pub glyph_set: crate::symbols::GlyphSet,
322 pub image_previews: super::image_preview::ImagePreviews,
327 pub header_context: InlineHeaderContext,
329 pub input_enabled: bool,
331 pub prompt_prefix: String,
333 pub placeholder: Option<String>,
335 pub shutdown_requested: bool,
337 pub message_buffer: String,
339 pub thinking_buffer: String,
341 pub md_cache: oxicode_vtui::tui::ui::markdown::MdRenderCache,
346 pub stream_anchor: Option<usize>,
352 pub agent_hub_open: bool,
354 pub hub_entries: Vec<(String, HubEntry)>,
356 pub hub: Option<crate::app::agent_hub_registry::SharedHubRegistry>,
359 pub pending_quit: bool,
361 pub slash_popup: SlashPopup,
363 pub reasoning_stage: Option<String>,
365 pub(crate) active_run: Option<RunState>,
369 pub stream_reveal: usize,
375 pub(crate) brain: BrainChip,
377 pub thinking_level: String,
379 pub context_tokens: Option<usize>,
382 pub context_window: usize,
384 pub overlay: Option<OverlayState>,
386 pub overlay_model_ids: Vec<String>,
388 pub overlay_catalog_models: Vec<(String, String)>,
391 pub overlay_providers: Vec<String>,
393 pub catalog: Option<std::sync::Arc<dyn oxicode_sdk::ports::catalog::ModelCatalog>>,
396 pub queued_inputs: Vec<String>,
398 pub queue_panel_open: bool,
400 pub queue_selected: usize,
402 pub shell_mode: bool,
404 pub follow_ups: Vec<String>,
406 pub todo_phases: Vec<TodoPhase>,
408 pub todo_expanded: bool,
410 pub todo_clear_deadline: Option<std::time::Instant>,
412 pub todo_clear_delay_secs: i64,
415 pub todo_provider: Option<Arc<dyn TodoStateProvider>>,
418 pub vim_state: crate::tui_vt::vim::VimState,
420 pub vim_clipboard: String,
422 pub search: Option<SearchState>,
424 pub block_display: std::collections::HashMap<usize, BlockDisplayMode>,
428 pub last_esc_at: Option<std::time::Instant>,
430 pub multiline_mode: bool,
432 pub autonomy_mode: Mode,
436 pub prompt_history: Vec<String>,
438 pub history_pos: Option<usize>,
440 pub next_block_id: usize,
442 pub cancel_grace_until: Option<std::time::Instant>,
445 pub confirmation: Option<ModalConfirmation>,
449 pub tip: Option<EphemeralTip>,
452 pub cwd: PathBuf,
454 pub file_search: Option<crate::tui_vt::file_search::FileSearchState>,
456 pub(crate) issues_panel: Option<crate::tui_vt::issues_panel::IssuesPanelState>,
458 pub issue_store: Option<std::sync::Arc<oxicode_sdk::FileIssueStore>>,
460 pub ownership_session_id: String,
466 pub seen_tips: std::collections::HashMap<&'static str, u32>,
468 pub file_commands: Vec<FileCommand>,
471 pub secure_input_origin: Option<SecureInputOrigin>,
482 pub session_swapper: Option<Arc<crate::app::agent_session_handle::SessionSwapper>>,
487 pub pending_resume: Option<PathBuf>,
492 pub session_state: Option<crate::SessionState>,
493 pub settings_active_tab: crate::tui_vt::settings_defs::SettingsTab,
497 pub settings_map_rows: Vec<Option<SettingsMapRow>>,
505 pub keymap: Arc<parking_lot::RwLock<crate::tui_vt::keymap::Keymap>>,
511 #[cfg(test)]
518 pub settings_override_path: Option<std::path::PathBuf>,
519
520 pub git_tui: Option<crate::tui_vt::git_tui::GitTuiState>,
525 pub git_tui_viewport: (u16, u16),
529}
530
531impl Default for RenderState {
532 fn default() -> Self {
533 Self {
535 composer: oxicode_textarea::TextArea::new(),
536 transcript: Vec::new(),
537 scroll_offset: usize::MAX,
538 committed_entries: 0,
539 header_context: InlineHeaderContext::default(),
540 input_enabled: false,
541 prompt_prefix: String::new(),
542 placeholder: None,
543 thinking_buffer: String::new(),
544 shutdown_requested: false,
545 message_buffer: String::new(),
546 stream_anchor: None,
547 md_cache: oxicode_vtui::tui::ui::markdown::MdRenderCache::default(),
548 agent_hub_open: false,
549 hub_entries: Vec::new(),
550 hub: None,
551 pending_quit: false,
552 slash_popup: SlashPopup::default(),
553 reasoning_stage: None,
554 active_run: None,
555 stream_reveal: usize::MAX,
556 thinking_level: "medium".to_string(),
557 viewport_width: 80,
558 last_viewport_height: 24,
559 glyph_set: crate::symbols::GlyphSet::default(),
560 image_previews: super::image_preview::ImagePreviews::default(),
561 overlay: None,
562 overlay_model_ids: Vec::new(),
563 overlay_catalog_models: Vec::new(),
564 overlay_providers: Vec::new(),
565 catalog: None,
566 queued_inputs: Vec::new(),
567 queue_panel_open: false,
568 queue_selected: 0,
569 shell_mode: false,
570 follow_ups: Vec::new(),
571 todo_phases: Vec::new(),
572 todo_expanded: false,
573 todo_clear_deadline: None,
574 todo_clear_delay_secs: -1,
575 todo_provider: None,
576 vim_state: crate::tui_vt::vim::VimState::default(),
577 vim_clipboard: String::new(),
578 search: None,
579 block_display: std::collections::HashMap::new(),
580 last_esc_at: None,
581 multiline_mode: false,
582 autonomy_mode: Mode::default(),
583 prompt_history: Vec::new(),
584 history_pos: None,
585 next_block_id: 0,
586 cancel_grace_until: None,
587 confirmation: None,
588 tip: None,
589 cwd: PathBuf::new(),
590 file_search: None,
591 issues_panel: None,
592 ownership_session_id: String::new(),
593 issue_store: None,
594 seen_tips: std::collections::HashMap::new(),
595 file_commands: Vec::new(),
596 secure_input_origin: None,
597 session_swapper: None,
598 context_tokens: None,
599 context_window: 128_000,
600 settings_active_tab: crate::tui_vt::settings_defs::SettingsTab::General,
601 settings_map_rows: Vec::new(),
602 keymap: Arc::new(parking_lot::RwLock::new(Keymap::from_settings(
606 &std::collections::HashMap::new(),
607 ))),
608 #[cfg(test)]
609 settings_override_path: None,
610 brain: BrainChip::default(),
611 pending_resume: None,
612 session_state: None,
613 git_tui: None,
614 git_tui_viewport: (80, 24),
615 }
616 }
617}
618
619#[derive(Clone, Debug, PartialEq, Eq)]
623pub enum SecureInputOrigin {
624 SetKey { provider: String },
628 NewlyAdded { provider: String },
632 ModelRoleKey,
635 ModelRoleValue { role: String },
638 TextEdit(crate::tui_vt::settings_defs::SettingKey),
643}
644
645#[derive(Clone, Debug)]
647pub struct SearchState {
648 pub query: String,
649 pub matches: Vec<usize>,
651 pub current: usize,
653}
654
655#[derive(Clone)]
657pub struct SlashPopupItem {
658 pub label: String,
660 pub description: String,
662 pub name: String,
664}
665
666#[derive(Default, Clone)]
671pub struct SlashPopup {
672 pub open: bool,
673 pub items: Vec<SlashPopupItem>,
674 pub selected: usize,
675}
676
677#[derive(Clone, Debug)]
681pub struct OverlayListItem {
682 pub title: String,
683 pub subtitle: Option<String>,
684 pub badge: Option<String>,
685 pub indent: u8,
686 pub search_value: Option<String>,
687 pub selection: Option<oxicode_vtui::tui::core::InlineListSelection>,
689}
690
691#[derive(Clone, Debug, Default)]
700pub struct OverlayState {
701 pub title: String,
702 pub lines: Vec<String>,
703 pub items: Vec<OverlayListItem>,
704 pub selected: usize,
705 pub search: Option<OverlaySearchState>,
706 pub secure_input: Option<OverlaySecureInput>,
707 pub tabs: Vec<String>,
709 pub active_tab: usize,
711 pub sections: Vec<String>,
714 pub active_section: usize,
717 pub key_capture: Option<String>,
723}
724
725#[derive(Clone, Debug)]
733pub struct OverlaySecureInput {
734 pub config: SecurePromptConfig,
735 pub editor: EditBuffer,
736}
737
738#[derive(Clone, Debug)]
742pub struct ModalConfirmation {
743 pub title: String,
744 pub message: String,
745 pub action: ConfirmationAction,
748}
749
750#[derive(Clone, Debug, PartialEq, Eq)]
752pub enum ConfirmationAction {
753 Quit,
755 ClearConversation,
757 RemoveProviderKey(String),
759 CloseIssue(u32),
761}
762
763#[derive(Clone, Debug)]
767pub struct EphemeralTip {
768 pub text: String,
769 pub born_tick: u64,
771 pub ttl_ticks: u64,
773 pub key: &'static str,
776 pub ambient: bool,
780}
781
782#[derive(Clone, Debug)]
784pub struct OverlaySearchState {
785 pub label: String,
786 pub placeholder: Option<String>,
787 pub value: String,
788}
789
790impl RenderState {
791 fn new_with_header(header: InlineHeaderContext) -> Self {
792 let mut s = Self::default();
793 s.header_context = header;
794 s.prompt_prefix = "> ".to_string();
795 s.input_enabled = true;
796 let bindings = crate::store::settings::Settings::load()
799 .unwrap_or_default()
800 .keybindings;
801 *s.keymap.write() = Keymap::from_settings(&bindings);
802 s
803 }
804
805 pub fn swapper(&self) -> Arc<crate::app::agent_session_handle::SessionSwapper> {
810 self.session_swapper
811 .clone()
812 .expect("RenderState::session_swapper must be initialized at TUI startup")
813 }
814
815 fn append_line(&mut self, kind: InlineMessageKind, segments: Vec<InlineSegment>) {
821 let block_id = self.block_id_for_kind(kind);
822 self.transcript
823 .extend(
824 Self::segments_by_explicit_line(segments)
825 .into_iter()
826 .map(|segments| TranscriptLine {
827 kind,
828 segments,
829 block_id,
830 }),
831 );
832 }
833
834 fn append_line_new_block(&mut self, kind: InlineMessageKind, segments: Vec<InlineSegment>) {
838 let block_id = self.fresh_block_id();
839 self.transcript
840 .extend(
841 Self::segments_by_explicit_line(segments)
842 .into_iter()
843 .map(|segments| TranscriptLine {
844 kind,
845 segments,
846 block_id,
847 }),
848 );
849 }
850
851 fn inline_segment(&mut self, kind: InlineMessageKind, segment: InlineSegment) {
854 let mut lines = Self::segments_by_explicit_line(vec![segment]).into_iter();
855
856 let streaming = self.stream_anchor.is_some();
861 if let Some(first) = lines.next() {
862 let merge_ok =
863 streaming && self.transcript.last().is_some_and(|last| last.kind == kind);
864 if merge_ok && let Some(last) = self.transcript.last_mut() {
865 last.segments.extend(first);
866 } else {
867 let block_id = self.fresh_block_id();
870 if self.stream_anchor.is_none() {
871 self.stream_anchor = Some(self.transcript.len());
872 }
873 self.transcript.push(TranscriptLine {
874 kind,
875 segments: first,
876 block_id,
877 });
878 }
879 }
880 let block_id = self
881 .stream_anchor
882 .and_then(|a| self.transcript.get(a))
883 .map(|l| l.block_id)
884 .unwrap_or_else(|| self.fresh_block_id());
885 self.transcript.extend(lines.map(|segments| TranscriptLine {
886 kind,
887 segments,
888 block_id,
889 }));
890 }
891
892 fn segments_by_explicit_line(segments: Vec<InlineSegment>) -> Vec<Vec<InlineSegment>> {
895 if !segments.iter().any(|segment| segment.text.contains('\n')) {
896 return vec![segments];
897 }
898
899 let mut lines = vec![Vec::new()];
900 for segment in segments {
901 let InlineSegment { text, style } = segment;
902 for (index, part) in text.split('\n').enumerate() {
903 if index > 0 {
904 lines.push(Vec::new());
905 }
906 if !part.is_empty()
907 && let Some(line) = lines.last_mut()
908 {
909 line.push(InlineSegment {
910 text: part.to_string(),
911 style: Arc::clone(&style),
912 });
913 }
914 }
915 }
916 lines
917 }
918
919 fn block_id_for_kind(&mut self, kind: InlineMessageKind) -> usize {
922 if let Some(last) = self.transcript.last()
923 && last.kind == kind
924 {
925 return last.block_id;
926 }
927 let id = self.next_block_id;
928 self.next_block_id += 1;
929 id
930 }
931
932 fn fresh_block_id(&mut self) -> usize {
934 let id = self.next_block_id;
935 self.next_block_id += 1;
936 id
937 }
938
939 pub fn start_search(&mut self, query: &str) {
943 let needle = query.to_lowercase();
944 let matches: Vec<usize> = self
945 .transcript
946 .iter()
947 .enumerate()
948 .filter(|(i, _)| *i >= self.committed_entries)
951 .filter(|(_, line)| {
952 line.segments
953 .iter()
954 .any(|s| s.text.to_lowercase().contains(&needle))
955 })
956 .map(|(i, _)| i)
957 .collect();
958 self.search = Some(SearchState {
959 query: query.to_string(),
960 matches,
961 current: 0,
962 });
963 if let Some(s) = &self.search
965 && let Some(&first) = s.matches.first()
966 {
967 self.scroll_offset = first;
968 }
969 }
970
971 pub fn search_next(&mut self) {
973 if let Some(s) = &mut self.search
974 && !s.matches.is_empty()
975 {
976 s.current = (s.current + 1) % s.matches.len();
977 let line = s.matches[s.current];
978 self.scroll_offset = line;
979 }
980 }
981
982 pub fn search_prev(&mut self) {
984 if let Some(s) = &mut self.search
985 && !s.matches.is_empty()
986 {
987 if s.current == 0 {
988 s.current = s.matches.len() - 1;
989 } else {
990 s.current -= 1;
991 }
992 let line = s.matches[s.current];
993 self.scroll_offset = line;
994 }
995 }
996
997 pub fn block_mode(&self, block_id: usize) -> BlockDisplayMode {
1003 self.block_display
1004 .get(&block_id)
1005 .copied()
1006 .unwrap_or(BlockDisplayMode::Expanded)
1007 }
1008
1009 pub fn cycle_block_at_view(&mut self) {
1012 let offset = self.effective_offset();
1013 if let Some(line) = self.transcript.get(offset) {
1014 let bid = line.block_id;
1015 let next = match self.block_mode(bid) {
1016 BlockDisplayMode::Collapsed => BlockDisplayMode::Truncated,
1017 BlockDisplayMode::Truncated => BlockDisplayMode::Expanded,
1018 BlockDisplayMode::Expanded => BlockDisplayMode::Collapsed,
1019 };
1020 if next == BlockDisplayMode::Expanded {
1023 self.block_display.remove(&bid);
1024 } else {
1025 self.block_display.insert(bid, next);
1026 }
1027 }
1028 }
1029
1030 pub fn expand_all(&mut self) {
1033 self.block_display.clear();
1034 }
1035
1036 pub fn fold_all(&mut self) {
1038 for bid in self.all_block_ids() {
1039 self.block_display.insert(bid, BlockDisplayMode::Collapsed);
1040 }
1041 }
1042
1043 pub fn truncate_all(&mut self) {
1045 for bid in self.all_block_ids() {
1048 self.block_display.insert(bid, BlockDisplayMode::Truncated);
1049 }
1050 }
1051
1052 fn all_block_ids(&self) -> Vec<usize> {
1054 let mut ids = Vec::new();
1055 let mut prev: Option<usize> = None;
1056 for l in &self.transcript {
1057 if prev != Some(l.block_id) {
1058 ids.push(l.block_id);
1059 prev = Some(l.block_id);
1060 }
1061 }
1062 ids
1063 }
1064
1065 pub fn jump_next_turn(&mut self) {
1069 let offset = self.effective_offset();
1070 let search_after = self
1071 .transcript
1072 .iter()
1073 .enumerate()
1074 .skip(offset + 1)
1075 .find(|(_, l)| l.kind == InlineMessageKind::Agent || l.kind == InlineMessageKind::User);
1076 if let Some((idx, _)) = search_after {
1077 self.scroll_offset = idx;
1078 }
1079 }
1080
1081 pub fn jump_prev_turn(&mut self) {
1083 let offset = self.effective_offset();
1084 let search_before = self
1085 .transcript
1086 .iter()
1087 .enumerate()
1088 .take(offset)
1089 .rev()
1090 .find(|(_, l)| l.kind == InlineMessageKind::User);
1091 if let Some((idx, _)) = search_before {
1092 self.scroll_offset = idx;
1093 }
1094 }
1095
1096 fn effective_offset(&self) -> usize {
1098 if self.scroll_offset == usize::MAX {
1099 self.transcript.len().saturating_sub(1)
1100 } else {
1101 self.scroll_offset
1102 }
1103 }
1104
1105 pub fn drain_queue_head(&mut self) {
1108 if !self.queued_inputs.is_empty() {
1109 self.queued_inputs.remove(0);
1110 }
1111 }
1112
1113 pub fn show_tip(&mut self, key: &'static str, text: &str, ttl: u64, ambient: bool) {
1116 let count = self.seen_tips.entry(key).or_insert(0);
1117 if *count >= SEEN_CAP {
1118 return;
1119 }
1120 *count += 1;
1121 self.tip = Some(EphemeralTip {
1122 text: text.to_string(),
1123 born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
1124 ttl_ticks: ttl,
1125 key,
1126 ambient,
1127 });
1128 }
1129}
1130
1131const SEEN_CAP: u32 = 3;
1133
1134pub async fn run_tui(app: App) -> Result<()> {
1141 let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
1144 let git_branch = crate::util::git_utils::get_current_branch(&cwd);
1145 super::host::activate_theme(app.settings());
1146 let theme_id = oxicode_vtui::theme::active_theme_id();
1148 let validation = oxicode_vtui::theme::validate_theme_contrast(&theme_id);
1149 if validation.warnings.is_empty() {
1150 tracing::debug!("theme '{theme_id}' passed contrast validation");
1151 } else {
1152 for w in &validation.warnings {
1153 tracing::warn!("theme contrast: {w}");
1154 }
1155 }
1156
1157 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
1160 let (evt_tx, mut evt_rx) = tokio::sync::mpsc::unbounded_channel::<InlineEvent>();
1161 let handle = InlineHandle::new_for_tests(cmd_tx);
1162
1163 let session = build_agent_session(&app).await?;
1167 let session_handle = session.clone_handle();
1171
1172 let session_swapper = Arc::new(crate::app::agent_session_handle::SessionSwapper::new(
1176 session_handle.clone(),
1177 ));
1178
1179 let (session_tx, mut session_rx) = tokio::sync::mpsc::unbounded_channel::<SessionEvent>();
1187 let _sub_guard = session.subscribe(Box::new(move |event| {
1188 let _ = session_tx.send(event.clone());
1189 }));
1190
1191 let header = build_header_context(&app, &cwd, git_branch.as_deref());
1193 handle.set_header_context(header.clone());
1194
1195 let mut tui = Tui::enter()?;
1198
1199 handle.set_prompt("> ".to_string(), InlineTextStyle::default());
1203 handle.set_placeholder(Some(
1204 "Describe the task, or type / for commands".to_string(),
1205 ));
1206
1207 let state = Arc::new(parking_lot::Mutex::new(RenderState::new_with_header(
1210 header,
1211 )));
1212 state.lock().ownership_session_id = app.ownership_session_id().to_string();
1213 state.lock().cwd = cwd.clone();
1214 state.lock().catalog = Some(app.catalog());
1215 state.lock().file_commands = crate::tui_vt::slash::file_commands::load_file_commands(&cwd);
1216 state.lock().todo_provider = session_handle.todo_provider();
1217 state.lock().todo_clear_delay_secs = app.settings().todo_clear_delay_secs;
1218 state.lock().hub = Some(session_handle.hub_arc());
1219 state.lock().session_swapper = Some(session_swapper.clone());
1220 state.lock().session_state = Some(app.session_state().clone());
1221 state.lock().thinking_level = format!("{:?}", session.thinking_level()).to_ascii_lowercase();
1222 {
1225 let mut s = state.lock();
1226 sync_model_chips(&mut s, &session_handle);
1227 }
1228 state.lock().tip = Some(EphemeralTip {
1231 text: "Press ? for shortcuts | /help for commands".to_string(),
1232 born_tick: 0,
1233 ttl_ticks: 900,
1234 key: "onboarding",
1235 ambient: true,
1236 });
1237 if std::env::var("SSH_CONNECTION").is_ok() {
1239 state.lock().show_tip(
1240 "ssh_wrap",
1241 "Over SSH? Consider tmux to keep sessions alive",
1242 600,
1243 true,
1244 );
1245 }
1246 let mode_handle = app.ask_bridge().map(|b| {
1250 let handle = b.mode_handle();
1251 state.lock().autonomy_mode = Mode::load(&handle);
1252 handle
1253 });
1254 state.lock().glyph_set = app.settings().glyph_set;
1255 state
1258 .lock()
1259 .image_previews
1260 .set_enabled(app.settings().inline_images);
1261 let prompt_queue = Arc::new(PromptQueue::default());
1262 let (issue_action_tx, mut issue_action_rx) =
1267 tokio::sync::mpsc::unbounded_channel::<crate::tui_vt::issues_panel::IssueActionRequest>();
1268 spawn_input_thread(
1269 state.clone(),
1270 evt_tx.clone(),
1271 mode_handle,
1272 prompt_queue.clone(),
1273 issue_action_tx.clone(),
1274 );
1275
1276 spawn_agent_worker(session_swapper.clone(), prompt_queue.clone());
1282 let (brain_tx, mut brain_rx) =
1288 tokio::sync::watch::channel(crate::services::initial_brain_chip(app.settings()));
1289 {
1290 let memory_enabled = app.settings().memory_enabled;
1291 let announce = handle.clone();
1292 tokio::spawn(async move {
1293 let backend = crate::foundation::brain::BrainMemoryBackend::new(
1294 crate::foundation::brain::default_socket_path(),
1295 );
1296 let mut tick = tokio::time::interval(std::time::Duration::from_secs(20));
1297 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1298 let mut auto_revive_attempted = false;
1301 loop {
1302 tick.tick().await;
1303 let mut chip = if !memory_enabled {
1304 BrainChip::Off
1305 } else if !crate::services::brain_socket_present(
1306 &crate::foundation::brain::default_socket_path(),
1307 ) {
1308 BrainChip::Down
1309 } else {
1310 match tokio::time::timeout(
1311 std::time::Duration::from_millis(1500),
1312 backend.ping(),
1313 )
1314 .await
1315 {
1316 Ok(Ok(())) => BrainChip::Ok,
1317 Ok(Err(_)) | Err(_) => BrainChip::Degraded,
1318 }
1319 };
1320 let down = matches!(chip, BrainChip::Down | BrainChip::Degraded);
1324 if crate::foundation::brain_control::should_auto_revive(
1325 memory_enabled,
1326 down,
1327 auto_revive_attempted,
1328 ) {
1329 auto_revive_attempted = true;
1330 let installed = crate::foundation::brain_control::probe_control()
1331 .binary
1332 .is_some();
1333 if installed {
1334 match crate::foundation::brain_control::revive().await {
1335 Ok(msg) => {
1336 announce.append_line(
1337 InlineMessageKind::Info,
1338 vec![plain_segment(format!("brain: daemon was down — {msg}"))],
1339 );
1340 chip = match tokio::time::timeout(
1342 std::time::Duration::from_millis(1500),
1343 backend.ping(),
1344 )
1345 .await
1346 {
1347 Ok(Ok(())) => BrainChip::Ok,
1348 _ => chip,
1349 };
1350 }
1351 Err(e) => {
1352 announce.append_line(
1353 InlineMessageKind::Warning,
1354 vec![plain_segment(format!(
1355 "brain: auto-restart failed — {e} (run /brain for details)"
1356 ))],
1357 );
1358 }
1359 }
1360 }
1361 }
1362 let _ = brain_tx.send(chip);
1363 }
1364 });
1365 }
1366
1367 let result = run_event_loop(
1368 &mut tui.terminal,
1369 &mut cmd_rx,
1370 &mut evt_rx,
1371 &mut session_rx,
1372 &mut brain_rx,
1373 &handle,
1374 &state,
1375 &session_swapper,
1376 &prompt_queue,
1377 &mut issue_action_rx,
1378 )
1379 .await;
1380
1381 handle.shutdown();
1382 drop(tui);
1384
1385 result
1386}
1387
1388#[allow(clippy::too_many_arguments)]
1392async fn run_event_loop(
1393 terminal: &mut Terminal<CrosstermBackend<Stdout>>,
1394 cmd_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineCommand>,
1395 evt_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineEvent>,
1396 session_rx: &mut tokio::sync::mpsc::UnboundedReceiver<SessionEvent>,
1397 brain_rx: &mut tokio::sync::watch::Receiver<BrainChip>,
1398 handle: &InlineHandle,
1399 state: &Arc<parking_lot::Mutex<RenderState>>,
1400 session_swapper: &Arc<crate::app::agent_session_handle::SessionSwapper>,
1401 prompt_queue: &Arc<PromptQueue>,
1402 issue_action_rx: &mut tokio::sync::mpsc::UnboundedReceiver<
1403 crate::tui_vt::issues_panel::IssueActionRequest,
1404 >,
1405) -> Result<()> {
1406 while let Ok(cmd) = cmd_rx.try_recv() {
1409 apply_command(&mut state.lock(), cmd);
1410 }
1411
1412 {
1421 let mut s = state.lock();
1422 match terminal.size() {
1423 Ok(size) => {
1424 s.viewport_width = size.width;
1425 s.last_viewport_height = size.height;
1426 }
1427 Err(_) => {
1428 s.viewport_width = 0;
1429 s.last_viewport_height = 0;
1430 }
1431 }
1432 }
1433
1434 {
1439 let snapshot = state.lock();
1440 let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
1441 if let Err(err) = terminal.draw(|frame| render_frame(frame, &snapshot, handle)) {
1442 tracing::warn!(?err, "initial tui draw failed");
1443 }
1444 let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
1445 }
1446
1447 let mut render_tick = tokio::time::interval(std::time::Duration::from_millis(50));
1453 render_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1454 let mut last_draw = std::time::Instant::now();
1459 let mut priority = false;
1460
1461 loop {
1462 tokio::select! {
1463 biased;
1466
1467 Some(cmd) = cmd_rx.recv() => {
1469 let shutdown = {
1470 let mut s = state.lock();
1471 apply_command(&mut s, cmd)
1472 };
1473 if shutdown {
1474 break;
1475 }
1476 }
1477
1478 Some(event) = session_rx.recv() => {
1480 if let SessionEvent::HandoffComplete { doc_path, auto_continue } = &event {
1482 let mut s = state.lock();
1483 s.transcript.clear();
1484 s.message_buffer.clear();
1485 s.scroll_offset = usize::MAX;
1486 s.append_line(
1487 InlineMessageKind::Info,
1488 vec![plain_segment(format!(
1489 "Handoff written to {}. New session started.",
1490 doc_path
1491 ))],
1492 );
1493 let user_typed = !s.composer.text().trim().is_empty();
1494 s.composer.set_text("");
1495 if *auto_continue && !user_typed {
1496 drop(s);
1497 prompt_queue.enqueue(format!(
1498 "Read the handoff document at {} and continue \
1499 from where the previous session left off.",
1500 doc_path
1501 ));
1502 } else if user_typed {
1503 drop(s);
1504 handle.append_line(
1505 InlineMessageKind::Info,
1506 vec![plain_segment(
1507 "Handoff complete. Auto-continue skipped \
1508 because input was non-empty \u{2014} press \
1509 Enter to submit your message in the new \
1510 session."
1511 .to_string(),
1512 )],
1513 );
1514 }
1515 let session = session_swapper.current();
1516 handle_session_event(&mut state.lock(), handle, &event, Some(&session));
1517 } else {
1518 let session = session_swapper.current();
1525 handle_session_event(&mut state.lock(), handle, &event, Some(&session));
1526 }
1527 }
1528
1529 Some(evt) = evt_rx.recv() => {
1531 let outcome = handle_inline_event(
1532 &mut state.lock(),
1533 handle,
1534 &session_swapper.current(),
1535 prompt_queue,
1536 evt,
1537 );
1538 if outcome == LoopOutcome::Exit {
1539 break;
1540 }
1541 priority = true;
1542 }
1543
1544 Some(req) = issue_action_rx.recv() => {
1547 crate::tui_vt::issues_panel::dispatch_action(req, state.clone());
1548 }
1549
1550 _ = tokio::signal::ctrl_c() => {
1554 let outcome = {
1555 let mut s = state.lock();
1556 handle_interrupt(&mut s, &session_swapper.current(), handle)
1557 };
1558 if outcome == LoopOutcome::Exit {
1559 break;
1560 }
1561 priority = true;
1562 }
1563 changed = brain_rx.changed() => {
1565 if changed.is_ok() {
1566 state.lock().brain = *brain_rx.borrow_and_update();
1567 priority = true;
1568 }
1569 }
1570
1571 _ = render_tick.tick() => {}
1574 }
1575
1576 if coalesce_draw(last_draw, priority, DRAW_MIN_INTERVAL) == DrawDecision::DrawNow {
1580 if let Ok(size) = terminal.size()
1582 && size.width < 40
1583 {
1584 let mut s = state.lock();
1585 if s.tip.is_none() {
1586 s.show_tip(
1587 "small_screen",
1588 "Terminal too narrow \u{2014} resize for full UI",
1589 300,
1590 true,
1591 );
1592 }
1593 }
1594 let mut snapshot = state.lock();
1596 let mut prev_size: Option<(u16, u16)> = None;
1607 if let Ok(size) = terminal.size() {
1608 prev_size = Some((snapshot.viewport_width, snapshot.last_viewport_height));
1609 snapshot.viewport_width = size.width;
1610 snapshot.last_viewport_height = size.height;
1611 }
1612 if let Some((prev_w, prev_h)) = prev_size
1613 && let Ok(size) = terminal.size()
1614 && should_rebuild_scrollback(prev_w, size.width, prev_h, size.height)
1615 {
1616 let _ = execute!(terminal.backend_mut(), crossterm::style::Print("\x1b[3J"));
1619 let _ = terminal.clear();
1620 snapshot.committed_entries = 0;
1621 }
1626 if let Some(provider) = snapshot.todo_provider.as_ref() {
1629 snapshot.todo_phases = refresh_todo_phases(provider, snapshot.hub.as_ref());
1630 }
1631 let clear_delay = snapshot.todo_clear_delay_secs;
1636 sync_todo_clear_timer(&mut snapshot, clear_delay);
1637 advance_stream_reveal(&mut snapshot);
1640 commit_scrollback(terminal, &mut snapshot, false);
1644 let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
1645 let draw_err = terminal
1646 .draw(|frame| render_frame(frame, &snapshot, handle))
1647 .err();
1648 let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
1649 let committed = snapshot.committed_entries;
1657 let image_escapes = snapshot.image_previews.emit_live(committed);
1658 if !image_escapes.is_empty() {
1659 let _ = execute!(
1660 terminal.backend_mut(),
1661 crossterm::style::Print(image_escapes)
1662 );
1663 }
1664 if let Some(err) = draw_err {
1665 tracing::warn!(?err, "tui draw failed");
1666 break;
1667 }
1668 last_draw = std::time::Instant::now();
1671 priority = false;
1672 }
1673 }
1674
1675 const MAX_EXIT_FLUSH_ITERATIONS: usize = 50;
1682 for _ in 0..MAX_EXIT_FLUSH_ITERATIONS {
1683 let mut snapshot = state.lock();
1684 let before = snapshot.committed_entries;
1685 if snapshot.transcript.is_empty() || before >= snapshot.transcript.len() {
1686 break;
1687 }
1688 if let Some(provider) = snapshot.todo_provider.as_ref() {
1691 snapshot.todo_phases = refresh_todo_phases(provider, snapshot.hub.as_ref());
1692 }
1693 let clear_delay = snapshot.todo_clear_delay_secs;
1698 sync_todo_clear_timer(&mut snapshot, clear_delay);
1699 advance_stream_reveal(&mut snapshot);
1702 commit_scrollback(terminal, &mut snapshot, true);
1706 let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
1707 let draw_err = terminal
1708 .draw(|frame| render_frame(frame, &snapshot, handle))
1709 .err();
1710 let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
1711 if let Some(err) = draw_err {
1712 tracing::warn!(?err, "tui draw failed");
1713 break;
1714 }
1715 }
1716
1717 Ok(())
1718}
1719
1720const DRAW_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50);
1725
1726#[derive(Debug, PartialEq, Eq)]
1728enum DrawDecision {
1729 DrawNow,
1731 Defer,
1734}
1735
1736fn coalesce_draw(
1739 last_draw_at: std::time::Instant,
1740 priority: bool,
1741 min_interval: std::time::Duration,
1742) -> DrawDecision {
1743 if priority || last_draw_at.elapsed() >= min_interval {
1744 DrawDecision::DrawNow
1745 } else {
1746 DrawDecision::Defer
1747 }
1748}
1749
1750#[derive(PartialEq, Eq)]
1751enum LoopOutcome {
1752 Continue,
1753 Exit,
1754}
1755
1756#[derive(PartialEq, Eq, Debug)]
1761enum CancelRoute {
1762 Interrupt,
1765 Exit,
1767}
1768
1769fn route_cancel(is_streaming: bool) -> CancelRoute {
1772 if is_streaming {
1773 CancelRoute::Interrupt
1774 } else {
1775 CancelRoute::Exit
1776 }
1777}
1778
1779fn apply_command(state: &mut RenderState, cmd: InlineCommand) -> bool {
1783 match cmd {
1784 InlineCommand::AppendLine { kind, segments } => {
1785 state.append_line(kind, segments);
1786 }
1787 InlineCommand::Inline { kind, segment } => {
1788 state.inline_segment(kind, segment);
1789 }
1790 InlineCommand::BeginStream { .. } => {
1791 state.stream_anchor = None;
1795 }
1796 InlineCommand::EndStream => {
1797 state.stream_anchor = None;
1803 }
1804 InlineCommand::AppendLineBlockStart { kind, segments } => {
1805 state.append_line_new_block(kind, segments);
1806 }
1807 InlineCommand::ReplaceLast { kind, lines, .. } => {
1808 let from = state.stream_anchor.unwrap_or(state.transcript.len());
1816 state.transcript.truncate(from);
1817 for line in lines {
1818 state.append_line(kind, line);
1819 }
1820 state.stream_anchor = Some(from);
1824 }
1825 InlineCommand::AppendPastedMessage { kind, text, .. } => {
1826 state.append_line(kind, vec![plain_segment(text)]);
1827 }
1828 InlineCommand::SetPrompt { prefix, .. } => {
1829 state.prompt_prefix = prefix;
1830 }
1831 InlineCommand::SetPlaceholder { hint, .. } => {
1832 state.placeholder = hint;
1833 }
1834 InlineCommand::SetHeaderContext { context } => {
1835 state.header_context = *context;
1836 }
1837 InlineCommand::SetInputStatus { .. } => {
1838 }
1842 InlineCommand::SetInputEnabled(enabled) => {
1843 state.input_enabled = enabled;
1844 }
1845 InlineCommand::SetCursorVisible(_) | InlineCommand::ForceRedraw => {}
1846 InlineCommand::SetReasoningStage(stage) => {
1847 state.reasoning_stage = stage;
1848 }
1849 InlineCommand::SetVimModeEnabled(enabled) => {
1850 state.vim_state.set_enabled(enabled);
1851 }
1852 InlineCommand::SetQueuedInputs { entries } => {
1853 state.queued_inputs = entries;
1854 }
1855 InlineCommand::ShowOverlay { request } => {
1856 let mut overlay = materialize_overlay(*request);
1857 let mut map_rows = Vec::new();
1864 if overlay.items.iter().any(|it| {
1865 matches!(
1866 it.selection,
1867 Some(InlineListSelection::ConfigAction(_))
1868 | Some(InlineListSelection::SettingsTab(_))
1869 | Some(InlineListSelection::SettingsSection(_))
1870 | Some(InlineListSelection::SettingKeyCapture(_))
1871 | Some(InlineListSelection::SettingTextEdit(_))
1872 | Some(InlineListSelection::SettingSubmenuOpen(_))
1873 | Some(InlineListSelection::SettingMultiselect(_))
1874 )
1875 }) {
1876 let (hydrated, rows) = build_settings_overlay(state.settings_active_tab, None);
1877 overlay = hydrated;
1878 map_rows = rows;
1879 }
1880 state.overlay = Some(overlay);
1881 state.settings_map_rows = map_rows;
1882 }
1883 InlineCommand::CloseOverlay => {
1884 state.overlay = None;
1885 }
1886 InlineCommand::Shutdown => {
1887 state.shutdown_requested = true;
1888 return true;
1889 }
1890 _ => {
1891 tracing::trace!("unhandled InlineCommand (not rendered)");
1894 }
1895 }
1896 false
1897}
1898
1899fn materialize_overlay(request: OverlayRequest) -> OverlayState {
1904 match request {
1905 OverlayRequest::Modal(req) => {
1906 let secure_input = req.secure_prompt.map(|cfg| OverlaySecureInput {
1907 config: cfg,
1908 editor: EditBuffer::new(),
1909 });
1910 OverlayState {
1911 title: req.title,
1912 lines: req.lines,
1913 items: Vec::new(),
1914 selected: 0,
1915 search: None,
1916 secure_input,
1917 ..Default::default()
1918 }
1919 }
1920 OverlayRequest::List(req) => {
1921 let search = req.search.map(|cfg| OverlaySearchState {
1922 label: cfg.label,
1923 placeholder: cfg.placeholder,
1924 value: String::new(),
1925 });
1926 OverlayState {
1927 title: req.title,
1928 lines: req.lines,
1929 items: req.items.into_iter().map(overlay_item_from).collect(),
1930 selected: 0,
1931 search,
1932 secure_input: None,
1933 ..Default::default()
1934 }
1935 }
1936 OverlayRequest::Wizard(req) => {
1937 let step_items = req
1941 .steps
1942 .first()
1943 .map(|s| {
1944 s.items
1945 .iter()
1946 .map(|it| overlay_item_from(it.clone()))
1947 .collect()
1948 })
1949 .unwrap_or_default();
1950 let search = req.search.map(|cfg| OverlaySearchState {
1951 label: cfg.label,
1952 placeholder: cfg.placeholder,
1953 value: String::new(),
1954 });
1955 OverlayState {
1956 title: req.title,
1957 lines: Vec::new(),
1958 items: step_items,
1959 selected: 0,
1960 search,
1961 secure_input: None,
1962 ..Default::default()
1963 }
1964 }
1965 }
1966}
1967fn overlay_item_from(item: InlineListItem) -> OverlayListItem {
1968 OverlayListItem {
1969 title: item.title,
1970 subtitle: item.subtitle,
1971 badge: item.badge,
1972 indent: item.indent,
1973 search_value: item.search_value,
1974 selection: item.selection,
1975 }
1976}
1977
1978const SETTINGS_TABS: &[(SettingsTab, &str)] = &[
1982 (SettingsTab::General, "General"),
1983 (SettingsTab::Model, "Model"),
1984 (SettingsTab::Interaction, "Interaction"),
1985 (SettingsTab::Tools, "Tools"),
1986 (SettingsTab::Ui, "UI"),
1987 (SettingsTab::AdvisorMemory, "Advisor & Memory"),
1988 (SettingsTab::Keybindings, "Keybindings"),
1989 (SettingsTab::Advanced, "Advanced"),
1990];
1991
1992fn build_settings_overlay(
2001 tab: SettingsTab,
2002 keep_search: Option<OverlaySearchState>,
2003) -> (OverlayState, Vec<Option<SettingsMapRow>>) {
2004 let settings = crate::store::settings::Settings::load().unwrap_or_default();
2005 let (items, map_rows) = settings_overlay_items(tab, &settings);
2006 let items: Vec<OverlayListItem> = items.into_iter().map(overlay_item_from).collect();
2007 let mut sections: Vec<String> = Vec::new();
2008 for def in defs_for_tab(tab, &settings) {
2009 if sections.last().map(String::as_str) != Some(def.group) {
2010 sections.push(def.group.to_string());
2011 }
2012 }
2013 let active_tab = SETTINGS_TABS
2014 .iter()
2015 .position(|(t, _)| *t == tab)
2016 .unwrap_or(0);
2017 (
2018 OverlayState {
2019 title: "Settings".into(),
2020 lines: vec!["Browse settings by group; filter with the search bar.".into()],
2021 items,
2022 selected: 0,
2023 search: keep_search.or(Some(OverlaySearchState {
2024 label: "Filter settings".into(),
2025 placeholder: Some("Type to filter".into()),
2026 value: String::new(),
2027 })),
2028 secure_input: None,
2029 tabs: SETTINGS_TABS
2030 .iter()
2031 .map(|(_, name)| name.to_string())
2032 .collect(),
2033 active_tab,
2034 sections,
2035 active_section: 0,
2036 key_capture: None,
2037 },
2038 map_rows,
2039 )
2040}
2041
2042fn reopen_settings_panel(state: &mut RenderState, tab: SettingsTab, status: Option<String>) {
2048 let keep_search = state.overlay.as_ref().and_then(|o| o.search.clone());
2049 state.settings_active_tab = tab;
2050 let (mut overlay, map_rows) = build_settings_overlay(tab, keep_search);
2051 if let Some(status) = status {
2052 if overlay.lines.is_empty() {
2053 overlay.lines.push(status);
2054 } else {
2055 overlay.lines[0] = status;
2056 }
2057 }
2058 state.overlay = Some(overlay);
2059 state.settings_map_rows = map_rows;
2060}
2061
2062fn switch_settings_tab(state: &mut RenderState, tab_idx: usize) {
2068 let Some(&(tab, _)) = SETTINGS_TABS.get(tab_idx) else {
2069 return;
2070 };
2071 let search = state.overlay.as_ref().and_then(|o| o.search.clone());
2072 state.settings_active_tab = tab;
2073 let (overlay, map_rows) = build_settings_overlay(tab, search);
2074 state.overlay = Some(overlay);
2075 state.settings_map_rows = map_rows;
2076}
2077
2078fn jump_settings_section(state: &mut RenderState, section_idx: usize) {
2083 let tab = state.settings_active_tab;
2084 let search = state.overlay.as_ref().and_then(|o| o.search.clone());
2085 let (mut overlay, map_rows) = build_settings_overlay(tab, search);
2086 if let Some(target) = overlay.sections.get(section_idx).cloned() {
2087 let mut in_target = false;
2091 let mut anchor: Option<usize> = None;
2092 for (idx, item) in overlay.items.iter().enumerate() {
2093 let is_heading =
2094 item.selection.is_none() && item.badge.is_none() && item.subtitle.is_none();
2095 if is_heading {
2096 in_target = item.title == target;
2097 } else if in_target && anchor.is_none() {
2098 anchor = Some(idx);
2099 }
2100 }
2101 if let Some(idx) = anchor {
2102 overlay.selected = idx;
2103 overlay.active_section = section_idx;
2104 }
2105 }
2106 state.overlay = Some(overlay);
2107 state.settings_map_rows = map_rows;
2108}
2109
2110fn build_key_capture_overlay(action_name: &str) -> OverlayState {
2117 OverlayState {
2118 title: format!("Keybinding: {action_name}"),
2119 lines: vec![format!(
2120 "Press a key combo for {action_name} (Esc to cancel)\u{2026}"
2121 )],
2122 key_capture: Some(action_name.to_string()),
2123 ..Default::default()
2124 }
2125}
2126
2127fn key_event_to_combo_text(key: KeyEvent) -> Option<(String, KeyCombo)> {
2137 use crossterm::event::KeyCode as Kc;
2138 let mods = key.modifiers & (KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT);
2139 let code = match key.code {
2140 Kc::Char(c) if c.is_ascii_lowercase() && mods.contains(KeyModifiers::SHIFT) => {
2143 Kc::Char(c.to_ascii_uppercase())
2144 }
2145 other => other,
2146 };
2147 let combo = KeyCombo {
2148 code,
2149 modifiers: mods,
2150 };
2151 let text = combo.to_string();
2152 (KeyCombo::parse(&text) == Some(combo.clone())).then_some((text, combo))
2156}
2157
2158fn handle_key_capture(state: &mut RenderState, key: KeyEvent) {
2166 let Some(action_name) = state.overlay.as_ref().and_then(|o| o.key_capture.clone()) else {
2167 return;
2168 };
2169 let Some(action) = GlobalAction::from_name(&action_name) else {
2170 state.overlay = None;
2173 state.settings_map_rows.clear();
2174 return;
2175 };
2176 if key.code == KeyCode::Esc
2178 && !key
2179 .modifiers
2180 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
2181 {
2182 reopen_settings_panel(state, SettingsTab::Keybindings, None);
2183 return;
2184 }
2185 let Some((text, _combo)) = key_event_to_combo_text(key) else {
2186 set_capture_prompt_line(
2187 state,
2188 "That key can't be captured (F-keys and arrows don't round-trip). \
2189 Try another combo, Esc to cancel\u{2026}",
2190 );
2191 return;
2192 };
2193 if !key
2194 .modifiers
2195 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
2196 {
2197 set_capture_prompt_line(
2198 state,
2199 &format!(
2200 "'{text}' has no Ctrl or Alt — it would hijack typing. \
2201 Try another combo, Esc to cancel\u{2026}"
2202 ),
2203 );
2204 return;
2205 }
2206 let mut combos: Vec<String> = state
2209 .keymap
2210 .read()
2211 .action_combos(action)
2212 .iter()
2213 .map(|c| c.to_string())
2214 .collect();
2215 if !combos.iter().any(|c| c == &text) {
2216 combos.push(text.clone());
2217 }
2218 match commit_keybindings(state, action, combos) {
2219 Ok(()) => reopen_settings_panel(
2220 state,
2221 SettingsTab::Keybindings,
2222 Some(format!("Captured {text} for {action_name}")),
2223 ),
2224 Err(e) => set_capture_prompt_line(
2225 state,
2226 &format!("Failed to save keybindings: {e} — Esc to cancel\u{2026}"),
2227 ),
2228 }
2229}
2230
2231fn set_capture_prompt_line(state: &mut RenderState, line: &str) {
2232 if let Some(overlay) = state.overlay.as_mut() {
2233 overlay.lines = vec![line.to_string()];
2234 }
2235}
2236
2237fn commit_keybindings(
2243 state: &mut RenderState,
2244 action: GlobalAction,
2245 combos: Vec<String>,
2246) -> anyhow::Result<()> {
2247 let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
2248 crate::tui_vt::settings_defs::set_action_combos(&mut settings, action, combos);
2249 save_settings_sandboxed(state, &settings)?;
2250 *state.keymap.write() = Keymap::from_settings(&settings.keybindings);
2251 Ok(())
2252}
2253
2254fn save_settings_sandboxed(
2260 state: &RenderState,
2261 settings: &crate::store::settings::Settings,
2262) -> anyhow::Result<()> {
2263 #[cfg(test)]
2264 {
2265 if let Some(path) = state.settings_override_path.as_ref() {
2266 if let Some(parent) = path.parent() {
2267 std::fs::create_dir_all(parent).ok();
2268 }
2269 return settings.save_to(path);
2270 }
2271 }
2272 let _ = state; settings.save()
2274}
2275
2276fn remove_keybinding_combo(state: &mut RenderState, action: GlobalAction, combo: &str) {
2282 let current: Vec<String> = state
2283 .keymap
2284 .read()
2285 .action_combos(action)
2286 .iter()
2287 .map(|c| c.to_string())
2288 .collect();
2289 if current.len() <= 1 {
2290 reopen_settings_panel(
2291 state,
2292 SettingsTab::Keybindings,
2293 Some(format!(
2294 "Refusing to remove the last combo for {} — add another first (Enter on the \
2295 action row)",
2296 action.name()
2297 )),
2298 );
2299 return;
2300 }
2301 let next: Vec<String> = current
2302 .iter()
2303 .filter(|c| c.as_str() != combo)
2304 .cloned()
2305 .collect();
2306 if next.len() == current.len() {
2307 return;
2309 }
2310 match commit_keybindings(state, action, next) {
2311 Ok(()) => reopen_settings_panel(
2312 state,
2313 SettingsTab::Keybindings,
2314 Some(format!("Removed {combo} from {}", action.name())),
2315 ),
2316 Err(e) => reopen_settings_panel(
2317 state,
2318 SettingsTab::Keybindings,
2319 Some(format!("Failed to save keybindings: {e}")),
2320 ),
2321 }
2322}
2323
2324fn open_model_role_value_prompt(state: &mut RenderState, role: &str) {
2332 let current = crate::store::settings::Settings::load()
2333 .map(|s| s.model_roles.get(role).cloned())
2334 .ok()
2335 .flatten();
2336 state.secure_input_origin = Some(SecureInputOrigin::ModelRoleValue {
2337 role: role.to_string(),
2338 });
2339 state.overlay = Some(text_prompt_overlay(
2340 format!("Model for role '{role}'"),
2341 "Enter the model pattern (provider/model). Enter saves, Esc cancels.".into(),
2342 "model",
2343 Some("provider/model".into()),
2344 current.as_deref(),
2345 ));
2346 state.settings_map_rows.clear();
2347}
2348
2349fn open_model_role_key_prompt(state: &mut RenderState) {
2351 state.secure_input_origin = Some(SecureInputOrigin::ModelRoleKey);
2352 state.overlay = Some(text_prompt_overlay(
2353 "New model role".to_string(),
2354 "Name the role (e.g. fast, reviewer). Enter continues, Esc cancels.".into(),
2355 "role",
2356 Some("role name".into()),
2357 None,
2358 ));
2359 state.settings_map_rows.clear();
2360}
2361
2362fn text_prompt_overlay(
2365 title: String,
2366 line: String,
2367 label: &str,
2368 placeholder: Option<String>,
2369 prefill: Option<&str>,
2370) -> OverlayState {
2371 let mut editor = EditBuffer::new();
2372 if let Some(text) = prefill {
2373 let _ = editor.insert_str(text);
2374 }
2375 OverlayState {
2376 title,
2377 lines: vec![line],
2378 secure_input: Some(OverlaySecureInput {
2379 config: SecurePromptConfig {
2380 label: label.to_string(),
2381 placeholder,
2382 mask_input: false,
2383 },
2384 editor,
2385 }),
2386 ..Default::default()
2387 }
2388}
2389
2390fn settings_map_editor_active(state: &RenderState) -> bool {
2393 state.overlay.as_ref().is_some_and(|o| {
2394 o.tabs.len() > 1
2395 && o.key_capture.is_none()
2396 && state.settings_map_rows.len() == o.items.len()
2397 })
2398}
2399
2400fn selected_settings_map_row(state: &RenderState) -> Option<SettingsMapRow> {
2402 if !settings_map_editor_active(state) {
2403 return None;
2404 }
2405 let selected = state.overlay.as_ref().map(|o| o.selected)?;
2406 state.settings_map_rows.get(selected).cloned().flatten()
2407}
2408
2409fn try_edit_model_role(state: &mut RenderState) -> bool {
2412 match selected_settings_map_row(state) {
2413 Some(SettingsMapRow::ModelRole(role)) => {
2414 open_model_role_value_prompt(state, &role);
2415 true
2416 }
2417 _ => false,
2418 }
2419}
2420
2421fn try_remove_settings_map_row(state: &mut RenderState) -> bool {
2425 match selected_settings_map_row(state) {
2426 Some(SettingsMapRow::KeybindingCombo(action, combo)) => {
2427 remove_keybinding_combo(state, action, &combo);
2428 true
2429 }
2430 Some(SettingsMapRow::ModelRole(role)) => {
2431 let status = match crate::store::settings::Settings::load() {
2432 Ok(mut settings) => {
2433 let existed =
2434 crate::tui_vt::settings_defs::remove_model_role(&mut settings, &role);
2435 match settings.save() {
2436 Ok(()) if existed => format!("Removed model role '{role}'"),
2437 Ok(()) => format!("Role '{role}' was already gone"),
2438 Err(e) => format!("Failed to save model roles: {e}"),
2439 }
2440 }
2441 Err(e) => format!("Failed to load settings: {e}"),
2442 };
2443 reopen_settings_panel(state, SettingsTab::Model, Some(status));
2444 true
2445 }
2446 _ => false,
2447 }
2448}
2449
2450fn try_start_new_model_role(state: &mut RenderState) -> bool {
2453 if settings_map_editor_active(state) && state.settings_active_tab == SettingsTab::Model {
2454 open_model_role_key_prompt(state);
2455 true
2456 } else {
2457 false
2458 }
2459}
2460
2461fn open_text_edit_prompt(state: &mut RenderState, key: SettingKey) {
2470 let current = crate::store::settings::Settings::load()
2471 .map(|s| get_display_value(key, &s))
2472 .unwrap_or_default();
2473 state.secure_input_origin = Some(SecureInputOrigin::TextEdit(key));
2474 state.overlay = Some(text_prompt_overlay(
2475 format!("Edit {}", label_for_setting(key)),
2476 format!(
2477 "Enter the new value for {} (Esc to cancel). Empty clears the override when the field supports it.",
2478 label_for_setting(key)
2479 ),
2480 "value",
2481 None,
2482 if current == "default" {
2483 None
2484 } else {
2485 Some(current.as_str())
2486 },
2487 ));
2488}
2489
2490fn open_submenu_select_prompt(state: &mut RenderState, key: SettingKey) {
2494 let Some(options) = submenu_options_for(key) else {
2495 reopen_settings_panel(
2500 state,
2501 state.settings_active_tab,
2502 Some(format!("'{:?}' is not a submenu-select setting", key)),
2503 );
2504 return;
2505 };
2506 let current = crate::store::settings::Settings::load()
2507 .map(|s| get_display_value(key, &s))
2508 .unwrap_or_default();
2509 let items: Vec<OverlayListItem> = options
2510 .iter()
2511 .map(|opt| InlineListItem {
2512 title: (*opt).to_string(),
2513 subtitle: None,
2514 badge: if *opt == current {
2515 Some("current".to_string())
2516 } else {
2517 None
2518 },
2519 indent: 0,
2520 selection: Some(InlineListSelection::ConfigAction(format!(
2521 "SubmenuCommit:{:?}:{}",
2522 key, opt
2523 ))),
2524 search_value: None,
2525 })
2526 .map(overlay_item_from)
2527 .collect();
2528 let label = label_for_setting(key);
2529 state.overlay = Some(OverlayState {
2530 title: format!("Pick value for {label}"),
2531 lines: vec![format!("Esc cancels — current: {current}")],
2532 items,
2533 selected: options.iter().position(|o| *o == current).unwrap_or(0),
2534 ..Default::default()
2535 });
2536 state.settings_map_rows.clear();
2537}
2538
2539fn open_disabled_tools_multiselect(
2544 state: &mut RenderState,
2545 session: &crate::app::agent_session::AgentSessionHandle,
2546) {
2547 let mut tools = session.agent_ref().tools().get_tools();
2548 tools.sort_by(|a, b| a.name().cmp(b.name()));
2549 let settings = crate::store::settings::Settings::load().unwrap_or_default();
2550 let disabled: std::collections::HashSet<String> =
2551 settings.disabled_tools.iter().cloned().collect();
2552 let items: Vec<OverlayListItem> = tools
2553 .iter()
2554 .map(|t| {
2555 let name = t.name();
2556 let is_disabled = disabled.contains(name);
2557 InlineListItem {
2558 title: name.to_string(),
2559 subtitle: Some(t.description().to_string()),
2560 badge: Some(if t.essential() {
2561 if is_disabled {
2562 "essential — locked".to_string()
2563 } else {
2564 "essential".to_string()
2565 }
2566 } else if is_disabled {
2567 "disabled".to_string()
2568 } else {
2569 "enabled".to_string()
2570 }),
2571 indent: 0,
2572 selection: Some(InlineListSelection::ConfigAction(format!(
2573 "DisabledToolToggle:{}",
2574 name
2575 ))),
2576 search_value: None,
2577 }
2578 })
2579 .map(overlay_item_from)
2580 .collect();
2581 let disabled_count = items
2582 .iter()
2583 .filter(|i| {
2584 i.badge
2585 .as_deref()
2586 .map(|b| b == "disabled" || b == "essential — locked")
2587 .unwrap_or(false)
2588 })
2589 .count();
2590 state.overlay = Some(OverlayState {
2591 title: "Disabled tools".into(),
2592 lines: vec![format!(
2593 "{disabled_count} disabled — Enter/Space toggles, Esc closes"
2594 )],
2595 items,
2596 selected: 0,
2597 ..Default::default()
2598 });
2599 state.settings_map_rows.clear();
2600}
2601
2602fn commit_text_edit(
2611 state: &mut RenderState,
2612 handle: &InlineHandle,
2613 session: Option<&crate::app::agent_session::AgentSessionHandle>,
2614 key: SettingKey,
2615 text: String,
2616) -> (anyhow::Result<()>, String) {
2617 let label = label_for_setting(key);
2618 let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
2619 let outcome =
2620 crate::tui_vt::settings_defs::apply_change(key, &mut settings, text.trim().to_string())
2621 .and_then(|_| save_settings_sandboxed(state, &settings));
2622 let new_display = get_display_value(key, &settings);
2623 match &outcome {
2624 Ok(()) => {
2625 if let Some(session) = session {
2626 if let Err(e) = sync_settings_live(state, session, key, &settings) {
2629 handle.append_line(
2630 InlineMessageKind::Error,
2631 vec![plain_segment(format!("{label}: {e}"))],
2632 );
2633 }
2634 }
2635 let status = format!("{label}: {new_display}");
2636 handle.append_line(InlineMessageKind::Info, vec![plain_segment(status.clone())]);
2640 reopen_settings_panel(state, state.settings_active_tab, Some(status.clone()));
2641 (Ok(()), status)
2642 }
2643 Err(e) => {
2644 let msg = format!("{label}: {e}");
2645 handle.append_line(InlineMessageKind::Error, vec![plain_segment(msg.clone())]);
2646 (Err(anyhow::anyhow!("{e}")), msg)
2647 }
2648 }
2649}
2650
2651fn commit_submenu_choice(
2654 state: &mut RenderState,
2655 key: SettingKey,
2656 value: String,
2657) -> anyhow::Result<String> {
2658 let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
2659 crate::tui_vt::settings_defs::apply_change(key, &mut settings, value.clone())?;
2660 save_settings_sandboxed(state, &settings)?;
2661 let new_display = get_display_value(key, &settings);
2662 let label = label_for_setting(key);
2663 let status = format!("{label}: {new_display}");
2664 reopen_settings_panel(state, state.settings_active_tab, Some(status.clone()));
2665 Ok(status)
2666}
2667
2668fn commit_disabled_tool_toggle(
2672 state: &mut RenderState,
2673 handle: &InlineHandle,
2674 session: &crate::app::agent_session::AgentSessionHandle,
2675 tool: String,
2676 essential: bool,
2677 currently_disabled: bool,
2678) {
2679 let label = label_for_setting(SettingKey::DisabledTools);
2680 if essential {
2681 handle.append_line(
2682 InlineMessageKind::Error,
2683 vec![plain_segment(format!(
2684 "'{tool}' is essential and cannot be disabled"
2685 ))],
2686 );
2687 open_disabled_tools_multiselect(state, session);
2690 return;
2691 }
2692 let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
2693 let new_enabled = currently_disabled; crate::tui_vt::settings_defs::toggle_disabled_tool(&mut settings, &tool, new_enabled);
2695 match save_settings_sandboxed(state, &settings) {
2696 Ok(()) => {
2697 let new_state = if new_enabled { "enabled" } else { "disabled" };
2698 handle.append_line(
2699 InlineMessageKind::Info,
2700 vec![plain_segment(format!("{label}: '{tool}' {new_state}"))],
2701 );
2702 open_disabled_tools_multiselect(state, session);
2703 }
2704 Err(e) => {
2705 handle.append_line(
2706 InlineMessageKind::Error,
2707 vec![plain_segment(format!("{label}: failed to save: {e}"))],
2708 );
2709 }
2710 }
2711}
2712
2713fn label_for_setting(key: SettingKey) -> &'static str {
2716 SETTING_DEFS
2717 .iter()
2718 .find(|d| d.key == key)
2719 .map(|d| d.label)
2720 .unwrap_or("setting")
2721}
2722
2723fn parse_setting_key(name: &str) -> Option<SettingKey> {
2728 SETTING_DEFS
2729 .iter()
2730 .map(|d| d.key)
2731 .find(|k| format!("{k:?}") == name)
2732}
2733
2734fn submenu_options_for(key: SettingKey) -> Option<&'static [&'static str]> {
2737 SETTING_DEFS
2738 .iter()
2739 .find(|d| d.key == key)
2740 .and_then(|d| match d.widget {
2741 SettingWidget::SubmenuSelect(opts) => Some(opts),
2742 _ => None,
2743 })
2744}
2745
2746fn item_section_idx(overlay: &OverlayState, idx: usize) -> Option<usize> {
2750 let mut current: Option<String> = None;
2751 for (i, item) in overlay.items.iter().enumerate() {
2752 let is_heading =
2753 item.selection.is_none() && item.badge.is_none() && item.subtitle.is_none();
2754 if is_heading {
2755 current = Some(item.title.clone());
2756 }
2757 if i == idx {
2758 return current
2759 .as_deref()
2760 .and_then(|g| overlay.sections.iter().position(|s| s == g));
2761 }
2762 }
2763 None
2764}
2765
2766fn next_cycle_value(key: SettingKey, s: &crate::store::settings::Settings) -> Option<String> {
2769 match key {
2770 SettingKey::ThinkingLevel => {
2772 const LEVELS: [&str; 6] = ["off", "minimal", "low", "medium", "high", "xhigh"];
2773 let cur = get_display_value(key, s);
2774 let idx = LEVELS.iter().position(|l| *l == cur).unwrap_or(0);
2775 Some(LEVELS[(idx + 1) % LEVELS.len()].to_string())
2776 }
2777 SettingKey::GlyphSet => Some(s.glyph_set.next().to_string()),
2778 SettingKey::EditFormat => Some(
2779 if get_display_value(key, s) == "hashline" {
2780 "str_replace"
2781 } else {
2782 "hashline"
2783 }
2784 .to_string(),
2785 ),
2786 _ => None,
2787 }
2788}
2789
2790fn sync_settings_live(
2800 state: &mut RenderState,
2801 session: &crate::app::agent_session::AgentSessionHandle,
2802 key: SettingKey,
2803 settings: &crate::store::settings::Settings,
2804) -> anyhow::Result<()> {
2805 match key {
2806 SettingKey::ThinkingLevel => {
2807 session.set_thinking_level(settings.thinking_level);
2808 state.thinking_level = get_display_value(key, settings);
2809 }
2810 SettingKey::GlyphSet => state.glyph_set = settings.glyph_set,
2811 SettingKey::AutoCompaction => session.set_auto_compaction(settings.auto_compaction),
2812 SettingKey::AdvisorEnabled if session.is_advisor_enabled() != settings.advisor.enabled => {
2813 session
2814 .set_advisor_enabled(settings.advisor.enabled)
2815 .map_err(|e| {
2816 anyhow::anyhow!(
2817 "failed to {} the advisor live: {e} (saved; restart to apply)",
2818 if settings.advisor.enabled {
2819 "enable"
2820 } else {
2821 "disable"
2822 }
2823 )
2824 })?;
2825 }
2826 _ => {}
2827 }
2828 Ok(())
2829}
2830
2831fn handle_session_event(
2835 state: &mut RenderState,
2836 handle: &InlineHandle,
2837 event: &SessionEvent,
2838 session: Option<&crate::app::agent_session::AgentSessionHandle>,
2839) {
2840 match event {
2841 SessionEvent::Agent(boxed) => {
2842 let event = *boxed.clone();
2843 if let (AgentEvent::Error { message, .. }, Some(session)) = (&event, session)
2844 && is_missing_api_key_error(message)
2845 {
2846 let provider = provider_from_model_id(&session.model_id());
2847 handle.append_line(
2848 InlineMessageKind::Info,
2849 vec![plain_segment(format!(
2850 "Authentication is required for '{provider}'. Enter an API key to continue."
2851 ))],
2852 );
2853 open_secure_prompt(state, handle, SecureInputOrigin::SetKey { provider });
2854 }
2855 map_agent_event(handle, event, state);
2856 }
2857 SessionEvent::CompactionStart { .. } => {
2858 handle.set_reasoning_stage(Some("Compacting\u{2026}".to_string()));
2859 }
2860 SessionEvent::CompactionEnd { error_message, .. } => {
2861 handle.set_reasoning_stage(None);
2862 if let Some(msg) = error_message {
2863 handle.append_line(
2864 InlineMessageKind::Error,
2865 vec![plain_segment(format!("Compaction failed: {msg}"))],
2866 );
2867 }
2868 }
2869 SessionEvent::ThinkingLevelChanged { level } => {
2870 state.thinking_level = format!("{level:?}").to_ascii_lowercase();
2871 }
2872 SessionEvent::QueueUpdate { .. } => {
2873 let pending = state.transcript.len();
2877 handle.set_input_status(
2878 None,
2879 Some(if pending == 0 {
2880 "ready".to_string()
2881 } else {
2882 "queued".to_string()
2883 }),
2884 );
2885 }
2886 SessionEvent::Advisor { body, .. } => {
2887 handle.append_line(InlineMessageKind::Info, vec![plain_segment(body.clone())]);
2888 }
2889 SessionEvent::SessionInfoChanged => {
2890 }
2893 SessionEvent::HandoffComplete { .. } => {
2894 }
2898 SessionEvent::HandoffFailed { error } => {
2899 handle.append_line(
2900 InlineMessageKind::Error,
2901 vec![plain_segment(format!("Handoff failed: {}", error))],
2902 );
2903 }
2904 }
2905}
2906
2907fn is_missing_api_key_error(message: &str) -> bool {
2911 let message = message.to_ascii_lowercase();
2912 message.contains("missing api key") || message.contains("api key is required")
2913}
2914
2915fn provider_from_model_id(model_id: &str) -> String {
2918 model_id
2919 .split_once('/')
2920 .map(|(provider, _)| provider)
2921 .filter(|provider| !provider.is_empty())
2922 .unwrap_or("provider")
2923 .to_string()
2924}
2925
2926pub(crate) fn apply_model_to_chips(state: &mut RenderState, model_id: &str, ctx_window: usize) {
2934 if model_id.is_empty() {
2935 return;
2936 }
2937 state.header_context.provider = provider_from_model_id(model_id);
2938 state.header_context.model = model_id.to_string();
2939 state.header_context.editor_context = Some(model_id.to_string());
2940 if ctx_window > 0 {
2941 state.context_window = ctx_window;
2942 }
2943}
2944
2945pub(crate) fn sync_model_chips(
2947 state: &mut RenderState,
2948 session: &crate::app::agent_session::AgentSessionHandle,
2949) {
2950 apply_model_to_chips(state, &session.model_id(), session.context_window());
2951}
2952fn render_streamed_message(state: &mut RenderState) -> Vec<Vec<InlineSegment>> {
2958 let mut lines = Vec::new();
2959 if !state.thinking_buffer.is_empty() {
2960 let styles = active_styles();
2961 let mut style = InlineTextStyle::default();
2962 style.color = styles.reasoning.get_fg_color();
2963 style.effects |= anstyle::Effects::DIMMED | anstyle::Effects::ITALIC;
2964 for chunk in state.thinking_buffer.split('\n') {
2965 lines.push(vec![InlineSegment {
2966 text: chunk.to_string(),
2967 style: Arc::new(style.clone()),
2968 }]);
2969 }
2970 if !state.message_buffer.is_empty() {
2973 lines.push(vec![plain_segment("")]);
2974 }
2975 }
2976 let body = revealed_stream_body(state).to_string();
2977 if !body.is_empty() {
2978 let (_, content_w) = super::frame_layout::scrollback_geometry(Rect {
2982 x: 0,
2983 y: 0,
2984 width: state.viewport_width,
2985 height: 24,
2986 });
2987 lines.extend(oxicode_vtui::tui::ui::markdown::render_markdown_cached(
2988 &body,
2989 content_w as usize,
2990 &mut state.md_cache,
2991 ));
2992 }
2993 lines
2994}
2995
2996fn revealed_stream_body(state: &RenderState) -> &str {
2999 if state.stream_reveal == usize::MAX {
3000 &state.message_buffer
3001 } else {
3002 let idx = floor_char_boundary(&state.message_buffer, state.stream_reveal);
3003 &state.message_buffer[..idx]
3004 }
3005}
3006
3007fn floor_char_boundary(s: &str, mut i: usize) -> usize {
3010 if i >= s.len() {
3011 return s.len();
3012 }
3013 while !s.is_char_boundary(i) {
3014 i -= 1;
3015 }
3016 i
3017}
3018
3019fn advance_stream_reveal(state: &mut RenderState) -> bool {
3029 if state.stream_anchor.is_none() || state.stream_reveal == usize::MAX {
3030 return false;
3031 }
3032 let len = state.message_buffer.len();
3033 if state.stream_reveal >= len {
3034 state.stream_reveal = len;
3035 return false;
3036 }
3037 let remaining = len - state.stream_reveal;
3038 let step = (remaining / 6).max(8);
3039 let target = (state.stream_reveal + step).min(len);
3040 state.stream_reveal = floor_char_boundary(&state.message_buffer, target);
3041 let lines = render_streamed_message(state);
3044 let from = state.stream_anchor.unwrap_or(state.transcript.len());
3045 state.transcript.truncate(from);
3046 for line in lines {
3047 state.append_line(InlineMessageKind::Agent, line);
3048 }
3049 state.stream_anchor = Some(from);
3050 true
3051}
3052
3053fn tool_args_preview(args: &serde_json::Value) -> String {
3059 use serde_json::Value;
3060 let raw = match args {
3061 Value::Null => return String::new(),
3062 Value::String(s) => s.clone(),
3063 Value::Object(map) => {
3064 if let Some(Value::String(cmd)) = map.get("command") {
3065 cmd.clone()
3066 } else {
3067 map.iter()
3068 .filter_map(|(k, v)| match v {
3069 Value::String(s) => Some(format!("{k}={s}")),
3070 _ => None,
3071 })
3072 .take(3)
3073 .collect::<Vec<_>>()
3074 .join(" ")
3075 }
3076 }
3077 other => other.to_string(),
3078 };
3079 if raw.chars().count() > 72 {
3080 let head: String = raw.chars().take(71).collect();
3081 format!("{head}\u{2026}")
3082 } else {
3083 raw
3084 }
3085}
3086fn tool_box_width(state: &RenderState) -> usize {
3091 let area = Rect {
3092 x: 0,
3093 y: 0,
3094 width: state.viewport_width,
3095 height: 24,
3096 };
3097 let (_x, w) = super::frame_layout::scrollback_geometry(area);
3098 w.max(24) as usize
3099}
3100
3101fn border_segment(text: impl Into<String>, color: anstyle::Color) -> InlineSegment {
3102 let mut style = InlineTextStyle::default();
3103 style.color = Some(color);
3104 InlineSegment {
3105 text: text.into(),
3106 style: Arc::new(style),
3107 }
3108}
3109
3110fn tool_box_top(w: usize, color: anstyle::Color) -> Vec<InlineSegment> {
3112 vec![border_segment(
3113 format!("\u{256D}{}\u{256E}", "\u{2500}".repeat(w.saturating_sub(2))),
3114 color,
3115 )]
3116}
3117
3118fn tool_box_bottom(w: usize, color: anstyle::Color) -> Vec<InlineSegment> {
3120 vec![border_segment(
3121 format!("\u{2570}{}\u{256F}", "\u{2500}".repeat(w.saturating_sub(2))),
3122 color,
3123 )]
3124}
3125
3126fn tool_box_divider(label: &str, w: usize, color: anstyle::Color) -> Vec<InlineSegment> {
3128 let text = format!(" {label} ");
3129 let dashes = w
3130 .saturating_sub(2)
3131 .saturating_sub(text.chars().count())
3132 .saturating_sub(2);
3133 vec![border_segment(
3134 format!(
3135 "\u{251C}\u{2500}{}{}\u{2500}\u{2524}",
3136 text,
3137 "\u{2500}".repeat(dashes)
3138 ),
3139 color,
3140 )]
3141}
3142
3143fn tool_box_rows(
3146 text: &str,
3147 w: usize,
3148 style: InlineTextStyle,
3149 color: anstyle::Color,
3150) -> Vec<Vec<InlineSegment>> {
3151 let inner = w.saturating_sub(4).max(1);
3152 text.split('\n')
3153 .map(|line| expand_tabs(line, TAB_WIDTH))
3154 .flat_map(|line| wrap_by_display_width(&line, inner))
3155 .map(|chunk| {
3156 let pad = inner.saturating_sub(chunk.width());
3159 vec![
3160 border_segment("\u{2502} ", color),
3161 InlineSegment {
3162 text: chunk,
3163 style: Arc::new(style.clone()),
3164 },
3165 border_segment(format!("{} \u{2502}", " ".repeat(pad)), color),
3166 ]
3167 })
3168 .collect()
3169}
3170
3171fn wrap_by_display_width(line: &str, inner: usize) -> Vec<String> {
3174 use unicode_width::UnicodeWidthChar as _;
3175 if line.width() <= inner {
3176 return vec![line.to_string()];
3177 }
3178 let mut out: Vec<String> = Vec::new();
3179 let mut cur = String::new();
3180 let mut cur_w = 0usize;
3181 for ch in line.chars() {
3182 let ch_w = ch.width().unwrap_or(0);
3183 if cur_w + ch_w > inner && !cur.is_empty() {
3184 out.push(std::mem::take(&mut cur));
3185 cur_w = 0;
3186 }
3187 cur.push(ch);
3188 cur_w += ch_w;
3189 }
3190 if !cur.is_empty() {
3191 out.push(cur);
3192 }
3193 out
3194}
3195
3196const TAB_WIDTH: usize = 4;
3202
3203fn expand_tabs(line: &str, tab_width: usize) -> String {
3205 use unicode_width::UnicodeWidthChar as _;
3206 if !line.contains('\t') {
3207 return line.to_string();
3208 }
3209 let mut out = String::with_capacity(line.len() + tab_width);
3210 let mut col = 0usize;
3211 for ch in line.chars() {
3212 if ch == '\t' {
3213 let spaces = tab_width - (col % tab_width);
3214 for _ in 0..spaces {
3215 out.push(' ');
3216 col += 1;
3217 }
3218 } else {
3219 out.push(ch);
3220 col += ch.width().unwrap_or(1).max(1);
3221 }
3222 }
3223 out
3224}
3225
3226fn diff_rows(content: &str) -> Option<Vec<(String, InlineTextStyle)>> {
3229 let lines: Vec<&str> = content.lines().collect();
3230 if !lines.iter().any(|l| l.starts_with("@@")) {
3234 return None;
3235 }
3236 let additions = lines
3237 .iter()
3238 .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
3239 .count();
3240 let deletions = lines
3241 .iter()
3242 .filter(|l| l.starts_with('-') && !l.starts_with("---"))
3243 .count();
3244 if additions + deletions < 2 {
3245 return None;
3246 }
3247
3248 let styles = active_styles();
3249 let green = styles.secondary.get_fg_color();
3250 let red = styles.error.get_fg_color();
3251 const MAX_DIFF_LINES: usize = 30;
3252
3253 let mut rows: Vec<(String, InlineTextStyle)> = Vec::new();
3254 let mut hdr = InlineTextStyle::default();
3255 hdr.effects |= anstyle::Effects::DIMMED;
3256 rows.push((format!("diff +{additions} -{deletions}"), hdr));
3257 for line in lines.iter().take(MAX_DIFF_LINES) {
3258 let mut style = InlineTextStyle::default();
3259 if line.starts_with('+') && !line.starts_with("+++") {
3260 style.color = green;
3261 } else if line.starts_with('-') && !line.starts_with("---") {
3262 style.color = red;
3263 } else {
3264 style.effects |= anstyle::Effects::DIMMED;
3265 }
3266 rows.push(((*line).to_string(), style));
3267 }
3268 if lines.len() > MAX_DIFF_LINES {
3269 let mut more = InlineTextStyle::default();
3270 more.effects |= anstyle::Effects::DIMMED;
3271 rows.push((
3272 format!("\u{2026} +{} lines", lines.len() - MAX_DIFF_LINES),
3273 more,
3274 ));
3275 }
3276 Some(rows)
3277}
3278
3279fn map_agent_event(handle: &InlineHandle, event: AgentEvent, state: &mut RenderState) {
3280 match event {
3281 AgentEvent::TextChunk { text } => {
3282 state.reasoning_stage = Some("generating response".to_string());
3283 state.message_buffer.push_str(&text);
3284 handle.inline(InlineMessageKind::Agent, plain_segment(text));
3285 }
3286 AgentEvent::AgentStart { .. } => {
3287 state.active_run = Some(RunState::default());
3291 }
3292 AgentEvent::MessageStart { .. } => {
3293 if let Some(run) = &mut state.active_run {
3294 run.turn += 1;
3295 }
3296 state.reasoning_stage = Some("generating response".to_string());
3297 state.message_buffer.clear();
3298 state.thinking_buffer.clear();
3299 state.stream_reveal = 0;
3300 handle.begin_stream(InlineMessageKind::Agent);
3305 }
3306 AgentEvent::MessageUpdate { delta, .. } => match &delta {
3307 oxicode_sdk::StreamDelta::Text(text) => {
3308 state.reasoning_stage = Some("generating response".to_string());
3314 state.message_buffer.push_str(text);
3315 handle.replace_last(0, InlineMessageKind::Agent, render_streamed_message(state));
3316 }
3317 oxicode_sdk::StreamDelta::Thinking(text) => {
3318 state.reasoning_stage = Some("thinking\u{2026}".to_string());
3324 state.thinking_buffer.push_str(text);
3325 handle.replace_last(0, InlineMessageKind::Agent, render_streamed_message(state));
3326 }
3327 oxicode_sdk::StreamDelta::Sync => {
3328 if !state.message_buffer.is_empty() || !state.thinking_buffer.is_empty() {
3330 handle.replace_last(
3331 0,
3332 InlineMessageKind::Agent,
3333 render_streamed_message(state),
3334 );
3335 state.message_buffer.clear();
3336 state.thinking_buffer.clear();
3337 state.stream_reveal = 0;
3338 }
3339 }
3340 },
3341 AgentEvent::MessageEnd { message } => {
3342 if state.active_run.is_none() {
3347 state.reasoning_stage = None;
3348 }
3349 if let oxicode_ai::Message::Assistant(a) = &message {
3357 state.thinking_buffer = a
3358 .content
3359 .iter()
3360 .filter_map(|b| b.as_thinking().map(|t| t.thinking.clone()))
3361 .collect();
3362 state.message_buffer = a.text_content();
3363 state.stream_reveal = usize::MAX;
3364 }
3365 if !state.message_buffer.is_empty() || !state.thinking_buffer.is_empty() {
3366 handle.replace_last(0, InlineMessageKind::Agent, render_streamed_message(state));
3367 state.message_buffer.clear();
3368 state.thinking_buffer.clear();
3369 }
3370 handle.end_stream();
3374 }
3375 AgentEvent::ToolExecutionStart {
3376 tool_name, args, ..
3377 } => {
3378 let styles = active_styles();
3381 let border = styles
3382 .tool
3383 .get_fg_color()
3384 .unwrap_or(anstyle::Color::Ansi(anstyle::AnsiColor::White));
3385 let w = tool_box_width(state);
3386 let header = match args.get("command").and_then(|v| v.as_str()) {
3387 Some(cmd) => format!("$ {cmd}"),
3388 None => {
3389 let preview = tool_args_preview(&args);
3390 if preview.is_empty() {
3391 tool_name.clone()
3392 } else {
3393 format!("{tool_name} {preview}")
3394 }
3395 }
3396 };
3397 handle.append_line_block_start(InlineMessageKind::Tool, tool_box_top(w, border));
3398 for row in tool_box_rows(&header, w, InlineTextStyle::default(), border) {
3399 handle.append_line(InlineMessageKind::Tool, row);
3400 }
3401 let stage = format!("tool: {tool_name}");
3402 if let Some(run) = &mut state.active_run {
3403 run.tool_calls += 1;
3404 }
3405 state.reasoning_stage = Some(stage.clone());
3406 handle.set_reasoning_stage(Some(stage));
3407 }
3408 AgentEvent::ToolExecutionEnd {
3409 tool_name,
3410 result,
3411 is_error,
3412 ..
3413 } => {
3414 let styles = active_styles();
3418 let (border, label) = if is_error {
3419 (
3420 styles
3421 .error
3422 .get_fg_color()
3423 .unwrap_or(anstyle::Color::Ansi(anstyle::AnsiColor::White)),
3424 "Error",
3425 )
3426 } else {
3427 (
3428 styles
3429 .tool
3430 .get_fg_color()
3431 .unwrap_or(anstyle::Color::Ansi(anstyle::AnsiColor::White)),
3432 "Output",
3433 )
3434 };
3435 let w = tool_box_width(state);
3436 handle.append_line(InlineMessageKind::Tool, tool_box_divider(label, w, border));
3437 let embedded_png = if tool_name == "generate_image" && !is_error {
3444 extract_generated_png(&result.content)
3445 } else {
3446 None
3447 };
3448 if let Some(png) = embedded_png {
3449 let id = super::image_preview::content_hash_id(&png);
3450 let label = format!("generate_image:{id:08x}");
3451 let mut dim = InlineTextStyle::default();
3452 dim.effects |= anstyle::Effects::DIMMED;
3453 let fallback = super::image_preview::text_fallback(&label);
3454 for row in tool_box_rows(&fallback, w, dim, border) {
3455 handle.append_line(InlineMessageKind::Tool, row);
3456 }
3457 state
3460 .image_previews
3461 .enqueue(id, std::sync::Arc::new(png), label);
3462 } else if let Some(rows) = diff_rows(&result.content) {
3463 for (text, style) in rows {
3464 for row in tool_box_rows(&text, w, style, border) {
3465 handle.append_line(InlineMessageKind::Tool, row);
3466 }
3467 }
3468 } else {
3469 const MAX_BOX_LINES: usize = 12;
3470 let preview = preview_tool_result(&result.content);
3471 let lines: Vec<&str> = preview.split('\n').collect();
3472 let mut dim = InlineTextStyle::default();
3473 dim.effects |= anstyle::Effects::DIMMED;
3474 for line in lines.iter().take(MAX_BOX_LINES) {
3475 for row in tool_box_rows(line, w, dim.clone(), border) {
3476 handle.append_line(InlineMessageKind::Tool, row);
3477 }
3478 }
3479 if lines.len() > MAX_BOX_LINES {
3480 let more = format!("\u{2026} +{} lines", lines.len() - MAX_BOX_LINES);
3481 for row in tool_box_rows(&more, w, dim, border) {
3482 handle.append_line(InlineMessageKind::Tool, row);
3483 }
3484 }
3485 }
3486 handle.append_line(InlineMessageKind::Tool, tool_box_bottom(w, border));
3487 state.reasoning_stage = Some("generating response".to_string());
3488 handle.set_reasoning_stage(Some("generating response".to_string()));
3489 handle.set_input_enabled(true);
3490 }
3491 AgentEvent::Error { message, .. } => {
3492 handle.append_line(InlineMessageKind::Error, vec![plain_segment(message)]);
3493 state.active_run = None;
3494 state.reasoning_stage = None;
3495 handle.set_input_enabled(true);
3496 handle.set_input_status(None, None);
3497 }
3498 AgentEvent::Compaction { .. } => {
3499 }
3502 AgentEvent::Cancelled => {
3503 state.active_run = None;
3504 state.reasoning_stage = None;
3505 handle.set_input_enabled(true);
3506 handle.set_input_status(None, Some("cancelled".to_string()));
3507 }
3508 AgentEvent::AutoRetryStart {
3509 attempt,
3510 max_attempts,
3511 ..
3512 } => {
3513 state.reasoning_stage = Some(format!("retrying {attempt} of {max_attempts}"));
3514 handle.set_input_status(None, Some(format!("retry {attempt}/{max_attempts}")));
3515 }
3516 AgentEvent::TurnEnd { .. } => {
3517 crate::tui_vt::notifications::emit_notification("oxicode", "Response complete");
3521 state.drain_queue_head();
3525 if state.active_run.is_none() {
3529 handle.set_reasoning_stage(None);
3530 }
3531 }
3532 AgentEvent::Usage { input_tokens, .. } => {
3533 state.context_tokens = Some(input_tokens);
3538 }
3539 AgentEvent::AgentEnd { .. } => {
3540 state.active_run = None;
3543 state.reasoning_stage = None;
3544 handle.set_reasoning_stage(None);
3545 }
3546 AgentEvent::TodoReminder { open, attempt, max } => {
3547 let header = format!(
3550 "⚠ {} incomplete todo{} — reminder {attempt}/{max}",
3551 open.len(),
3552 if open.len() == 1 { "" } else { "s" }
3553 );
3554 handle.append_line(InlineMessageKind::Warning, vec![plain_segment(header)]);
3555 for t in &open {
3556 handle.append_line(
3557 InlineMessageKind::Warning,
3558 vec![plain_segment(format!(" ☐ {}", t.content))],
3559 );
3560 }
3561 }
3562 _ => {
3563 tracing::debug!(?event, "ignored AgentEvent variant");
3567 }
3568 }
3569}
3570
3571pub(crate) fn next_provider_actions(has_key: bool, oauth_capable: bool) -> Vec<AuthAction> {
3578 match (has_key, oauth_capable) {
3579 (true, true) => vec![
3580 AuthAction::SetApiKey,
3581 AuthAction::StartOAuth,
3582 AuthAction::RemoveKey,
3583 ],
3584 (true, false) => vec![AuthAction::SetApiKey, AuthAction::RemoveKey],
3585 (false, true) => vec![AuthAction::SetApiKey, AuthAction::StartOAuth],
3586 (false, false) => vec![AuthAction::SetApiKey],
3587 }
3588}
3589
3590pub(crate) fn open_secure_prompt(
3603 state: &mut RenderState,
3604 handle: &InlineHandle,
3605 origin: SecureInputOrigin,
3606) {
3607 let provider = match &origin {
3610 SecureInputOrigin::SetKey { provider } | SecureInputOrigin::NewlyAdded { provider } => {
3611 provider.clone()
3612 }
3613 SecureInputOrigin::ModelRoleKey | SecureInputOrigin::ModelRoleValue { .. } => return,
3614 SecureInputOrigin::TextEdit(_) => return,
3615 };
3616 state.secure_input_origin = Some(origin);
3617 handle.show_modal(
3618 format!("Set API key for {provider}"),
3619 vec![
3620 "Paste the API key. Press Enter to save, Esc to cancel.".into(),
3621 "The key is masked on screen; nothing is logged.".into(),
3622 ],
3623 Some(SecurePromptConfig {
3624 label: format!("{provider} key"),
3625 placeholder: Some("sk-...".into()),
3626 mask_input: true,
3627 }),
3628 );
3629}
3630
3631pub(crate) fn handle_auth_action(
3641 provider: &str,
3642 action: &AuthAction,
3643 auth: &Arc<crate::store::auth_storage::AuthStorage>,
3644 handle: &InlineHandle,
3645 state: &mut RenderState,
3646) -> bool {
3647 match action {
3660 AuthAction::SetApiKey => {
3661 open_secure_prompt(
3662 state,
3663 handle,
3664 SecureInputOrigin::SetKey {
3665 provider: provider.to_string(),
3666 },
3667 );
3668 true
3669 }
3670 AuthAction::StartOAuth => {
3671 let spec = match crate::provider_oauth::spec_for(provider) {
3682 Some(s) => s,
3683 None => {
3684 handle.append_line(
3685 InlineMessageKind::Error,
3686 vec![plain_segment(format!(
3687 "OAuth: no OAuth config for '{provider}'."
3688 ))],
3689 );
3690 return false;
3691 }
3692 };
3693 let provider_owned = provider.to_string();
3698 let tx = handle.clone();
3699 let auth_clone = Arc::clone(auth);
3700 tokio::spawn(async move {
3701 run_oauth_flow(provider_owned, spec, tx, auth_clone).await;
3702 });
3703 false
3704 }
3705 AuthAction::RemoveKey => {
3706 state.confirmation = Some(ModalConfirmation {
3707 title: format!("Remove key for {provider}?"),
3708 message: " y \u{2014} remove key n / x \u{2014} cancel".into(),
3709 action: ConfirmationAction::RemoveProviderKey(provider.to_string()),
3710 });
3711 false
3712 }
3713 }
3714}
3715
3716pub(crate) async fn run_oauth_flow(
3744 provider: String,
3745 spec: crate::provider_oauth::ProviderOAuthSpec,
3746 handle: InlineHandle,
3747 auth: Arc<crate::store::auth_storage::AuthStorage>,
3748) {
3749 use std::time::Duration;
3750 let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0u16)).await {
3759 Ok(l) => l,
3760 Err(e) => {
3761 handle.append_line(
3762 InlineMessageKind::Error,
3763 vec![plain_segment(format!(
3764 "OAuth: could not bind loopback listener for '{provider}': {e}"
3765 ))],
3766 );
3767 return;
3768 }
3769 };
3770 let port = match listener.local_addr() {
3771 Ok(addr) => addr.port(),
3772 Err(e) => {
3773 handle.append_line(
3774 InlineMessageKind::Error,
3775 vec![plain_segment(format!(
3776 "OAuth: could not read loopback port for '{provider}': {e}"
3777 ))],
3778 );
3779 return;
3780 }
3781 };
3782
3783 let (verifier, challenge) = crate::provider_oauth::pkce_pair();
3787 let state_token = crate::provider_oauth::pkce_pair().0; let auth_url = crate::provider_oauth::build_auth_url(&spec, port, &state_token, &challenge);
3793 handle.append_line(
3794 InlineMessageKind::Info,
3795 vec![plain_segment(format!(
3796 "OAuth: opening browser for '{provider}' on http://127.0.0.1:{port}{}",
3797 spec.redirect_path
3798 ))],
3799 );
3800 let callback_timeout = match crate::provider_oauth::open_browser(&auth_url) {
3808 Ok(()) => Duration::from_secs(120),
3809 Err(e) => {
3810 handle.append_line(
3811 InlineMessageKind::Info,
3812 vec![plain_segment(format!(
3813 "OAuth: could not open a browser ({e}).\nOpen this URL manually within 5 minutes:\n {auth_url}"
3814 ))],
3815 );
3816 Duration::from_secs(300)
3817 }
3818 };
3819
3820 let callback = match crate::oauth_listener::await_callback(
3823 listener,
3824 state_token.clone(),
3825 spec.redirect_path.clone(),
3826 callback_timeout,
3827 )
3828 .await
3829 {
3830 Ok(c) => c,
3831 Err(crate::oauth_listener::CallbackError::Timeout) => {
3832 handle.append_line(
3833 InlineMessageKind::Error,
3834 vec![plain_segment(format!(
3835 "OAuth: timed out waiting for '{provider}' callback (after {}s)",
3836 callback_timeout.as_secs()
3837 ))],
3838 );
3839 return;
3840 }
3841 Err(e) => {
3842 handle.append_line(
3843 InlineMessageKind::Error,
3844 vec![plain_segment(format!(
3845 "OAuth: callback failed for '{provider}': {e}"
3846 ))],
3847 );
3848 return;
3849 }
3850 };
3851
3852 let tokens =
3854 match crate::provider_oauth::exchange_code(&spec, port, &callback.code, &verifier).await {
3855 Ok(t) => t,
3856 Err(e) => {
3857 handle.append_line(
3858 InlineMessageKind::Error,
3859 vec![plain_segment(format!(
3860 "OAuth: token exchange failed for '{provider}': {e}"
3861 ))],
3862 );
3863 return;
3864 }
3865 };
3866
3867 let new_expires_at: u64 = tokens.expires_at.max(0) as u64;
3873 let access_token_len = tokens.access_token.chars().count();
3874 auth.set_oauth_full(
3877 &provider,
3878 tokens.access_token,
3879 tokens.refresh_token,
3880 new_expires_at,
3881 if tokens.scopes.is_empty() {
3882 None
3883 } else {
3884 Some(tokens.scopes.join(" "))
3885 },
3886 None,
3887 );
3888 handle.append_line(
3889 InlineMessageKind::Info,
3890 vec![plain_segment(format!(
3891 "OAuth: '{provider}' logged in. Token stored ({} chars).",
3892 access_token_len
3893 ))],
3894 );
3895}
3896
3897fn handle_inline_event(
3899 state: &mut RenderState,
3900 handle: &InlineHandle,
3901 session: &crate::app::agent_session::AgentSessionHandle,
3902 prompt_queue: &Arc<PromptQueue>,
3903 evt: InlineEvent,
3904) -> LoopOutcome {
3905 match evt {
3906 InlineEvent::Submit(text) => {
3907 if let Some(path) = state.pending_resume.take() {
3909 let swapper = state.swapper();
3910 let agent_arc = Arc::clone(&session.agent_arc());
3911 let settings = session.settings_clone();
3912 let session_state = state
3913 .session_state
3914 .clone()
3915 .expect("RenderState::session_state must be initialized at TUI startup");
3916 let path_for_log = path.clone();
3917 let handle = handle.clone();
3918 let swapper_for_swap = swapper.clone();
3919 tokio::spawn(async move {
3920 match crate::app::agent_session::resume_from_file(
3921 agent_arc,
3922 settings,
3923 session_state,
3924 &path,
3925 None,
3926 )
3927 .await
3928 {
3929 Ok(new_session) => {
3930 swapper_for_swap.swap(new_session.clone_handle());
3931 let n = new_session.messages().len();
3932 let id = new_session.session_id();
3933 handle.append_line(
3934 InlineMessageKind::Info,
3935 vec![plain_segment(format!(
3936 "Resumed session {id} ({n} messages)"
3937 ))],
3938 );
3939 }
3940 Err(crate::app::agent_session::ResumeError::FileNotFound(p)) => {
3941 handle.append_line(
3942 InlineMessageKind::Error,
3943 vec![plain_segment(format!("No session file: {}", p.display()))],
3944 );
3945 }
3946 Err(crate::app::agent_session::ResumeError::CwdInvalid(cwd)) => {
3947 handle.append_line(
3948 InlineMessageKind::Error,
3949 vec![plain_segment(format!(
3950 "Cannot resume {}: the session was recorded in `{cwd}`, which no longer exists. \
3951 Use /export to save its content, then /clear.",
3952 path_for_log.display()
3953 ))],
3954 );
3955 }
3956 }
3957 });
3958 return LoopOutcome::Continue;
3959 }
3960 let prompt = text.to_string();
3964 state.composer.set_text("");
3965 if prompt.is_empty() {
3966 return LoopOutcome::Continue;
3967 }
3968 state.pending_quit = false;
3969 if prompt.trim_start().starts_with('/') {
3973 state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
3974 let mut ctx = SlashCtx {
3975 session,
3976 handle,
3977 state,
3978 };
3979 return match SlashRegistry::builtins().dispatch(&prompt, &mut ctx) {
3980 SlashOutcome::Quit => LoopOutcome::Exit,
3981 SlashOutcome::Handled => LoopOutcome::Continue,
3982 SlashOutcome::NotHandled => {
3983 if let Some(expanded) = crate::tui_vt::slash::file_commands::try_expand(
3985 &ctx.state.file_commands,
3986 &prompt,
3987 ) {
3988 prompt_queue.enqueue(expanded);
3991 LoopOutcome::Continue
3992 } else {
3993 ctx.reply(
3994 InlineMessageKind::Error,
3995 format!("Unknown command: {}", prompt.trim()),
3996 );
3997 LoopOutcome::Continue
3998 }
3999 }
4000 };
4001 }
4002 state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
4003 if session.is_streaming() {
4007 state.queued_inputs.push(prompt.clone());
4008 state.show_tip(
4009 "send_now",
4010 "Ctrl+Enter sends now | Ctrl+; manages queue",
4011 240,
4012 true,
4013 );
4014 }
4015 prompt_queue.enqueue(prompt);
4018 }
4019 InlineEvent::Cancel => {
4020 return match route_cancel(session.is_streaming()) {
4026 CancelRoute::Interrupt => handle_interrupt(state, session, handle),
4027 CancelRoute::Exit => LoopOutcome::Exit,
4028 };
4029 }
4030 InlineEvent::Exit => {
4031 return LoopOutcome::Exit;
4032 }
4033 InlineEvent::Interrupt => {
4034 return handle_interrupt(state, session, handle);
4035 }
4036 InlineEvent::ScrollLineUp => {
4037 state.scroll_offset = state.scroll_offset.saturating_add(1);
4038 }
4039 InlineEvent::ScrollLineDown => {
4040 state.scroll_offset = state.scroll_offset.saturating_sub(1);
4041 }
4042 InlineEvent::ScrollPageUp => {
4043 state.scroll_offset = state.scroll_offset.saturating_add(10);
4044 }
4045 InlineEvent::ScrollPageDown => {
4046 state.scroll_offset = state.scroll_offset.saturating_sub(10);
4047 }
4048 InlineEvent::CyclePrimaryAgent => {
4049 let _ = session.cycle_model();
4050 }
4051 InlineEvent::CyclePrimaryAgentPrevious => {
4052 let _ = session.cycle_model();
4055 }
4056 InlineEvent::Overlay(overlay_evt) => {
4057 use oxicode_vtui::tui::core::OverlayEvent;
4058 match overlay_evt {
4059 OverlayEvent::Submitted(sub) => {
4060 let mut opened_new_overlay = false;
4070 if let OverlaySubmission::Selection(InlineListSelection::Model(idx)) = &sub
4072 && idx < &state.overlay_model_ids.len()
4073 {
4074 let model_id = state.overlay_model_ids[*idx].clone();
4075 match session.set_model(&model_id) {
4076 Ok(()) => {
4077 sync_model_chips(state, session);
4078 handle.append_line(
4079 InlineMessageKind::Info,
4080 vec![plain_segment(format!("Switched to {model_id}"))],
4081 );
4082 }
4083 Err(e) => handle.append_line(
4084 InlineMessageKind::Error,
4085 vec![plain_segment(format!("Failed to set model: {e}"))],
4086 ),
4087 }
4088 }
4089 if let OverlaySubmission::Selection(InlineListSelection::Theme(theme_id)) = &sub
4091 {
4092 match oxicode_vtui::theme::set_active_theme(theme_id) {
4093 Ok(()) => {
4094 let label = oxicode_vtui::theme::theme_label(theme_id)
4095 .unwrap_or(theme_id.as_ref())
4096 .to_string();
4097 handle.append_line(
4098 InlineMessageKind::Info,
4099 vec![plain_segment(format!("Theme: {label}"))],
4100 );
4101 }
4102 Err(e) => handle.append_line(
4103 InlineMessageKind::Error,
4104 vec![plain_segment(format!("Unknown theme: {e}"))],
4105 ),
4106 }
4107 }
4108 if let OverlaySubmission::Selection(InlineListSelection::SlashCommand(name)) =
4110 &sub
4111 {
4112 state.composer.set_text(&format!("/{name} "));
4113 }
4114 let synthetic_dispatched = if let OverlaySubmission::Selection(
4125 InlineListSelection::ConfigAction(payload),
4126 ) = &sub
4127 {
4128 if let Some(rest) = payload.strip_prefix("SubmenuCommit:") {
4129 if let Some((key_str, value)) = rest.split_once(':') {
4130 if let Some(key) = parse_setting_key(key_str) {
4131 match commit_submenu_choice(state, key, value.to_string()) {
4132 Ok(status) => {
4133 handle.append_line(
4134 InlineMessageKind::Info,
4135 vec![plain_segment(status.clone())],
4136 );
4137 opened_new_overlay = state.overlay.is_some();
4138 }
4139 Err(e) => handle.append_line(
4140 InlineMessageKind::Error,
4141 vec![plain_segment(format!(
4142 "Failed to save setting: {e}"
4143 ))],
4144 ),
4145 }
4146 } else {
4147 handle.append_line(
4148 InlineMessageKind::Error,
4149 vec![plain_segment(format!(
4150 "Unknown setting key in submenu commit: {key_str}"
4151 ))],
4152 );
4153 }
4154 } else {
4155 handle.append_line(
4156 InlineMessageKind::Error,
4157 vec![plain_segment(format!(
4158 "Malformed submenu commit payload: {payload}"
4159 ))],
4160 );
4161 }
4162 true
4163 } else if let Some(tool) = payload.strip_prefix("DisabledToolToggle:") {
4164 let essential = session
4165 .agent_ref()
4166 .tools()
4167 .get_tools()
4168 .into_iter()
4169 .find(|t| t.name() == tool)
4170 .is_some_and(|t| t.essential());
4171 let currently_disabled = crate::store::settings::Settings::load()
4172 .map(|s| s.disabled_tools.iter().any(|t| t == tool))
4173 .unwrap_or(false);
4174 commit_disabled_tool_toggle(
4175 state,
4176 handle,
4177 session,
4178 tool.to_string(),
4179 essential,
4180 currently_disabled,
4181 );
4182 opened_new_overlay = state.overlay.is_some();
4183 true
4184 } else {
4185 false
4186 }
4187 } else {
4188 false
4189 };
4190 if !synthetic_dispatched
4191 && let OverlaySubmission::Selection(InlineListSelection::ConfigAction(key)) =
4192 &sub
4193 {
4194 let def = SETTING_DEFS.iter().find(|d| format!("{:?}", d.key) == *key);
4195 match def {
4196 Some(def) => {
4197 let mut settings =
4198 crate::store::settings::Settings::load().unwrap_or_default();
4199 let next_value = match def.widget {
4204 SettingWidget::Toggle => Some(
4205 (get_display_value(def.key, &settings) != "true")
4206 .to_string(),
4207 ),
4208 SettingWidget::Cycle => next_cycle_value(def.key, &settings),
4209 _ => None,
4210 };
4211 if let Some(next) = next_value {
4212 match crate::tui_vt::settings_defs::apply_change(
4213 def.key,
4214 &mut settings,
4215 next,
4216 ) {
4217 Ok(()) => match settings.save() {
4218 Ok(()) => {
4219 if let Err(e) = sync_settings_live(
4220 state, session, def.key, &settings,
4221 ) {
4222 handle.append_line(
4227 InlineMessageKind::Error,
4228 vec![plain_segment(format!(
4229 "{}: {e}",
4230 def.label
4231 ))],
4232 );
4233 } else {
4234 handle.append_line(
4235 InlineMessageKind::Info,
4236 vec![plain_segment(format!(
4237 "{}: {}",
4238 def.label,
4239 get_display_value(def.key, &settings)
4240 ))],
4241 );
4242 }
4243 }
4244 Err(e) => handle.append_line(
4245 InlineMessageKind::Error,
4246 vec![plain_segment(format!(
4247 "Failed to save {}: {e}",
4248 def.label
4249 ))],
4250 ),
4251 },
4252 Err(e) => handle.append_line(
4253 InlineMessageKind::Error,
4254 vec![plain_segment(format!(
4255 "Failed to apply {}: {e}",
4256 def.label
4257 ))],
4258 ),
4259 }
4260 }
4261 }
4262 None => handle.append_line(
4263 InlineMessageKind::Error,
4264 vec![plain_segment(format!("Unknown setting: {key}"))],
4265 ),
4266 }
4267 }
4268 if let OverlaySubmission::Selection(InlineListSelection::SettingsTab(idx)) =
4272 &sub
4273 {
4274 switch_settings_tab(state, *idx);
4275 opened_new_overlay = state.overlay.is_some();
4276 }
4277 if let OverlaySubmission::Selection(InlineListSelection::SettingsSection(idx)) =
4281 &sub
4282 {
4283 jump_settings_section(state, *idx);
4284 opened_new_overlay = state.overlay.is_some();
4285 }
4286 if let OverlaySubmission::Selection(InlineListSelection::SettingKeyCapture(
4292 name,
4293 )) = &sub
4294 {
4295 if GlobalAction::from_name(name).is_some() {
4296 state.overlay = Some(build_key_capture_overlay(name));
4297 state.settings_map_rows.clear();
4298 opened_new_overlay = true;
4299 } else {
4300 handle.append_line(
4301 InlineMessageKind::Error,
4302 vec![plain_segment(format!("Unknown keybinding action: {name}"))],
4303 );
4304 }
4305 }
4306 if let OverlaySubmission::Selection(InlineListSelection::SettingTextEdit(
4310 key_name,
4311 )) = &sub
4312 {
4313 if let Some(key) = parse_setting_key(key_name) {
4314 open_text_edit_prompt(state, key);
4315 opened_new_overlay = true;
4316 } else {
4317 handle.append_line(
4318 InlineMessageKind::Error,
4319 vec![plain_segment(format!("Unknown setting key: {key_name}"))],
4320 );
4321 }
4322 }
4323 if let OverlaySubmission::Selection(InlineListSelection::SettingSubmenuOpen(
4328 key_name,
4329 )) = &sub
4330 {
4331 if let Some(key) = parse_setting_key(key_name) {
4332 open_submenu_select_prompt(state, key);
4333 opened_new_overlay = true;
4334 } else {
4335 handle.append_line(
4336 InlineMessageKind::Error,
4337 vec![plain_segment(format!("Unknown setting key: {key_name}"))],
4338 );
4339 }
4340 }
4341 if let OverlaySubmission::Selection(InlineListSelection::SettingMultiselect(
4346 key_name,
4347 )) = &sub
4348 {
4349 if let Some(parsed) = parse_setting_key(key_name) {
4350 if parsed == SettingKey::DisabledTools {
4351 open_disabled_tools_multiselect(state, session);
4352 opened_new_overlay = true;
4353 } else {
4354 handle.append_line(
4355 InlineMessageKind::Error,
4356 vec![plain_segment(format!(
4357 "'{parsed:?}' has no multiselect editor"
4358 ))],
4359 );
4360 }
4361 } else {
4362 handle.append_line(
4363 InlineMessageKind::Error,
4364 vec![plain_segment(format!("Unknown setting key: {key_name}"))],
4365 );
4366 }
4367 }
4368 if let OverlaySubmission::Selection(InlineListSelection::Session(id)) = &sub {
4371 if session.is_streaming() {
4377 handle.append_line(
4378 InlineMessageKind::Error,
4379 vec![plain_segment(
4380 "Cannot resume while agent is running. Use /cancel first.",
4381 )],
4382 );
4383 } else {
4384 let path = crate::tui_vt::slash::registry::sessions_dir()
4385 .join(format!("{id}.jsonl"));
4386 if !path.is_file() {
4387 handle.append_line(
4388 InlineMessageKind::Error,
4389 vec![plain_segment(format!(
4390 "No session file: {}",
4391 path.display()
4392 ))],
4393 );
4394 } else {
4395 state.pending_resume = Some(path);
4396 }
4397 }
4398 }
4399 if let OverlaySubmission::Selection(InlineListSelection::CatalogModel(idx)) =
4401 &sub
4402 && idx < &state.overlay_catalog_models.len()
4403 {
4404 let (provider, model_id) = &state.overlay_catalog_models[*idx];
4405 let full = format!("{provider}/{model_id}");
4406 match session.set_model(&full) {
4407 Ok(()) => {
4408 sync_model_chips(state, session);
4409 handle.append_line(
4410 InlineMessageKind::Info,
4411 vec![plain_segment(format!("Switched to {full}"))],
4412 );
4413 }
4414 Err(e) => handle.append_line(
4415 InlineMessageKind::Error,
4416 vec![plain_segment(format!("Failed to set model: {e}"))],
4417 ),
4418 }
4419 }
4420 if let OverlaySubmission::Selection(InlineListSelection::ProviderRow(idx)) =
4427 &sub
4428 && idx < &state.overlay_providers.len()
4429 {
4430 let name = state.overlay_providers[*idx].clone();
4431 let auth = crate::store::auth_storage::shared_auth_storage();
4432 let has_key = auth.has(&name);
4433 let oauth_capable = crate::provider_oauth::spec_for(&name).is_some();
4434 let actions = next_provider_actions(has_key, oauth_capable);
4435 if actions.len() == 1 {
4436 opened_new_overlay |=
4438 handle_auth_action(&name, &actions[0], &auth, handle, state);
4439 } else {
4440 let items: Vec<InlineListItem> = actions
4442 .iter()
4443 .map(|a| InlineListItem {
4444 title: match a {
4445 AuthAction::SetApiKey => "Set API key".into(),
4446 AuthAction::StartOAuth => "Login with OAuth".into(),
4447 AuthAction::RemoveKey => "Remove key".into(),
4448 },
4449 subtitle: None,
4450 badge: None,
4451 indent: 0,
4452 selection: Some(InlineListSelection::ProviderAction {
4453 provider: name.clone(),
4454 action: a.clone(),
4455 }),
4456 search_value: None,
4457 })
4458 .collect();
4459 handle.show_list_modal(
4460 name.clone(),
4461 vec!["Pick an action".into()],
4462 items,
4463 None,
4464 None,
4465 );
4466 opened_new_overlay = true;
4467 }
4468 }
4469 if let OverlaySubmission::Selection(InlineListSelection::ProviderAction {
4475 provider,
4476 action,
4477 }) = &sub
4478 {
4479 let auth = crate::store::auth_storage::shared_auth_storage();
4480 opened_new_overlay |=
4481 handle_auth_action(provider, action, &auth, handle, state);
4482 }
4483 if let OverlaySubmission::SecureInput(text) = &sub
4491 && let Some(origin) = state.secure_input_origin.take()
4492 {
4493 match origin {
4494 SecureInputOrigin::ModelRoleKey => {
4495 let role = text.trim().to_string();
4499 if role.is_empty() {
4500 handle.append_line(
4501 InlineMessageKind::Error,
4502 vec![plain_segment(
4503 "Model role name can't be empty".to_string(),
4504 )],
4505 );
4506 } else {
4507 open_model_role_value_prompt(state, &role);
4508 opened_new_overlay = true;
4509 }
4510 }
4511 SecureInputOrigin::TextEdit(key) => {
4512 let (_outcome, _msg) = commit_text_edit(
4513 state,
4514 handle,
4515 Some(session),
4516 key,
4517 text.clone(),
4518 );
4519 opened_new_overlay = state.overlay.is_some();
4520 }
4521 SecureInputOrigin::ModelRoleValue { role } => {
4522 let model = text.trim().to_string();
4523 let outcome = if model.is_empty() {
4524 Err("model pattern can't be empty".to_string())
4525 } else {
4526 crate::store::settings::Settings::load()
4527 .map_err(|e| e.to_string())
4528 .and_then(|mut settings| {
4529 crate::tui_vt::settings_defs::set_model_role(
4530 &mut settings,
4531 &role,
4532 model.clone(),
4533 );
4534 settings.save().map_err(|e| e.to_string())
4535 })
4536 };
4537 match outcome {
4538 Ok(()) => {
4539 handle.append_line(
4540 InlineMessageKind::Info,
4541 vec![plain_segment(format!(
4542 "Model role '{role}' \u{2192} {model}"
4543 ))],
4544 );
4545 reopen_settings_panel(
4546 state,
4547 SettingsTab::Model,
4548 Some(format!("Saved '{role}' \u{2192} {model}")),
4549 );
4550 opened_new_overlay = true;
4551 }
4552 Err(e) => handle.append_line(
4553 InlineMessageKind::Error,
4554 vec![plain_segment(format!(
4555 "Failed to save model role '{role}': {e}"
4556 ))],
4557 ),
4558 }
4559 }
4560 origin @ (SecureInputOrigin::SetKey { .. }
4561 | SecureInputOrigin::NewlyAdded { .. }) => {
4562 let provider = match &origin {
4563 SecureInputOrigin::SetKey { provider }
4564 | SecureInputOrigin::NewlyAdded { provider } => {
4565 provider.clone()
4566 }
4567 _ => unreachable!("auth arm only matches auth origins"),
4568 };
4569 let auth = crate::store::auth_storage::shared_auth_storage();
4570 auth.set_api_key(&provider, text.clone());
4571 let refreshed = session.refresh_api_key();
4577 let msg = match origin {
4578 SecureInputOrigin::SetKey { .. } => format!(
4579 "Saved API key for '{provider}'. {}",
4580 match refreshed {
4581 Ok(()) => "Ready to retry your message.",
4582 Err(_) => "Restart this session before retrying.",
4583 }
4584 ),
4585 SecureInputOrigin::NewlyAdded { .. } => format!(
4586 "Added and configured '{provider}'. {}",
4587 match refreshed {
4588 Ok(()) =>
4589 "Use /models to choose a model, or send a message.",
4590 Err(_) => "Restart this session before using it.",
4591 }
4592 ),
4593 SecureInputOrigin::ModelRoleKey
4594 | SecureInputOrigin::ModelRoleValue { .. } => {
4595 unreachable!("auth branch reached with a model-role origin")
4596 }
4597 SecureInputOrigin::TextEdit(_) => {
4598 unreachable!("auth branch reached with a text-edit origin")
4599 }
4600 };
4601 handle
4602 .append_line(InlineMessageKind::Info, vec![plain_segment(msg)]);
4603 }
4604 }
4605 }
4606 state.overlay_catalog_models.clear();
4607 state.overlay_providers.clear();
4608 state.overlay_model_ids.clear();
4609 if !opened_new_overlay {
4610 handle.close_overlay();
4611 }
4612 }
4613 OverlayEvent::Cancelled => {
4614 handle.close_overlay();
4615 }
4616 OverlayEvent::SelectionChanged(_) => {}
4617 }
4618 }
4619 _ => {
4620 }
4624 }
4625 LoopOutcome::Continue
4626}
4627
4628struct StreamingGuard<'a>(&'a std::sync::atomic::AtomicBool);
4635
4636impl Drop for StreamingGuard<'_> {
4637 fn drop(&mut self) {
4638 use std::sync::atomic::Ordering;
4639 self.0.store(false, Ordering::SeqCst);
4640 }
4641}
4642
4643fn handle_interrupt(
4656 state: &mut RenderState,
4657 session: &crate::app::agent_session::AgentSessionHandle,
4658 _handle: &InlineHandle,
4659) -> LoopOutcome {
4660 if state.confirmation.is_some() {
4662 return LoopOutcome::Exit;
4663 }
4664 if state.pending_quit {
4667 state.confirmation = Some(quit_confirmation());
4668 state.pending_quit = false;
4669 return LoopOutcome::Continue;
4670 }
4671 if session.is_streaming() {
4675 let s = session.clone();
4676 tokio::spawn(async move {
4677 s.abort().await;
4678 });
4679 state.pending_quit = true;
4680 } else {
4681 state.confirmation = Some(quit_confirmation());
4682 }
4683 LoopOutcome::Continue
4684}
4685
4686fn quit_confirmation() -> ModalConfirmation {
4688 ModalConfirmation {
4689 title: "Quit oxicode?".into(),
4690 message: " y \u{2014} quit now n / x \u{2014} stay".into(),
4691 action: ConfirmationAction::Quit,
4692 }
4693}
4694
4695pub(super) fn clear_confirmation() -> ModalConfirmation {
4697 ModalConfirmation {
4698 title: "Clear conversation?".into(),
4699 message: " y \u{2014} clear all n / x \u{2014} cancel".into(),
4700 action: ConfirmationAction::ClearConversation,
4701 }
4702}
4703
4704fn apply_global_action(
4717 action: GlobalAction,
4718 state: &Arc<parking_lot::Mutex<RenderState>>,
4719 evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
4720) {
4721 match action {
4722 GlobalAction::Interrupt => {
4726 let _ = evt_tx.send(InlineEvent::Interrupt);
4727 }
4728 GlobalAction::ToggleMultiline => {
4730 let mut s = state.lock();
4731 s.multiline_mode = !s.multiline_mode;
4732 }
4733 GlobalAction::OpenCommandPalette => {
4735 let mut s = state.lock();
4736 s.overlay = Some(build_command_palette());
4737 }
4738 GlobalAction::ToggleQueuePanel => {
4740 let mut s = state.lock();
4741 s.queue_panel_open = !s.queue_panel_open;
4742 if s.queue_panel_open {
4743 s.queue_selected = 0;
4744 }
4745 }
4746 GlobalAction::FoldAll => {
4748 let mut s = state.lock();
4749 s.fold_all();
4750 }
4751 GlobalAction::SendNow => {
4754 let submitted = harvest_and_clear_input(state);
4755 if !submitted.is_empty() {
4756 let _ = evt_tx.send(InlineEvent::Interrupt);
4757 let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
4758 }
4759 }
4760 GlobalAction::Submit => {
4765 let submitted = harvest_and_clear_input(state);
4766 let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
4767 }
4768 GlobalAction::ScrollUp => {
4770 let _ = evt_tx.send(InlineEvent::ScrollPageUp);
4771 }
4772 GlobalAction::ScrollDown => {
4774 let _ = evt_tx.send(InlineEvent::ScrollPageDown);
4775 }
4776 GlobalAction::Clear => {
4781 let mut s = state.lock();
4782 s.fold_all();
4783 }
4784 GlobalAction::Help => {
4788 let mut s = state.lock();
4789 s.overlay = Some(cheatsheet_overlay());
4790 }
4791 GlobalAction::ModelPicker => {
4796 let mut s = state.lock();
4797 s.overlay = Some(build_command_palette());
4798 }
4799 GlobalAction::ToggleThinking => {
4804 let mut s = state.lock();
4805 s.multiline_mode = !s.multiline_mode;
4806 }
4807 }
4808}
4809
4810#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4816pub(crate) enum KeymapDispatch {
4817 Submit,
4818 ScrollPageUp,
4819 ScrollPageDown,
4820 Help,
4821 None,
4822}
4823
4824pub(crate) fn keymap_pre_match(
4840 keymap: &Keymap,
4841 key: &crossterm::event::KeyEvent,
4842 multiline: bool,
4843) -> KeymapDispatch {
4844 let plain_enter_multiline =
4845 key.code == KeyCode::Enter && multiline && !key.modifiers.contains(KeyModifiers::SHIFT);
4846 if keymap.matches(GlobalAction::Submit, key) && !plain_enter_multiline {
4847 return KeymapDispatch::Submit;
4848 }
4849 if keymap.matches(GlobalAction::ScrollUp, key) {
4850 return KeymapDispatch::ScrollPageUp;
4851 }
4852 if keymap.matches(GlobalAction::ScrollDown, key) {
4853 return KeymapDispatch::ScrollPageDown;
4854 }
4855 if keymap.matches(GlobalAction::Help, key) && !matches!(key.code, KeyCode::Char(_)) {
4856 return KeymapDispatch::Help;
4857 }
4858 KeymapDispatch::None
4859}
4860
4861fn harvest_and_clear_input(state: &Arc<parking_lot::Mutex<RenderState>>) -> String {
4866 let mut s = state.lock();
4867 let buf = if s.slash_popup.open && !s.slash_popup.items.is_empty() {
4868 format!("/{}", s.slash_popup.items[s.slash_popup.selected].name)
4869 } else {
4870 let buf = s.composer.text().to_string();
4871 s.composer.set_text("");
4872 buf
4873 };
4874 s.slash_popup = SlashPopup::default();
4875 s.history_pos = None;
4876 if !buf.is_empty() && !buf.starts_with('/') {
4878 s.prompt_history.insert(0, buf.clone());
4879 s.prompt_history.truncate(100);
4880 }
4881 buf
4882}
4883
4884fn cheatsheet_overlay() -> OverlayState {
4888 OverlayState {
4889 title: "Keyboard Shortcuts".into(),
4890 lines: cheatsheet_lines(),
4891 items: vec![],
4892 selected: 0,
4893 search: None,
4894 secure_input: None,
4895 ..Default::default()
4896 }
4897}
4898
4899fn spawn_input_thread(
4900 state: Arc<parking_lot::Mutex<RenderState>>,
4901 evt_tx: tokio::sync::mpsc::UnboundedSender<InlineEvent>,
4902 mode_handle: Option<std::sync::Arc<std::sync::atomic::AtomicU8>>,
4903 prompt_queue: Arc<PromptQueue>,
4904 issue_action_tx: tokio::sync::mpsc::UnboundedSender<
4905 crate::tui_vt::issues_panel::IssueActionRequest,
4906 >,
4907) -> std::thread::JoinHandle<()> {
4908 std::thread::spawn(move || {
4909 loop {
4917 match event::poll(std::time::Duration::from_millis(50)) {
4918 Ok(true) => {}
4919 Ok(false) => continue,
4920 Err(_) => break,
4921 }
4922 let event = match event::read() {
4923 Ok(ev) => ev,
4924 Err(_) => continue,
4925 };
4926
4927 let mut pasted = String::new();
4930 let mut key_event = None;
4931 match event {
4932 Event::Key(k) if k.kind == KeyEventKind::Press => key_event = Some(k),
4933 Event::Paste(p) => pasted = p,
4934 _ => {}
4935 }
4936
4937 if !pasted.is_empty() && state.lock().issues_panel.is_some() {
4943 continue;
4944 }
4945 if !pasted.is_empty() {
4946 let routed_to_secure = {
4950 let mut s = state.lock();
4951 if let Some(overlay) = s.overlay.as_mut() {
4952 if let Some(secure) = overlay.secure_input.as_mut() {
4953 let trimmed = pasted.trim_end_matches('\n');
4957 for ch in trimmed.chars() {
4958 if ch.is_ascii_graphic() || ch == ' ' {
4959 let _ = secure
4960 .editor
4961 .apply(oxicode_textarea::EditCommand::Insert(ch));
4962 }
4963 }
4964 true
4965 } else {
4966 false
4967 }
4968 } else {
4969 false
4970 }
4971 };
4972 if routed_to_secure {
4973 continue;
4974 }
4975 let mut s = state.lock();
4976 s.composer.insert_str(&pasted);
4977 refresh_input_popups(&mut s);
4984 continue;
4985 }
4986 let Some(key) = key_event else { continue };
4987
4988 let keymap = state.lock().keymap.read().clone();
4994
4995 {
5001 let capturing = {
5002 let s = state.lock();
5003 s.overlay.as_ref().is_some_and(|o| o.key_capture.is_some())
5004 };
5005 if capturing {
5006 let mut s = state.lock();
5007 handle_key_capture(&mut s, key);
5008 continue;
5009 }
5010 }
5011
5012 {
5022 let s = state.lock();
5023 if s.confirmation.is_some() {
5024 drop(s);
5025 let interrupt = {
5026 let s = state.lock();
5027 matches!(s.keymap.read().resolve(key), Some(GlobalAction::Interrupt))
5028 };
5029 if interrupt {
5030 let _ = evt_tx.send(InlineEvent::Interrupt);
5031 continue;
5032 }
5033 handle_confirmation_key(&state, &evt_tx, &issue_action_tx, key.code);
5034 continue;
5035 }
5036 }
5037
5038 {
5044 let s = state.lock();
5045 if s.issues_panel.is_some() {
5046 drop(s);
5047 if crate::tui_vt::issues_panel::handle_issues_panel_key(
5048 &state,
5049 &issue_action_tx,
5050 key,
5051 ) {
5052 continue;
5053 }
5054 }
5055 }
5056
5057 {
5064 let action = {
5065 let s = state.lock();
5066 s.keymap.read().resolve(key)
5067 };
5068 if let Some(action) = action {
5069 apply_global_action(action, &state, &evt_tx);
5070 continue;
5071 }
5072 }
5073
5074 {
5080 let s = state.lock();
5081 if s.overlay.is_some() {
5082 drop(s);
5083 if handle_overlay_key(&state, &evt_tx, key.code) {
5084 continue;
5085 }
5086 }
5087 }
5088
5089 {
5093 let s = state.lock();
5094 if s.file_search.is_some() {
5095 drop(s);
5096 if handle_file_search_key(&state, &evt_tx, key.code) {
5097 continue;
5098 }
5099 }
5100 }
5101
5102 if state.lock().git_tui.is_some() && handle_git_tui_key(&state, key.code, key.modifiers)
5107 {
5108 continue;
5109 }
5110
5111 let multiline_mode = state.lock().multiline_mode;
5122 match keymap_pre_match(&keymap, &key, multiline_mode) {
5123 KeymapDispatch::Submit => {
5124 if state.lock().shell_mode {
5127 let submitted = {
5128 let mut s = state.lock();
5129 let buf = s.composer.text().to_string();
5130 s.composer.set_text("");
5131 s.shell_mode = false;
5132 s.history_pos = None;
5133 if !buf.is_empty() {
5134 s.prompt_history.insert(0, buf.clone());
5135 s.prompt_history.truncate(100);
5136 }
5137 buf
5138 };
5139 if !submitted.is_empty() {
5140 let prompt = format!("Run this shell command: `{submitted}`");
5141 let _ = evt_tx.send(InlineEvent::Submit(prompt.into()));
5142 }
5143 continue;
5144 }
5145 let submitted = harvest_and_clear_input(&state);
5146 let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
5147 continue;
5148 }
5149 KeymapDispatch::ScrollPageUp => {
5150 let _ = evt_tx.send(InlineEvent::ScrollPageUp);
5151 continue;
5152 }
5153 KeymapDispatch::ScrollPageDown => {
5154 let _ = evt_tx.send(InlineEvent::ScrollPageDown);
5155 continue;
5156 }
5157 KeymapDispatch::Help => {
5158 let mut s = state.lock();
5159 s.overlay = Some(cheatsheet_overlay());
5160 continue;
5161 }
5162 KeymapDispatch::None => {}
5163 }
5164
5165 match key.code {
5166 KeyCode::BackTab => {
5168 if let Some(h) = &mode_handle {
5169 let new_mode = Mode::load(h).toggle();
5170 h.store(new_mode.as_u8(), std::sync::atomic::Ordering::SeqCst);
5171 let label = new_mode.label();
5172 let detail = if new_mode.is_auto() {
5173 "autonomous — no questions, runs to completion"
5174 } else {
5175 "interactive — may ask questions"
5176 };
5177 let mut s = state.lock();
5178 s.autonomy_mode = new_mode;
5179 s.tip = Some(EphemeralTip {
5180 text: format!("Mode: {label} — {detail}"),
5181 born_tick: 0,
5182 ttl_ticks: 240,
5183 key: "mode_toggle",
5184 ambient: false,
5185 });
5186 }
5187 continue;
5188 }
5189 KeyCode::Enter => {
5190 let multiline = state.lock().multiline_mode;
5201 let shift = key
5202 .modifiers
5203 .contains(crossterm::event::KeyModifiers::SHIFT);
5204 if multiline && !shift {
5205 let mut s = state.lock();
5206 s.composer.insert_str("\n");
5207 }
5208 }
5209 KeyCode::Esc => {
5210 let mut s = state.lock();
5217 if s.shell_mode {
5218 s.shell_mode = false;
5219 s.composer.set_text("");
5220 } else if s.slash_popup.open {
5221 s.slash_popup = SlashPopup::default();
5222 } else if !s.composer.is_empty() {
5223 let now = std::time::Instant::now();
5224 let is_double = s
5225 .last_esc_at
5226 .map(|t| now.duration_since(t).as_millis() < 800)
5227 .unwrap_or(false);
5228 if is_double {
5229 s.composer.set_text("");
5230 s.last_esc_at = None;
5231 } else {
5232 s.last_esc_at = Some(now);
5233 s.tip = Some(EphemeralTip {
5236 text: "Press Esc again to clear input".to_string(),
5237 born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
5238 ttl_ticks: 120,
5239 key: "esc_clear",
5240 ambient: false,
5241 });
5242 }
5243 } else {
5244 let now = std::time::Instant::now();
5245 let in_grace = s.cancel_grace_until.map(|t| t > now).unwrap_or(false);
5246 if in_grace {
5247 } else {
5249 s.cancel_grace_until = Some(now + std::time::Duration::from_secs(1));
5250 s.last_esc_at = None;
5251 drop(s);
5252 let _ = evt_tx.send(InlineEvent::Cancel);
5253 }
5254 }
5255 }
5256 KeyCode::Tab => {
5257 let mut s = state.lock();
5259 if s.slash_popup.open && !s.slash_popup.items.is_empty() {
5260 let name = s.slash_popup.items[s.slash_popup.selected].name.clone();
5261 s.composer.set_text(&format!("/{} ", name));
5262 refresh_input_popups(&mut s);
5263 }
5264 }
5265 KeyCode::Backspace => {
5266 let mut s = state.lock();
5267 s.composer.input(crossterm::event::KeyEvent::new(
5268 KeyCode::Backspace,
5269 KeyModifiers::NONE,
5270 ));
5271 refresh_input_popups(&mut s);
5272 }
5273 KeyCode::Delete => {
5274 let mut s = state.lock();
5275 s.composer.input(crossterm::event::KeyEvent::new(
5276 KeyCode::Delete,
5277 KeyModifiers::NONE,
5278 ));
5279 refresh_input_popups(&mut s);
5280 }
5281 KeyCode::Left => {
5282 let mut s = state.lock();
5283 s.composer.input(crossterm::event::KeyEvent::new(
5284 KeyCode::Left,
5285 KeyModifiers::NONE,
5286 ));
5287 }
5288 KeyCode::Right => {
5289 let mut s = state.lock();
5290 s.composer.input(crossterm::event::KeyEvent::new(
5291 KeyCode::Right,
5292 KeyModifiers::NONE,
5293 ));
5294 }
5295 KeyCode::Up => {
5296 let mut s = state.lock();
5297 if s.slash_popup.open && !s.slash_popup.items.is_empty() {
5298 let len = s.slash_popup.items.len();
5299 s.slash_popup.selected = if s.slash_popup.selected == 0 {
5300 len - 1
5301 } else {
5302 s.slash_popup.selected - 1
5303 };
5304 } else if s.queue_panel_open
5305 && !s.queued_inputs.is_empty()
5306 && s.composer.is_empty()
5307 {
5308 s.queue_selected = if s.queue_selected == 0 {
5309 s.queued_inputs.len() - 1
5310 } else {
5311 s.queue_selected - 1
5312 };
5313 } else if s.composer.is_empty() && !s.prompt_history.is_empty() {
5314 let pos = s.history_pos.unwrap_or(0);
5316 let next = (pos + 1).min(s.prompt_history.len() - 1);
5317 s.history_pos = Some(next);
5318 let entry = s.prompt_history[next].clone();
5319 s.composer.set_text(&entry);
5320 drop(s);
5321 let _ = evt_tx.send(InlineEvent::ScrollLineUp);
5322 }
5323 }
5324 KeyCode::Down => {
5325 let mut s = state.lock();
5326 if s.slash_popup.open && !s.slash_popup.items.is_empty() {
5327 let len = s.slash_popup.items.len();
5328 s.slash_popup.selected = if s.slash_popup.selected + 1 >= len {
5329 0
5330 } else {
5331 s.slash_popup.selected + 1
5332 };
5333 } else if s.queue_panel_open
5334 && !s.queued_inputs.is_empty()
5335 && s.composer.is_empty()
5336 {
5337 s.queue_selected = if s.queue_selected + 1 >= s.queued_inputs.len() {
5338 0
5339 } else {
5340 s.queue_selected + 1
5341 };
5342 } else {
5343 drop(s);
5344 let _ = evt_tx.send(InlineEvent::ScrollLineDown);
5345 }
5346 }
5347 KeyCode::Char(ch) => {
5352 let mut s = state.lock();
5353 if s.file_search.is_some()
5357 && ch == '!'
5358 && s.composer.text()[..s.composer.cursor()].ends_with('@')
5359 {
5360 let cwd = s.cwd.clone();
5361 if let Some(fs) = s.file_search.as_mut() {
5362 fs.toggle_hidden(&cwd);
5363 }
5364 continue;
5365 }
5366 if s.agent_hub_open && ch == 'q' {
5367 s.agent_hub_open = false;
5368 } else if s.vim_state.enabled() && !s.slash_popup.open {
5369 let s = &mut *s;
5372 let vkey =
5373 crossterm::event::KeyEvent::new(KeyCode::Char(ch), key.modifiers);
5374 let mut editor = InputEditor::new(&mut s.composer);
5375 let outcome = crate::tui_vt::vim::handle_key(
5376 &mut s.vim_state,
5377 &mut editor,
5378 &mut s.vim_clipboard,
5379 &vkey,
5380 );
5381 if outcome.handled {
5382 refresh_input_popups(s);
5383 }
5384 } else if s.composer.is_empty() && !s.slash_popup.open {
5385 if ch == '!' && !s.shell_mode {
5387 s.shell_mode = true;
5388 continue;
5389 }
5390 if s.queue_panel_open && !s.queued_inputs.is_empty() {
5394 let idx = s.queue_selected.min(s.queued_inputs.len() - 1);
5395 match ch {
5396 'x' | 'X' => {
5397 let _ = prompt_queue.remove(idx);
5398 s.queued_inputs.remove(idx);
5399 if s.queue_selected >= s.queued_inputs.len()
5400 && !s.queued_inputs.is_empty()
5401 {
5402 s.queue_selected = s.queued_inputs.len() - 1;
5403 }
5404 continue;
5405 }
5406 'e' => {
5407 if let Some(entry) = prompt_queue.remove(idx) {
5408 s.queued_inputs.remove(idx);
5409 s.composer.set_text(&entry);
5410 s.queue_panel_open = false;
5411 continue;
5412 }
5413 }
5414 'J' => {
5415 if prompt_queue.move_by(idx, 1)
5416 && idx + 1 < s.queued_inputs.len()
5417 {
5418 s.queued_inputs.swap(idx, idx + 1);
5419 s.queue_selected = idx + 1;
5420 }
5421 continue;
5422 }
5423 'K' => {
5424 if idx > 0 && prompt_queue.move_by(idx, -1) {
5425 s.queued_inputs.swap(idx, idx - 1);
5426 s.queue_selected = idx - 1;
5427 }
5428 continue;
5429 }
5430 _ => {} }
5432 }
5433 if keymap.matches(GlobalAction::Help, &key) {
5442 s.overlay = Some(cheatsheet_overlay());
5443 } else if matches!(ch, 'e') {
5444 s.cycle_block_at_view();
5445 } else if matches!(ch, 'E') {
5446 s.expand_all();
5447 } else if matches!(ch, 'J') {
5448 s.jump_next_turn();
5449 } else if matches!(ch, 'K') {
5450 s.jump_prev_turn();
5451 } else if matches!(ch, 'n') && s.search.is_some() {
5452 s.search_next();
5453 } else if matches!(ch, 'N') && s.search.is_some() {
5454 s.search_prev();
5455 } else {
5456 s.composer.input(crossterm::event::KeyEvent::new(
5457 KeyCode::Char(ch),
5458 key.modifiers,
5459 ));
5460 refresh_input_popups(&mut s);
5461 }
5462 } else {
5463 s.composer.input(crossterm::event::KeyEvent::new(
5464 KeyCode::Char(ch),
5465 key.modifiers,
5466 ));
5467 refresh_input_popups(&mut s);
5468 }
5469 if s.tip.is_none() && s.composer.text().to_lowercase().contains("plan") {
5471 s.show_tip(
5472 "plan_nudge",
5473 "Try /compact to summarize and plan ahead",
5474 180,
5475 true,
5476 );
5477 }
5478 }
5479 _ => {}
5480 }
5481 }
5482 })
5483}
5484
5485fn handle_confirmation_key(
5489 state: &Arc<parking_lot::Mutex<RenderState>>,
5490 evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
5491 issue_action_tx: &tokio::sync::mpsc::UnboundedSender<
5492 crate::tui_vt::issues_panel::IssueActionRequest,
5493 >,
5494 code: KeyCode,
5495) {
5496 let mut s = state.lock();
5497 let Some(confirm) = s.confirmation.clone() else {
5498 return;
5499 };
5500 match code {
5501 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
5502 s.confirmation = None;
5503 drop(s);
5504 match confirm.action {
5505 ConfirmationAction::Quit => {
5506 let _ = evt_tx.send(InlineEvent::Exit);
5507 }
5508 ConfirmationAction::ClearConversation => {
5509 let _ = evt_tx.send(InlineEvent::Submit("/clear --yes".into()));
5513 }
5514 ConfirmationAction::CloseIssue(id) => {
5515 let (caller, hash, cwd) = {
5516 let s = state.lock();
5517 let hash = s
5518 .issue_store
5519 .as_ref()
5520 .and_then(|store| store.read(id).ok())
5521 .map(|(_, h)| h);
5522 (s.ownership_session_id.clone(), hash, s.cwd.clone())
5523 };
5524 let _ = cwd; let _ = issue_action_tx.send(
5526 crate::tui_vt::issues_panel::IssueActionRequest::Close { id, caller, hash },
5527 );
5528 let mut s = state.lock();
5529 if let Some(panel) = s.issues_panel.as_mut() {
5530 panel.pending = true;
5531 }
5532 }
5533 ConfirmationAction::RemoveProviderKey(name) => {
5534 let _ = evt_tx.send(InlineEvent::Submit(
5538 format!("/providers remove {name} --yes").into(),
5539 ));
5540 }
5541 }
5542 }
5543 KeyCode::Char('n')
5544 | KeyCode::Char('N')
5545 | KeyCode::Char('x')
5546 | KeyCode::Char('X')
5547 | KeyCode::Esc => {
5548 s.confirmation = None;
5549 }
5550 _ => {}
5551 }
5552}
5553
5554fn handle_overlay_key(
5559 state: &Arc<parking_lot::Mutex<RenderState>>,
5560 evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
5561 code: KeyCode,
5562) -> bool {
5563 use oxicode_vtui::tui::core::{OverlayEvent, OverlaySubmission};
5564
5565 let mut s = state.lock();
5566 let Some(overlay) = s.overlay.as_mut() else {
5567 return false;
5568 };
5569 if let Some(secure) = overlay.secure_input.as_mut() {
5572 use oxicode_textarea::EditCommand;
5573 match code {
5574 KeyCode::Backspace => {
5575 if secure.editor.cursor_byte() > 0 {
5579 let _ = secure.editor.apply(EditCommand::DeleteGraphemeBackward);
5580 }
5581 }
5582 KeyCode::Left => {
5583 let _ = secure.editor.apply(EditCommand::MoveGraphemeLeft);
5584 }
5585 KeyCode::Right => {
5586 let _ = secure.editor.apply(EditCommand::MoveGraphemeRight);
5587 }
5588 KeyCode::Enter => {
5589 let submission = OverlaySubmission::SecureInput(secure.editor.text().to_string());
5594 drop(s);
5595 state.lock().overlay = None;
5596 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Submitted(submission)));
5597 }
5598 KeyCode::Esc => {
5599 drop(s);
5600 state.lock().overlay = None;
5601 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
5602 }
5603 KeyCode::Char(ch) if ch.is_ascii_graphic() || ch == ' ' => {
5604 let _ = secure.editor.apply(EditCommand::Insert(ch));
5607 }
5608 _ => {} }
5610 return true;
5611 }
5612
5613 let search_empty = s
5620 .overlay
5621 .as_ref()
5622 .and_then(|o| o.search.as_ref())
5623 .is_none_or(|search| search.value.is_empty());
5624 let map_row_consumed = match code {
5625 KeyCode::Enter if try_edit_model_role(&mut s) => true,
5626 KeyCode::Char('d') if search_empty && try_remove_settings_map_row(&mut s) => true,
5627 KeyCode::Char('n') if search_empty && try_start_new_model_role(&mut s) => true,
5628 _ => false,
5629 };
5630 if map_row_consumed {
5631 return true;
5632 }
5633 let Some(overlay) = s.overlay.as_mut() else {
5634 return false;
5635 };
5636
5637 match code {
5638 KeyCode::Esc => {
5639 drop(s);
5641 state.lock().overlay = None;
5642 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
5643 }
5644 KeyCode::Enter => {
5645 let submission = if let Some(item) = overlay.items.get(overlay.selected) {
5648 match item.selection.clone() {
5649 Some(sel) => sel,
5650 None => {
5651 return true;
5657 }
5658 }
5659 } else {
5660 drop(s);
5661 state.lock().overlay = None;
5662 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
5663 return true;
5664 };
5665 let title = overlay.title.clone();
5666 let selected = overlay.selected;
5667 drop(s);
5668 state.lock().overlay = None;
5669 tracing::debug!(
5670 overlay = %title,
5671 selected,
5672 "overlay submitted"
5673 );
5674 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Submitted(
5675 OverlaySubmission::Selection(submission),
5676 )));
5677 }
5678 KeyCode::Up => {
5679 let len = overlay_filtered_indices(overlay).len();
5680 if len == 0 {
5681 return true;
5682 }
5683 let pos = overlay_filtered_indices(overlay)
5684 .iter()
5685 .position(|&i| i == overlay.selected)
5686 .unwrap_or(0);
5687 let new_pos = if pos == 0 { len - 1 } else { pos - 1 };
5688 overlay.selected = overlay_filtered_indices(overlay)[new_pos];
5689 }
5690 KeyCode::Down => {
5691 let filtered = overlay_filtered_indices(overlay);
5692 let len = filtered.len();
5693 if len == 0 {
5694 return true;
5695 }
5696 let pos = filtered
5697 .iter()
5698 .position(|&i| i == overlay.selected)
5699 .unwrap_or(0);
5700 let new_pos = if pos + 1 >= len { 0 } else { pos + 1 };
5701 overlay.selected = filtered[new_pos];
5702 }
5703 KeyCode::Backspace => {
5704 if let Some(search) = overlay.search.as_mut() {
5705 search.value.pop();
5706 overlay.selected = 0;
5707 }
5708 }
5709 KeyCode::Char(ch) => {
5710 if let Some(search) = overlay.search.as_mut() {
5711 search.value.push(ch);
5712 overlay.selected = 0;
5713 }
5714 }
5715 KeyCode::Left | KeyCode::Right => {
5716 let tab_count = overlay.tabs.len();
5720 if tab_count > 1 {
5721 let next = if code == KeyCode::Right {
5722 (overlay.active_tab + 1) % tab_count
5723 } else {
5724 overlay.active_tab.checked_sub(1).unwrap_or(tab_count - 1)
5725 };
5726 switch_settings_tab(&mut s, next);
5727 }
5728 }
5729 _ => {
5730 }
5732 }
5733 true
5734}
5735
5736fn overlay_filtered_indices(overlay: &OverlayState) -> Vec<usize> {
5741 let needle = overlay
5742 .search
5743 .as_ref()
5744 .map(|s| s.value.to_lowercase())
5745 .unwrap_or_default();
5746 if needle.is_empty() {
5747 return (0..overlay.items.len()).collect();
5748 }
5749 overlay
5750 .items
5751 .iter()
5752 .enumerate()
5753 .filter_map(|(idx, item)| {
5754 let title_hit = item.title.to_lowercase().contains(&needle);
5755 let sv_hit = item
5756 .search_value
5757 .as_deref()
5758 .map(|v| v.to_lowercase().contains(&needle))
5759 .unwrap_or(false);
5760 if title_hit || sv_hit { Some(idx) } else { None }
5761 })
5762 .collect()
5763}
5764
5765fn handle_file_search_key(
5771 state: &Arc<parking_lot::Mutex<RenderState>>,
5772 _evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
5773 code: KeyCode,
5774) -> bool {
5775 match code {
5776 KeyCode::Up => {
5777 let mut s = state.lock();
5778 if let Some(fs) = s.file_search.as_mut() {
5779 fs.up();
5780 true
5781 } else {
5782 false
5783 }
5784 }
5785 KeyCode::Down => {
5786 let mut s = state.lock();
5787 if let Some(fs) = s.file_search.as_mut() {
5788 fs.down();
5789 true
5790 } else {
5791 false
5792 }
5793 }
5794 KeyCode::Tab | KeyCode::Enter => {
5795 let mut s = state.lock();
5796 if s.file_search
5797 .as_ref()
5798 .and_then(|fs| fs.selected_result())
5799 .is_some()
5800 {
5801 accept_file_search(&mut s, false);
5802 true
5803 } else {
5804 s.file_search = None;
5806 false
5807 }
5808 }
5809 KeyCode::Esc => {
5810 let mut s = state.lock();
5811 s.file_search = None;
5812 true
5813 }
5814 _ => false,
5815 }
5816}
5817
5818fn handle_git_tui_key(
5826 state: &Arc<parking_lot::Mutex<RenderState>>,
5827 code: KeyCode,
5828 modifiers: KeyModifiers,
5829) -> bool {
5830 use crate::tui_vt::git_tui::{GitKeyAction, match_git_key};
5831 use crossterm::event::KeyEvent;
5832
5833 let mut s = state.lock();
5834 if s.git_tui.is_none() {
5835 return false;
5836 }
5837 let cwd = s.cwd.clone();
5838 let Some(git) = s.git_tui.as_mut() else {
5839 unreachable!("checked above");
5840 };
5841 if git.commit_mode {
5842 match code {
5843 KeyCode::Esc => {
5844 git.commit_mode = false;
5845 git.commit_msg.clear();
5846 return true;
5847 }
5848 KeyCode::Enter => {
5849 if let Err(err) = git.commit(&cwd) {
5850 tracing::warn!(?err, "git commit failed");
5851 s.tip = Some(EphemeralTip {
5853 text: format!("git commit failed: {err}"),
5854 born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
5855 ttl_ticks: 240,
5856 key: "git-commit-error",
5857 ambient: false,
5858 });
5859 }
5860 return true;
5861 }
5862 KeyCode::Backspace => {
5863 git.commit_backspace();
5864 return true;
5865 }
5866 KeyCode::Char(c) => {
5867 if !modifiers.contains(KeyModifiers::CONTROL)
5868 && !modifiers.contains(KeyModifiers::ALT)
5869 {
5870 git.commit_input_char(c);
5871 }
5872 return true;
5873 }
5874 _ => return true, }
5876 }
5877
5878 let key = KeyEvent::new(code, modifiers);
5881 let Some(action) = match_git_key(&key) else {
5882 return true;
5883 };
5884 if matches!(action, GitKeyAction::Close) {
5885 s.git_tui = None;
5887 return true;
5888 }
5889 if let Err(err) = git.apply_action(&cwd, action) {
5890 s.tip = Some(EphemeralTip {
5891 text: format!("/git: {err}"),
5892 born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
5893 ttl_ticks: 240,
5894 key: "git-action-error",
5895 ambient: false,
5896 });
5897 }
5898 true
5899}
5900
5901fn spawn_agent_worker(
5907 session_swapper: Arc<crate::app::agent_session_handle::SessionSwapper>,
5908 prompt_queue: Arc<PromptQueue>,
5909) {
5910 std::thread::spawn(move || {
5911 let runtime = match tokio::runtime::Builder::new_current_thread()
5912 .enable_all()
5913 .build()
5914 {
5915 Ok(rt) => rt,
5916 Err(err) => {
5917 tracing::error!(?err, "failed to build agent worker runtime");
5918 return;
5919 }
5920 };
5921
5922 runtime.block_on(async move {
5923 let local = tokio::task::LocalSet::new();
5924 local
5925 .run_until(async move {
5926 loop {
5927 let prompt = prompt_queue.next().await;
5928 run_one_prompt(&session_swapper.current(), prompt).await;
5929 }
5930 })
5931 .await;
5932 });
5933 });
5934}
5935
5936async fn run_one_prompt(session: &crate::app::agent_session::AgentSessionHandle, prompt: String) {
5937 let session_for_forward = session.clone();
5938 let (event_tx, event_rx) = std::sync::mpsc::channel::<AgentEvent>();
5939
5940 let forwarder = std::thread::spawn(move || {
5944 while let Ok(event) = event_rx.recv() {
5945 session_for_forward.forward_event_to_extensions(&event);
5946 }
5947 });
5948
5949 use std::sync::atomic::Ordering;
5953 session.reset_should_stop();
5954 let streaming = session.streaming_flag();
5955 streaming.store(true, Ordering::SeqCst);
5956 let _stream_guard = StreamingGuard(&streaming);
5957
5958 let agent = session.agent_ref();
5959 let local = tokio::task::LocalSet::new();
5960 let result = local
5961 .run_until(agent.run_with_channel(prompt, event_tx))
5962 .await;
5963
5964 let _ = forwarder.join();
5967 if let Err(err) = result {
5968 tracing::warn!(?err, "agent run failed");
5969 }
5970}
5971
5972fn build_header_context(
5981 app: &App,
5982 cwd: &std::path::Path,
5983 git_branch: Option<&str>,
5984) -> InlineHeaderContext {
5985 let workspace_name = cwd
5986 .file_name()
5987 .map(|n| n.to_string_lossy().into_owned())
5988 .unwrap_or_else(|| "oxicode".to_string());
5989 let model_id = app.model_id();
5990 let provider = model_id
5991 .split_once('/')
5992 .map(|(p, _)| p.to_string())
5993 .unwrap_or_else(|| "Provider".to_string());
5994 let branch = git_branch.unwrap_or("\u{2014}").to_string();
5995 let mut ctx = InlineHeaderContext::default();
5996 ctx.app_name = "oxicode".to_string();
5997 ctx.provider = provider;
5998 ctx.model = model_id.clone();
5999 ctx.git = format!("git: {workspace_name}@{branch}");
6000 ctx.tools = "Tools: ready".to_string();
6001 ctx.search_tools = Some(InlineHeaderStatusBadge {
6002 text: workspace_name,
6003 tone: InlineHeaderStatusTone::Ready,
6004 });
6005 ctx.persistent_memory = Some(InlineHeaderStatusBadge {
6006 text: branch,
6007 tone: InlineHeaderStatusTone::Ready,
6008 });
6009 ctx.editor_context = Some(model_id);
6010 ctx
6011}
6012
6013async fn build_agent_session(app: &App) -> Result<crate::app::agent_session::AgentSession> {
6016 use crate::app::agent_session_runtime::{
6017 CreateAgentSessionFromServicesOptions, CreateAgentSessionServicesOptions,
6018 create_agent_session_from_services, create_agent_session_services,
6019 };
6020 use crate::store::session::SessionManager;
6021
6022 let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
6023 let hook_runner = Arc::clone(&app.oxicode().ports().hooks);
6024 let services = create_agent_session_services(
6025 CreateAgentSessionServicesOptions::new(cwd.clone()),
6026 Some(hook_runner),
6027 )?;
6028 let services = Arc::new(services);
6029
6030 let model_id = app.model_id();
6031 let tools = app.agent_tools();
6032
6033 let session_manager = SessionManager::create(&cwd.to_string_lossy(), None);
6034
6035 let result = create_agent_session_from_services(CreateAgentSessionFromServicesOptions {
6036 services,
6037 session_manager,
6038 model_id: if model_id.is_empty() {
6039 None
6040 } else {
6041 Some(model_id)
6042 },
6043 thinking_level: None,
6044 scoped_models: Vec::new(),
6045 tool_registry: Some(tools),
6046 oxicode: Some(app.oxicode().clone()),
6047 session_state: Some(app.session_state().clone()),
6050 })
6051 .await?;
6052
6053 if let Some(msg) = result.model_fallback_message {
6054 tracing::warn!(message = %msg, "agent session model fallback");
6055 }
6056 Ok(result.session)
6057}
6058
6059fn cheatsheet_lines() -> Vec<String> {
6065 vec![
6066 "".into(),
6067 " Navigation".into(),
6068 " j / ↓ Scroll down".into(),
6069 " k / ↑ Scroll up".into(),
6070 " J (Shift+j) Next turn".into(),
6071 " K (Shift+k) Previous turn".into(),
6072 " PgDn / PgUp Page scroll".into(),
6073 " g / G Top / bottom".into(),
6074 "".into(),
6075 " Blocks".into(),
6076 " e Cycle block (collapse/truncate/expand)".into(),
6077 " E Expand all blocks".into(),
6078 " Ctrl+E Collapse all blocks".into(),
6079 "".into(),
6080 " Search".into(),
6081 " /find <q> Search transcript".into(),
6082 " n / N Next / previous match".into(),
6083 "".into(),
6084 " Commands".into(),
6085 " /theme Cycle color theme".into(),
6086 " /model Pick a model".into(),
6087 " /vim Toggle vim mode".into(),
6088 " /compact Compact context".into(),
6089 " /clear Clear conversation".into(),
6090 " Ctrl+C Cancel run (then y to quit)".into(),
6091 " Ctrl+Enter Send now (abort + submit)".into(),
6092 " Ctrl+M Toggle multiline input".into(),
6093 " Shift+Tab Toggle Auto mode (no questions, runs to end)".into(),
6094 " Ctrl+; Toggle queue panel".into(),
6095 "".into(),
6096 " Special Input".into(),
6097 " @ File picker (fuzzy search)".into(),
6098 " @! Toggle hidden files in picker".into(),
6099 " ! Shell mode (bash command)".into(),
6100 ]
6101}
6102
6103fn build_command_palette() -> OverlayState {
6106 use oxicode_vtui::tui::core::{InlineListItem, InlineListSelection};
6107
6108 let catalog = SlashRegistry::builtin_commands();
6109 let mut items: Vec<InlineListItem> = catalog
6110 .iter()
6111 .map(|(name, desc, aliases)| {
6112 let title = if aliases.is_empty() {
6113 format!("/{name}")
6114 } else {
6115 format!(
6116 "/{name} ({})",
6117 aliases
6118 .iter()
6119 .map(|a| format!("/{a}"))
6120 .collect::<Vec<_>>()
6121 .join(", ")
6122 )
6123 };
6124 InlineListItem {
6125 title,
6126 subtitle: Some(desc.to_string()),
6127 badge: None,
6128 indent: 0,
6129 selection: Some(InlineListSelection::SlashCommand(name.to_string())),
6130 search_value: Some(format!("{name} {desc}")),
6131 }
6132 })
6133 .collect();
6134 items.sort_by(|a, b| a.title.cmp(&b.title));
6135
6136 OverlayState {
6137 title: "Command Palette".into(),
6138 lines: vec!["Type to filter, Enter to select".into()],
6139 items: items
6140 .into_iter()
6141 .map(|item| OverlayListItem {
6142 title: item.title,
6143 subtitle: item.subtitle,
6144 badge: item.badge,
6145 indent: item.indent,
6146 search_value: item.search_value,
6147 selection: item.selection,
6148 })
6149 .collect(),
6150 selected: 0,
6151 search: Some(OverlaySearchState {
6152 label: "search".into(),
6153 placeholder: Some("filter commands\u{2026}".into()),
6154 value: String::new(),
6155 }),
6156 secure_input: None,
6157 ..Default::default()
6158 }
6159}
6160
6161static FRAME_TICK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
6163static ANIMATION_T0: std::sync::LazyLock<std::time::Instant> =
6168 std::sync::LazyLock::new(std::time::Instant::now);
6169
6170fn animation_frame(period_ms: u64) -> u64 {
6174 ANIMATION_T0.elapsed().as_millis() as u64 / period_ms.max(1)
6175}
6176static TITLE_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
6178const TITLE_SPINNER: &[&str] = &["-", "\\", "|", "/"];
6180
6181const RUN_SPINNER: &[&str] = &[
6185 "\u{280B}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283C}", "\u{2834}", "\u{2826}", "\u{2827}",
6186 "\u{2807}", "\u{280F}",
6187];
6188
6189fn format_elapsed_secs(secs: u64) -> String {
6192 if secs < 60 {
6193 format!("{secs}s")
6194 } else {
6195 format!("{}m {:02}s", secs / 60, secs % 60)
6196 }
6197}
6198
6199fn blend_rgb(base: Color, target: Color, ratio: f64) -> Color {
6201 match (base, target) {
6202 (Color::Rgb(br, bg, bb), Color::Rgb(tr, tg, tb)) => {
6203 let r = (br as f64 + (tr as f64 - br as f64) * ratio).round() as u8;
6204 let g = (bg as f64 + (tg as f64 - bg as f64) * ratio).round() as u8;
6205 let b = (bb as f64 + (tb as f64 - bb as f64) * ratio).round() as u8;
6206 Color::Rgb(r, g, b)
6207 }
6208 _ => base,
6209 }
6210}
6211
6212fn accent_color_for_kind(kind: InlineMessageKind, styles: &ThemeStyles) -> Color {
6214 match kind {
6215 InlineMessageKind::User => color_from_anstyle(styles.user.get_fg_color()),
6216 InlineMessageKind::Agent => color_from_anstyle(styles.response.get_fg_color()),
6217 InlineMessageKind::Tool => color_from_anstyle(styles.tool.get_fg_color()),
6218 InlineMessageKind::Error => color_from_anstyle(styles.error.get_fg_color()),
6219 InlineMessageKind::Warning => color_from_anstyle(styles.status.get_fg_color()),
6220 InlineMessageKind::Info => color_from_anstyle(styles.info.get_fg_color()),
6221 InlineMessageKind::Policy => color_from_anstyle(styles.mcp.get_fg_color()),
6222 InlineMessageKind::Pty => color_from_anstyle(styles.pty_output.get_fg_color()),
6223 }
6224}
6225
6226fn render_frame(frame: &mut Frame<'_>, state: &RenderState, _handle: &InlineHandle) {
6232 let area = frame.area();
6233 let bg = active_styles().background;
6238 frame
6239 .buffer_mut()
6240 .set_style(area, Style::default().bg(color_from_anstyle(Some(bg))));
6241 let layout = super::frame_layout::compute_chrome(area);
6242 let tick = FRAME_TICK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6243 {
6245 let running = state.active_run.is_some() || state.reasoning_stage.is_some();
6246 let was_running = TITLE_RUNNING.swap(running, std::sync::atomic::Ordering::Relaxed);
6247 if running || was_running {
6248 let title = if running {
6249 let spin = TITLE_SPINNER[(animation_frame(120) as usize) % TITLE_SPINNER.len()];
6250 let model = state
6251 .header_context
6252 .editor_context
6253 .as_deref()
6254 .unwrap_or("oxicode");
6255 format!("{spin} oxicode \u{2014} {model}")
6256 } else {
6257 "oxicode".to_string()
6258 };
6259 use std::io::Write;
6260 let _ = write!(std::io::stderr(), "\x1b]2;{}\x07", title);
6261 let _ = std::io::stderr().flush();
6262 }
6263 }
6264 if let Some(panel) = &state.issues_panel {
6270 crate::tui_vt::issues_panel::render_issues_panel(frame, area, panel);
6271 if let Some(overlay) = &state.overlay {
6272 render_overlay(frame, area, overlay);
6273 }
6274 if let Some(confirm) = &state.confirmation {
6275 render_confirmation(frame, area, confirm);
6276 }
6277 return;
6278 }
6279 if let Some(git) = &state.git_tui {
6283 crate::tui_vt::git_tui::render::render_overlay_lines(frame, area, git);
6284 } else {
6285 render_transcript(frame, layout.scrollback, state);
6286 let mut pinned_area = layout.scrollback;
6287 if !state.queued_inputs.is_empty() {
6288 let used = render_queue_pane(frame, pinned_area, state);
6289 pinned_area.y = pinned_area.y.saturating_add(used);
6290 pinned_area.height = pinned_area.height.saturating_sub(used);
6291 }
6292 if !state.todo_phases.is_empty() {
6293 if frame.area().height < TODO_COMPACT_ROWS_THRESHOLD {
6294 let line = render_todo_compact_line(&state.todo_phases);
6295 frame.render_widget(
6296 Paragraph::new(vec![line]),
6297 Rect {
6298 height: 1,
6299 ..pinned_area
6300 },
6301 );
6302 } else {
6303 let is_matched = build_matched_closure(state.hub.as_ref());
6304 render_todo_pane(
6305 frame,
6306 pinned_area,
6307 &state.todo_phases,
6308 state.todo_expanded,
6309 is_matched,
6310 );
6311 }
6312 }
6313 if state.active_run.is_some() || state.reasoning_stage.is_some() {
6316 render_reasoning_indicator(frame, layout.prompt, state);
6317 } else if state.pending_quit {
6318 render_pending_quit_hint(frame, layout.prompt);
6319 } else if !state.follow_ups.is_empty() {
6320 render_follow_ups(frame, layout.prompt, &state.follow_ups);
6321 } else {
6322 let occluded = state.overlay.is_some() || state.confirmation.is_some();
6324 if let Some(tip) = &state.tip
6325 && tip_is_visible(tip, tick)
6326 && !(tip.ambient && occluded)
6327 {
6328 render_tip(frame, layout.prompt, &tip.text);
6329 }
6330 }
6331 render_composer(frame, layout.prompt, state);
6332 if state.slash_popup.open {
6333 render_slash_popup(frame, layout.prompt, state);
6334 }
6335 if state.file_search.is_some() {
6336 render_file_search_dropdown(frame, layout.prompt, state);
6337 }
6338 }
6339 if state.agent_hub_open {
6340 render_agent_hub(frame, area, state);
6341 }
6342 if let Some(overlay) = &state.overlay {
6343 render_overlay(frame, area, overlay);
6344 }
6345 if let Some(confirm) = &state.confirmation {
6346 render_confirmation(frame, area, confirm);
6347 }
6348 }
6351fn render_confirmation(frame: &mut Frame, area: Rect, confirm: &ModalConfirmation) {
6353 let styles = active_styles();
6354 let accent = color_from_anstyle(styles.error.get_fg_color());
6355 let inner_w = confirm
6356 .title
6357 .chars()
6358 .count()
6359 .max(confirm.message.chars().count())
6360 .max(36) as u16;
6361 let width = inner_w + 4;
6362 let height = 5;
6363 let x = area.x + area.width.saturating_sub(width) / 2;
6364 let y = area.y + area.height.saturating_sub(height) / 2;
6365 let popup_area = Rect {
6366 x,
6367 y,
6368 width,
6369 height,
6370 };
6371 let block = Block::default()
6372 .borders(Borders::ALL)
6373 .border_type(BorderType::Rounded)
6374 .title(Span::styled(
6375 format!(" {} ", confirm.title),
6376 Style::default().fg(accent).bold(),
6377 ))
6378 .border_style(Style::default().fg(accent));
6379 let msg = Line::styled(
6380 confirm.message.clone(),
6381 Style::default().fg(color_from_anstyle(Some(styles.foreground))),
6382 );
6383 frame.render_widget(
6384 Paragraph::new(vec![Line::default(), msg]).block(block),
6385 popup_area,
6386 );
6387}
6388
6389fn render_agent_hub(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
6393 let rows = state.hub_entries.len() as u16;
6394 let height = rows.saturating_add(4).min(area.height.saturating_sub(1));
6395 let width = area.width.clamp(30, 80);
6396 let rect = Rect {
6397 x: area.x + (area.width.saturating_sub(width)) / 2,
6398 y: area.y + (area.height.saturating_sub(height)) / 2,
6399 width,
6400 height,
6401 };
6402 frame.render_widget(Clear, rect);
6403
6404 let title = Line::from(Span::styled(
6405 " Agent Hub ",
6406 Style::default().add_modifier(Modifier::BOLD),
6407 ));
6408 let block = Block::default().borders(Borders::ALL).title(title);
6409
6410 let items: Vec<ListItem<'_>> = if state.hub_entries.is_empty() {
6411 vec![ListItem::new(Line::from(Span::raw(
6412 "No agents registered.",
6413 )))]
6414 } else {
6415 state
6416 .hub_entries
6417 .iter()
6418 .map(|(id, e)| {
6419 ListItem::new(Line::from(vec![
6420 Span::raw(format!("{:?} ", e.kind)),
6421 Span::raw(e.display_name.clone()),
6422 Span::raw(format!(" — {:?} ({})", e.status, id)),
6423 ]))
6424 })
6425 .collect()
6426 };
6427 frame.render_widget(List::new(items).block(block), rect);
6428}
6429
6430fn render_overlay(frame: &mut Frame<'_>, area: Rect, overlay: &OverlayState) {
6435 let styles = active_styles();
6436 if let Some(secure) = &overlay.secure_input {
6439 let lines_count = overlay.lines.len();
6441 let desired_h = (lines_count as u16).saturating_add(1).saturating_add(2); let height = desired_h.min(area.height.saturating_sub(2));
6443 let width = area.width.clamp(30, 80);
6444 let rect = Rect {
6445 x: area.x + (area.width.saturating_sub(width)) / 2,
6446 y: area.y + (area.height.saturating_sub(height)) / 2,
6447 width,
6448 height,
6449 };
6450 frame.render_widget(Clear, rect);
6451
6452 let title = Line::from(Span::styled(
6453 format!(" {} ", overlay.title),
6454 Style::default()
6455 .fg(color_from_anstyle(styles.primary.get_fg_color()))
6456 .add_modifier(Modifier::BOLD),
6457 ));
6458 let block = Block::default()
6459 .borders(Borders::ALL)
6460 .border_type(BorderType::Plain)
6461 .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
6462 .title(title);
6463 let inner = block.inner(rect);
6464 frame.render_widget(&block, rect);
6465
6466 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
6467
6468 let mut row = inner.top();
6469 for line_text in &overlay.lines {
6470 let row_area = Rect {
6471 x: inner.left(),
6472 y: row,
6473 width: inner.width,
6474 height: 1,
6475 };
6476 let line = Line::from(Span::styled(
6477 line_text.clone(),
6478 Style::default().fg(secondary),
6479 ));
6480 frame.render_widget(Paragraph::new(line), row_area);
6481 row = row.saturating_add(1);
6482 }
6483
6484 let label = &secure.config.label;
6491 let label_prefix = format!("{label}: ");
6492 let prefix_columns = UnicodeWidthStr::width(label_prefix.as_str()) as u16;
6493 let prefix_area = Rect {
6494 x: inner.left(),
6495 y: row,
6496 width: prefix_columns.min(inner.width),
6497 height: 1,
6498 };
6499 frame.render_widget(
6500 Paragraph::new(Line::from(Span::styled(
6501 label_prefix.clone(),
6502 Style::default().fg(secondary),
6503 ))),
6504 prefix_area,
6505 );
6506 let textarea_area = Rect {
6507 x: inner.left().saturating_add(prefix_columns),
6508 y: row,
6509 width: inner.width.saturating_sub(prefix_columns),
6510 height: 1,
6511 };
6512 let inner_left = textarea_area.left();
6513 let inner_right = textarea_area.right().saturating_sub(1);
6514
6515 let value = secure.editor.text();
6516 if value.is_empty() {
6517 if let Some(placeholder) = secure.config.placeholder.as_deref() {
6520 frame.render_widget(
6521 Paragraph::new(Line::from(Span::styled(
6522 placeholder.to_string(),
6523 Style::default().fg(secondary).dim(),
6524 ))),
6525 textarea_area,
6526 );
6527 }
6528 if textarea_area.width > 0 {
6529 frame.set_cursor_position(Position::new(inner_left, row));
6530 }
6531 return;
6532 }
6533
6534 let display_line: Line<'static> = if secure.config.mask_input {
6537 Line::from("*".repeat(value.chars().count()))
6538 } else {
6539 Line::from(value.to_string())
6544 };
6545 let mut ta = TextArea::new();
6546 ta.set_text(value);
6547 ta.replace_range_with_element(
6548 0..value.len(),
6549 value,
6550 MASKED_ELEMENT_KIND,
6551 Some(display_line),
6552 );
6553 ta.set_cursor(secure.editor.cursor_byte());
6557 frame.render_widget_ref(&ta, textarea_area);
6558 if let Some((cx, cy)) = ta.cursor_pos_with_state(textarea_area, TextAreaState::default()) {
6561 let caret_x = cx.min(inner_right);
6562 frame.set_cursor_position(Position::new(caret_x, cy));
6563 }
6564 return;
6565 }
6566 let visible_max = (area.height as usize).saturating_sub(7).max(3);
6569
6570 let filtered: Vec<usize> = match &overlay.search {
6572 Some(search) if !search.value.is_empty() => {
6573 let needle = search.value.to_lowercase();
6574 overlay
6575 .items
6576 .iter()
6577 .enumerate()
6578 .filter_map(|(idx, item)| {
6579 let title_match = item.title.to_lowercase().contains(&needle);
6580 let sv_match = item
6581 .search_value
6582 .as_deref()
6583 .map(|v| v.to_lowercase().contains(&needle))
6584 .unwrap_or(false);
6585 if title_match || sv_match {
6586 Some(idx)
6587 } else {
6588 None
6589 }
6590 })
6591 .collect()
6592 }
6593 _ => (0..overlay.items.len()).collect(),
6594 };
6595
6596 let has_search = overlay.search.is_some();
6597 let has_tabs = overlay.tabs.len() > 1;
6598 let lines_count = overlay.lines.len();
6599 let items_count = filtered.len().min(visible_max);
6600 let height_inner =
6601 (lines_count + items_count + usize::from(has_search) + usize::from(has_tabs)) as u16;
6602 let desired_h = height_inner.saturating_add(3); let height = desired_h.min(area.height.saturating_sub(2));
6604 let width = area.width.clamp(30, 80);
6605 let rect = Rect {
6606 x: area.x + (area.width.saturating_sub(width)) / 2,
6607 y: area.y + (area.height.saturating_sub(height)) / 2,
6608 width,
6609 height,
6610 };
6611 frame.render_widget(Clear, rect);
6612
6613 let title = Line::from(Span::styled(
6614 format!(" {} ", overlay.title),
6615 Style::default()
6616 .fg(color_from_anstyle(styles.primary.get_fg_color()))
6617 .add_modifier(Modifier::BOLD),
6618 ));
6619 let block = Block::default()
6620 .borders(Borders::ALL)
6621 .border_type(BorderType::Plain)
6622 .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
6623 .title(title);
6624 let inner = block.inner(rect);
6625 frame.render_widget(&block, rect);
6626
6627 let primary = color_from_anstyle(styles.primary.get_fg_color());
6628 let fg = color_from_anstyle(Some(styles.foreground));
6629 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
6630
6631 let selected_filtered_pos = filtered
6633 .iter()
6634 .position(|&idx| idx == overlay.selected)
6635 .unwrap_or(0);
6636
6637 let mut row = inner.top();
6638
6639 if has_tabs {
6642 let mut spans: Vec<Span> = Vec::new();
6643 for (i, name) in overlay.tabs.iter().enumerate() {
6644 if i > 0 {
6645 spans.push(Span::raw(" "));
6646 }
6647 let style = if i == overlay.active_tab {
6648 Style::default().fg(primary).add_modifier(Modifier::BOLD)
6649 } else {
6650 Style::default().fg(secondary).add_modifier(Modifier::DIM)
6651 };
6652 spans.push(Span::styled(name.clone(), style));
6653 }
6654 let row_area = Rect {
6655 x: inner.left(),
6656 y: row,
6657 width: inner.width,
6658 height: 1,
6659 };
6660 frame.render_widget(Paragraph::new(Line::from(spans)), row_area);
6661 row = row.saturating_add(1);
6662 }
6663 if let Some(search) = &overlay.search {
6665 let prompt = format!("{}: {}", search.label, search.value);
6666 let line = Line::from(vec![
6667 Span::styled(
6668 format!("{}: ", search.label),
6669 Style::default().fg(secondary),
6670 ),
6671 Span::styled(
6672 if search.value.is_empty() {
6673 search
6674 .placeholder
6675 .clone()
6676 .unwrap_or_else(|| "type to filter\u{2026}".to_string())
6677 } else {
6678 search.value.clone()
6679 },
6680 if search.value.is_empty() {
6681 Style::default().fg(secondary).add_modifier(Modifier::DIM)
6682 } else {
6683 Style::default().fg(fg)
6684 },
6685 ),
6686 ]);
6687 let _ = prompt; let row_area = Rect {
6689 x: inner.left(),
6690 y: row,
6691 width: inner.width,
6692 height: 1,
6693 };
6694 frame.render_widget(Paragraph::new(line), row_area);
6695 row = row.saturating_add(1);
6696 }
6697
6698 for line_text in &overlay.lines {
6700 let row_area = Rect {
6701 x: inner.left(),
6702 y: row,
6703 width: inner.width,
6704 height: 1,
6705 };
6706 let line = Line::from(Span::styled(
6707 line_text.clone(),
6708 Style::default().fg(secondary),
6709 ));
6710 frame.render_widget(Paragraph::new(line), row_area);
6711 row = row.saturating_add(1);
6712 }
6713
6714 let searching = overlay.search.as_ref().is_some_and(|s| !s.value.is_empty());
6720 let use_sidebar = overlay.sections.len() >= 2 && inner.width >= 60 && !searching;
6721 let sidebar_w = if use_sidebar {
6722 let longest = overlay
6723 .sections
6724 .iter()
6725 .map(|s| s.chars().count())
6726 .max()
6727 .unwrap_or(0);
6728 (22usize.min(longest) + 4) as u16
6729 } else {
6730 0
6731 };
6732 let active_section = if use_sidebar {
6735 item_section_idx(overlay, overlay.selected).unwrap_or(overlay.active_section)
6736 } else {
6737 overlay.active_section
6738 };
6739 let list_x = inner.left() + sidebar_w;
6740 let list_w = inner.width.saturating_sub(sidebar_w);
6741 if use_sidebar {
6742 let mut srow = row;
6743 for (i, name) in overlay.sections.iter().enumerate() {
6744 let style = if i == active_section {
6745 Style::default().fg(primary).add_modifier(Modifier::BOLD)
6746 } else {
6747 Style::default().fg(secondary)
6748 };
6749 let marker = if i == active_section { "> " } else { " " };
6750 let row_area = Rect {
6751 x: inner.left(),
6752 y: srow,
6753 width: sidebar_w,
6754 height: 1,
6755 };
6756 frame.render_widget(
6757 Paragraph::new(Line::from(vec![
6758 Span::styled(marker, style),
6759 Span::styled(name.clone(), style),
6760 ])),
6761 row_area,
6762 );
6763 srow = srow.saturating_add(1);
6764 }
6765 }
6766
6767 if filtered.is_empty() {
6769 if overlay.key_capture.is_some() {
6773 return;
6774 }
6775 let row_area = Rect {
6776 x: inner.left(),
6777 y: row,
6778 width: inner.width,
6779 height: 1,
6780 };
6781 let empty_text = if overlay.search.is_some() {
6782 " (no matches)"
6783 } else {
6784 " (no items)"
6785 };
6786 frame.render_widget(
6787 Paragraph::new(Line::from(Span::styled(
6788 empty_text,
6789 Style::default().fg(secondary).add_modifier(Modifier::DIM),
6790 ))),
6791 row_area,
6792 );
6793 } else {
6794 let first_visible = selected_filtered_pos
6795 .saturating_sub(visible_max / 2)
6796 .min(filtered.len().saturating_sub(visible_max));
6797 for &item_idx in filtered.iter().skip(first_visible).take(visible_max) {
6798 let item = &overlay.items[item_idx];
6799 let is_selected = item_idx == overlay.selected;
6800 let marker = if is_selected { "> " } else { " " };
6801 let indent = " ".repeat(item.indent as usize);
6802 let mut item_style = if is_selected {
6803 Style::default().fg(primary).add_modifier(Modifier::BOLD)
6804 } else {
6805 Style::default().fg(fg)
6806 };
6807 if use_sidebar
6810 && item_section_idx(overlay, item_idx).is_some_and(|sec| sec != active_section)
6811 {
6812 item_style = item_style.add_modifier(Modifier::DIM);
6813 }
6814 let mut spans = vec![
6815 Span::styled(marker, item_style),
6816 Span::styled(indent, item_style),
6817 Span::styled(item.title.clone(), item_style),
6818 ];
6819 if let Some(badge) = &item.badge {
6820 spans.push(Span::raw(" "));
6821 spans.push(Span::styled(
6822 badge.clone(),
6823 Style::default().fg(secondary).add_modifier(Modifier::DIM),
6824 ));
6825 }
6826 if let Some(subtitle) = &item.subtitle {
6827 spans.push(Span::raw(" "));
6828 spans.push(Span::styled(
6829 subtitle.clone(),
6830 Style::default().fg(secondary),
6831 ));
6832 }
6833 let line = Line::from(spans);
6834 let row_area = Rect {
6835 x: list_x,
6836 y: row,
6837 width: list_w,
6838 height: 1,
6839 };
6840 frame.render_widget(Paragraph::new(line), row_area);
6841 row = row.saturating_add(1);
6842 }
6843 }
6844
6845 if row < inner.bottom() {
6848 let hint = if overlay.items.iter().any(|item| item.selection.is_some()) {
6849 if has_tabs {
6850 "Enter select | Up/Down move | ←/→ tabs | Esc close"
6851 } else {
6852 "Enter select | Up/Down move | Esc close"
6853 }
6854 } else {
6855 "Esc close"
6856 };
6857 frame.render_widget(
6858 Paragraph::new(Line::from(Span::styled(
6859 hint,
6860 Style::default().fg(secondary).add_modifier(Modifier::DIM),
6861 ))),
6862 Rect {
6863 x: inner.left(),
6864 y: inner.bottom().saturating_sub(1),
6865 width: inner.width,
6866 height: 1,
6867 },
6868 );
6869 }
6870}
6871
6872fn render_transcript(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
6873 if state.transcript.is_empty() {
6874 render_welcome(frame, area, state);
6875 return;
6876 }
6877 let styles = active_styles();
6878 let bg_color = color_from_anstyle(Some(styles.background));
6879
6880 let content_area = Rect {
6884 x: area.x,
6885 y: area.y,
6886 width: area.width,
6887 height: area.height,
6888 };
6889
6890 let display =
6891 build_transcript_display(state, &styles, state.committed_entries, content_area.width);
6892
6893 let total = display.len();
6895 let raw_start = if state.scroll_offset == usize::MAX {
6896 total.saturating_sub(content_area.height as usize)
6897 } else {
6898 display
6899 .iter()
6900 .position(|d| d.source_index >= state.scroll_offset)
6901 .unwrap_or(total.saturating_sub(1))
6902 };
6903 let start = effective_scroll_offset(raw_start, total, content_area.height as usize);
6904
6905 let sticky_first: Option<usize> = display.get(start).and_then(|d| {
6909 let bid = state.transcript.get(d.source_index)?.block_id;
6910 let first_idx = state.transcript.iter().position(|l| l.block_id == bid)?;
6911 (first_idx != d.source_index).then_some(first_idx)
6912 });
6913 let sticky_h: u16 = if sticky_first.is_some() { 1 } else { 0 };
6914 let body_top = content_area.top() + sticky_h;
6915
6916 const FADE_ROWS: usize = 5;
6921 let sticky_opacity: f64 = if let Some(sidx) = sticky_first {
6922 let sticky_bid = state.transcript[sidx].block_id;
6923 let next_offset = display.iter().skip(start).position(|d| {
6926 state
6927 .transcript
6928 .get(d.source_index)
6929 .map(|l| l.block_id != sticky_bid)
6930 .unwrap_or(false)
6931 });
6932 match next_offset {
6933 Some(off) if off <= FADE_ROWS => off as f64 / FADE_ROWS as f64,
6934 _ => 1.0,
6935 }
6936 } else {
6937 1.0
6938 };
6939
6940 if let Some(sidx) = sticky_first {
6943 let tl = &state.transcript[sidx];
6944 let accent_base = accent_color_for_kind(tl.kind, &styles);
6945 let bg_blend = 0.1 * sticky_opacity;
6946 let line =
6947 transcript_line_marked(tl, &styles, false, false, false, true, content_area.width);
6948 let row = Rect {
6949 x: content_area.x,
6950 y: content_area.top(),
6951 width: content_area.width,
6952 height: 1,
6953 };
6954 if bg_blend > 0.01 {
6955 frame.buffer_mut().set_style(
6956 row,
6957 Style::default().bg(blend_rgb(bg_color, accent_base, bg_blend)),
6958 );
6959 }
6960 frame.render_widget(Paragraph::new(line), row);
6961 }
6962 let live_budget = content_area.height.saturating_sub(sticky_h) as usize;
6969 let (alloc_by_block, hidden_count, natural_by_block) =
6970 compute_block_allocations(state, state.committed_entries, live_budget);
6971 let pulse = animation_frame(1000).is_multiple_of(2);
6973 let banner_row_used = hidden_count > 0;
6975 let banner_y = content_area.bottom().saturating_sub(1);
6976 let mut y = body_top;
6978 let width = content_area.width.max(1) as usize;
6979 let image_block_plans: Vec<(usize, u32, u16, usize)> = state
6985 .image_previews
6986 .pending()
6987 .iter()
6988 .filter_map(|p| {
6989 let row_index = state
6992 .transcript
6993 .iter()
6994 .position(|l| l.segments.iter().any(|s| s.text.contains(&p.label)))?;
6995 let bid = state.transcript[row_index].block_id;
6996 let mut block_rows: u16 = 0;
6997 for d in &display {
6998 let Some(l) = state.transcript.get(d.source_index) else {
6999 continue;
7000 };
7001 if l.block_id != bid {
7002 continue;
7003 }
7004 block_rows = block_rows.saturating_add(match &d.line {
7005 None => 1,
7006 Some(line) => {
7007 let lw = line.width();
7008 if lw == 0 {
7009 1
7010 } else {
7011 lw.div_ceil(width).max(1) as u16
7012 }
7013 }
7014 });
7015 }
7016 (block_rows > 0).then_some((bid, p.id, block_rows, row_index))
7017 })
7018 .collect();
7019 let mut current_block: Option<usize> = None;
7020 let mut skipped_blocks: std::collections::HashSet<usize> = std::collections::HashSet::new();
7021 for d in display.into_iter().skip(start) {
7022 if y >= content_area.bottom() {
7023 break;
7024 }
7025 if banner_row_used && y >= banner_y {
7028 break;
7029 }
7030 let d_bid = state.transcript.get(d.source_index).map(|l| l.block_id);
7031 let Some(d_bid) = d_bid else {
7032 continue;
7033 };
7034 if current_block != Some(d_bid) {
7036 if y < content_area.bottom()
7041 && let Some((_, pid, prows, row_index)) =
7042 image_block_plans.iter().find(|(b, _, _, _)| *b == d_bid)
7043 {
7044 let visible_rows = content_area.bottom().saturating_sub(y).max(1);
7045 state.image_previews.record_anchor(
7046 *pid,
7047 content_area.x,
7048 y,
7049 (*prows).min(visible_rows),
7050 *row_index,
7051 );
7052 }
7053 current_block = Some(d_bid);
7054 let alloc = alloc_by_block
7055 .get(&d_bid)
7056 .copied()
7057 .unwrap_or(BlockAlloc { rows: 0 });
7058 let natural = natural_by_block.get(&d_bid).copied().unwrap_or(0);
7065 if alloc.rows < natural {
7066 if skipped_blocks.contains(&d_bid) {
7071 continue;
7072 }
7073 match alloc.rows {
7074 0 => {
7075 skipped_blocks.insert(d_bid);
7076 continue;
7077 }
7078 1 => {
7079 let activity = block_activity(&state.transcript, d_bid);
7080 render_glyph_row(frame, content_area, y, &activity, &styles, pulse);
7081 y += 1;
7082 skipped_blocks.insert(d_bid);
7083 continue;
7084 }
7085 2 => {
7086 let activity = block_activity(&state.transcript, d_bid);
7087 y += render_folded_card(frame, content_area, y, &activity, &styles, pulse);
7088 skipped_blocks.insert(d_bid);
7089 continue;
7090 }
7091 _ => {}
7092 }
7093 }
7094 }
7099 let Some(line) = d.line else {
7100 y += 1;
7101 continue;
7102 };
7103 let text_w = line.width();
7104 let wrapped_h = if text_w == 0 {
7105 1
7106 } else {
7107 text_w.div_ceil(width).max(1) as u16
7108 };
7109 let row = Rect {
7110 x: content_area.x,
7111 y,
7112 width: content_area.width,
7113 height: wrapped_h.min(content_area.bottom().saturating_sub(y)),
7114 };
7115 frame.render_widget(Paragraph::new(line).wrap(Wrap { trim: false }), row);
7116 y += wrapped_h;
7117 }
7118
7119 if banner_row_used {
7123 render_hidden_banner(frame, content_area, banner_y, hidden_count);
7124 }
7125
7126 let _ = (total, sticky_h);
7127}
7128
7129#[derive(Clone)]
7132struct TranscriptDisplayItem<'a> {
7133 source_index: usize,
7134 line: Option<Line<'a>>,
7136}
7137
7138fn block_activity(transcript: &[TranscriptLine], block_id: usize) -> String {
7144 let mut activity = String::new();
7145 for line in transcript.iter().filter(|l| l.block_id == block_id) {
7146 for seg in &line.segments {
7147 let text = seg.text.trim();
7148 if !text.is_empty() {
7149 activity.push_str(text);
7150 break;
7151 }
7152 }
7153 if !activity.is_empty() {
7154 break;
7155 }
7156 }
7157 if !activity.is_empty() {
7158 return activity;
7159 }
7160 transcript
7162 .iter()
7163 .find(|l| l.block_id == block_id)
7164 .map(|l| kind_label(l.kind))
7165 .unwrap_or_else(|| "block".to_string())
7166}
7167
7168fn kind_label(kind: InlineMessageKind) -> String {
7171 match kind {
7172 InlineMessageKind::Agent => "agent".to_string(),
7173 InlineMessageKind::User => "user".to_string(),
7174 InlineMessageKind::Tool => "tool".to_string(),
7175 InlineMessageKind::Error => "error".to_string(),
7176 InlineMessageKind::Warning => "warning".to_string(),
7177 InlineMessageKind::Info => "info".to_string(),
7178 InlineMessageKind::Policy => "policy".to_string(),
7179 InlineMessageKind::Pty => "pty".to_string(),
7180 }
7181}
7182
7183fn block_natural_heights(
7188 transcript: &[TranscriptLine],
7189 mode_for: impl Fn(usize) -> BlockDisplayMode,
7190 from_entry: usize,
7191) -> (Vec<usize>, Vec<usize>) {
7192 let mut block_ids: Vec<usize> = Vec::new();
7193 let mut heights: Vec<usize> = Vec::new();
7194 let mut index_of: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
7195 for item in visible_items(transcript, mode_for) {
7196 let bid = match item {
7197 VisibleItem::Line { source_index, .. } => transcript[source_index].block_id,
7198 VisibleItem::Gap { source_index, .. } => transcript[source_index].block_id,
7199 };
7200 let live_source = match item {
7205 VisibleItem::Line { source_index, .. } => source_index,
7206 VisibleItem::Gap { source_index, .. } => source_index,
7207 };
7208 if live_source < from_entry {
7209 continue;
7210 }
7211 let idx = *index_of.entry(bid).or_insert_with(|| {
7212 block_ids.push(bid);
7213 heights.push(0);
7214 block_ids.len() - 1
7215 });
7216 heights[idx] += 1;
7217 }
7218 (block_ids, heights)
7219}
7220
7221fn compute_block_allocations(
7228 state: &RenderState,
7229 from_entry: usize,
7230 budget: usize,
7231) -> (
7232 std::collections::HashMap<usize, BlockAlloc>,
7233 usize,
7234 std::collections::HashMap<usize, usize>,
7235) {
7236 let (block_ids, heights) =
7237 block_natural_heights(&state.transcript, |bid| state.block_mode(bid), from_entry);
7238 let total_blocks = block_ids.len();
7239 let allocs = allocate_rows(&heights, budget);
7240 let mut by_block: std::collections::HashMap<usize, BlockAlloc> =
7241 std::collections::HashMap::with_capacity(total_blocks);
7242 let mut natural_by_block: std::collections::HashMap<usize, usize> =
7243 std::collections::HashMap::with_capacity(total_blocks);
7244 let mut hidden = 0usize;
7245 for (i, &bid) in block_ids.iter().enumerate() {
7246 let alloc = match state.block_mode(bid) {
7255 BlockDisplayMode::Collapsed => BlockAlloc { rows: heights[i] },
7260 BlockDisplayMode::Truncated | BlockDisplayMode::Expanded => allocs[i],
7261 };
7262 if alloc.rows == 0 {
7263 hidden += 1;
7264 }
7265 natural_by_block.insert(bid, heights[i]);
7266 by_block.insert(bid, alloc);
7267 }
7268 (by_block, hidden, natural_by_block)
7269}
7270
7271fn clamp_fold_text(text: &str, prefix_w: usize, width: usize, ellipsis: &str) -> String {
7277 let budget = width.saturating_sub(prefix_w);
7278 if budget == 0 || width == 0 {
7279 return String::new();
7280 }
7281 let text_w = text.width();
7282 if text_w <= budget {
7283 return text.to_string();
7284 }
7285 let ell_w = ellipsis.width();
7288 let cap = budget.saturating_sub(ell_w);
7289 let mut out = String::new();
7290 let mut used = 0usize;
7291 for ch in text.chars() {
7292 let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
7293 if used + w > cap {
7294 break;
7295 }
7296 out.push(ch);
7297 used += w;
7298 }
7299 out.push_str(ellipsis);
7300 out
7301}
7302
7303fn render_folded_card(
7309 frame: &mut Frame<'_>,
7310 area: Rect,
7311 y: u16,
7312 activity: &str,
7313 styles: &ThemeStyles,
7314 pulse: bool,
7315) -> u16 {
7316 let tool_color = styles
7317 .tool
7318 .get_fg_color()
7319 .or_else(|| styles.secondary.get_fg_color())
7320 .or(styles.response.get_fg_color());
7321 let style = Style::default().fg(color_from_anstyle(tool_color));
7322 let pulse_mark = if pulse { " \u{2022}" } else { "" };
7323 let head_prefix_w = "\u{256D}\u{2500} ".width() + pulse_mark.width();
7327 let head_activity = clamp_fold_text(activity, head_prefix_w, area.width as usize, "\u{2026}");
7328 let head = Line::from(vec![Span::styled(
7329 format!("\u{256D}\u{2500} {head_activity}{pulse_mark}"),
7330 style,
7331 )]);
7332 let tail = Line::from(vec![Span::styled("\u{2570}\u{2500} \u{2026}", style)]);
7333 if y < area.bottom() {
7334 let row = Rect {
7335 x: area.x,
7336 y,
7337 width: area.width,
7338 height: 1,
7339 };
7340 frame.render_widget(Paragraph::new(head), row);
7341 }
7342 let y2 = y.saturating_add(1);
7343 if y2 < area.bottom() {
7344 let row = Rect {
7345 x: area.x,
7346 y: y2,
7347 width: area.width,
7348 height: 1,
7349 };
7350 frame.render_widget(Paragraph::new(tail), row);
7351 }
7352 2
7353}
7354
7355fn render_glyph_row(
7361 frame: &mut Frame<'_>,
7362 area: Rect,
7363 y: u16,
7364 activity: &str,
7365 styles: &ThemeStyles,
7366 pulse: bool,
7367) -> u16 {
7368 let tool_color = styles
7369 .tool
7370 .get_fg_color()
7371 .or_else(|| styles.secondary.get_fg_color())
7372 .or(styles.response.get_fg_color());
7373 let style = Style::default().fg(color_from_anstyle(tool_color));
7374 let pulse_mark = if pulse { " \u{2022}" } else { "" };
7375 let glyph_prefix_w = "\u{25B8} ".width() + pulse_mark.width();
7377 let glyph_activity = clamp_fold_text(activity, glyph_prefix_w, area.width as usize, "\u{2026}");
7378 let line = Line::from(vec![Span::styled(
7379 format!("\u{25B8} {glyph_activity}{pulse_mark}"),
7380 style,
7381 )]);
7382 if y < area.bottom() {
7383 let row = Rect {
7384 x: area.x,
7385 y,
7386 width: area.width,
7387 height: 1,
7388 };
7389 frame.render_widget(Paragraph::new(line), row);
7390 }
7391 1
7392}
7393fn render_hidden_banner(frame: &mut Frame<'_>, area: Rect, y: u16, hidden: usize) -> u16 {
7397 let style = Style::default().fg(color_from_anstyle(active_styles().secondary.get_fg_color()));
7398 let text = if hidden == 1 {
7399 "\u{2026} 1 earlier block hidden".to_string()
7400 } else {
7401 format!("\u{2026} {hidden} earlier blocks hidden")
7402 };
7403 let clamped = clamp_fold_text(&text, 0, area.width as usize, "\u{2026}");
7404 let line = Line::from(vec![Span::styled(clamped, style)]);
7405 if y < area.bottom() {
7406 let row = Rect {
7407 x: area.x,
7408 y,
7409 width: area.width,
7410 height: 1,
7411 };
7412 frame.render_widget(Paragraph::new(line), row);
7413 }
7414 1
7415}
7416fn build_transcript_display<'a>(
7420 state: &'a RenderState,
7421 styles: &'a ThemeStyles,
7422 from_entry: usize,
7423 width: u16,
7424) -> Vec<TranscriptDisplayItem<'a>> {
7425 let search_set: std::collections::HashSet<usize> = state
7426 .search
7427 .as_ref()
7428 .map(|s| s.matches.iter().copied().collect())
7429 .unwrap_or_default();
7430 let current_match = state
7431 .search
7432 .as_ref()
7433 .and_then(|s| (!s.matches.is_empty()).then(|| s.matches[s.current]));
7434
7435 let mut display = Vec::with_capacity(state.transcript.len());
7436 let dim_style = Style::default()
7437 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
7438 .add_modifier(Modifier::DIM);
7439 let mut prev_block: Option<usize> = None;
7440 let mut prev_kind: Option<InlineMessageKind> = None;
7441 for item in visible_items(&state.transcript, |block_id| state.block_mode(block_id)) {
7442 match item {
7443 VisibleItem::Line {
7444 source_index,
7445 folded,
7446 } => {
7447 if source_index < from_entry {
7448 continue;
7449 }
7450 let tl = &state.transcript[source_index];
7451 let is_block_start = prev_block != Some(tl.block_id);
7452 let needs_spacer = is_block_start
7455 && prev_block.is_some()
7456 && (tl.kind == InlineMessageKind::User
7457 || prev_kind == Some(InlineMessageKind::User));
7458 if needs_spacer {
7459 display.push(TranscriptDisplayItem {
7460 source_index,
7461 line: None,
7462 });
7463 }
7464 let is_match = search_set.contains(&source_index);
7465 let line = transcript_line_marked(
7466 tl,
7467 styles,
7468 folded,
7469 is_match,
7470 current_match == Some(source_index),
7471 is_block_start,
7472 width,
7473 );
7474 display.push(TranscriptDisplayItem {
7475 source_index,
7476 line: Some(line),
7477 });
7478 prev_block = Some(tl.block_id);
7479 prev_kind = Some(tl.kind);
7480 }
7481 VisibleItem::Gap {
7482 source_index,
7483 hidden_lines,
7484 } => {
7485 if source_index < from_entry {
7486 continue;
7487 }
7488 let gap = Line::styled(format!(" \u{2026} +{hidden_lines} lines"), dim_style);
7489 display.push(TranscriptDisplayItem {
7490 source_index,
7491 line: Some(gap),
7492 });
7493 }
7494 }
7495 }
7496 display
7497}
7498pub(crate) fn should_rebuild_scrollback(
7511 prev_w: u16,
7512 new_w: u16,
7513 _prev_h: u16,
7514 _new_h: u16,
7515) -> bool {
7516 prev_w != 0 && prev_w != new_w
7517}
7518
7519pub(crate) fn plan_full_flush(display_len: usize) -> usize {
7527 display_len
7528}
7529
7530struct ScrollbackCommit {
7533 rows: u16,
7535 boundary_item: usize,
7537 new_committed_entries: usize,
7539}
7540fn scrollback_commit_plan(
7546 display: &[TranscriptDisplayItem<'_>],
7547 transcript: &[TranscriptLine],
7548 width: usize,
7549 keep_rows: usize,
7550 anchor_entry: Option<usize>,
7551) -> Option<ScrollbackCommit> {
7552 let width = width.max(1);
7553 let mut ends: Vec<usize> = Vec::with_capacity(display.len());
7556 let mut y = 0usize;
7557 for d in display {
7558 let h = match &d.line {
7559 None => 1,
7560 Some(line) => {
7561 let w = line.width();
7562 if w == 0 { 1 } else { w.div_ceil(width).max(1) }
7563 }
7564 };
7565 y += h;
7566 ends.push(y);
7567 }
7568 let total_rows = y;
7569 if total_rows <= keep_rows || display.is_empty() {
7570 return None;
7571 }
7572 let limit = total_rows - keep_rows;
7573
7574 let mut boundary_item = ends.iter().rposition(|&e| e <= limit)? + 1;
7576
7577 if let Some(anchor) = anchor_entry
7580 && let Some(anchor_item) = display.iter().position(|d| d.source_index >= anchor)
7581 {
7582 boundary_item = boundary_item.min(anchor_item);
7583 }
7584
7585 boundary_item = boundary_item.min(display.len().saturating_sub(1));
7587 let bid_of = |i: usize| transcript.get(display[i].source_index).map(|t| t.block_id);
7588 while boundary_item > 0 {
7589 let last = display[boundary_item - 1].source_index;
7590 let next = display[boundary_item].source_index;
7591 let same_block = matches!(
7592 (transcript.get(last), transcript.get(next)),
7593 (Some(a), Some(b)) if a.block_id == b.block_id
7594 );
7595 if !same_block {
7596 break;
7597 }
7598 let block_bid = bid_of(boundary_item);
7605 let block_start = (0..boundary_item)
7606 .rev()
7607 .find(|&i| bid_of(i) != block_bid)
7608 .map_or(0, |i| i + 1);
7609 let block_end = (boundary_item..display.len())
7610 .find(|&i| bid_of(i) != block_bid)
7611 .unwrap_or(display.len());
7612 let before_rows = if block_start == 0 {
7613 0
7614 } else {
7615 ends[block_start - 1]
7616 };
7617 if ends[block_end - 1] - before_rows > keep_rows {
7618 break; }
7620 boundary_item -= 1;
7621 }
7622 if boundary_item == 0 {
7623 return None;
7624 }
7625
7626 let first_live = display[boundary_item].source_index;
7630 let last_committed = display[boundary_item - 1].source_index;
7631 let new_committed_entries = if matches!(
7632 (transcript.get(last_committed), transcript.get(first_live)),
7633 (Some(a), Some(b)) if a.block_id == b.block_id
7634 ) {
7635 first_live
7636 } else {
7637 let live_bid = transcript.get(first_live)?.block_id;
7638 transcript.iter().position(|t| t.block_id == live_bid)?
7639 };
7640
7641 let rows = ends[boundary_item - 1].min(u16::MAX as usize) as u16;
7642 Some(ScrollbackCommit {
7643 rows,
7644 boundary_item,
7645 new_committed_entries,
7646 })
7647}
7648
7649fn render_committed_chunk(
7653 buf: &mut Buffer,
7654 items: &[TranscriptDisplayItem<'_>],
7655 x: u16,
7656 width: u16,
7657) {
7658 use ratatui::widgets::Widget;
7659 let width = width.max(1);
7660 let mut y = 0u16;
7661 for item in items {
7662 let Some(line) = &item.line else {
7663 y += 1;
7664 continue;
7665 };
7666 let text_w = line.width();
7667 let wrapped_h = if text_w == 0 {
7668 1
7669 } else {
7670 text_w.div_ceil(width as usize).max(1) as u16
7671 };
7672 let area = Rect {
7673 x,
7674 y,
7675 width,
7676 height: wrapped_h,
7677 };
7678 Paragraph::new(line.clone())
7679 .wrap(Wrap { trim: false })
7680 .render(area, buf);
7681 y += wrapped_h;
7682 }
7683}
7684
7685fn commit_scrollback(
7694 terminal: &mut Terminal<CrosstermBackend<Stdout>>,
7695 state: &mut RenderState,
7696 force_all: bool,
7697) {
7698 if state.scroll_offset != usize::MAX
7699 || !state.message_buffer.is_empty()
7700 || !state.thinking_buffer.is_empty()
7701 || state.overlay.is_some()
7702 || state.confirmation.is_some()
7703 || state.agent_hub_open
7704 || state.slash_popup.open
7705 || state.file_search.is_some()
7706 || state.search.is_some()
7707 || state.transcript.is_empty()
7708 {
7709 return;
7710 }
7711 let Ok(size) = terminal.size() else {
7712 return;
7713 };
7714 let area = Rect {
7715 x: 0,
7716 y: 0,
7717 width: size.width,
7718 height: size.height,
7719 };
7720 let keep_rows = super::frame_layout::scrollback_height(area) as usize;
7721 if keep_rows == 0 && !force_all {
7722 return;
7723 }
7724 let styles = active_styles();
7725 let (gutter_x, scrollback_w) = super::frame_layout::scrollback_geometry(area);
7726 let content_w = scrollback_w as usize;
7727 let display =
7728 build_transcript_display(state, &styles, state.committed_entries, content_w as u16);
7729 if force_all {
7730 let boundary_item = plan_full_flush(display.len()).min(display.len());
7735 if boundary_item == 0 {
7736 return;
7737 }
7738 let mut total_rows = 0usize;
7741 let width = content_w.max(1);
7742 for d in &display[..boundary_item] {
7743 total_rows += match &d.line {
7744 None => 1,
7745 Some(line) => {
7746 let w = line.width();
7747 if w == 0 { 1 } else { w.div_ceil(width).max(1) }
7748 }
7749 };
7750 }
7751 let rows = total_rows.min(u16::MAX as usize) as u16;
7752 let chunk = &display[..boundary_item];
7753 let res = terminal.insert_before(rows, |buf| {
7754 render_committed_chunk(buf, chunk, gutter_x, content_w as u16);
7755 });
7756 if res.is_ok() {
7757 state.committed_entries = state.transcript.len();
7761 }
7762 return;
7763 }
7764 let Some(plan) = scrollback_commit_plan(
7765 &display,
7766 &state.transcript,
7767 content_w,
7768 keep_rows,
7769 state.stream_anchor,
7770 ) else {
7771 return;
7772 };
7773 let chunk = &display[..plan.boundary_item];
7774 let res = terminal.insert_before(plan.rows, |buf| {
7775 render_committed_chunk(buf, chunk, gutter_x, content_w as u16);
7776 });
7777 if res.is_ok() {
7778 state.committed_entries = plan.new_committed_entries;
7779 }
7780}
7781
7782fn transcript_line_marked<'a>(
7791 line: &'a TranscriptLine,
7792 styles: &'a ThemeStyles,
7793 folded: bool,
7794 is_match: bool,
7795 is_current: bool,
7796 is_block_start: bool,
7797 width: u16,
7798) -> Line<'a> {
7799 let kind_style = match line.kind {
7800 InlineMessageKind::Agent => {
7801 Style::default().fg(color_from_anstyle(styles.response.get_fg_color()))
7802 }
7803 InlineMessageKind::User => {
7804 Style::default().fg(color_from_anstyle(styles.user.get_fg_color()))
7805 }
7806 InlineMessageKind::Tool => {
7807 Style::default().fg(color_from_anstyle(styles.tool.get_fg_color()))
7808 }
7809 InlineMessageKind::Error => {
7810 Style::default().fg(color_from_anstyle(styles.error.get_fg_color()))
7811 }
7812 InlineMessageKind::Warning => {
7813 Style::default().fg(color_from_anstyle(styles.status.get_fg_color()))
7814 }
7815 InlineMessageKind::Info => {
7816 Style::default().fg(color_from_anstyle(styles.info.get_fg_color()))
7817 }
7818 InlineMessageKind::Policy => {
7819 Style::default().fg(color_from_anstyle(styles.mcp.get_fg_color()))
7820 }
7821 InlineMessageKind::Pty => {
7822 Style::default().fg(color_from_anstyle(styles.pty_output.get_fg_color()))
7823 }
7824 };
7825
7826 let severity_label = match line.kind {
7829 InlineMessageKind::Error => Some("error: "),
7830 InlineMessageKind::Warning => Some("warning: "),
7831 InlineMessageKind::Info => Some("info: "),
7832 InlineMessageKind::Policy => Some("policy: "),
7833 _ => None,
7834 };
7835 let mut prefix = String::new();
7836 if folded {
7837 prefix.push_str("[+] ");
7838 }
7839 if let Some(label) = severity_label
7840 && (folded || is_block_start)
7841 {
7842 prefix.push_str(label);
7843 }
7844
7845 let highlight = if is_current {
7847 Some(Style::default().reversed())
7848 } else if is_match {
7849 Some(Style::default().add_modifier(Modifier::UNDERLINED))
7850 } else {
7851 None
7852 };
7853 let prefix_w = prefix.len() as u16;
7859 let budget = width.saturating_sub(prefix_w);
7860 let clamped = clamp_segments_to_width(&line.segments, budget);
7861 let mut spans = Vec::with_capacity(clamped.len() + 1);
7862 if !prefix.is_empty() {
7863 spans.push(Span::styled(prefix, kind_style));
7864 }
7865 for segment in &clamped {
7866 let mut style = segment_style(segment, kind_style, styles);
7867 if line.kind == InlineMessageKind::User {
7869 style = style.add_modifier(Modifier::BOLD);
7870 }
7871 if let Some(h) = highlight {
7872 style = style.patch(h);
7873 }
7874 spans.push(Span::styled(segment.text.clone(), style));
7875 }
7876 Line::from(spans)
7877}
7878
7879pub(crate) fn segment_style(
7880 segment: &InlineSegment,
7881 fallback: Style,
7882 _styles: &ThemeStyles,
7883) -> Style {
7884 let mut style = fallback;
7885 let inline = segment.style.as_ref();
7886 if let Some(color) = inline.color {
7887 style = style.fg(color_from_anstyle(Some(color)));
7888 }
7889 if inline.effects.contains(anstyle::Effects::BOLD) {
7893 style = style.add_modifier(Modifier::BOLD);
7894 }
7895 if inline.effects.contains(anstyle::Effects::ITALIC) {
7896 style = style.add_modifier(Modifier::ITALIC);
7897 }
7898 if inline.effects.contains(anstyle::Effects::UNDERLINE) {
7899 style = style.add_modifier(Modifier::UNDERLINED);
7900 }
7901 if inline.effects.contains(anstyle::Effects::DIMMED) {
7902 style = style.add_modifier(Modifier::DIM);
7903 }
7904 style
7905}
7906fn render_composer(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
7907 let styles = active_styles();
7908 let prefix_style = Style::default()
7909 .fg(color_from_anstyle(styles.primary.get_fg_color()))
7910 .bold();
7911
7912 let prefix = state.prompt_prefix.clone();
7913 let placeholder = state.placeholder.clone();
7914
7915 let mut prefix_w: u16 = 0;
7923 let mut line_spans = Vec::new();
7924 if let Some(label) = state.vim_state.status_label() {
7925 let seg = format!("[{label}] ");
7926 prefix_w = prefix_w.saturating_add(seg.width() as u16);
7927 line_spans.push(Span::styled(
7928 seg,
7929 Style::default()
7930 .fg(color_from_anstyle(styles.tool.get_fg_color()))
7931 .add_modifier(Modifier::BOLD),
7932 ));
7933 }
7934 if state.autonomy_mode.is_auto() {
7935 let seg = "[auto] ";
7936 prefix_w = prefix_w.saturating_add(seg.width() as u16);
7937 line_spans.push(Span::styled(
7938 seg,
7939 Style::default()
7940 .fg(Color::Yellow)
7941 .add_modifier(Modifier::BOLD),
7942 ));
7943 }
7944 prefix_w = prefix_w.saturating_add(UnicodeWidthStr::width(prefix.as_str()) as u16);
7945 line_spans.push(Span::styled(prefix, prefix_style));
7946 if state.shell_mode {
7947 let seg = "! ";
7948 prefix_w = prefix_w.saturating_add(seg.width() as u16);
7949 line_spans.push(Span::styled(
7950 seg,
7951 Style::default()
7952 .fg(Color::Yellow)
7953 .add_modifier(Modifier::BOLD),
7954 ));
7955 }
7956
7957 let context_line = composer_context_line(state, area.width);
7958 let used: usize = context_line.spans.iter().map(|s| s.width()).sum();
7959 let mut block = Block::default()
7960 .borders(Borders::ALL)
7961 .border_type(BorderType::Plain)
7962 .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
7963 .title(context_line);
7967 if let Some(chip) = composer_brain_chip(state, area.width, used) {
7968 block = block.title(chip);
7969 }
7970
7971 let inner = area.inner(Margin::new(1, 1));
7976 let textarea_area = Rect {
7977 x: inner.left().saturating_add(prefix_w),
7978 y: inner.top(),
7979 width: inner.width.saturating_sub(prefix_w),
7980 height: inner.height,
7981 };
7982 if state.composer.is_empty()
7983 && let Some(ph) = placeholder.as_deref()
7984 {
7985 line_spans.push(Span::styled(
7987 ph.to_string(),
7988 Style::default()
7989 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
7990 .dim(),
7991 ));
7992 let paragraph = Paragraph::new(Line::from(line_spans))
7993 .block(block)
7994 .wrap(Wrap { trim: false });
7995 frame.render_widget(paragraph, area);
7996 if state.input_enabled {
7997 frame.set_cursor_position(Position::new(
8000 inner.left().saturating_add(prefix_w),
8001 area.top().saturating_add(1),
8002 ));
8003 }
8004 return;
8005 }
8006 let prefix_area = Rect {
8011 x: inner.left(),
8012 y: inner.top(),
8013 width: prefix_w,
8014 height: inner.height,
8015 };
8016 let frame_paragraph = Paragraph::new(Line::from(Vec::<Span>::new()))
8019 .block(block)
8020 .wrap(Wrap { trim: false });
8021 frame.render_widget(frame_paragraph, area);
8022 frame.render_widget(Paragraph::new(Line::from(line_spans)), prefix_area);
8023 frame.render_widget_ref(&state.composer, textarea_area);
8024
8025 if state.input_enabled
8026 && let Some((cx, cy)) = state
8027 .composer
8028 .cursor_pos_with_state(textarea_area, TextAreaState::default())
8029 {
8030 frame.set_cursor_position(Position::new(cx, cy));
8037 }
8038}
8039
8040fn composer_context_line<'a>(state: &'a RenderState, width: u16) -> Line<'a> {
8047 let styles = active_styles();
8048 let primary = color_from_anstyle(styles.primary.get_fg_color());
8049 let fg = color_from_anstyle(Some(styles.foreground));
8050 let muted = color_from_anstyle(styles.secondary.get_fg_color());
8051 let info = color_from_anstyle(styles.info.get_fg_color());
8052
8053 let model = state
8054 .header_context
8055 .model
8056 .strip_prefix(&format!("{}/", state.header_context.provider))
8057 .unwrap_or(&state.header_context.model);
8058 let workspace = state
8059 .cwd
8060 .file_name()
8061 .map(|name| name.to_string_lossy().into_owned())
8062 .filter(|name| !name.is_empty())
8063 .unwrap_or_else(|| "workspace".to_string());
8064 let branch = state
8065 .header_context
8066 .persistent_memory
8067 .as_ref()
8068 .map(|badge| badge.text.as_str())
8069 .filter(|branch| !branch.is_empty())
8070 .unwrap_or("—");
8071 let context = match state.context_tokens {
8072 Some(used) => {
8073 let percent = used.saturating_mul(100) / state.context_window.max(1);
8074 format!(
8075 "{}/{} {percent}%",
8076 compact_token_count(used),
8077 compact_token_count(state.context_window)
8078 )
8079 }
8080 None => format!("0/{}", compact_token_count(state.context_window)),
8081 };
8082
8083 use crate::symbols::nerd as icons;
8087 let nerd = state.glyph_set == crate::symbols::GlyphSet::Nerd;
8088 let label =
8089 |text: &'static str, icon: &'static str| -> &'static str { if nerd { icon } else { text } };
8090 let mut fields: Vec<(&str, String, Style, u16)> = vec![
8091 (
8092 label("MODEL ", icons::MODEL),
8093 model.to_string(),
8094 Style::default().fg(fg).add_modifier(Modifier::BOLD),
8095 0,
8096 ),
8097 (
8098 label("THINK ", icons::THINK),
8099 state.thinking_level.clone(),
8100 Style::default().fg(info),
8101 58,
8102 ),
8103 (
8104 label("DIR ", icons::DIR),
8105 workspace,
8106 Style::default().fg(fg),
8107 82,
8108 ),
8109 (
8110 label("GIT ", icons::GIT),
8111 branch.to_string(),
8112 Style::default().fg(fg),
8113 104,
8114 ),
8115 (
8116 label("CTX ", icons::CTX),
8117 context,
8118 Style::default().fg(info),
8119 124,
8120 ),
8121 ];
8122 if state.active_run.is_some() || state.reasoning_stage.is_some() {
8123 fields.push((
8124 label("RUN ", icons::RUN),
8125 state
8126 .reasoning_stage
8127 .clone()
8128 .unwrap_or_else(|| "working\u{2026}".to_string()),
8129 Style::default().fg(primary).add_modifier(Modifier::BOLD),
8130 148,
8131 ));
8132 }
8133
8134 let mut spans = Vec::new();
8135 for (i, (label, value, value_style, min_width)) in fields.into_iter().enumerate() {
8136 if width < min_width {
8137 break;
8138 }
8139 if i > 0 {
8140 spans.push(Span::styled(" | ", Style::default().fg(muted)));
8141 }
8142 spans.push(Span::styled(
8143 (*label).to_string(),
8144 Style::default().fg(muted),
8145 ));
8146 spans.push(Span::styled(value, value_style));
8147 }
8148 Line::from(spans)
8149}
8150
8151fn composer_brain_chip<'a>(state: &'a RenderState, width: u16, used: usize) -> Option<Line<'a>> {
8162 let styles = active_styles();
8163 let (chip_label, healthy) = state.brain.chip_label()?;
8164 let chip_color = if healthy {
8165 color_from_anstyle(styles.info.get_fg_color())
8166 } else {
8167 color_from_anstyle(styles.error.get_fg_color())
8168 };
8169 let nerd = state.glyph_set == crate::symbols::GlyphSet::Nerd;
8170 let text = if nerd {
8171 let state_word = chip_label.trim_start_matches("brain\u{b7}");
8172 format!("{} {}", crate::symbols::nerd::BRAIN, state_word)
8173 } else {
8174 chip_label.to_string()
8175 };
8176 let chip = format!(" {text} ");
8177 let usable = width.saturating_sub(2) as usize;
8179 (used + chip.width() < usable)
8180 .then(|| Line::from(Span::styled(chip, Style::default().fg(chip_color))).right_aligned())
8181}
8182
8183fn compact_token_count(tokens: usize) -> String {
8184 if tokens >= 1_000 {
8185 let whole = tokens / 1_000;
8186 let decimal = (tokens % 1_000) / 100;
8187 if decimal == 0 {
8188 format!("{whole}K")
8189 } else {
8190 format!("{whole}.{decimal}K")
8191 }
8192 } else {
8193 tokens.to_string()
8194 }
8195}
8196
8197fn render_welcome(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
8202 let styles = active_styles();
8203 let primary = color_from_anstyle(styles.primary.get_fg_color());
8204 let fg = color_from_anstyle(Some(styles.foreground));
8205 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
8206 let workspace = state
8207 .cwd
8208 .file_name()
8209 .map(|name| name.to_string_lossy().into_owned())
8210 .filter(|name| !name.is_empty())
8211 .unwrap_or_else(|| "workspace".to_string());
8212 let lines = vec![
8213 Line::from(Span::styled(
8214 "OXICODE",
8215 Style::default().fg(primary).add_modifier(Modifier::BOLD),
8216 )),
8217 Line::from(Span::styled(
8218 "Terminal coding assistant",
8219 Style::default().fg(secondary).add_modifier(Modifier::DIM),
8220 )),
8221 Line::from(""),
8222 Line::from(vec![
8223 Span::styled("WORKSPACE ", Style::default().fg(secondary)),
8224 Span::styled(
8225 workspace,
8226 Style::default().fg(fg).add_modifier(Modifier::BOLD),
8227 ),
8228 ]),
8229 Line::from(vec![
8230 Span::styled("MODEL ", Style::default().fg(secondary)),
8231 Span::styled(
8232 format!(
8233 "{} / {}",
8234 state.header_context.provider, state.header_context.model
8235 ),
8236 Style::default().fg(fg),
8237 ),
8238 ]),
8239 Line::from(""),
8240 Line::from(Span::styled(
8241 "Enter send / commands @ attach a file",
8242 Style::default().fg(fg),
8243 )),
8244 Line::from(Span::styled(
8245 "? shortcuts /model change model /help all commands",
8246 Style::default().fg(secondary),
8247 )),
8248 ];
8249 let height = lines.len().min(area.height as usize) as u16;
8250 let card = Rect {
8251 x: area.x,
8252 y: area.y + area.height.saturating_sub(height) / 2,
8253 width: area.width,
8254 height,
8255 };
8256 frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), card);
8257}
8258
8259fn render_reasoning_indicator(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
8267 let styles = active_styles();
8268 let indicator_area = Rect {
8269 x: composer_area.x,
8270 y: composer_area.top().saturating_sub(1),
8271 width: composer_area.width,
8272 height: 1,
8273 };
8274 let primary = color_from_anstyle(styles.primary.get_fg_color());
8275 let muted = color_from_anstyle(styles.secondary.get_fg_color());
8276 let spin = RUN_SPINNER[(animation_frame(80) as usize) % RUN_SPINNER.len()];
8279 let stage = state
8280 .reasoning_stage
8281 .as_deref()
8282 .unwrap_or("working\u{2026}");
8283 let mut spans = vec![
8284 Span::styled(spin, Style::default().fg(primary)),
8285 Span::styled(
8286 " RUNNING",
8287 Style::default().fg(primary).add_modifier(Modifier::BOLD),
8288 ),
8289 Span::styled(" | ", Style::default().fg(muted)),
8290 Span::styled(
8291 stage.to_string(),
8292 Style::default().fg(muted).add_modifier(Modifier::DIM),
8293 ),
8294 ];
8295 if let Some(run) = &state.active_run {
8296 let elapsed = format_elapsed_secs(run.started_at.elapsed().as_secs());
8297 let facts = if run.turn > 0 {
8298 format!(
8299 " \u{b7} turn {} \u{b7} {} tool call{} \u{b7} {elapsed}",
8300 run.turn,
8301 run.tool_calls,
8302 if run.tool_calls == 1 { "" } else { "s" },
8303 )
8304 } else {
8305 format!(" \u{b7} {elapsed}")
8306 };
8307 spans.push(Span::styled(
8308 facts,
8309 Style::default().fg(muted).add_modifier(Modifier::DIM),
8310 ));
8311 }
8312 spans.push(Span::styled(
8315 " Esc abort \u{b7} Ctrl+C quit",
8316 Style::default().fg(muted).add_modifier(Modifier::DIM),
8317 ));
8318 frame.render_widget(Paragraph::new(Line::from(spans)), indicator_area);
8319}
8320
8321fn render_pending_quit_hint(frame: &mut Frame<'_>, composer_area: Rect) {
8325 let styles = active_styles();
8326 let hint_area = Rect {
8327 x: composer_area.x,
8328 y: composer_area.top().saturating_sub(1),
8329 width: composer_area.width,
8330 height: 1,
8331 };
8332 let line = Line::from(Span::styled(
8333 "press Ctrl+C again to quit",
8334 Style::default()
8335 .fg(color_from_anstyle(styles.error.get_fg_color()))
8336 .add_modifier(Modifier::BOLD),
8337 ));
8338 frame.render_widget(Paragraph::new(line), hint_area);
8339}
8340
8341fn render_queue_pane(frame: &mut Frame<'_>, scrollback: Rect, state: &RenderState) -> u16 {
8343 let styles = active_styles();
8344 let entries = &state.queued_inputs;
8345 let interactive = state.queue_panel_open;
8346 let selected = state.queue_selected.min(entries.len().saturating_sub(1));
8347 let height = if interactive {
8348 entries.len() as u16 + 1
8349 } else {
8350 1
8351 };
8352 let area = Rect {
8353 x: scrollback.x,
8354 y: scrollback.y,
8355 width: scrollback.width,
8356 height,
8357 };
8358 let info = color_from_anstyle(styles.info.get_fg_color());
8359 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
8360 let primary = color_from_anstyle(styles.primary.get_fg_color());
8361 if !interactive {
8362 frame.render_widget(
8363 Paragraph::new(Line::from(vec![
8364 Span::styled(
8365 format!("QUEUED {}", entries.len()),
8366 Style::default().fg(primary).add_modifier(Modifier::BOLD),
8367 ),
8368 Span::styled(
8369 " | Ctrl+; manage",
8370 Style::default().fg(secondary).add_modifier(Modifier::DIM),
8371 ),
8372 ])),
8373 area,
8374 );
8375 return height;
8376 }
8377 let items: Vec<Line<'_>> = entries
8378 .iter()
8379 .enumerate()
8380 .map(|(i, e)| {
8381 let prefix = format!("#{} ", i + 1);
8382 let prefix_style = if i == selected {
8383 Style::default().fg(primary).add_modifier(Modifier::BOLD)
8384 } else {
8385 Style::default().fg(info)
8386 };
8387 let text_style = if i == selected {
8388 Style::default().fg(primary).add_modifier(Modifier::BOLD)
8389 } else {
8390 Style::default().fg(secondary)
8391 };
8392 let marker = if i == selected { "> " } else { " " };
8393 Line::from(vec![
8394 Span::styled(prefix, prefix_style),
8395 Span::styled(marker, prefix_style),
8396 Span::styled(e.clone(), text_style),
8397 ])
8398 })
8399 .collect();
8400 frame.render_widget(
8401 Paragraph::new(items).block(Block::default().borders(Borders::TOP).border_style(
8402 Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
8403 )),
8404 area,
8405 );
8406 height
8407}
8408
8409fn format_todo_line(todo: &TodoItem, matched: bool, styles: &ThemeStyles) -> Line<'static> {
8412 let notes_marker = match todo.notes.as_ref().map(|n| n.len()).unwrap_or(0) {
8413 0 => String::new(),
8414 n => format!(" ·{n}"),
8415 };
8416 let (marker, color, strike, suffix) = match todo.status {
8417 TodoStatus::Completed => ("✓", styles.foreground, true, String::new()),
8418 TodoStatus::InProgress => (
8419 "▸",
8420 styles.primary.get_fg_color().unwrap_or(styles.foreground),
8421 false,
8422 String::new(),
8423 ),
8424 TodoStatus::Abandoned => (
8425 "☐",
8426 styles.error.get_fg_color().unwrap_or(styles.foreground),
8427 true,
8428 String::new(),
8429 ),
8430 TodoStatus::Blocked => {
8431 let reason = todo
8432 .block_reason
8433 .as_deref()
8434 .map(|r| format!(" (blocked: {r})"))
8435 .unwrap_or_else(|| " (blocked)".to_string());
8436 (
8437 "☐",
8438 styles.info.get_fg_color().unwrap_or(styles.foreground),
8439 false,
8440 reason,
8441 )
8442 }
8443 TodoStatus::Pending if matched => (
8444 "☐",
8445 styles.primary.get_fg_color().unwrap_or(styles.foreground),
8446 false,
8447 String::new(),
8448 ),
8449 TodoStatus::Pending => (
8450 "☐",
8451 styles.secondary.get_fg_color().unwrap_or(styles.foreground),
8452 false,
8453 String::new(),
8454 ),
8455 };
8456 let mut text_style = Style::default().fg(color_from_anstyle(Some(color)));
8457 if strike {
8458 text_style = text_style.add_modifier(Modifier::CROSSED_OUT);
8459 }
8460 Line::from(vec![
8461 Span::styled(
8462 format!("{marker} "),
8463 Style::default().fg(color_from_anstyle(Some(color))),
8464 ),
8465 Span::styled(
8466 format!("{}{}{}", todo.content, suffix, notes_marker),
8467 text_style,
8468 ),
8469 ])
8470}
8471
8472const TREE_BRANCH: &str = "├─";
8473const TREE_VERTICAL: &str = "│ ";
8474const TREE_HOOK: &str = "└";
8475const SUBSEQUENT_STAGE_CAP: usize = 4;
8476const ACTIVE_TASK_CAP: usize = 5;
8477
8478fn active_phase_index(phases: &[&TodoPhase]) -> usize {
8481 phases
8482 .iter()
8483 .position(|p| {
8484 p.tasks
8485 .iter()
8486 .any(|t| matches!(t.status, TodoStatus::Pending | TodoStatus::InProgress))
8487 })
8488 .unwrap_or_else(|| phases.len().saturating_sub(1))
8489}
8490
8491fn closed_count(tasks: &[TodoItem]) -> usize {
8493 tasks
8494 .iter()
8495 .filter(|t| matches!(t.status, TodoStatus::Completed | TodoStatus::Abandoned))
8496 .count()
8497}
8498
8499fn phase_display_name(name: &str, one_based: usize) -> String {
8501 format!(
8502 "{}. {name}",
8503 oxicode_agent::tools::todo::roman_numeral(one_based)
8504 )
8505}
8506
8507fn render_todo_pane(
8511 frame: &mut Frame<'_>,
8512 area: Rect,
8513 phases: &[TodoPhase],
8514 expanded: bool,
8515 is_matched: impl Fn(&TodoItem) -> bool,
8516) -> u16 {
8517 let phases: Vec<&TodoPhase> = phases.iter().filter(|p| !p.tasks.is_empty()).collect();
8518 if phases.is_empty() {
8519 return 0;
8520 }
8521 let styles = active_styles();
8522 let multi_phase = phases.len() > 1;
8523 let active_idx = active_phase_index(&phases);
8524
8525 let render_tasks = |phase: &TodoPhase| -> Vec<Line<'static>> {
8526 if expanded {
8527 phase
8528 .tasks
8529 .iter()
8530 .map(|t| format_todo_line(t, is_matched(t), &styles))
8531 .collect()
8532 } else {
8533 let sel = oxicode_agent::tools::todo::select_collapsed_todos(
8534 &phase.tasks,
8535 &is_matched,
8536 ACTIVE_TASK_CAP,
8537 );
8538 let mut lines: Vec<Line<'static>> = sel
8539 .items
8540 .iter()
8541 .map(|t| format_todo_line(t, is_matched(t), &styles))
8542 .collect();
8543 if let Some(summary) = sel.summary {
8544 lines.push(Line::from(Span::styled(
8545 summary,
8546 Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
8547 )));
8548 }
8549 lines
8550 }
8551 };
8552
8553 let base_idx = if expanded { 0 } else { active_idx };
8554 let phase_slice: &[&TodoPhase] = if expanded {
8555 &phases[base_idx..]
8556 } else {
8557 &phases[base_idx..(base_idx + 1 + SUBSEQUENT_STAGE_CAP).min(phases.len())]
8558 };
8559 let hidden_stages = phases.len() - base_idx - phase_slice.len();
8560
8561 let mut content_lines: Vec<Line<'static>> = Vec::new();
8562 for (i, phase) in phase_slice.iter().enumerate() {
8563 let one_based = base_idx + i + 1;
8564 let is_active = base_idx + i == active_idx;
8565 let done = closed_count(&phase.tasks);
8566 let header_text = if multi_phase {
8567 format!(
8568 "{} · {done}/{}",
8569 phase_display_name(&phase.name, one_based),
8570 phase.tasks.len()
8571 )
8572 } else {
8573 phase.name.clone()
8574 };
8575 let header_style = if is_active {
8576 Style::default()
8577 .fg(color_from_anstyle(styles.primary.get_fg_color()))
8578 .add_modifier(Modifier::BOLD)
8579 } else {
8580 Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color()))
8581 };
8582 content_lines.push(Line::from(Span::styled(header_text, header_style)));
8583 if is_active || expanded {
8584 content_lines.extend(render_tasks(phase));
8585 }
8586 }
8587 if hidden_stages > 0 {
8588 content_lines.push(Line::from(Span::styled(
8589 format!(
8590 "… {hidden_stages} more stage{}",
8591 if hidden_stages == 1 { "" } else { "s" }
8592 ),
8593 Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
8594 )));
8595 }
8596
8597 let total: usize = phases.iter().map(|p| p.tasks.len()).sum();
8601 let closed: usize = phases.iter().map(|p| closed_count(&p.tasks)).sum();
8602 let path_len = content_lines.len() + 1;
8603 let mut filled = (closed * path_len).checked_div(total).unwrap_or(0);
8604 if closed > 0 {
8605 filled = filled.max(1);
8606 }
8607 if closed < total {
8608 filled = filled.min(path_len.saturating_sub(1));
8609 }
8610
8611 let mut lines: Vec<Line<'static>> = vec![Line::from(Span::styled(
8612 "TODO",
8613 Style::default()
8614 .fg(color_from_anstyle(styles.primary.get_fg_color()))
8615 .add_modifier(Modifier::BOLD),
8616 ))];
8617 for (i, content) in content_lines.into_iter().enumerate() {
8618 let glyph = if i == 0 { TREE_BRANCH } else { TREE_VERTICAL };
8619 let glyph_color = if i < filled {
8620 styles.primary.get_fg_color()
8621 } else {
8622 styles.secondary.get_fg_color()
8623 };
8624 let mut spans = vec![Span::styled(
8625 format!(" {glyph}"),
8626 Style::default().fg(color_from_anstyle(glyph_color)),
8627 )];
8628 spans.extend(content.spans);
8629 lines.push(Line::from(spans));
8630 }
8631 let hook_color = if filled >= path_len {
8634 styles.primary.get_fg_color()
8635 } else {
8636 styles.secondary.get_fg_color()
8637 };
8638 lines.push(Line::from(Span::styled(
8639 format!(" {TREE_HOOK}"),
8640 Style::default().fg(color_from_anstyle(hook_color)),
8641 )));
8642
8643 let height = lines.len() as u16;
8644 frame.render_widget(
8645 Paragraph::new(lines),
8646 Rect {
8647 x: area.x,
8648 y: area.y,
8649 width: area.width,
8650 height,
8651 },
8652 );
8653 height
8654}
8655
8656const TODO_COMPACT_ROWS_THRESHOLD: u16 = 18;
8657
8658fn next_actionable_task(phases: &[TodoPhase]) -> Option<&TodoItem> {
8661 let mut first_pending = None;
8662 for phase in phases {
8663 for task in &phase.tasks {
8664 if task.status == TodoStatus::InProgress {
8665 return Some(task);
8666 }
8667 if first_pending.is_none() && task.status == TodoStatus::Pending {
8668 first_pending = Some(task);
8669 }
8670 }
8671 }
8672 first_pending
8673}
8674
8675fn render_todo_compact_line(phases: &[TodoPhase]) -> Line<'static> {
8678 let styles = active_styles();
8679 let total: usize = phases.iter().map(|p| p.tasks.len()).sum();
8680 let closed: usize = phases.iter().map(|p| closed_count(&p.tasks)).sum();
8681 let mut spans = vec![Span::styled(
8682 format!("TODO {closed}/{total} "),
8683 Style::default()
8684 .fg(color_from_anstyle(styles.primary.get_fg_color()))
8685 .add_modifier(Modifier::BOLD),
8686 )];
8687 match next_actionable_task(phases) {
8688 Some(task) => spans.extend(format_todo_line(task, false, &styles).spans),
8689 None => spans.push(Span::styled(
8690 "✓ done",
8691 Style::default().fg(color_from_anstyle(Some(styles.foreground))),
8692 )),
8693 }
8694 Line::from(spans)
8695}
8696
8697fn refresh_todo_phases(
8702 provider: &std::sync::Arc<dyn TodoStateProvider>,
8703 hub: Option<&crate::app::agent_hub_registry::SharedHubRegistry>,
8704) -> Vec<TodoPhase> {
8705 let phases = provider.get_phases();
8706 let Some(hub) = hub else {
8707 return phases;
8708 };
8709 let completed: Vec<String> = hub
8710 .snapshot()
8711 .into_iter()
8712 .filter(|(_, e)| {
8713 e.kind == oxicode_sdk::HubKind::Subagent && e.status == oxicode_sdk::HubStatus::Idle
8714 })
8715 .filter_map(|(_, e)| e.current_task)
8716 .collect();
8717 let (updated, mutated) =
8718 oxicode_agent::tools::todo::reconcile_with_subagents(&phases, &completed);
8719 if mutated {
8720 provider.set_phases_sync(updated.clone());
8721 }
8722 updated
8723}
8724
8725fn is_todo_list_settled(phases: &[TodoPhase]) -> bool {
8729 let mut seen_task = false;
8730 for phase in phases {
8731 for task in &phase.tasks {
8732 if !matches!(task.status, TodoStatus::Completed | TodoStatus::Abandoned) {
8733 return false;
8734 }
8735 seen_task = true;
8736 }
8737 }
8738 seen_task
8739}
8740
8741fn sync_todo_clear_timer(state: &mut RenderState, delay_secs: i64) {
8746 if delay_secs < 0 || !is_todo_list_settled(&state.todo_phases) {
8747 state.todo_clear_deadline = None;
8748 return;
8749 }
8750 if delay_secs == 0 {
8751 state.todo_phases.clear();
8752 state.todo_clear_deadline = None;
8753 return;
8754 }
8755 let deadline = state.todo_clear_deadline.get_or_insert_with(|| {
8756 std::time::Instant::now() + std::time::Duration::from_secs(delay_secs as u64)
8757 });
8758 if std::time::Instant::now() >= *deadline {
8759 state.todo_phases.clear();
8760 state.todo_clear_deadline = None;
8761 }
8762}
8763
8764fn build_matched_closure(
8768 hub: Option<&crate::app::agent_hub_registry::SharedHubRegistry>,
8769) -> impl Fn(&TodoItem) -> bool + '_ {
8770 let active_descs: Vec<String> = hub
8771 .map(|h| {
8772 h.snapshot()
8773 .into_iter()
8774 .filter(|(_, e)| {
8775 e.kind == oxicode_sdk::HubKind::Subagent
8776 && e.status == oxicode_sdk::HubStatus::Running
8777 })
8778 .filter_map(|(_, e)| e.current_task)
8779 .collect()
8780 })
8781 .unwrap_or_default();
8782 move |t| {
8783 !active_descs.is_empty()
8784 && oxicode_agent::tools::todo::todo_matches_any_description(&t.content, &active_descs)
8785 }
8786}
8787
8788fn render_follow_ups(frame: &mut Frame<'_>, composer_area: Rect, chips: &[String]) {
8790 let styles = active_styles();
8791 let area = Rect {
8792 x: composer_area.x,
8793 y: composer_area.top().saturating_sub(1),
8794 width: composer_area.width,
8795 height: 1,
8796 };
8797 let mut spans = vec![Span::styled(
8798 "Suggestions: ",
8799 Style::default()
8800 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
8801 .add_modifier(Modifier::DIM),
8802 )];
8803 for (i, chip) in chips.iter().enumerate() {
8804 if i > 0 {
8805 spans.push(Span::raw(" "));
8806 }
8807 spans.push(Span::styled(
8808 format!("[{}]", chip),
8809 Style::default().fg(color_from_anstyle(styles.primary.get_fg_color())),
8810 ));
8811 }
8812 frame.render_widget(Paragraph::new(Line::from(spans)), area);
8813}
8814
8815fn tip_is_visible(tip: &EphemeralTip, now_tick: u64) -> bool {
8817 now_tick.saturating_sub(tip.born_tick) < tip.ttl_ticks
8818}
8819
8820fn render_tip(frame: &mut Frame, composer_area: Rect, text: &str) {
8822 let styles = active_styles();
8823 let area = Rect {
8824 x: composer_area.x,
8825 y: composer_area.top().saturating_sub(1),
8826 width: composer_area.width,
8827 height: 1,
8828 };
8829 let line = Line::styled(
8830 format!(" note: {text}"),
8831 Style::default()
8832 .fg(color_from_anstyle(styles.info.get_fg_color()))
8833 .add_modifier(Modifier::DIM),
8834 );
8835 frame.render_widget(Paragraph::new(line), area);
8836}
8837
8838fn render_slash_popup(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
8841 let styles = active_styles();
8842 let items = &state.slash_popup.items;
8843 if items.is_empty() {
8844 return;
8845 }
8846
8847 let max_visible = 7usize;
8848 let visible = items.len().min(max_visible);
8849 let popup_h = visible as u16 + 3; let width = composer_area.width.min(64);
8851 let popup_area = Rect {
8852 x: composer_area.left(),
8853 y: composer_area.top().saturating_sub(popup_h),
8854 width,
8855 height: popup_h,
8856 };
8857 frame.render_widget(Clear, popup_area);
8858
8859 let border_color = color_from_anstyle(styles.secondary.get_fg_color());
8860 let title = Line::from(Span::styled(
8861 " COMMANDS ",
8862 Style::default()
8863 .fg(color_from_anstyle(styles.primary.get_fg_color()))
8864 .add_modifier(Modifier::BOLD),
8865 ));
8866 let block = Block::default()
8867 .borders(Borders::ALL)
8868 .border_type(BorderType::Plain)
8869 .border_style(Style::default().fg(border_color))
8870 .title(title);
8871 let inner = block.inner(popup_area);
8872 frame.render_widget(&block, popup_area);
8873
8874 let max_label = items
8876 .iter()
8877 .take(visible)
8878 .map(|i| i.label.chars().count())
8879 .max()
8880 .unwrap_or(0);
8881
8882 let primary = color_from_anstyle(styles.primary.get_fg_color());
8883 let fg = color_from_anstyle(Some(styles.foreground));
8884 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
8885
8886 for (i, item) in items.iter().take(visible).enumerate() {
8887 let is_selected = i == state.slash_popup.selected;
8888 let y = inner.top() + i as u16;
8889 let row_area = Rect {
8890 x: inner.left(),
8891 y,
8892 width: inner.width,
8893 height: 1,
8894 };
8895
8896 let marker = if is_selected { "> " } else { " " };
8897 let label_style = if is_selected {
8898 Style::default().fg(primary).add_modifier(Modifier::BOLD)
8899 } else {
8900 Style::default().fg(fg)
8901 };
8902 let label_padded = format!("{:<width$}", item.label, width = max_label);
8903 let line = Line::from(vec![
8904 Span::styled(marker, label_style),
8905 Span::styled(label_padded, label_style),
8906 Span::raw(" "),
8907 Span::styled(&item.description, Style::default().fg(secondary)),
8908 ]);
8909 frame.render_widget(Paragraph::new(line), row_area);
8910 }
8911 frame.render_widget(
8912 Paragraph::new(Line::from(Span::styled(
8913 "Enter insert | Up/Down move | Esc close",
8914 Style::default().fg(secondary).add_modifier(Modifier::DIM),
8915 ))),
8916 Rect {
8917 x: inner.left(),
8918 y: inner.bottom().saturating_sub(1),
8919 width: inner.width,
8920 height: 1,
8921 },
8922 );
8923}
8924
8925fn render_file_search_dropdown(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
8929 let styles = active_styles();
8930 let Some(fs) = &state.file_search else {
8931 return;
8932 };
8933 let items = &fs.results;
8934 if items.is_empty() {
8935 return;
8936 }
8937
8938 let max_visible = 10usize;
8939 let visible = items.len().min(max_visible);
8940 let popup_h = visible as u16 + 3; let width = composer_area.width.min(72);
8942 let popup_area = Rect {
8943 x: composer_area.left(),
8944 y: composer_area.top().saturating_sub(popup_h),
8945 width,
8946 height: popup_h,
8947 };
8948 frame.render_widget(Clear, popup_area);
8949
8950 let border_color = color_from_anstyle(styles.secondary.get_fg_color());
8951 let title_str = if fs.hidden_mode {
8952 " FILES: HIDDEN "
8953 } else {
8954 " FILES "
8955 };
8956 let title = Line::from(Span::styled(
8957 title_str,
8958 Style::default()
8959 .fg(color_from_anstyle(styles.primary.get_fg_color()))
8960 .add_modifier(Modifier::BOLD),
8961 ));
8962 let block = Block::default()
8963 .borders(Borders::ALL)
8964 .border_type(BorderType::Plain)
8965 .border_style(Style::default().fg(border_color))
8966 .title(title);
8967 let inner = block.inner(popup_area);
8968 frame.render_widget(&block, popup_area);
8969
8970 let primary = color_from_anstyle(styles.primary.get_fg_color());
8971 let fg = color_from_anstyle(Some(styles.foreground));
8972 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
8973
8974 for (i, result) in items.iter().take(visible).enumerate() {
8975 let is_selected = i == fs.selected;
8976 let y = inner.top() + i as u16;
8977 let row_area = Rect {
8978 x: inner.left(),
8979 y,
8980 width: inner.width,
8981 height: 1,
8982 };
8983
8984 let marker = if is_selected { "> " } else { " " };
8985 let path_style = if is_selected {
8986 Style::default().fg(primary).add_modifier(Modifier::BOLD)
8987 } else {
8988 Style::default().fg(fg)
8989 };
8990 let line = Line::from(vec![
8991 Span::styled(marker, path_style),
8992 Span::styled(&result.path, path_style),
8993 ]);
8994 frame.render_widget(Paragraph::new(line), row_area);
8995 }
8996
8997 if popup_h >= 4 {
8999 let hint_y = inner.bottom().saturating_sub(1);
9000 let hint_area = Rect {
9001 x: inner.left(),
9002 y: hint_y,
9003 width: inner.width,
9004 height: 1,
9005 };
9006 let count = items.len();
9007 let hint = format!("{count} files | Tab accept | Esc cancel");
9008 let _ = secondary; frame.render_widget(
9010 Paragraph::new(Line::from(Span::styled(
9011 hint,
9012 Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
9013 )))
9014 .style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color()))),
9015 hint_area,
9016 );
9017 }
9018}
9019
9020struct InputEditor<'a> {
9032 composer: &'a mut oxicode_textarea::TextArea,
9033}
9034
9035impl<'a> InputEditor<'a> {
9036 fn new(composer: &'a mut oxicode_textarea::TextArea) -> Self {
9037 Self { composer }
9038 }
9039}
9040
9041impl<'a> crate::tui_vt::vim::Editor for InputEditor<'a> {
9042 fn content(&self) -> &str {
9043 self.composer.text()
9044 }
9045 fn cursor(&self) -> usize {
9046 self.composer.cursor()
9047 }
9048 fn set_cursor(&mut self, pos: usize) {
9049 self.composer.set_cursor(pos);
9050 }
9051 fn move_left(&mut self) {
9052 let new_pos = self.composer.cursor().saturating_sub(1);
9055 self.composer.set_cursor(new_pos);
9056 }
9057 fn move_right(&mut self) {
9058 let new_pos = self.composer.cursor().saturating_add(1);
9059 self.composer.set_cursor(new_pos);
9060 }
9061 fn delete_char_forward(&mut self) {
9062 self.composer.input(crossterm::event::KeyEvent::new(
9063 crossterm::event::KeyCode::Delete,
9064 crossterm::event::KeyModifiers::NONE,
9065 ));
9066 }
9067 fn insert_text(&mut self, text: &str) {
9068 self.composer.insert_str(text);
9069 }
9070 fn replace(&mut self, content: String, cursor: usize) {
9071 self.composer.set_text(&content);
9072 self.composer.set_cursor(cursor);
9073 }
9074 fn replace_range(&mut self, start: usize, end: usize, text: &str) {
9075 self.composer.replace_range(start..end, text);
9076 }
9077}
9078
9079pub(crate) fn plain_segment(text: impl Into<String>) -> InlineSegment {
9084 InlineSegment {
9085 text: text.into(),
9086 style: Arc::new(InlineTextStyle::default()),
9087 }
9088}
9089
9090pub(super) fn effective_scroll_offset(offset: usize, total: usize, viewport: usize) -> usize {
9091 if offset == usize::MAX {
9092 return total.saturating_sub(viewport);
9093 }
9094 let max_start = total.saturating_sub(viewport);
9096 offset.min(max_start)
9097}
9098
9099fn slash_filter(token: &str, file_commands: &[FileCommand]) -> Vec<SlashPopupItem> {
9110 let builtins = SlashRegistry::builtin_commands();
9111 let builtin_names: std::collections::HashSet<&str> =
9112 builtins.iter().map(|(n, _, _)| *n).collect();
9113
9114 let mut items: Vec<SlashPopupItem> = builtins
9115 .into_iter()
9116 .filter(|(name, _, aliases)| {
9117 token.is_empty()
9118 || name.starts_with(token)
9119 || aliases.iter().any(|a| a.starts_with(token))
9120 })
9121 .map(|(name, desc, aliases)| {
9122 let mut label = format!("/{name}");
9123 for a in &aliases {
9124 label.push_str(&format!(", /{a}"));
9125 }
9126 SlashPopupItem {
9127 label,
9128 description: desc.to_string(),
9129 name: name.to_string(),
9130 }
9131 })
9132 .collect();
9133
9134 for fc in file_commands {
9136 if builtin_names.contains(fc.name.as_str())
9137 || fc
9138 .aliases
9139 .iter()
9140 .any(|alias| builtin_names.contains(alias.as_str()))
9141 {
9142 continue;
9143 }
9144 if token.is_empty()
9145 || fc.name.starts_with(token)
9146 || fc.aliases.iter().any(|a| a.starts_with(token))
9147 {
9148 let mut label = format!("/{}", fc.name);
9149 for a in &fc.aliases {
9150 label.push_str(&format!(", /{a}"));
9151 }
9152 items.push(SlashPopupItem {
9153 label,
9154 description: fc.description.clone(),
9155 name: fc.name.clone(),
9156 });
9157 }
9158 }
9159
9160 items
9161}
9162
9163fn refresh_slash_popup(state: &mut RenderState) {
9168 let buf = state.composer.text();
9169 let active = buf.starts_with('/') && !buf[1..].contains(' ');
9170 if !active {
9171 state.slash_popup.open = false;
9172 state.slash_popup.items.clear();
9173 state.slash_popup.selected = 0;
9174 return;
9175 }
9176 let token = &buf[1..];
9177 let items = slash_filter(token, &state.file_commands);
9178 state.slash_popup.open = !items.is_empty();
9179 if items.is_empty() {
9180 state.slash_popup.selected = 0;
9181 } else {
9182 state.slash_popup.selected = state.slash_popup.selected.min(items.len() - 1);
9183 }
9184 state.slash_popup.items = items;
9185}
9186fn refresh_input_popups(state: &mut RenderState) {
9190 refresh_slash_popup(state);
9191 refresh_file_search(state);
9192}
9193
9194fn refresh_file_search(state: &mut RenderState) {
9200 use crate::tui_vt::file_search;
9201 if state.slash_popup.open {
9203 state.file_search = None;
9204 return;
9205 }
9206 match file_search::parse_at_cursor(state.composer.text(), state.composer.cursor()) {
9207 Some(token) => match &mut state.file_search {
9208 None => {
9209 let cwd = state.cwd.clone();
9210 state.file_search = Some(file_search::open(&cwd, token.at_offset, false));
9211 }
9212 Some(fs) => {
9213 if fs.query != token.path_query {
9214 fs.refresh(&token.path_query);
9215 }
9216 }
9217 },
9218 None => state.file_search = None,
9219 }
9220}
9221
9222fn accept_file_search(state: &mut RenderState, line_mode: bool) -> bool {
9227 use crate::tui_vt::file_search;
9228 let Some(fs) = &state.file_search else {
9229 return false;
9230 };
9231 let Some(result) = fs.selected_result().cloned() else {
9232 return false;
9233 };
9234 let at_offset = fs.at_offset;
9235 let text = file_search::insertion_text(&result.path, None, line_mode);
9236 let cursor_end = state.composer.cursor();
9237 state.composer.replace_range(
9239 at_offset..cursor_end.min(state.composer.text().len()),
9240 &text,
9241 );
9242 state.composer.set_cursor(at_offset + text.len());
9243 state.file_search = None;
9244 true
9245}
9246
9247fn preview_tool_result(content: &str) -> String {
9248 const MAX: usize = 500;
9249 if content.chars().count() <= MAX {
9250 return content.to_string();
9251 }
9252 let truncated: String = content.chars().take(MAX).collect();
9253 format!("{truncated}\u{2026}")
9254}
9255
9256fn extract_generated_png(content: &str) -> Option<Vec<u8>> {
9263 use base64::{Engine, engine::general_purpose};
9264 const MARKER: &str = "base64):";
9265 let rest = &content[content.find(MARKER)? + MARKER.len()..];
9266 let blob = rest.lines().map(str::trim).find(|l| !l.is_empty())?;
9268 if blob.is_empty() {
9269 return None;
9270 }
9271 let bytes = general_purpose::STANDARD.decode(blob).ok()?;
9272 (bytes.len() >= 8).then_some(bytes)
9275}
9276
9277fn color_from_anstyle(color: Option<anstyle::Color>) -> Color {
9278 match color {
9279 Some(anstyle::Color::Ansi(a)) => ansi_to_ratatui(a),
9280 Some(anstyle::Color::Ansi256(idx)) => Color::Indexed(idx.0),
9281 Some(anstyle::Color::Rgb(rgb)) => Color::Rgb(rgb.0, rgb.1, rgb.2),
9282 None => Color::Reset,
9283 }
9284}
9285fn ansi_to_ratatui(color: anstyle::AnsiColor) -> Color {
9286 use anstyle::AnsiColor as A;
9287 match color {
9288 A::Black => Color::Black,
9289 A::Red => Color::Red,
9290 A::Green => Color::Green,
9291 A::Yellow => Color::Yellow,
9292 A::Blue => Color::Blue,
9293 A::Magenta => Color::Magenta,
9294 A::Cyan => Color::Cyan,
9295 A::White => Color::Gray,
9296 A::BrightBlack => Color::DarkGray,
9297 A::BrightRed => Color::LightRed,
9298 A::BrightGreen => Color::LightGreen,
9299 A::BrightYellow => Color::LightYellow,
9300 A::BrightBlue => Color::LightBlue,
9301 A::BrightMagenta => Color::LightMagenta,
9302 A::BrightCyan => Color::LightCyan,
9303 A::BrightWhite => Color::White,
9304 }
9305}
9306
9307#[allow(dead_code, clippy::declare_interior_mutable_const)]
9310const _ATOMIC_REFS: (AtomicBool, Ordering) = (AtomicBool::new(false), Ordering::SeqCst);
9311
9312#[cfg(test)]
9313mod slash_popup_tests {
9314 use super::*;
9315
9316 #[test]
9317 fn empty_token_lists_all_commands() {
9318 let items = slash_filter("", &[]);
9319 assert!(items.len() >= 7);
9321 assert!(items.iter().any(|i| i.name == "quit"));
9322 assert!(items.iter().any(|i| i.name == "clear"));
9323 assert!(items.iter().any(|i| i.name == "model"));
9324 }
9325
9326 #[test]
9327 fn prefix_filter_matches_name() {
9328 let items = slash_filter("qu", &[]);
9329 assert_eq!(items.len(), 1);
9330 assert_eq!(items[0].name, "quit");
9331 assert!(items[0].label.contains("/quit"));
9332 }
9333
9334 #[test]
9335 fn prefix_filter_matches_alias() {
9336 let items = slash_filter("cl", &[]);
9338 let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
9339 assert!(names.contains(&"clear"));
9340 }
9341
9342 #[test]
9343 fn file_commands_appear_in_filter() {
9344 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
9345 "review",
9346 "---\ndescription: proj cmd\naliases: cr\n---\nbody",
9347 );
9348 let items = slash_filter("", &[fc]);
9349 assert!(items.iter().any(|i| i.name == "review"));
9350 assert!(items.iter().any(|i| i.name == "quit")); }
9352
9353 #[test]
9354 fn file_commands_filtered_by_prefix() {
9355 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
9356 "review",
9357 "---\ndescription: x\n---\nbody",
9358 );
9359 let items = slash_filter("rev", &[fc]);
9360 assert!(items.iter().any(|i| i.name == "review"));
9361 }
9362
9363 #[test]
9364 fn file_commands_shadowed_by_builtins_are_dropped() {
9365 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
9370 "quit",
9371 "---\ndescription: hijack\n---\nbody",
9372 );
9373 let items = slash_filter("", &[fc]);
9374 let quit_count = items.iter().filter(|i| i.name == "quit").count();
9375 assert_eq!(quit_count, 1, "shadowed file command must not appear");
9376 assert!(
9378 items
9379 .iter()
9380 .any(|i| i.name == "quit" && !i.description.contains("hijack"))
9381 );
9382 }
9383
9384 #[test]
9385 fn file_commands_with_builtin_aliases_are_dropped() {
9386 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
9387 "review",
9388 "---\ndescription: hijack\naliases: quit\n---\nbody",
9389 );
9390 let items = slash_filter("", &[fc]);
9391 assert!(!items.iter().any(|item| item.name == "review"));
9392 }
9393
9394 #[test]
9395 fn popup_opens_on_slash() {
9396 let mut state = RenderState::default();
9397 state.composer.set_text("/");
9398 refresh_input_popups(&mut state);
9399 assert!(state.slash_popup.open);
9400 assert!(!state.slash_popup.items.is_empty());
9401 }
9402
9403 #[test]
9404 fn popup_closes_on_space() {
9405 let mut state = RenderState::default();
9406 state.composer.set_text("/quit ");
9407 refresh_input_popups(&mut state);
9408 assert!(!state.slash_popup.open);
9409 }
9410
9411 #[test]
9412 fn popup_closes_on_non_slash() {
9413 let mut state = RenderState::default();
9414 state.composer.set_text("hello");
9415 refresh_input_popups(&mut state);
9416 assert!(!state.slash_popup.open);
9417 }
9418
9419 #[test]
9420 fn popup_filters_as_user_types() {
9421 let mut state = RenderState::default();
9422 state.composer.set_text("/m");
9423 refresh_input_popups(&mut state);
9424 assert!(state.slash_popup.open);
9425 assert!(
9428 state
9429 .slash_popup
9430 .items
9431 .iter()
9432 .all(|i| i.name.starts_with('m'))
9433 );
9434 }
9435
9436 #[test]
9437 fn popup_selection_clamps_on_shrink() {
9438 let mut state = RenderState::default();
9439 state.composer.set_text("/");
9440 refresh_input_popups(&mut state);
9441 let full_count = state.slash_popup.items.len();
9442 state.slash_popup.selected = full_count - 1;
9443 state.composer.set_text("/qu");
9445 refresh_input_popups(&mut state);
9446 assert!(state.slash_popup.selected < state.slash_popup.items.len());
9447 }
9448}
9449
9450#[cfg(test)]
9451mod keymap_dispatch_tests {
9452 use super::*;
9453 use crate::tui_vt::keymap::Keymap;
9454 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
9455
9456 fn press(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
9457 KeyEvent::new(code, mods)
9458 }
9459
9460 fn keymap_with(overrides: &[(&str, &str)]) -> Keymap {
9465 let mut o = std::collections::HashMap::new();
9466 for (action, combo) in overrides {
9467 o.insert(action.to_string(), vec![combo.to_string()]);
9468 }
9469 Keymap::from_settings(&o)
9470 }
9471
9472 fn default_keymap() -> Keymap {
9473 Keymap::from_settings(&std::collections::HashMap::new())
9474 }
9475
9476 #[test]
9477 fn rebound_submit_fires_through_generic_dispatch() {
9478 let km = keymap_with(&[("Submit", "Alt+s")]);
9483 let alt_s = press(KeyCode::Char('s'), KeyModifiers::ALT);
9484 assert_eq!(keymap_pre_match(&km, &alt_s, false), KeymapDispatch::Submit);
9487 assert_eq!(keymap_pre_match(&km, &alt_s, true), KeymapDispatch::Submit);
9488 let enter = press(KeyCode::Enter, KeyModifiers::NONE);
9491 assert_eq!(keymap_pre_match(&km, &enter, false), KeymapDispatch::None);
9492 assert_eq!(keymap_pre_match(&km, &enter, true), KeymapDispatch::None);
9493 }
9494
9495 #[test]
9496 fn default_submit_dispatch_preserves_muscle_memory() {
9497 let km = default_keymap();
9498 let enter = press(KeyCode::Enter, KeyModifiers::NONE);
9499 let shift_enter = press(KeyCode::Enter, KeyModifiers::SHIFT);
9500 assert_eq!(keymap_pre_match(&km, &enter, false), KeymapDispatch::Submit);
9502 assert_eq!(keymap_pre_match(&km, &enter, true), KeymapDispatch::None);
9505 assert_eq!(
9506 keymap_pre_match(&km, &shift_enter, true),
9507 KeymapDispatch::Submit
9508 );
9509 }
9510
9511 #[test]
9512 fn scroll_and_help_dispatch_via_keymap() {
9513 let km = default_keymap();
9514 assert_eq!(
9515 keymap_pre_match(&km, &press(KeyCode::PageUp, KeyModifiers::NONE), false),
9516 KeymapDispatch::ScrollPageUp
9517 );
9518 assert_eq!(
9519 keymap_pre_match(&km, &press(KeyCode::PageDown, KeyModifiers::NONE), false),
9520 KeymapDispatch::ScrollPageDown
9521 );
9522 let km = keymap_with(&[("ScrollUp", "Ctrl+u")]);
9523 assert_eq!(
9524 keymap_pre_match(
9525 &km,
9526 &press(KeyCode::Char('u'), KeyModifiers::CONTROL),
9527 false
9528 ),
9529 KeymapDispatch::ScrollPageUp
9530 );
9531 let km = default_keymap();
9534 assert_eq!(
9535 keymap_pre_match(&km, &press(KeyCode::Char('?'), KeyModifiers::NONE), false),
9536 KeymapDispatch::None
9537 );
9538 let km = keymap_with(&[("Help", "Ctrl+PageUp")]);
9539 assert_eq!(
9540 keymap_pre_match(&km, &press(KeyCode::PageUp, KeyModifiers::CONTROL), false),
9541 KeymapDispatch::Help
9542 );
9543 assert_eq!(
9545 keymap_pre_match(&km, &press(KeyCode::Char('x'), KeyModifiers::NONE), false),
9546 KeymapDispatch::None
9547 );
9548 }
9549}
9550
9551#[cfg(test)]
9552mod render_tests {
9553 use super::*;
9554 use oxicode_vtui::tui::core::{InlineHandle, OverlayEvent};
9555 use ratatui::{Terminal, backend::TestBackend};
9556 use tokio::sync::mpsc;
9557
9558 fn render_frame_to_string(state: &RenderState) -> String {
9563 let backend = TestBackend::new(80, 24);
9564 let mut terminal = Terminal::new(backend).expect("backend");
9565 let (tx, _rx) = mpsc::unbounded_channel();
9566 let handle = InlineHandle::new_for_tests(tx);
9567 terminal
9568 .draw(|f| render_frame(f, state, &handle))
9569 .expect("draw");
9570 let buf = terminal.backend().buffer();
9571 let area = buf.area();
9572 let mut out = String::new();
9573 for y in 0..area.height {
9574 for x in 0..area.width {
9575 if let Some(cell) = buf.cell((x, y)) {
9576 out.push_str(cell.symbol());
9577 }
9578 }
9579 out.push('\n');
9580 }
9581 out
9582 }
9583
9584 #[allow(dead_code)]
9588 fn render_frame_to_string_at(state: &RenderState, width: u16, height: u16) -> String {
9589 let backend = TestBackend::new(width, height);
9590 let mut terminal = Terminal::new(backend).expect("backend");
9591 let (tx, _rx) = mpsc::unbounded_channel();
9592 let handle = InlineHandle::new_for_tests(tx);
9593 terminal
9594 .draw(|f| render_frame(f, state, &handle))
9595 .expect("draw");
9596 let buf = terminal.backend().buffer();
9597 let area = buf.area();
9598 let mut out = String::new();
9599 for y in 0..area.height {
9600 for x in 0..area.width {
9601 if let Some(cell) = buf.cell((x, y)) {
9602 out.push_str(cell.symbol());
9603 }
9604 }
9605 out.push('\n');
9606 }
9607 out
9608 }
9609
9610 fn terminal_caret(state: &RenderState) -> Option<(u16, u16)> {
9613 let backend = TestBackend::new(80, 24);
9614 let mut terminal = Terminal::new(backend).expect("backend");
9615 let (tx, _rx) = mpsc::unbounded_channel();
9616 let handle = InlineHandle::new_for_tests(tx);
9617 terminal
9618 .draw(|f| render_frame(f, state, &handle))
9619 .expect("draw");
9620 terminal
9621 .get_cursor_position()
9622 .ok()
9623 .map(|position| (position.x, position.y))
9624 }
9625
9626 #[test]
9627 fn composer_caret_aligns_after_ascii() {
9628 let mut state = RenderState::default();
9629 state.prompt_prefix = "> ".to_string();
9630 state.input_enabled = true;
9631 let mut composer = oxicode_textarea::TextArea::new();
9632 composer.set_text("hello");
9633 composer.set_cursor(5);
9634 state.composer = composer;
9635 let caret = terminal_caret(&state);
9636 assert_eq!(
9640 caret,
9641 Some((9, 22)),
9642 "ASCII caret must sit right after '> hello'"
9643 );
9644 }
9645
9646 #[test]
9647 fn composer_caret_aligns_after_cjk_display_columns() {
9648 let mut state = RenderState::default();
9649 state.prompt_prefix = "> ".to_string();
9650 state.input_enabled = true;
9651 let body = "안녕";
9652 let mut composer = oxicode_textarea::TextArea::new();
9653 composer.set_text(body);
9654 composer.set_cursor(body.len()); state.composer = composer;
9656 let caret = terminal_caret(&state);
9657 assert_eq!(
9659 caret,
9660 Some((8, 22)),
9661 "CJK caret must sit after 4 display columns (not 6 bytes)"
9662 );
9663 }
9664
9665 #[test]
9666 fn composer_caret_aligns_after_mixed_ascii_cjk() {
9667 let body = "hi안녕";
9668 let mut state = RenderState::default();
9669 state.prompt_prefix = "> ".to_string();
9670 state.input_enabled = true;
9671 let mut composer = oxicode_textarea::TextArea::new();
9672 composer.set_text(body);
9673 composer.set_cursor(body.len()); state.composer = composer;
9675 let caret = terminal_caret(&state);
9676 assert_eq!(
9678 caret,
9679 Some((10, 22)),
9680 "Mixed caret must sit after 6 display columns"
9681 );
9682 }
9683
9684 #[test]
9685 fn agent_session_event_reaches_the_transcript_bridge() {
9686 let (tx, mut rx) = mpsc::unbounded_channel();
9687 let handle = InlineHandle::new_for_tests(tx);
9688 let mut state = RenderState::default();
9689
9690 handle_session_event(
9691 &mut state,
9692 &handle,
9693 &SessionEvent::Agent(Box::new(AgentEvent::TextChunk {
9694 text: "streamed reply".to_string(),
9695 })),
9696 None,
9697 );
9698
9699 let command = rx
9700 .try_recv()
9701 .expect("an agent event must produce a render command");
9702 apply_command(&mut state, command);
9703 assert_eq!(state.transcript.len(), 1);
9704 assert_eq!(state.transcript[0].kind, InlineMessageKind::Agent);
9705 assert_eq!(state.transcript[0].segments[0].text, "streamed reply");
9706 }
9707
9708 #[test]
9709 fn missing_key_errors_are_distinguished_from_other_provider_failures() {
9710 assert!(is_missing_api_key_error(
9711 "Provider stream error: Missing API key — configure a credential"
9712 ));
9713 assert!(!is_missing_api_key_error("Provider returned HTTP 429"));
9714 assert_eq!(
9715 provider_from_model_id("deepseek/deepseek-v4-flash"),
9716 "deepseek"
9717 );
9718 }
9719
9720 #[test]
9721 fn prompt_queue_mutations_change_the_execution_queue() {
9722 let queue = PromptQueue::default();
9723 queue.enqueue("first".to_string());
9724 queue.enqueue("second".to_string());
9725 queue.enqueue("third".to_string());
9726
9727 assert!(queue.move_by(2, -1));
9728 assert_eq!(queue.remove(0).as_deref(), Some("first"));
9729 let pending: Vec<_> = queue.pending.lock().iter().cloned().collect();
9730 assert_eq!(pending, ["third", "second"]);
9731 }
9732
9733 #[test]
9734 fn welcome_screen_shown_when_transcript_empty() {
9735 let state = RenderState::default();
9736 let rendered = render_frame_to_string(&state);
9737 assert!(
9738 rendered.contains("OXICODE") && rendered.contains("WORKSPACE"),
9739 "welcome banner must appear when transcript is empty"
9740 );
9741 }
9742
9743 #[test]
9744 fn composer_is_painted() {
9745 let mut state = RenderState::default();
9749 state.input_enabled = true;
9750 state.prompt_prefix = "> ".to_string();
9751 let rendered = render_frame_to_string(&state);
9752 assert!(
9753 rendered.contains('>'),
9754 "composer prompt prefix must be painted"
9755 );
9756 }
9757
9758 #[test]
9759 fn slash_popup_renders_command_list() {
9760 let mut state = RenderState::default();
9761 state.slash_popup.open = true;
9762 state.slash_popup.items = slash_filter("", &[]);
9763 let rendered = render_frame_to_string(&state);
9764 assert!(rendered.contains("COMMANDS"), "popup title must render");
9765 assert!(rendered.contains("/quit"), "popup must list /quit");
9766 }
9767
9768 #[test]
9769 fn composer_and_popup_render_together() {
9770 let mut state = RenderState::default();
9771 state.prompt_prefix = "> ".to_string();
9772 state.composer.set_text("/qu");
9773 state.slash_popup.open = true;
9774 state.slash_popup.items = slash_filter("qu", &[]);
9775 let rendered = render_frame_to_string(&state);
9776 assert!(rendered.contains("COMMANDS"), "popup must render");
9777 assert!(rendered.contains("/quit"), "popup must list /quit");
9778 assert!(rendered.contains('>'), "composer must still render");
9779 }
9780
9781 #[test]
9782 fn transcript_wraps_long_lines() {
9783 let mut state = RenderState::default();
9791 state.transcript.push(TranscriptLine {
9792 kind: InlineMessageKind::Agent,
9793 segments: vec![plain_segment(
9794 "This is a very long agent response line that should wrap across multiple terminal rows when rendered at a narrow width.".to_string()
9795 )],
9796 block_id: 0,
9797 });
9798 let backend = TestBackend::new(40, 24);
9799 let mut terminal = Terminal::new(backend).expect("backend");
9800 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
9801 let handle = InlineHandle::new_for_tests(tx);
9802 terminal
9803 .draw(|f| render_frame(f, &state, &handle))
9804 .expect("draw");
9805 let buf = terminal.backend().buffer();
9806 let mut full = String::new();
9809 for y in 0..buf.area.height {
9810 for x in 0..buf.area.width {
9811 if let Some(cell) = buf.cell((x, y)) {
9812 full.push_str(cell.symbol());
9813 }
9814 }
9815 full.push('\n');
9816 }
9817 assert!(
9818 !full.contains("wrap"),
9819 "long line is clamped at the viewport edge — content past col 40 must be dropped, not wrapped"
9820 );
9821 assert!(
9824 full.contains("This"),
9825 "the truncated prefix of the clamped line is visible: {full:?}"
9826 );
9827 }
9828
9829 fn sample_overlay_items() -> Vec<OverlayListItem> {
9832 vec![
9833 OverlayListItem {
9834 title: "model-a".to_string(),
9835 subtitle: Some("first".to_string()),
9836 badge: Some("ready".to_string()),
9837 indent: 0,
9838 search_value: None,
9839 selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(0)),
9840 },
9841 OverlayListItem {
9842 title: "model-b".to_string(),
9843 subtitle: None,
9844 badge: None,
9845 indent: 0,
9846 search_value: None,
9847 selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(1)),
9848 },
9849 OverlayListItem {
9850 title: "model-c".to_string(),
9851 subtitle: None,
9852 badge: None,
9853 indent: 0,
9854 search_value: None,
9855 selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(2)),
9856 },
9857 ]
9858 }
9859
9860 #[test]
9861 fn overlay_renders_title_and_items() {
9862 let mut state = RenderState::default();
9863 state.overlay = Some(OverlayState {
9864 title: "Select model".to_string(),
9865 lines: vec!["Pick one".to_string()],
9866 items: sample_overlay_items(),
9867 selected: 0,
9868 search: None,
9869 secure_input: None,
9870 ..Default::default()
9871 });
9872 let rendered = render_frame_to_string(&state);
9873 assert!(
9874 rendered.contains("Select model"),
9875 "overlay title must render"
9876 );
9877 assert!(rendered.contains("model-a"), "first item must render");
9878 assert!(rendered.contains("model-b"), "second item must render");
9879 assert!(rendered.contains("model-c"), "third item must render");
9880 assert!(
9881 rendered.contains("Pick one"),
9882 "descriptive line must render"
9883 );
9884 }
9885
9886 #[test]
9887 fn overlay_search_filters_items() {
9888 let mut state = RenderState::default();
9889 state.overlay = Some(OverlayState {
9890 title: "Select".to_string(),
9891 lines: Vec::new(),
9892 items: sample_overlay_items(),
9893 selected: 0,
9894 search: Some(OverlaySearchState {
9895 label: "filter".to_string(),
9896 placeholder: Some("type".to_string()),
9897 value: "model-b".to_string(),
9898 }),
9899 secure_input: None,
9900 ..Default::default()
9901 });
9902 let rendered = render_frame_to_string(&state);
9903 assert!(rendered.contains("model-b"), "matching item must render");
9904 assert!(
9905 !rendered.contains("model-a"),
9906 "non-matching item must not render (got: {})",
9907 rendered
9908 );
9909 assert!(
9910 !rendered.contains("model-c"),
9911 "non-matching item must not render"
9912 );
9913 }
9914
9915 #[test]
9916 fn overlay_keyboard_nav_moves_selection() {
9917 let mut state = RenderState::default();
9918 state.overlay = Some(OverlayState {
9919 title: "Select".to_string(),
9920 lines: Vec::new(),
9921 items: sample_overlay_items(),
9922 selected: 0,
9923 search: None,
9924 secure_input: None,
9925 ..Default::default()
9926 });
9927 let state_arc = Arc::new(parking_lot::Mutex::new(state));
9928 let (tx, mut _rx) = mpsc::unbounded_channel();
9929
9930 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 0);
9932
9933 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
9935 assert!(consumed, "Down must be consumed while overlay is open");
9936 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 1);
9937
9938 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
9940 assert!(consumed);
9941 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 2);
9942
9943 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
9945 assert!(consumed);
9946 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 0);
9947
9948 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Up);
9950 assert!(consumed);
9951 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 2);
9952
9953 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
9955 assert!(consumed);
9956 assert!(
9957 state_arc.lock().overlay.is_none(),
9958 "overlay must be cleared after Enter"
9959 );
9960 let evt = _rx.try_recv().expect("submit event must arrive");
9961 match evt {
9962 InlineEvent::Overlay(OverlayEvent::Submitted(_)) => {}
9963 other => panic!("expected Submitted overlay event, got {other:?}"),
9964 }
9965 }
9966
9967 #[test]
9968 fn overlay_esc_closes_and_emits_cancelled() {
9969 let mut state = RenderState::default();
9970 state.overlay = Some(OverlayState {
9971 title: "Select".to_string(),
9972 lines: Vec::new(),
9973 items: sample_overlay_items(),
9974 selected: 0,
9975 search: None,
9976 secure_input: None,
9977 ..Default::default()
9978 });
9979 let state_arc = Arc::new(parking_lot::Mutex::new(state));
9980 let (tx, mut rx) = mpsc::unbounded_channel();
9981
9982 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Esc);
9983 assert!(consumed);
9984 assert!(
9985 state_arc.lock().overlay.is_none(),
9986 "overlay must be cleared after Esc"
9987 );
9988 let evt = rx.try_recv().expect("cancel event must arrive");
9989 assert!(
9990 matches!(evt, InlineEvent::Overlay(OverlayEvent::Cancelled)),
9991 "expected Cancelled overlay event"
9992 );
9993 }
9994
9995 #[test]
9996 fn overlay_enter_on_readonly_item_is_noop() {
9997 let mut state = RenderState::default();
10001 state.overlay = Some(OverlayState {
10002 title: "Tools".to_string(),
10003 lines: Vec::new(),
10004 items: vec![OverlayListItem {
10005 title: "read".to_string(),
10006 subtitle: Some("Read a file".to_string()),
10007 badge: None,
10008 indent: 0,
10009 search_value: None,
10010 selection: None,
10011 }],
10012 selected: 0,
10013 search: None,
10014 secure_input: None,
10015 ..Default::default()
10016 });
10017 let state_arc = Arc::new(parking_lot::Mutex::new(state));
10018 let (tx, mut rx) = mpsc::unbounded_channel();
10019
10020 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
10021 assert!(consumed, "Enter must be consumed even on read-only items");
10022 assert!(
10023 state_arc.lock().overlay.is_some(),
10024 "overlay must stay open when Enter hits a read-only item"
10025 );
10026 assert!(
10027 rx.try_recv().is_err(),
10028 "no overlay event must be emitted for a read-only Enter"
10029 );
10030 }
10031
10032 #[test]
10033 fn overlay_chars_route_to_search_field() {
10034 let mut state = RenderState::default();
10035 state.overlay = Some(OverlayState {
10036 title: "Select".to_string(),
10037 lines: Vec::new(),
10038 items: sample_overlay_items(),
10039 selected: 0,
10040 search: Some(OverlaySearchState {
10041 label: "filter".to_string(),
10042 placeholder: None,
10043 value: String::new(),
10044 }),
10045 secure_input: None,
10046 ..Default::default()
10047 });
10048 let state_arc = Arc::new(parking_lot::Mutex::new(state));
10049 let (tx, _rx) = mpsc::unbounded_channel();
10050
10051 handle_overlay_key(&state_arc, &tx, KeyCode::Char('m'));
10052 handle_overlay_key(&state_arc, &tx, KeyCode::Char('o'));
10053 handle_overlay_key(&state_arc, &tx, KeyCode::Backspace);
10054 let value = state_arc
10055 .lock()
10056 .overlay
10057 .as_ref()
10058 .unwrap()
10059 .search
10060 .as_ref()
10061 .unwrap()
10062 .value
10063 .clone();
10064 assert_eq!(value, "m", "Backspace should drop last char");
10065 }
10066
10067 #[test]
10068 fn overlay_key_no_op_when_no_overlay_open() {
10069 let state = RenderState::default();
10070 let state_arc = Arc::new(parking_lot::Mutex::new(state));
10071 let (tx, _rx) = mpsc::unbounded_channel();
10072 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
10073 assert!(
10074 !consumed,
10075 "handle_overlay_key must return false when no overlay is open"
10076 );
10077 }
10078
10079 #[test]
10080 fn apply_command_show_overlay_populates_state() {
10081 use oxicode_vtui::tui::core::{InlineListItem, ListOverlayRequest};
10082 let mut state = RenderState::default();
10083 let items = vec![
10084 InlineListItem {
10085 title: "alpha".to_string(),
10086 subtitle: None,
10087 badge: None,
10088 indent: 0,
10089 selection: None,
10090 search_value: None,
10091 },
10092 InlineListItem {
10093 title: "beta".to_string(),
10094 subtitle: None,
10095 badge: None,
10096 indent: 0,
10097 selection: None,
10098 search_value: None,
10099 },
10100 ];
10101 let request = OverlayRequest::List(ListOverlayRequest {
10102 title: "Pick".to_string(),
10103 lines: vec!["desc".to_string()],
10104 footer_hint: None,
10105 items,
10106 selected: None,
10107 search: None,
10108 hotkeys: Vec::new(),
10109 });
10110 let shutdown = apply_command(
10111 &mut state,
10112 InlineCommand::ShowOverlay {
10113 request: Box::new(request),
10114 },
10115 );
10116 assert!(!shutdown, "ShowOverlay must not request shutdown");
10117 let overlay = state.overlay.as_ref().expect("overlay must be Some");
10118 assert_eq!(overlay.title, "Pick");
10119 assert_eq!(overlay.items.len(), 2);
10120 assert_eq!(overlay.items[0].title, "alpha");
10121 assert_eq!(overlay.items[1].title, "beta");
10122 assert_eq!(overlay.lines.len(), 1);
10123
10124 apply_command(&mut state, InlineCommand::CloseOverlay);
10126 assert!(state.overlay.is_none(), "CloseOverlay must clear state");
10127 }
10128
10129 #[test]
10130 fn materialize_overlay_modal_with_secure_prompt_populates_secure_input() {
10131 use oxicode_vtui::tui::core::{ModalOverlayRequest, SecurePromptConfig};
10132 let request = OverlayRequest::Modal(ModalOverlayRequest {
10133 title: "API key".into(),
10134 lines: vec!["Paste your key".into()],
10135 secure_prompt: Some(SecurePromptConfig {
10136 label: "Key".into(),
10137 placeholder: Some("sk-...".into()),
10138 mask_input: true,
10139 }),
10140 });
10141 let state = materialize_overlay(request);
10142 let secure = state
10143 .secure_input
10144 .expect("secure_input must be Some when secure_prompt is Some");
10145 assert_eq!(secure.config.label, "Key");
10146 assert!(secure.config.mask_input);
10147 assert_eq!(secure.editor.text(), "");
10148 assert_eq!(secure.editor.cursor_byte(), 0);
10149 }
10150
10151 #[test]
10152 fn materialize_overlay_modal_without_secure_prompt_has_none_secure_input() {
10153 use oxicode_vtui::tui::core::ModalOverlayRequest;
10154 let request = OverlayRequest::Modal(ModalOverlayRequest {
10155 title: "Confirm".into(),
10156 lines: vec!["y/n".into()],
10157 secure_prompt: None,
10158 });
10159 let state = materialize_overlay(request);
10160 assert!(
10161 state.secure_input.is_none(),
10162 "secure_input must be None when secure_prompt is None"
10163 );
10164 }
10165
10166 fn three_block_transcript() -> Vec<TranscriptLine> {
10169 vec![
10171 TranscriptLine {
10172 kind: InlineMessageKind::User,
10173 segments: vec![plain_segment("hi")],
10174 block_id: 0,
10175 },
10176 TranscriptLine {
10177 kind: InlineMessageKind::Agent,
10178 segments: vec![plain_segment("hello")],
10179 block_id: 1,
10180 },
10181 TranscriptLine {
10182 kind: InlineMessageKind::Agent,
10183 segments: vec![plain_segment("world")],
10184 block_id: 1,
10185 },
10186 TranscriptLine {
10187 kind: InlineMessageKind::User,
10188 segments: vec![plain_segment("bye")],
10189 block_id: 2,
10190 },
10191 ]
10192 }
10193
10194 #[test]
10195 fn fold_all_collapses_every_block() {
10196 let mut state = RenderState::default();
10197 state.transcript = three_block_transcript();
10198 state.fold_all();
10199 assert_eq!(state.block_display.len(), 3, "3 distinct block ids");
10200 assert_eq!(state.block_mode(0), BlockDisplayMode::Collapsed);
10201 assert_eq!(state.block_mode(1), BlockDisplayMode::Collapsed);
10202 assert_eq!(state.block_mode(2), BlockDisplayMode::Collapsed);
10203 }
10204
10205 #[test]
10206 fn expand_all_after_fold_all_shows_expanded() {
10207 let mut state = RenderState::default();
10208 state.transcript = three_block_transcript();
10209 state.fold_all();
10210 state.expand_all();
10211 assert!(
10212 state.block_display.is_empty(),
10213 "Expanded is the default — no overrides"
10214 );
10215 assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
10216 assert_eq!(state.block_mode(2), BlockDisplayMode::Expanded);
10217 }
10218
10219 #[test]
10220 fn truncate_all_sets_explicit_truncated() {
10221 let mut state = RenderState::default();
10222 state.transcript = three_block_transcript();
10223 state.fold_all();
10224 state.truncate_all();
10225 assert_eq!(state.block_display.len(), 3);
10226 assert_eq!(state.block_mode(1), BlockDisplayMode::Truncated);
10227 }
10228
10229 #[test]
10230 fn default_block_mode_is_expanded() {
10231 let state = RenderState::default();
10232 assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
10233 assert!(state.block_display.is_empty(), "default needs no map entry");
10234 }
10235
10236 #[test]
10237 fn cycle_block_advances_through_three_states() {
10238 let mut state = RenderState::default();
10239 state.transcript = three_block_transcript();
10240 state.scroll_offset = 0; state.cycle_block_at_view();
10243 assert_eq!(state.block_mode(0), BlockDisplayMode::Collapsed);
10244 state.cycle_block_at_view();
10246 assert_eq!(state.block_mode(0), BlockDisplayMode::Truncated);
10247 state.cycle_block_at_view();
10249 assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
10250 assert!(!state.block_display.contains_key(&0));
10251 }
10252
10253 #[test]
10254 fn cancel_grace_field_defaults_none() {
10255 let state = RenderState::default();
10256 assert!(
10257 state.cancel_grace_until.is_none(),
10258 "cancel_grace_until must default to None"
10259 );
10260 }
10261
10262 #[test]
10263 fn cancel_routes_to_interrupt_when_streaming() {
10264 assert_eq!(
10265 route_cancel(true),
10266 CancelRoute::Interrupt,
10267 "Esc while streaming must route through the interrupt path"
10268 );
10269 }
10270
10271 #[test]
10272 fn cancel_routes_to_exit_when_idle() {
10273 assert_eq!(
10274 route_cancel(false),
10275 CancelRoute::Exit,
10276 "Esc while idle must exit immediately (one-press quit)"
10277 );
10278 }
10279 #[test]
10280 fn no_scrollbar_even_when_content_overflows() {
10281 let mut state = RenderState::default();
10286 for i in 0..40u32 {
10287 state.transcript.push(TranscriptLine {
10288 kind: InlineMessageKind::Agent,
10289 segments: vec![plain_segment(format!("line {i}"))],
10290 block_id: i as usize,
10291 });
10292 }
10293 let rendered = render_frame_to_string(&state);
10294 assert!(
10295 !rendered.contains('\u{2588}'),
10296 "no scrollbar thumb (█): native scrollback owns history"
10297 );
10298 }
10299
10300 #[test]
10303 fn confirmation_modal_renders_title() {
10304 let mut state = RenderState::default();
10305 state.confirmation = Some(quit_confirmation());
10306 let rendered = render_frame_to_string(&state);
10307 assert!(
10308 rendered.contains("Quit oxicode?"),
10309 "confirmation title must render"
10310 );
10311 }
10312
10313 #[test]
10314 fn confirmation_yes_sends_exit_and_closes() {
10315 let mut state = RenderState::default();
10316 state.confirmation = Some(quit_confirmation());
10317 let state_arc = Arc::new(parking_lot::Mutex::new(state));
10318 let (tx, mut rx) = mpsc::unbounded_channel();
10319 let (issue_tx, _issue_rx) = mpsc::unbounded_channel();
10320 handle_confirmation_key(&state_arc, &tx, &issue_tx, KeyCode::Char('y'));
10321 assert!(
10322 state_arc.lock().confirmation.is_none(),
10323 "yes must close the modal"
10324 );
10325 let ev = rx.try_recv().expect("yes must send an event");
10326 assert!(matches!(ev, InlineEvent::Exit), "yes must send Exit");
10327 }
10328
10329 #[test]
10330 fn confirmation_no_closes_without_event() {
10331 let mut state = RenderState::default();
10332 state.confirmation = Some(quit_confirmation());
10333 let state_arc = Arc::new(parking_lot::Mutex::new(state));
10334 let (tx, mut rx) = mpsc::unbounded_channel();
10335 let (issue_tx, _issue_rx) = mpsc::unbounded_channel();
10336 handle_confirmation_key(&state_arc, &tx, &issue_tx, KeyCode::Char('n'));
10337 assert!(
10338 state_arc.lock().confirmation.is_none(),
10339 "no must close the modal"
10340 );
10341 assert!(rx.try_recv().is_err(), "no must not send an event");
10342 }
10343 #[test]
10346 fn tip_banner_renders_when_active() {
10347 let mut state = RenderState::default();
10348 let now_tick = FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed);
10349 state.tip = Some(EphemeralTip {
10350 text: "hello-tip-marker".to_string(),
10351 born_tick: now_tick,
10352 ttl_ticks: 100,
10353 key: "test",
10354 ambient: false,
10355 });
10356 let rendered = render_frame_to_string(&state);
10357 assert!(
10358 rendered.contains("hello-tip-marker"),
10359 "active tip must render above the composer"
10360 );
10361 }
10362
10363 #[test]
10364 fn tip_visible_within_ttl_window() {
10365 let tip = EphemeralTip {
10366 text: "x".to_string(),
10367 born_tick: 10,
10368 ttl_ticks: 5,
10369 key: "test",
10370 ambient: false,
10371 };
10372 assert!(tip_is_visible(&tip, 12), "within TTL must be visible");
10373 assert!(
10374 !tip_is_visible(&tip, 15),
10375 "at TTL boundary (born + ttl) must expire"
10376 );
10377 assert!(!tip_is_visible(&tip, 99), "past TTL must expire");
10378 }
10379
10380 #[test]
10383 fn sticky_header_pins_block_head_when_scrolled_into_body() {
10384 let mut state = RenderState::default();
10387 for i in 0..40u32 {
10388 state.transcript.push(TranscriptLine {
10389 kind: InlineMessageKind::Agent,
10390 segments: vec![plain_segment(format!("body-line-{i:02}"))],
10391 block_id: 0,
10392 });
10393 }
10394 state.scroll_offset = 10;
10395 let rendered = render_frame_to_string(&state);
10396 assert!(
10397 rendered.contains("body-line-00"),
10398 "sticky header must pin the block head when scrolled into the body"
10399 );
10400 }
10401
10402 #[test]
10403 fn sticky_header_absent_when_viewport_at_block_head() {
10404 let mut state = RenderState::default();
10406 for i in 0..40u32 {
10407 state.transcript.push(TranscriptLine {
10408 kind: InlineMessageKind::Agent,
10409 segments: vec![plain_segment(format!("head-line-{i:02}"))],
10410 block_id: 0,
10411 });
10412 }
10413 state.scroll_offset = 0;
10414 let rendered = render_frame_to_string(&state);
10415 assert!(rendered.contains("head-line-00"));
10418 }
10419
10420 #[test]
10423 fn turn_end_drains_queue_head() {
10424 let mut state = RenderState::default();
10425 state.queued_inputs = vec!["queued-1".into(), "queued-2".into()];
10426 state.drain_queue_head();
10427 assert_eq!(
10428 state.queued_inputs.len(),
10429 1,
10430 "drain_queue_head must drop the head (now running)"
10431 );
10432 assert_eq!(state.queued_inputs[0], "queued-2");
10433 }
10434
10435 #[test]
10438 fn render_frame_paints_transcript_content() {
10439 let mut state = RenderState::default();
10443 state.transcript.push(TranscriptLine {
10444 kind: InlineMessageKind::Agent,
10445 segments: vec![plain_segment("frame-content-marker-xyz")],
10446 block_id: 0,
10447 });
10448 let rendered = render_frame_to_string(&state);
10449 assert!(
10450 rendered.contains("frame-content-marker-xyz"),
10451 "render_frame must paint transcript content"
10452 );
10453 }
10454
10455 #[test]
10456 fn user_turns_get_one_blank_spacer_row() {
10457 let mut state = RenderState::default();
10458 state.transcript = vec![
10459 TranscriptLine {
10460 kind: InlineMessageKind::Agent,
10461 segments: vec![plain_segment("agent-answer")],
10462 block_id: 0,
10463 },
10464 TranscriptLine {
10465 kind: InlineMessageKind::User,
10466 segments: vec![plain_segment("next-question")],
10467 block_id: 1,
10468 },
10469 ];
10470 let rendered = render_frame_to_string(&state);
10471 let rows: Vec<&str> = rendered.split('\n').collect();
10472 let agent_row = rows
10473 .iter()
10474 .position(|r| r.contains("agent-answer"))
10475 .expect("agent row");
10476 assert!(
10477 rows[agent_row + 1].trim().is_empty(),
10478 "blank spacer between turns: {:?}",
10479 &rows[agent_row..agent_row + 3]
10480 );
10481 assert!(
10482 rows[agent_row + 2].contains("next-question"),
10483 "user line follows the spacer"
10484 );
10485 }
10486
10487 #[test]
10488 fn transcript_snapshot_at_120_cols_matches_role_layout() {
10489 let mut state = RenderState::default();
10490 state.brain = BrainChip::Ok;
10491 state.append_line(
10492 InlineMessageKind::User,
10493 vec![plain_segment("intro message\nsecond line")],
10494 );
10495 state.append_line(
10496 InlineMessageKind::Agent,
10497 vec![plain_segment("answer paragraph line one\nline two")],
10498 );
10499 state.append_line(
10500 InlineMessageKind::User,
10501 vec![plain_segment("follow-up question")],
10502 );
10503 let rendered = render_frame_to_string_at(&state, 120, 24);
10504 let rows: Vec<&str> = rendered.split('\n').collect();
10505 let first_user = rows
10507 .iter()
10508 .position(|row| row.contains("intro message"))
10509 .expect("intro user row");
10510 let continuation = rows
10511 .iter()
10512 .position(|row| row.contains("second line"))
10513 .expect("user continuation visible");
10514 assert_eq!(
10515 continuation,
10516 first_user + 1,
10517 "user continuation on next row"
10518 );
10519 assert!(
10520 !rows[first_user].contains("> "),
10521 "plain style has no prompt glyph: {rows:?}"
10522 );
10523
10524 let agent_row = rows
10527 .iter()
10528 .position(|row| row.contains("answer paragraph"))
10529 .expect("agent row");
10530 assert_eq!(
10531 agent_row,
10532 continuation + 2,
10533 "one blank row separates request from response: {:?}",
10534 &rows[continuation..=agent_row]
10535 );
10536 assert!(
10537 !rows[agent_row].trim_start().starts_with('>'),
10538 "agent rows carry no prompt glyph: {rows:?}"
10539 );
10540
10541 let next_user = rows
10542 .iter()
10543 .position(|row| row.contains("follow-up question"))
10544 .expect("second user row");
10545 assert_eq!(
10546 next_user,
10547 agent_row + 3,
10548 "answer (2 rows) + one blank + next user turn: {:?}",
10549 &rows[agent_row..=next_user]
10550 );
10551
10552 let shortcuts_row = rows
10554 .iter()
10555 .position(|row| row.contains("brain·ok"))
10556 .expect("brain chip on shortcuts row");
10557 assert!(shortcuts_row > next_user, "chip below the chat surface");
10558 }
10559
10560 #[test]
10561 fn response_breathes_after_the_user_request() {
10562 let mut state = RenderState::default();
10563 state.append_line(InlineMessageKind::User, vec![plain_segment("the request")]);
10564 state.append_line(InlineMessageKind::Agent, vec![plain_segment("the answer")]);
10565 let rendered = render_frame_to_string(&state);
10566 let rows: Vec<&str> = rendered.split('\n').collect();
10567 let request_row = rows
10568 .iter()
10569 .position(|r| r.contains("the request"))
10570 .expect("request row");
10571 let answer_row = rows
10572 .iter()
10573 .position(|r| r.contains("the answer"))
10574 .expect("answer row");
10575 assert_eq!(
10576 answer_row,
10577 request_row + 2,
10578 "one blank row must separate request from response: {:?}",
10579 &rows[request_row..=answer_row]
10580 );
10581 }
10582
10583 #[test]
10584 fn assistant_tool_flow_stays_contiguous() {
10585 let mut state = RenderState::default();
10586 state.append_line(InlineMessageKind::Tool, vec![plain_segment("[tool] read")]);
10587 state.append_line(InlineMessageKind::Tool, vec![plain_segment("[done] ok")]);
10588 state.append_line(InlineMessageKind::Agent, vec![plain_segment("the answer")]);
10589 let rendered = render_frame_to_string(&state);
10590 let rows: Vec<&str> = rendered.split('\n').collect();
10591 let tool_row = rows
10592 .iter()
10593 .position(|r| r.contains("[tool] read"))
10594 .expect("tool row");
10595 let answer_row = rows
10596 .iter()
10597 .position(|r| r.contains("the answer"))
10598 .expect("answer row");
10599 assert_eq!(
10600 answer_row,
10601 tool_row + 2,
10602 "tool → answer is one assistant turn — no blank inside it: {:?}",
10603 &rows[tool_row..=answer_row]
10604 );
10605 }
10606
10607 #[test]
10608 fn no_spacer_above_the_first_transcript_line() {
10609 let mut state = RenderState::default();
10610 state.transcript = vec![TranscriptLine {
10611 kind: InlineMessageKind::User,
10612 segments: vec![plain_segment("opening-question")],
10613 block_id: 0,
10614 }];
10615 let rendered = render_frame_to_string(&state);
10616 let rows: Vec<&str> = rendered.split('\n').collect();
10617 let user_row = rows
10618 .iter()
10619 .position(|r| r.contains("opening-question"))
10620 .expect("user row");
10621 assert!(
10622 rows[..user_row].iter().all(|r| r.trim().is_empty()),
10623 "transcript starts at the top with no spacer"
10624 );
10625 }
10626
10627 #[test]
10628 fn long_response_renders_every_line_by_default() {
10629 let mut state = RenderState::default();
10630 let mut segments = Vec::new();
10631 for i in 0..8 {
10632 if i > 0 {
10633 segments.push(plain_segment("\n"));
10634 }
10635 segments.push(plain_segment(format!("line-{i}")));
10636 }
10637 state.append_line(InlineMessageKind::Agent, segments);
10638 let rendered = render_frame_to_string(&state);
10639 assert!(
10640 !rendered.contains("lines"),
10641 "no elision gap by default — full text scrolls instead: {rendered}"
10642 );
10643 for i in 0..8 {
10644 assert!(
10645 rendered.contains(&format!("line-{i}")),
10646 "line-{i} must be reachable by scrolling: {rendered}"
10647 );
10648 }
10649 }
10650 #[test]
10651 fn multiline_user_input_renders_every_explicit_line() {
10652 let mut state = RenderState::default();
10653 state.append_line(
10654 InlineMessageKind::User,
10655 vec![plain_segment("first line\nsecond line")],
10656 );
10657 let rendered = render_frame_to_string(&state);
10658 let rows: Vec<&str> = rendered.split('\n').collect();
10659 let first_row = rows
10660 .iter()
10661 .position(|row| row.contains("first line"))
10662 .expect("first user row");
10663 let second_row = rows
10664 .iter()
10665 .position(|row| row.contains("second line"))
10666 .expect("explicit continuation line is visible");
10667 assert_eq!(
10668 second_row,
10669 first_row + 1,
10670 "explicit newline must occupy the following visual row"
10671 );
10672
10673 assert!(
10674 !rows[second_row].contains("> second line"),
10675 "continuation row must not look like a second user turn"
10676 );
10677 }
10678
10679 #[test]
10680 fn streaming_agent_delta_renders_every_explicit_line() {
10681 let mut state = RenderState::default();
10682 state.inline_segment(
10683 InlineMessageKind::Agent,
10684 plain_segment("first answer\nsecond answer"),
10685 );
10686 let rendered = render_frame_to_string(&state);
10687 let rows: Vec<&str> = rendered.split('\n').collect();
10688 let first_row = rows
10689 .iter()
10690 .position(|row| row.contains("first answer"))
10691 .expect("first agent row");
10692 let second_row = rows
10693 .iter()
10694 .position(|row| row.contains("second answer"))
10695 .expect("second agent row");
10696 assert_eq!(
10697 second_row,
10698 first_row + 1,
10699 "streamed newline must occupy the following visual row"
10700 );
10701 }
10702
10703 #[test]
10704 fn file_search_dropdown_renders_results() {
10705 use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
10706 let mut state = RenderState::default();
10707 state.input_enabled = true;
10708 state.file_search = Some(FileSearchState {
10709 query: "main".into(),
10710 at_offset: 0,
10711 hidden_mode: false,
10712 results: vec![
10713 FileSearchResult {
10714 path: "src/main.rs".into(),
10715 score: 100,
10716 },
10717 FileSearchResult {
10718 path: "tests/main.rs".into(),
10719 score: 50,
10720 },
10721 ],
10722 selected: 0,
10723 index: vec![],
10724 line_mode: false,
10725 });
10726 let rendered = render_frame_to_string(&state);
10727 assert!(rendered.contains("FILES"), "dropdown title must render");
10728 assert!(
10729 rendered.contains("src/main.rs"),
10730 "dropdown must show file paths"
10731 );
10732 }
10733
10734 #[test]
10735 fn file_search_dropdown_hidden_mode_title() {
10736 use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
10737 let mut state = RenderState::default();
10738 state.input_enabled = true;
10739 state.file_search = Some(FileSearchState {
10740 query: "".into(),
10741 at_offset: 0,
10742 hidden_mode: true,
10743 results: vec![FileSearchResult {
10744 path: ".env".into(),
10745 score: 0,
10746 }],
10747 selected: 0,
10748 index: vec![],
10749 line_mode: false,
10750 });
10751 let rendered = render_frame_to_string(&state);
10752 assert!(
10753 rendered.contains("HIDDEN"),
10754 "hidden mode must be indicated in title"
10755 );
10756 }
10757
10758 #[test]
10759 fn file_search_and_composer_render_together() {
10760 use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
10761 let mut state = RenderState::default();
10762 state.input_enabled = true;
10763 state.prompt_prefix = "> ".into();
10764 state.composer.set_text("@main");
10765 state.file_search = Some(FileSearchState {
10766 query: "main".into(),
10767 at_offset: 0,
10768 hidden_mode: false,
10769 results: vec![FileSearchResult {
10770 path: "src/main.rs".into(),
10771 score: 100,
10772 }],
10773 selected: 0,
10774 index: vec![],
10775 line_mode: false,
10776 });
10777 let rendered = render_frame_to_string(&state);
10778 assert!(rendered.contains('>'), "composer must still render");
10780 assert!(
10781 rendered.contains("src/main.rs"),
10782 "dropdown must render alongside composer"
10783 );
10784 }
10785
10786 #[test]
10787 fn format_todo_line_shows_block_reason_and_notes_marker() {
10788 let styles = active_styles();
10789 let todo = TodoItem {
10790 content: "Wire OAuth".into(),
10791 status: TodoStatus::Blocked,
10792 notes: Some(vec!["waiting on vendor".into()]),
10793 block_reason: Some("vendor sandbox pending".into()),
10794 };
10795 let line = format_todo_line(&todo, false, &styles);
10796 let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
10797 assert!(text.contains("Wire OAuth"));
10798 assert!(text.contains("blocked: vendor sandbox pending"));
10799 assert!(text.contains("·1"));
10800 }
10801
10802 #[test]
10803 fn format_todo_line_abandoned_is_strikethrough() {
10804 let styles = active_styles();
10805 let todo = TodoItem {
10806 content: "Drop this".into(),
10807 status: TodoStatus::Abandoned,
10808 notes: None,
10809 block_reason: None,
10810 };
10811 let line = format_todo_line(&todo, false, &styles);
10812 assert!(
10813 line.spans
10814 .iter()
10815 .any(|s| s.style.add_modifier.contains(Modifier::CROSSED_OUT))
10816 );
10817 }
10818
10819 #[test]
10820 fn render_todo_pane_multi_phase_shows_roman_header_and_progress() {
10821 use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10822 let mut state = RenderState::default();
10823 state.todo_phases = vec![
10824 TodoPhase {
10825 name: "Foundation".into(),
10826 tasks: vec![
10827 TodoItem {
10828 content: "a".into(),
10829 status: TodoStatus::Completed,
10830 notes: None,
10831 block_reason: None,
10832 },
10833 TodoItem {
10834 content: "b".into(),
10835 status: TodoStatus::Completed,
10836 notes: None,
10837 block_reason: None,
10838 },
10839 ],
10840 },
10841 TodoPhase {
10842 name: "Auth".into(),
10843 tasks: vec![
10844 TodoItem {
10845 content: "c".into(),
10846 status: TodoStatus::Completed,
10847 notes: None,
10848 block_reason: None,
10849 },
10850 TodoItem {
10851 content: "d".into(),
10852 status: TodoStatus::InProgress,
10853 notes: None,
10854 block_reason: None,
10855 },
10856 TodoItem {
10857 content: "e".into(),
10858 status: TodoStatus::Pending,
10859 notes: None,
10860 block_reason: None,
10861 },
10862 ],
10863 },
10864 ];
10865 let rendered = render_frame_to_string(&state);
10866 assert!(
10867 rendered.contains("II. Auth"),
10868 "multi-phase HUD must show the roman-numeral phase header"
10869 );
10870 assert!(
10871 rendered.contains("1/3"),
10872 "active phase must show its done/total progress"
10873 );
10874 }
10875
10876 #[test]
10877 fn todo_auto_clear_fires_after_delay_when_all_closed() {
10878 use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10879 let mut state = RenderState::default();
10880 state.todo_phases = vec![TodoPhase {
10881 name: "Auth".into(),
10882 tasks: vec![TodoItem {
10883 content: "a".into(),
10884 status: TodoStatus::Completed,
10885 notes: None,
10886 block_reason: None,
10887 }],
10888 }];
10889 sync_todo_clear_timer(&mut state, 0); assert!(state.todo_phases.is_empty());
10891 }
10892
10893 #[test]
10894 fn todo_auto_clear_does_not_fire_while_open_tasks_remain() {
10895 use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10896 let mut state = RenderState::default();
10897 let phases = vec![TodoPhase {
10898 name: "Auth".into(),
10899 tasks: vec![TodoItem {
10900 content: "a".into(),
10901 status: TodoStatus::Pending,
10902 notes: None,
10903 block_reason: None,
10904 }],
10905 }];
10906 state.todo_phases = phases.clone();
10907 sync_todo_clear_timer(&mut state, 0);
10908 assert_eq!(state.todo_phases.len(), phases.len());
10909 assert_eq!(state.todo_phases[0].name, "Auth");
10910 }
10911
10912 #[test]
10913 fn todo_auto_clear_negative_delay_disables_clearing() {
10914 use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10915 let mut state = RenderState::default();
10916 let phases = vec![TodoPhase {
10917 name: "Auth".into(),
10918 tasks: vec![TodoItem {
10919 content: "a".into(),
10920 status: TodoStatus::Completed,
10921 notes: None,
10922 block_reason: None,
10923 }],
10924 }];
10925 state.todo_phases = phases.clone();
10926 sync_todo_clear_timer(&mut state, -1);
10927 assert_eq!(state.todo_phases.len(), phases.len());
10928 }
10929
10930 #[test]
10931 fn render_todo_pane_single_phase_has_no_roman_header() {
10932 use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10933 let mut state = RenderState::default();
10934 state.todo_phases = vec![TodoPhase {
10935 name: "Todos".into(),
10936 tasks: vec![TodoItem {
10937 content: "a".into(),
10938 status: TodoStatus::Pending,
10939 notes: None,
10940 block_reason: None,
10941 }],
10942 }];
10943 let rendered = render_frame_to_string(&state);
10944 assert!(
10945 !rendered.contains("I. Todos"),
10946 "single phase must skip roman header"
10947 );
10948 assert!(rendered.contains("Todos"), "single-phase name must render");
10949 }
10950
10951 #[test]
10952 fn render_todo_compact_line_shows_counts_and_active_task() {
10953 use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10954 let phases = vec![TodoPhase {
10955 name: "Auth".into(),
10956 tasks: vec![
10957 TodoItem {
10958 content: "a".into(),
10959 status: TodoStatus::Completed,
10960 notes: None,
10961 block_reason: None,
10962 },
10963 TodoItem {
10964 content: "b".into(),
10965 status: TodoStatus::InProgress,
10966 notes: None,
10967 block_reason: None,
10968 },
10969 ],
10970 }];
10971 let line = render_todo_compact_line(&phases);
10972 let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
10973 assert!(text.contains("TODO 1/2"));
10974 assert!(text.contains("b"));
10975 }
10976
10977 #[test]
10978 fn render_todo_compact_line_all_done_shows_done_marker() {
10979 use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10980 let phases = vec![TodoPhase {
10981 name: "Auth".into(),
10982 tasks: vec![TodoItem {
10983 content: "a".into(),
10984 status: TodoStatus::Completed,
10985 notes: None,
10986 block_reason: None,
10987 }],
10988 }];
10989 let line = render_todo_compact_line(&phases);
10990 let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
10991 assert!(text.contains("done"));
10992 }
10993
10994 fn test_todo_state() -> std::sync::Arc<crate::store::todo_state::TodoState> {
10995 use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10996 std::sync::Arc::new(crate::store::todo_state::TodoState::with_phases(vec![
10997 TodoPhase {
10998 name: "Auth".into(),
10999 tasks: vec![TodoItem {
11000 content: "implement authentication module".into(),
11001 status: TodoStatus::Pending,
11002 notes: None,
11003 block_reason: None,
11004 }],
11005 },
11006 ]))
11007 }
11008
11009 fn hub_with_subagent(
11010 status: oxicode_sdk::HubStatus,
11011 current_task: Option<&str>,
11012 ) -> std::sync::Arc<crate::app::agent_hub_registry::HubRegistry> {
11013 use crate::app::agent_hub_registry::{HubEntry, HubRegistry};
11014 let hub = HubRegistry::new();
11015 hub.register(
11016 "sub".into(),
11017 HubEntry {
11018 kind: oxicode_sdk::HubKind::Subagent,
11019 status,
11020 display_name: "sub".into(),
11021 current_task: current_task.map(str::to_string),
11022 last_activity_ms: 0,
11023 session_file: None,
11024 },
11025 );
11026 std::sync::Arc::new(hub)
11027 }
11028
11029 #[test]
11030 fn frame_refresh_reconciles_todo_with_completed_subagent() {
11031 let state = test_todo_state();
11032 let provider: std::sync::Arc<dyn TodoStateProvider> =
11033 crate::store::todo_state::provider_from_state(state.clone());
11034 let hub = hub_with_subagent(oxicode_sdk::HubStatus::Idle, Some("authentication module"));
11035 let phases = refresh_todo_phases(&provider, Some(&hub));
11036 assert_eq!(phases[0].tasks[0].status, TodoStatus::Completed);
11037 assert_eq!(state.get_phases()[0].tasks[0].status, TodoStatus::Completed);
11039 }
11040
11041 #[test]
11042 fn matched_closure_lights_pending_todo_for_running_subagent() {
11043 let hub = hub_with_subagent(
11044 oxicode_sdk::HubStatus::Running,
11045 Some("authentication module"),
11046 );
11047 let matched = build_matched_closure(Some(&hub));
11048 let t = oxicode_agent::tools::todo::TodoItem {
11049 content: "implement authentication module".into(),
11050 status: TodoStatus::Pending,
11051 notes: None,
11052 block_reason: None,
11053 };
11054 assert!(matched(&t));
11055 }
11056
11057 #[test]
11058 fn todo_pane_renders_when_items_present() {
11059 let mut state = RenderState::default();
11062 state.todo_phases = vec![oxicode_agent::tools::todo::TodoPhase {
11063 name: "Work".into(),
11064 tasks: vec![
11065 oxicode_agent::tools::todo::TodoItem {
11066 content: "active task".into(),
11067 status: TodoStatus::InProgress,
11068 notes: None,
11069 block_reason: None,
11070 },
11071 oxicode_agent::tools::todo::TodoItem {
11072 content: "open task".into(),
11073 status: TodoStatus::Pending,
11074 notes: None,
11075 block_reason: None,
11076 },
11077 ],
11078 }];
11079 let rendered = render_frame_to_string(&state);
11080 assert!(
11081 rendered.contains("active task"),
11082 "in-progress task must render"
11083 );
11084 assert!(rendered.contains("open task"), "pending task must render");
11085 assert!(rendered.contains("▸"), "in-progress status must render");
11087 }
11088
11089 #[test]
11090 fn todo_pane_hidden_when_empty() {
11091 let state = RenderState::default();
11092 let rendered = render_frame_to_string(&state);
11093 assert!(!rendered.contains("done"), "no completed state when empty");
11095 }
11096
11097 #[test]
11098 fn render_overlay_secure_input_shows_label_mask_value_and_placeholder() {
11099 use ratatui::{Terminal, backend::TestBackend};
11100 let backend = TestBackend::new(80, 24);
11101 let mut terminal = Terminal::new(backend).unwrap();
11102 let overlay = OverlayState {
11103 title: "OpenAI key".into(),
11104 lines: vec!["Paste your API key".into()],
11105 items: Vec::new(),
11106 selected: 0,
11107 search: None,
11108 secure_input: Some(OverlaySecureInput {
11109 config: SecurePromptConfig {
11110 label: "Key".into(),
11111 placeholder: Some("sk-...".into()),
11112 mask_input: true,
11113 },
11114 editor: oxicode_textarea::EditBuffer::from_parts("sk-abc", 6),
11115 }),
11116 ..Default::default()
11117 };
11118 terminal
11119 .draw(|f| render_overlay(f, f.area(), &overlay))
11120 .unwrap();
11121 let buf = terminal.backend().buffer().clone();
11122 let text: String = buf
11124 .content()
11125 .iter()
11126 .map(|c| c.symbol())
11127 .collect::<Vec<_>>()
11128 .join("");
11129 assert!(text.contains("Key:"));
11130 assert!(text.contains("******"));
11131 assert!(!text.contains("sk-abc"));
11132 }
11133
11134 #[test]
11135 fn render_overlay_secure_input_placeholder_when_empty() {
11136 use ratatui::{Terminal, backend::TestBackend};
11137 let backend = TestBackend::new(80, 24);
11138 let mut terminal = Terminal::new(backend).unwrap();
11139 let overlay = OverlayState {
11140 title: "OpenAI key".into(),
11141 lines: vec!["Paste your API key".into()],
11142 items: Vec::new(),
11143 selected: 0,
11144 search: None,
11145 secure_input: Some(OverlaySecureInput {
11146 config: SecurePromptConfig {
11147 label: "Key".into(),
11148 placeholder: Some("sk-...".into()),
11149 mask_input: true,
11150 },
11151 editor: oxicode_textarea::EditBuffer::new(),
11152 }),
11153 ..Default::default()
11154 };
11155 terminal
11156 .draw(|f| render_overlay(f, f.area(), &overlay))
11157 .unwrap();
11158 let buf = terminal.backend().buffer().clone();
11159 let text: String = buf
11160 .content()
11161 .iter()
11162 .map(|c| c.symbol())
11163 .collect::<Vec<_>>()
11164 .join("");
11165 assert!(text.contains("sk-..."));
11166 }
11167 #[test]
11172 fn ladder_collapses_oldest_blocks_to_glyph_row_and_banner() {
11173 let mut state = RenderState::default();
11179 for i in 0..6 {
11180 let bid = i;
11181 state.transcript.push(TranscriptLine {
11182 kind: InlineMessageKind::Tool,
11183 segments: vec![plain_segment(format!("tool-{i}-headline"))],
11184 block_id: bid,
11185 });
11186 state.transcript.push(TranscriptLine {
11187 kind: InlineMessageKind::Tool,
11188 segments: vec![plain_segment(format!("tool-{i}-middle"))],
11189 block_id: bid,
11190 });
11191 state.transcript.push(TranscriptLine {
11192 kind: InlineMessageKind::Tool,
11193 segments: vec![plain_segment(format!("tool-{i}-trailer"))],
11194 block_id: bid,
11195 });
11196 }
11197 let rendered = render_frame_to_string_at(&state, 80, 10);
11200 assert!(
11207 rendered.contains("tool-5")
11208 || rendered.contains("tool-5-headline")
11209 || rendered.contains("\u{25B8}")
11210 || rendered.contains("earlier blocks hidden"),
11211 "live region must surface a recent block or its folded form"
11212 );
11213 }
11214
11215 #[test]
11218 fn ladder_keeps_newest_block_full_under_pressure() {
11219 let mut state = RenderState::default();
11223 for j in 0..2 {
11225 state.transcript.push(TranscriptLine {
11226 kind: InlineMessageKind::Agent,
11227 segments: vec![plain_segment(format!("old-block-line-{j}"))],
11228 block_id: 0,
11229 });
11230 }
11231 for j in 0..2 {
11233 state.transcript.push(TranscriptLine {
11234 kind: InlineMessageKind::Agent,
11235 segments: vec![plain_segment(format!("mid-block-line-{j}"))],
11236 block_id: 1,
11237 });
11238 }
11239 for j in 0..5 {
11241 state.transcript.push(TranscriptLine {
11242 kind: InlineMessageKind::Agent,
11243 segments: vec![plain_segment(format!("new-block-line-{j}"))],
11244 block_id: 2,
11245 });
11246 }
11247 let rendered = render_frame_to_string_at(&state, 80, 12);
11248 assert!(
11250 rendered.contains("new-block-line-0"),
11251 "newest block's leading line must be visible"
11252 );
11253 let old_visible = (0..2).all(|j| rendered.contains(&format!("old-block-line-{j}")));
11257 assert!(
11258 !old_visible,
11259 "oldest block must be folded or hidden when under pressure"
11260 );
11261 }
11262 #[test]
11266 fn long_activity_folded_card_stays_within_width() {
11267 let mut state = RenderState::default();
11268 for i in 0..8 {
11273 let bid = i;
11274 let long = format!("tool-{i}-{}", "x".repeat(120));
11275 state.transcript.push(TranscriptLine {
11276 kind: InlineMessageKind::Tool,
11277 segments: vec![plain_segment(format!("tool-{i}-head"))],
11278 block_id: bid,
11279 });
11280 state.transcript.push(TranscriptLine {
11281 kind: InlineMessageKind::Tool,
11282 segments: vec![plain_segment(format!("tool-{i}-body"))],
11283 block_id: bid,
11284 });
11285 state.transcript.push(TranscriptLine {
11286 kind: InlineMessageKind::Tool,
11287 segments: vec![plain_segment(long)],
11288 block_id: bid,
11289 });
11290 }
11291 let rendered = render_frame_to_string_at(&state, 80, 10);
11292 for line in rendered.split('\n') {
11297 assert!(
11298 line.width() <= 80,
11299 "rendered row exceeded the viewport width: '{}' ({} cells)",
11300 line,
11301 line.width()
11302 );
11303 }
11304 assert!(
11308 rendered.contains('\u{25B8}'),
11309 "glyph rows must be painted even with long activities"
11310 );
11311 }
11312
11313 #[test]
11317 fn manual_collapsed_block_keeps_the_plus_marker() {
11318 let mut state = RenderState::default();
11319 for j in 0..3 {
11321 state.transcript.push(TranscriptLine {
11322 kind: InlineMessageKind::Error,
11323 segments: vec![plain_segment(format!("boom-line-{j}"))],
11324 block_id: 0,
11325 });
11326 }
11327 state.block_display.insert(0, BlockDisplayMode::Collapsed);
11329 state.transcript.push(TranscriptLine {
11331 kind: InlineMessageKind::Agent,
11332 segments: vec![plain_segment("after-collapsed")],
11333 block_id: 1,
11334 });
11335 let rendered = render_frame_to_string_at(&state, 80, 12);
11336 assert!(
11339 rendered.contains("[+] error: boom-line-0"),
11340 "manual Collapsed must keep the [+] marker (got: {rendered:?})"
11341 );
11342 assert!(
11344 !rendered.contains('\u{25B8}'),
11345 "manual Collapsed must NOT be replaced by the ladder glyph"
11346 );
11347 }
11348
11349 #[test]
11353 fn clamp_fold_text_truncates_at_unicode_width() {
11354 let out = clamp_fold_text(&"x".repeat(100), 3, 80, "\u{2026}");
11356 assert!(out.ends_with('\u{2026}'), "ellipsis appended on overflow");
11357 assert!(out.width() <= 80, "clamped to budget: got {}", out.width());
11358 let cjk = "\u{4ECA}\u{65E5}\u{306F}\u{667A}\u{6167}".repeat(20);
11360 let out_cjk = clamp_fold_text(&cjk, 2, 20, "\u{2026}");
11361 assert!(out_cjk.width() <= 20, "CJK clamp: got {}", out_cjk.width());
11362 assert!(out_cjk.ends_with('\u{2026}'));
11363 assert_eq!(clamp_fold_text("short", 0, 80, "\u{2026}"), "short");
11365 assert_eq!(clamp_fold_text("text", 0, 0, "\u{2026}"), "");
11367 }
11368
11369 }
11374
11375#[cfg(test)]
11376mod secure_input_tests {
11377 use super::*;
11378 use oxicode_vtui::tui::core::OverlaySubmission;
11379
11380 #[test]
11381 fn overlay_submission_secure_input_is_routed_to_host() {
11382 let _ = OverlaySubmission::SecureInput("sk-test".into());
11385 let serialized = format!("{:?}", OverlaySubmission::SecureInput("x".into()));
11386 assert!(serialized.contains("SecureInput"));
11387 }
11388
11389 #[test]
11390 fn providers_action_matrix_branches_correctly() {
11391 assert_eq!(
11395 next_provider_actions(true, true),
11396 vec![
11397 AuthAction::SetApiKey,
11398 AuthAction::StartOAuth,
11399 AuthAction::RemoveKey,
11400 ],
11401 "has key + oauth-capable: replace, oauth, remove"
11402 );
11403 assert_eq!(
11404 next_provider_actions(true, false),
11405 vec![AuthAction::SetApiKey, AuthAction::RemoveKey],
11406 "has key, key-only provider: replace, remove"
11407 );
11408 assert_eq!(
11409 next_provider_actions(false, true),
11410 vec![AuthAction::SetApiKey, AuthAction::StartOAuth],
11411 "no key + oauth-capable: set key, oauth"
11412 );
11413 assert_eq!(
11414 next_provider_actions(false, false),
11415 vec![AuthAction::SetApiKey],
11416 "no key + key-only provider: set key only"
11417 );
11418 }
11419
11420 fn render_secure_to_text(secure: &OverlaySecureInput) -> String {
11432 use ratatui::{Terminal, backend::TestBackend};
11433 let backend = TestBackend::new(80, 24);
11434 let mut terminal = Terminal::new(backend).unwrap();
11435 let overlay = OverlayState {
11436 title: "OpenAI key".into(),
11437 lines: vec!["Paste your API key".into()],
11438 items: Vec::new(),
11439 selected: 0,
11440 search: None,
11441 secure_input: Some(secure.clone()),
11442 ..Default::default()
11443 };
11444 terminal
11445 .draw(|f| render_overlay(f, f.area(), &overlay))
11446 .unwrap();
11447 terminal
11448 .backend()
11449 .buffer()
11450 .content()
11451 .iter()
11452 .map(|c| c.symbol())
11453 .collect::<Vec<_>>()
11454 .join("")
11455 }
11456
11457 #[test]
11458 fn masked_render_shows_asterisks_not_value() {
11459 let mut editor = oxicode_textarea::EditBuffer::new();
11463 let _ = editor.insert_str("ABCDE");
11464 let rendered = render_secure_to_text(&OverlaySecureInput {
11465 config: SecurePromptConfig {
11466 label: "Key".into(),
11467 placeholder: Some("sk-...".into()),
11468 mask_input: true,
11469 },
11470 editor,
11471 });
11472 assert!(rendered.contains("*****"), "mask must render asterisks");
11473 assert!(
11474 !rendered.contains("ABCDE"),
11475 "masked render must NEVER carry the real value"
11476 );
11477 assert!(rendered.contains("Key:"), "label prefix must still render");
11478 }
11479
11480 #[test]
11481 fn masked_render_caret_lands_after_mask() {
11482 let mut editor = oxicode_textarea::EditBuffer::new();
11486 let _ = editor.insert_str("ABCD");
11487 let secure = OverlaySecureInput {
11488 config: SecurePromptConfig {
11489 label: "Key".into(),
11490 placeholder: Some("sk-...".into()),
11491 mask_input: true,
11492 },
11493 editor,
11494 };
11495 use ratatui::{Terminal, backend::TestBackend};
11498 let backend = TestBackend::new(80, 24);
11499 let mut terminal = Terminal::new(backend).unwrap();
11500 let overlay = OverlayState {
11501 title: "OpenAI key".into(),
11502 lines: vec!["Paste your API key".into()],
11503 items: Vec::new(),
11504 selected: 0,
11505 search: None,
11506 secure_input: Some(secure.clone()),
11507 ..Default::default()
11508 };
11509 terminal
11510 .draw(|f| render_overlay(f, f.area(), &overlay))
11511 .unwrap();
11512 let value = secure.editor.text();
11515 let mut ta = oxicode_textarea::TextArea::new();
11516 ta.set_text(value);
11517 ta.replace_range_with_element(
11518 0..value.len(),
11519 value,
11520 MASKED_ELEMENT_KIND,
11521 Some(Line::from("*".repeat(value.chars().count()))),
11522 );
11523 ta.set_cursor(secure.editor.cursor_byte());
11524 let caret = ta
11525 .cursor_pos_with_state(
11526 Rect {
11527 x: 0,
11528 y: 0,
11529 width: 80,
11530 height: 24,
11531 },
11532 oxicode_textarea::TextAreaState::default(),
11533 )
11534 .expect("caret must be visible");
11535 assert_eq!(caret.0, 4);
11538 }
11539
11540 #[test]
11541 fn backspace_removes_previous_grapheme() {
11542 let mut editor = oxicode_textarea::EditBuffer::new();
11548 let _ = editor.insert_str("XYZ");
11549 assert_eq!(editor.text(), "XYZ");
11550 assert_eq!(editor.cursor_byte(), 3);
11551 let _ = editor.apply(oxicode_textarea::EditCommand::DeleteGraphemeBackward);
11552 assert_eq!(editor.text(), "XY");
11553 assert_eq!(editor.cursor_byte(), 2);
11554 let _ = editor.apply(oxicode_textarea::EditCommand::DeleteGraphemeBackward);
11555 assert_eq!(editor.text(), "X");
11556 let _ = editor.apply(oxicode_textarea::EditCommand::DeleteGraphemeBackward);
11557 assert_eq!(editor.text(), "");
11558 assert_eq!(editor.cursor_byte(), 0);
11559 }
11560
11561 #[test]
11562 fn empty_editor_renders_placeholder_not_asterisks() {
11563 let rendered = render_secure_to_text(&OverlaySecureInput {
11565 config: SecurePromptConfig {
11566 label: "Key".into(),
11567 placeholder: Some("sk-...".into()),
11568 mask_input: true,
11569 },
11570 editor: oxicode_textarea::EditBuffer::new(),
11571 });
11572 assert!(rendered.contains("sk-..."));
11573 assert!(!rendered.contains("*"));
11574 }
11575
11576 #[test]
11577 fn paste_filter_drops_newline_and_non_ascii_via_edit_command() {
11578 let mut editor = oxicode_textarea::EditBuffer::new();
11583 let pasted = "sk-xyz\nABC\u{1F600}";
11584 let trimmed = pasted.trim_end_matches('\n');
11585 for ch in trimmed.chars() {
11586 if ch.is_ascii_graphic() || ch == ' ' {
11587 let _ = editor.apply(oxicode_textarea::EditCommand::Insert(ch));
11588 }
11589 }
11590 assert_eq!(editor.text(), "sk-xyzABC");
11591 assert_eq!(editor.cursor_byte(), 9);
11592 }
11593}
11594#[cfg(test)]
11605mod provider_overlay_tests {
11606 use super::*;
11607 use crate::app::agent_session::{AgentSession, AgentSessionHandle};
11608 use crate::store::session::SessionManager;
11609 use crate::store::settings::Settings;
11610 use oxicode_agent::{Agent, AgentConfig};
11611 use oxicode_sdk::{Provider, ProviderError, ProviderEvent};
11612 use oxicode_vtui::tui::core::OverlayEvent;
11613 use std::pin::Pin;
11614 use std::sync::Arc;
11615 use std::task::{Context as TaskContext, Poll};
11616
11617 struct EmptyStream;
11620 impl futures::Stream for EmptyStream {
11621 type Item = ProviderEvent;
11622 fn poll_next(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
11623 Poll::Ready(None)
11624 }
11625 }
11626
11627 struct StubProvider;
11628 impl Provider for StubProvider {
11629 fn stream<'a>(
11630 &'a self,
11631 _model: &'a oxicode_sdk::Model,
11632 _context: &'a oxicode_sdk::Context,
11633 _options: Option<oxicode_sdk::StreamOptions>,
11634 ) -> Pin<
11635 Box<
11636 dyn Future<
11637 Output = Result<
11638 Pin<Box<dyn futures::Stream<Item = ProviderEvent> + Send>>,
11639 ProviderError,
11640 >,
11641 > + Send
11642 + 'a,
11643 >,
11644 > {
11645 Box::pin(async move {
11646 Ok::<_, ProviderError>(Box::pin(EmptyStream)
11647 as Pin<Box<dyn futures::Stream<Item = ProviderEvent> + Send>>)
11648 })
11649 }
11650 }
11651
11652 struct StubEssentialTool {
11655 name: &'static str,
11656 essential: bool,
11657 }
11658 #[async_trait::async_trait]
11659 impl oxicode_agent::AgentTool for StubEssentialTool {
11660 fn name(&self) -> &str {
11661 self.name
11662 }
11663 fn label(&self) -> &str {
11664 self.name
11665 }
11666 fn description(&self) -> &str {
11667 "stub tool for multiselect editor tests"
11668 }
11669 fn parameters_schema(&self) -> serde_json::Value {
11670 serde_json::json!({ "type": "object", "properties": {} })
11671 }
11672 fn essential(&self) -> bool {
11673 self.essential
11674 }
11675 async fn execute(
11676 &self,
11677 _id: &str,
11678 _params: serde_json::Value,
11679 _signal: Option<tokio::sync::oneshot::Receiver<()>>,
11680 _ctx: &oxicode_agent::ToolContext,
11681 ) -> Result<oxicode_agent::AgentToolResult, String> {
11682 Ok(oxicode_agent::AgentToolResult::success("ok"))
11683 }
11684 }
11685
11686 fn make_session() -> AgentSessionHandle {
11687 let provider = Arc::new(StubProvider);
11688 let config = AgentConfig::new("anthropic/claude-sonnet-4-20250514");
11689 let agent = Arc::new(Agent::new(
11690 provider,
11691 config,
11692 Arc::new(oxicode_agent::ToolRegistry::new()),
11693 ));
11694 let settings = Settings::default();
11695 let session_manager = SessionManager::in_memory("/tmp/test_providers");
11696 let session = AgentSession::new(
11697 agent,
11698 settings,
11699 session_manager,
11700 "/tmp/test_providers".to_string(),
11701 crate::SessionState::default(),
11702 );
11703 session.clone_handle()
11704 }
11705
11706 pub(super) fn make_session_with_tools_for_tests() -> AgentSessionHandle {
11712 let provider = Arc::new(StubProvider);
11713 let config = AgentConfig::new("anthropic/claude-sonnet-4-20250514");
11714 let registry = oxicode_agent::ToolRegistry::new();
11715 registry.register_arc(Arc::new(StubEssentialTool {
11716 name: "bash",
11717 essential: true,
11718 }));
11719 registry.register_arc(Arc::new(StubEssentialTool {
11720 name: "commit",
11721 essential: false,
11722 }));
11723 let agent = Arc::new(Agent::new(provider, config, Arc::new(registry)));
11724 let settings = Settings::default();
11725 let session_manager = SessionManager::in_memory("/tmp/test_providers");
11726 let session = AgentSession::new(
11727 agent,
11728 settings,
11729 session_manager,
11730 "/tmp/test_providers".to_string(),
11731 crate::SessionState::default(),
11732 );
11733 session.clone_handle()
11734 }
11735
11736 fn summarise(cmds: &[InlineCommand]) -> String {
11739 let mut show = 0;
11740 let mut close = 0;
11741 let mut other = 0;
11742 for c in cmds {
11743 match c {
11744 InlineCommand::ShowOverlay { .. } => show += 1,
11745 InlineCommand::CloseOverlay => close += 1,
11746 _ => other += 1,
11747 }
11748 }
11749 format!("[ShowOverlay={show}, CloseOverlay={close}, other={other}]")
11750 }
11751
11752 #[test]
11759 fn provider_row_opens_action_menu_without_close() {
11760 let session = make_session();
11764 let mut state = RenderState::default();
11765 state.overlay_providers = vec!["openai".to_string()];
11766 state.overlay = Some(OverlayState {
11767 title: "Providers".to_string(),
11768 lines: Vec::new(),
11769 items: Vec::new(),
11770 selected: 0,
11771 search: None,
11772 secure_input: None,
11773 ..Default::default()
11774 });
11775
11776 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
11777 let handle = InlineHandle::new_for_tests(cmd_tx);
11778 let prompt_queue = Arc::new(PromptQueue::default());
11779
11780 let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
11781 InlineListSelection::ProviderRow(0),
11782 )));
11783 let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
11784
11785 let cmds: Vec<InlineCommand> = {
11786 let mut out = Vec::new();
11787 while let Ok(cmd) = cmd_rx.try_recv() {
11788 out.push(cmd);
11789 }
11790 out
11791 };
11792 let show_count = cmds
11793 .iter()
11794 .filter(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
11795 .count();
11796 assert_eq!(
11797 show_count,
11798 1,
11799 "submitting a provider row must ShowOverlay exactly once (commands: {})",
11800 summarise(&cmds)
11801 );
11802
11803 let show_idx = cmds
11807 .iter()
11808 .position(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
11809 .expect("ShowOverlay must be present");
11810 let trailing = &cmds[show_idx + 1..];
11811 assert!(
11812 !trailing
11813 .iter()
11814 .any(|c| matches!(c, InlineCommand::CloseOverlay)),
11815 "no CloseOverlay may follow the action-menu ShowOverlay (commands: {})",
11816 summarise(&cmds)
11817 );
11818
11819 assert!(
11822 state.overlay_providers.is_empty(),
11823 "overlay_providers must be cleared after dispatch (got {:?})",
11824 state.overlay_providers
11825 );
11826 }
11827
11828 #[test]
11835 fn provider_row_set_api_key_opens_secure_prompt_without_close() {
11836 let session = make_session();
11838 let mut state = RenderState::default();
11839 state.overlay_providers = vec!["cerebras".to_string()];
11840 state.overlay = Some(OverlayState {
11841 title: "Providers".to_string(),
11842 lines: Vec::new(),
11843 items: Vec::new(),
11844 selected: 0,
11845 search: None,
11846 secure_input: None,
11847 ..Default::default()
11848 });
11849
11850 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
11851 let handle = InlineHandle::new_for_tests(cmd_tx);
11852 let prompt_queue = Arc::new(PromptQueue::default());
11853
11854 let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
11855 InlineListSelection::ProviderRow(0),
11856 )));
11857 let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
11858
11859 let cmds: Vec<InlineCommand> = {
11860 let mut out = Vec::new();
11861 while let Ok(cmd) = cmd_rx.try_recv() {
11862 out.push(cmd);
11863 }
11864 out
11865 };
11866 let show_count = cmds
11867 .iter()
11868 .filter(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
11869 .count();
11870 assert_eq!(
11871 show_count,
11872 1,
11873 "submitting a provider row must ShowOverlay exactly once (commands: {})",
11874 summarise(&cmds)
11875 );
11876
11877 let show_idx = cmds
11878 .iter()
11879 .position(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
11880 .expect("ShowOverlay must be present");
11881 let trailing = &cmds[show_idx + 1..];
11882 assert!(
11883 !trailing
11884 .iter()
11885 .any(|c| matches!(c, InlineCommand::CloseOverlay)),
11886 "no CloseOverlay may follow the secure-prompt ShowOverlay (commands: {})",
11887 summarise(&cmds)
11888 );
11889
11890 assert_eq!(
11894 state.secure_input_origin,
11895 Some(SecureInputOrigin::SetKey {
11896 provider: "cerebras".to_string(),
11897 }),
11898 "secure_input_origin must be stashed by SetApiKey"
11899 );
11900 }
11901
11902 #[test]
11906 fn catalog_model_selection_still_closes_overlay() {
11907 let session = make_session();
11908 let mut state = RenderState::default();
11909 state.overlay_catalog_models = vec![(
11910 "anthropic".to_string(),
11911 "claude-sonnet-4-20250514".to_string(),
11912 )];
11913 state.overlay = Some(OverlayState {
11914 title: "Models".to_string(),
11915 lines: Vec::new(),
11916 items: Vec::new(),
11917 selected: 0,
11918 search: None,
11919 secure_input: None,
11920 ..Default::default()
11921 });
11922
11923 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
11924 let handle = InlineHandle::new_for_tests(cmd_tx);
11925 let prompt_queue = Arc::new(PromptQueue::default());
11926
11927 let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
11928 InlineListSelection::CatalogModel(0),
11929 )));
11930 let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
11931
11932 let cmds: Vec<InlineCommand> = {
11933 let mut out = Vec::new();
11934 while let Ok(cmd) = cmd_rx.try_recv() {
11935 out.push(cmd);
11936 }
11937 out
11938 };
11939 assert!(
11940 cmds.iter()
11941 .any(|c| matches!(c, InlineCommand::CloseOverlay)),
11942 "catalog model selection must close the overlay (commands: {})",
11943 summarise(&cmds)
11944 );
11945 }
11946
11947 fn secure_input_origin_carries_provider_independently_of_variant() {
11953 let set = SecureInputOrigin::SetKey {
11954 provider: "openai".to_string(),
11955 };
11956 let added = SecureInputOrigin::NewlyAdded {
11957 provider: "minimax".to_string(),
11958 };
11959 let provider_of = |o: &SecureInputOrigin| match o {
11964 SecureInputOrigin::SetKey { provider } | SecureInputOrigin::NewlyAdded { provider } => {
11965 provider.clone()
11966 }
11967 SecureInputOrigin::ModelRoleKey | SecureInputOrigin::ModelRoleValue { .. } => {
11968 unreachable!("model-role origins have no provider")
11969 }
11970 SecureInputOrigin::TextEdit(_) => {
11971 unreachable!("text-edit origin has no provider")
11972 }
11973 };
11974 assert_eq!(provider_of(&set), "openai");
11975 assert_eq!(provider_of(&added), "minimax");
11976 assert_ne!(set, added);
11978 }
11979
11980 #[test]
11989 fn session_picker_resume_refused_while_streaming() {
11990 let session = make_session();
11991 session
11995 .streaming_flag()
11996 .store(true, std::sync::atomic::Ordering::SeqCst);
11997
11998 let mut state = RenderState::default();
11999 assert!(
12001 state.pending_resume.is_none(),
12002 "precondition: pending_resume must start None"
12003 );
12004
12005 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
12006 let handle = InlineHandle::new_for_tests(cmd_tx);
12007 let prompt_queue = Arc::new(PromptQueue::default());
12008
12009 let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
12010 InlineListSelection::Session("some-id".to_string()),
12011 )));
12012 let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
12013
12014 assert!(
12016 state.pending_resume.is_none(),
12017 "streaming session must not enqueue pending_resume (got {:?})",
12018 state.pending_resume
12019 );
12020
12021 let mut cmds: Vec<InlineCommand> = Vec::new();
12023 while let Ok(cmd) = cmd_rx.try_recv() {
12024 cmds.push(cmd);
12025 }
12026 let mut found_error = false;
12027 let mut error_text = String::new();
12028 for cmd in &cmds {
12029 if let InlineCommand::AppendLine { kind, segments } = cmd
12030 && matches!(kind, InlineMessageKind::Error)
12031 {
12032 error_text = segments
12033 .iter()
12034 .map(|s| s.text.as_str())
12035 .collect::<Vec<_>>()
12036 .join("");
12037 found_error = true;
12038 }
12039 }
12040 assert!(
12041 found_error,
12042 "expected an error AppendLine (commands: {})",
12043 summarise(&cmds)
12044 );
12045 assert!(
12046 error_text.contains("Cannot resume while agent is running"),
12047 "error text must match the direct-path wording (got {error_text:?})"
12048 );
12049
12050 session
12053 .streaming_flag()
12054 .store(false, std::sync::atomic::Ordering::SeqCst);
12055 }
12056
12057 #[test]
12061 fn settings_tab_selection_rebuilds_item_list() {
12062 let session = make_session();
12063 let mut state = RenderState::default();
12064 state.overlay = None;
12066
12067 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
12068 let handle = InlineHandle::new_for_tests(cmd_tx);
12069 let prompt_queue = Arc::new(PromptQueue::default());
12070
12071 let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
12072 InlineListSelection::SettingsTab(1),
12073 )));
12074 let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
12075
12076 let overlay = state.overlay.as_ref().expect("panel reopened on tab 1");
12077 assert_eq!(overlay.active_tab, 1);
12078 assert_eq!(overlay.tabs.get(1).map(String::as_str), Some("Model"));
12079 assert_eq!(
12080 overlay.sections,
12081 vec!["Defaults".to_string(), "Pointers".to_string()]
12082 );
12083 let settings = Settings::load().unwrap_or_default();
12085 let expected = settings_overlay_items(SettingsTab::Model, &settings).0;
12086 assert_eq!(overlay.items.len(), expected.len());
12087 assert_eq!(
12088 overlay
12089 .items
12090 .iter()
12091 .map(|i| i.title.clone())
12092 .collect::<Vec<_>>(),
12093 expected.iter().map(|i| i.title.clone()).collect::<Vec<_>>(),
12094 );
12095 assert_eq!(state.settings_active_tab, SettingsTab::Model);
12096 while let Ok(cmd) = cmd_rx.try_recv() {
12099 assert!(
12100 !matches!(cmd, InlineCommand::CloseOverlay),
12101 "tab switch must not close the reopened panel"
12102 );
12103 }
12104 }
12105}
12106#[cfg(test)]
12107mod thinking_stream_tests {
12108 use super::*;
12115 use oxicode_ai::{Api, AssistantMessage, ContentBlock, Message, TextContent};
12116 use oxicode_vtui::tui::core::InlineHandle;
12117 use tokio::sync::mpsc;
12118
12119 fn fresh_handle() -> (InlineHandle, mpsc::UnboundedReceiver<InlineCommand>) {
12120 let (tx, rx) = mpsc::unbounded_channel();
12121 (InlineHandle::new_for_tests(tx), rx)
12122 }
12123
12124 fn assistant() -> Message {
12125 Message::Assistant(AssistantMessage::new(
12126 Api::OpenAiCompletions,
12127 "test",
12128 "test",
12129 ))
12130 }
12131
12132 fn assistant_with_text(text: &str) -> Message {
12133 let mut a = AssistantMessage::new(Api::OpenAiCompletions, "test", "test");
12134 a.content.push(ContentBlock::Text(TextContent::new(text)));
12135 Message::Assistant(a)
12136 }
12137 #[test]
12138 fn thinking_delta_sets_fixed_stage_label_not_raw_text() {
12139 let mut state = RenderState::default();
12140 let (handle, mut cmd_rx) = fresh_handle();
12141
12142 let event = AgentEvent::MessageUpdate {
12146 message: assistant(),
12147 delta: oxicode_sdk::StreamDelta::Thinking("considering options".into()),
12148 };
12149 map_agent_event(&handle, event, &mut state);
12150
12151 assert_eq!(
12152 state.reasoning_stage.as_deref(),
12153 Some("thinking\u{2026}"),
12154 "reasoning stage must show a fixed label, never the streamed fragment"
12155 );
12156 while let Ok(cmd) = cmd_rx.try_recv() {
12157 assert!(
12158 !matches!(cmd, InlineCommand::Inline { .. }),
12159 "thinking must not emit a transcript Inline command"
12160 );
12161 }
12162 }
12163
12164 #[test]
12165 fn thinking_streams_as_dimmed_block_above_the_answer() {
12166 let mut state = RenderState::default();
12167 let (handle, mut rx) = fresh_handle();
12168
12169 map_agent_event(
12170 &handle,
12171 AgentEvent::MessageStart {
12172 message: assistant(),
12173 },
12174 &mut state,
12175 );
12176 map_agent_event(
12177 &handle,
12178 AgentEvent::MessageUpdate {
12179 message: assistant(),
12180 delta: oxicode_sdk::StreamDelta::Thinking("weighing alternatives".into()),
12181 },
12182 &mut state,
12183 );
12184 apply_all(&mut state, &mut rx);
12185 let text: String = state
12186 .transcript
12187 .iter()
12188 .flat_map(|l| l.segments.iter().map(|s| s.text.as_str()))
12189 .collect::<Vec<_>>()
12190 .join(" ");
12191 assert!(
12192 text.contains("weighing alternatives"),
12193 "thinking must render in the transcript: {text}"
12194 );
12195 let dim_italic = state.transcript.iter().any(|l| {
12196 l.segments.iter().any(|s| {
12197 let st = s.style.as_ref();
12198 st.effects.contains(anstyle::Effects::DIMMED)
12199 && st.effects.contains(anstyle::Effects::ITALIC)
12200 })
12201 });
12202 assert!(
12203 dim_italic,
12204 "thinking lines render in the dimmed italic reasoning style"
12205 );
12206
12207 map_agent_event(
12209 &handle,
12210 AgentEvent::MessageUpdate {
12211 message: assistant(),
12212 delta: oxicode_sdk::StreamDelta::Text("the answer".into()),
12213 },
12214 &mut state,
12215 );
12216 apply_all(&mut state, &mut rx);
12217 type_out_stream(&mut state);
12218
12219 let texts: Vec<String> = state
12221 .transcript
12222 .iter()
12223 .map(|l| {
12224 l.segments
12225 .iter()
12226 .map(|s| s.text.as_str())
12227 .collect::<String>()
12228 })
12229 .collect();
12230 let think_idx = texts
12231 .iter()
12232 .position(|t| t.contains("weighing alternatives"))
12233 .expect("thinking line");
12234 let answer_idx = texts
12235 .iter()
12236 .position(|t| t.contains("the answer"))
12237 .expect("answer line");
12238 assert!(
12239 answer_idx > think_idx,
12240 "answer renders below thinking: {texts:?}"
12241 );
12242 assert_eq!(
12243 state.reasoning_stage.as_deref(),
12244 Some("generating response"),
12245 "the stage label moves on once the answer streams"
12246 );
12247 }
12248
12249 #[test]
12250 fn tool_lines_survive_the_full_turn_event_sequence() {
12251 let mut state = RenderState::default();
12252 let (handle, mut rx) = fresh_handle();
12253
12254 map_agent_event(
12257 &handle,
12258 AgentEvent::MessageStart {
12259 message: assistant(),
12260 },
12261 &mut state,
12262 );
12263 map_agent_event(
12264 &handle,
12265 AgentEvent::MessageUpdate {
12266 message: assistant(),
12267 delta: oxicode_sdk::StreamDelta::Text("I will check.".into()),
12268 },
12269 &mut state,
12270 );
12271 map_agent_event(
12272 &handle,
12273 AgentEvent::MessageEnd {
12274 message: assistant(),
12275 },
12276 &mut state,
12277 );
12278 map_agent_event(
12279 &handle,
12280 AgentEvent::ToolExecutionStart {
12281 tool_call_id: "tc1".into(),
12282 tool_name: "bash".into(),
12283 args: serde_json::json!({"command": "echo hi"}),
12284 intent: None,
12285 context: None,
12286 },
12287 &mut state,
12288 );
12289 map_agent_event(
12290 &handle,
12291 AgentEvent::ToolExecutionEnd {
12292 tool_call_id: "tc1".into(),
12293 tool_name: "bash".into(),
12294 intent: None,
12295 result: oxicode_ai::ToolResult {
12296 tool_call_id: "tc1".into(),
12297 content: "ls output".into(),
12298 status: "success".into(),
12299 },
12300 is_error: false,
12301 },
12302 &mut state,
12303 );
12304 map_agent_event(
12305 &handle,
12306 AgentEvent::MessageStart {
12307 message: assistant(),
12308 },
12309 &mut state,
12310 );
12311 map_agent_event(
12312 &handle,
12313 AgentEvent::MessageUpdate {
12314 message: assistant(),
12315 delta: oxicode_sdk::StreamDelta::Text("All done.".into()),
12316 },
12317 &mut state,
12318 );
12319 map_agent_event(
12320 &handle,
12321 AgentEvent::MessageEnd {
12322 message: assistant(),
12323 },
12324 &mut state,
12325 );
12326 apply_all(&mut state, &mut rx);
12327
12328 let text: String = state
12329 .transcript
12330 .iter()
12331 .flat_map(|l| l.segments.iter().map(|s| s.text.as_str()))
12332 .collect::<Vec<_>>()
12333 .join("|");
12334 assert!(
12335 text.contains("$ echo hi"),
12336 "box header shows the shell command: {text}"
12337 );
12338 assert!(
12339 text.contains("Output") && text.contains("ls output"),
12340 "labeled divider separates the call from its output: {text}"
12341 );
12342 assert!(
12343 text.contains("\u{256D}") && text.contains("\u{2570}"),
12344 "rounded top and bottom borders close the box: {text}"
12345 );
12346 let block_ids: std::collections::HashSet<usize> = state
12349 .transcript
12350 .iter()
12351 .filter(|l| l.kind == InlineMessageKind::Tool)
12352 .map(|l| l.block_id)
12353 .collect();
12354 assert_eq!(block_ids.len(), 1, "one tool call = one block");
12355 }
12356 #[test]
12357 fn first_text_delta_overrides_thinking_stage_with_generating_response() {
12358 let mut state = RenderState::default();
12359 let (handle, _cmd_rx) = fresh_handle();
12360
12361 map_agent_event(
12367 &handle,
12368 AgentEvent::MessageUpdate {
12369 message: assistant(),
12370 delta: oxicode_sdk::StreamDelta::Thinking("considering".into()),
12371 },
12372 &mut state,
12373 );
12374 map_agent_event(
12375 &handle,
12376 AgentEvent::MessageUpdate {
12377 message: assistant(),
12378 delta: oxicode_sdk::StreamDelta::Text("hi".into()),
12379 },
12380 &mut state,
12381 );
12382
12383 assert_eq!(
12384 state.reasoning_stage.as_deref(),
12385 Some("generating response"),
12386 "first Text delta must move the stage off `thinking\u{2026}`"
12387 );
12388 }
12389
12390 fn apply_all(state: &mut RenderState, rx: &mut mpsc::UnboundedReceiver<InlineCommand>) {
12391 while let Ok(cmd) = rx.try_recv() {
12392 apply_command(state, cmd);
12393 }
12394 }
12395
12396 #[test]
12397 fn message_end_replaces_the_streamed_block_without_duplicates() {
12398 let mut state = RenderState::default();
12399 let (handle, mut rx) = fresh_handle();
12400
12401 map_agent_event(
12402 &handle,
12403 AgentEvent::MessageStart {
12404 message: assistant(),
12405 },
12406 &mut state,
12407 );
12408 map_agent_event(
12409 &handle,
12410 AgentEvent::MessageUpdate {
12411 message: assistant(),
12412 delta: oxicode_sdk::StreamDelta::Text("para one".into()),
12413 },
12414 &mut state,
12415 );
12416 map_agent_event(
12417 &handle,
12418 AgentEvent::MessageUpdate {
12419 message: assistant(),
12420 delta: oxicode_sdk::StreamDelta::Text("\n\npara two".into()),
12421 },
12422 &mut state,
12423 );
12424 map_agent_event(
12425 &handle,
12426 AgentEvent::MessageEnd {
12427 message: assistant_with_text("para one\n\npara two"),
12428 },
12429 &mut state,
12430 );
12431 apply_all(&mut state, &mut rx);
12432
12433 let text: String = state
12434 .transcript
12435 .iter()
12436 .flat_map(|l| l.segments.iter().map(|s| s.text.as_str()))
12437 .collect::<Vec<_>>()
12438 .join(" ");
12439 assert_eq!(
12440 text.matches("para one").count(),
12441 1,
12442 "the markdown re-render must fully replace the streamed raw lines: {text}"
12443 );
12444 }
12445
12446 #[test]
12447 fn consecutive_messages_stream_into_separate_blocks() {
12448 let mut state = RenderState::default();
12449 let (handle, mut rx) = fresh_handle();
12450
12451 for body in ["first answer", "second answer"] {
12452 map_agent_event(
12453 &handle,
12454 AgentEvent::MessageStart {
12455 message: assistant(),
12456 },
12457 &mut state,
12458 );
12459 map_agent_event(
12460 &handle,
12461 AgentEvent::MessageUpdate {
12462 message: assistant(),
12463 delta: oxicode_sdk::StreamDelta::Text(body.into()),
12464 },
12465 &mut state,
12466 );
12467 map_agent_event(
12468 &handle,
12469 AgentEvent::MessageEnd {
12470 message: assistant_with_text(body),
12471 },
12472 &mut state,
12473 );
12474 }
12475 apply_all(&mut state, &mut rx);
12476
12477 let joined = state
12478 .transcript
12479 .iter()
12480 .map(|l| {
12481 l.segments
12482 .iter()
12483 .map(|s| s.text.as_str())
12484 .collect::<String>()
12485 })
12486 .collect::<Vec<_>>()
12487 .join("|");
12488 assert!(
12489 joined.contains("first answer") && joined.contains("second answer"),
12490 "both messages survive: {joined}"
12491 );
12492 assert!(
12493 !joined.contains("first answersecond answer"),
12494 "a new message must not append into the previous message's line: {joined}"
12495 );
12496 }
12497
12498 #[test]
12499 fn text_deltas_render_markdown_live_not_raw() {
12500 let mut state = RenderState::default();
12501 let (handle, mut rx) = fresh_handle();
12502 map_agent_event(
12503 &handle,
12504 AgentEvent::MessageStart {
12505 message: assistant(),
12506 },
12507 &mut state,
12508 );
12509 map_agent_event(
12510 &handle,
12511 AgentEvent::MessageUpdate {
12512 message: assistant(),
12513 delta: oxicode_sdk::StreamDelta::Text("a **bold** claim".into()),
12514 },
12515 &mut state,
12516 );
12517 apply_all(&mut state, &mut rx);
12518 type_out_stream(&mut state);
12519
12520 let text = state
12521 .transcript
12522 .iter()
12523 .flat_map(|l| l.segments.iter().map(|s| s.text.as_str()))
12524 .collect::<Vec<_>>()
12525 .join(" ");
12526 assert!(
12527 !text.contains("**"),
12528 "the live stream must render markdown, not raw syntax: {text}"
12529 );
12530 assert!(text.contains("bold"), "content survives: {text}");
12531 }
12532
12533 #[test]
12534 fn message_end_does_not_reflow_the_streamed_block() {
12535 let mut state = RenderState::default();
12536 let (handle, mut rx) = fresh_handle();
12537 map_agent_event(
12538 &handle,
12539 AgentEvent::MessageStart {
12540 message: assistant(),
12541 },
12542 &mut state,
12543 );
12544 map_agent_event(
12545 &handle,
12546 AgentEvent::MessageUpdate {
12547 message: assistant(),
12548 delta: oxicode_sdk::StreamDelta::Text("hello **world**".into()),
12549 },
12550 &mut state,
12551 );
12552 apply_all(&mut state, &mut rx);
12553 type_out_stream(&mut state);
12554 let streamed: Vec<String> = state
12555 .transcript
12556 .iter()
12557 .map(|l| {
12558 l.segments
12559 .iter()
12560 .map(|s| s.text.as_str())
12561 .collect::<String>()
12562 })
12563 .collect();
12564
12565 map_agent_event(
12566 &handle,
12567 AgentEvent::MessageEnd {
12568 message: assistant_with_text("hello **world**"),
12569 },
12570 &mut state,
12571 );
12572 apply_all(&mut state, &mut rx);
12573 let final_: Vec<String> = state
12574 .transcript
12575 .iter()
12576 .map(|l| {
12577 l.segments
12578 .iter()
12579 .map(|s| s.text.as_str())
12580 .collect::<String>()
12581 })
12582 .collect();
12583 assert_eq!(
12584 streamed, final_,
12585 "MessageEnd must not re-render what the live stream already shows"
12586 );
12587 }
12588
12589 #[test]
12590 fn final_message_renders_the_authoritative_tail() {
12591 let mut state = RenderState::default();
12597 let (handle, mut rx) = fresh_handle();
12598 map_agent_event(
12599 &handle,
12600 AgentEvent::MessageStart {
12601 message: assistant(),
12602 },
12603 &mut state,
12604 );
12605 map_agent_event(
12606 &handle,
12607 AgentEvent::MessageUpdate {
12608 message: assistant(),
12609 delta: oxicode_sdk::StreamDelta::Text("visible prefix ".into()),
12610 },
12611 &mut state,
12612 );
12613 apply_all(&mut state, &mut rx);
12614 type_out_stream(&mut state);
12615
12616 map_agent_event(
12617 &handle,
12618 AgentEvent::MessageEnd {
12619 message: assistant_with_text("visible prefix HIDDEN-TAIL"),
12620 },
12621 &mut state,
12622 );
12623 apply_all(&mut state, &mut rx);
12624 let text: String = state
12625 .transcript
12626 .iter()
12627 .map(|l| {
12628 l.segments
12629 .iter()
12630 .map(|s| s.text.as_str())
12631 .collect::<String>()
12632 })
12633 .collect();
12634 assert!(
12635 text.contains("HIDDEN-TAIL"),
12636 "the final message is authoritative — its tail must render: {text}"
12637 );
12638 assert_eq!(
12639 text.matches("visible prefix").count(),
12640 1,
12641 "no duplicated block: {text}"
12642 );
12643 }
12644
12645 #[test]
12646 fn streamed_body_renders_only_the_revealed_prefix() {
12647 let mut state = RenderState::default();
12648 state.message_buffer = "hello world".to_string();
12649 state.stream_reveal = 5; let lines = render_streamed_message(&mut state);
12651 let text: String = lines
12652 .iter()
12653 .map(|l| l.iter().map(|s| s.text.as_str()).collect::<String>())
12654 .collect();
12655 assert!(text.contains("hello"), "revealed prefix renders: {text}");
12656 assert!(!text.contains("world"), "unrevealed text waits: {text}");
12657 }
12658
12659 #[test]
12660 fn advance_stream_reveal_types_out_in_bounded_steps() {
12661 let mut state = RenderState::default();
12662 state.stream_anchor = Some(0);
12663 state.message_buffer = "x".repeat(6000);
12664 state.stream_reveal = 0;
12665
12666 assert!(advance_stream_reveal(&mut state), "first tick paints");
12667 assert!(
12668 state.stream_reveal > 0 && state.stream_reveal < 6000,
12669 "bounded step, not a lump: {}",
12670 state.stream_reveal
12671 );
12672 let transcript_after_step: usize = state.transcript.len();
12673 assert!(
12674 transcript_after_step > 0,
12675 "the revealed prefix lands in the transcript"
12676 );
12677
12678 while advance_stream_reveal(&mut state) {}
12679 assert_eq!(
12680 state.stream_reveal, 6000,
12681 "repeated ticks drain the backlog completely"
12682 );
12683 }
12684
12685 fn type_out_stream(state: &mut RenderState) {
12688 while advance_stream_reveal(state) {}
12689 }
12690
12691 #[test]
12692 fn message_end_clears_reasoning_stage() {
12693 let mut state = RenderState::default();
12694 let (handle, _cmd_rx) = fresh_handle();
12695 state.reasoning_stage = Some("thinking\u{2026}".into());
12696
12697 map_agent_event(
12698 &handle,
12699 AgentEvent::MessageEnd {
12700 message: assistant(),
12701 },
12702 &mut state,
12703 );
12704
12705 assert!(
12706 state.reasoning_stage.is_none(),
12707 "MessageEnd must clear the reasoning stage so follow-ups / tips can render"
12708 );
12709 }
12710
12711 #[test]
12712 fn run_tracker_spans_the_whole_tool_loop() {
12713 let mut state = RenderState::default();
12714 let (handle, _cmd_rx) = fresh_handle();
12715
12716 map_agent_event(
12717 &handle,
12718 AgentEvent::AgentStart {
12719 prompts: vec![],
12720 session_id: None,
12721 },
12722 &mut state,
12723 );
12724 assert!(
12725 state.active_run.is_some(),
12726 "AgentStart opens the run tracker"
12727 );
12728
12729 map_agent_event(
12730 &handle,
12731 AgentEvent::MessageStart {
12732 message: assistant(),
12733 },
12734 &mut state,
12735 );
12736 map_agent_event(
12737 &handle,
12738 AgentEvent::ToolExecutionStart {
12739 tool_call_id: "tc-1".into(),
12740 tool_name: "read".into(),
12741 args: serde_json::json!({}),
12742 intent: None,
12743 context: None,
12744 },
12745 &mut state,
12746 );
12747 map_agent_event(
12748 &handle,
12749 AgentEvent::MessageEnd {
12750 message: assistant(),
12751 },
12752 &mut state,
12753 );
12754
12755 let run = state.active_run.as_ref().expect("run stays live");
12758 assert_eq!(run.turn, 1, "MessageStart counts a turn");
12759 assert_eq!(run.tool_calls, 1, "ToolExecutionStart counts a call");
12760
12761 map_agent_event(
12762 &handle,
12763 AgentEvent::AgentEnd {
12764 messages: vec![],
12765 stop_reason: None,
12766 session_id: None,
12767 },
12768 &mut state,
12769 );
12770 assert!(state.active_run.is_none(), "AgentEnd closes the tracker");
12771 assert!(
12772 state.reasoning_stage.is_none(),
12773 "AgentEnd releases the indicator row"
12774 );
12775 }
12776
12777 #[test]
12778 fn message_end_releases_the_stream_anchor() {
12779 let mut state = RenderState::default();
12780 let (handle, mut rx) = fresh_handle();
12781 map_agent_event(
12782 &handle,
12783 AgentEvent::MessageStart {
12784 message: assistant(),
12785 },
12786 &mut state,
12787 );
12788 map_agent_event(
12789 &handle,
12790 AgentEvent::MessageUpdate {
12791 message: assistant(),
12792 delta: oxicode_sdk::StreamDelta::Text("done".into()),
12793 },
12794 &mut state,
12795 );
12796 map_agent_event(
12797 &handle,
12798 AgentEvent::MessageEnd {
12799 message: assistant(),
12800 },
12801 &mut state,
12802 );
12803 while let Ok(cmd) = rx.try_recv() {
12804 apply_command(&mut state, cmd);
12805 }
12806 assert!(
12807 state.stream_anchor.is_none(),
12808 "MessageEnd finalizes the message — the anchor must release so the finished block can commit to scrollback"
12809 );
12810 }
12811}
12812
12813#[cfg(test)]
12814mod composer_border_tests {
12815 use super::*;
12818
12819 fn spans_to_string(line: &Line<'_>) -> String {
12820 line.spans.iter().map(|s| s.content.as_ref()).collect()
12821 }
12822
12823 #[test]
12824 fn composer_border_has_no_app_badge() {
12825 let mut state = RenderState::default();
12826 state.header_context.provider = "prov".to_string();
12827 state.header_context.model = "prov/m-1".to_string();
12828
12829 let text = spans_to_string(&composer_context_line(&state, 200));
12830
12831 assert!(
12832 text.starts_with("MODEL "),
12833 "model leads with no leading separator: {text}"
12834 );
12835 assert!(
12836 text.contains("MODEL m-1"),
12837 "provider prefix is stripped from the model: {text}"
12838 );
12839 }
12840
12841 #[test]
12842 fn composer_border_fields_drop_by_width() {
12843 let mut state = RenderState::default();
12844 state.header_context.provider = "prov".to_string();
12845 state.header_context.model = "prov/m-1".to_string();
12846
12847 let narrow = spans_to_string(&composer_context_line(&state, 60));
12849 assert!(
12850 narrow.contains("MODEL ") && !narrow.contains("CTX "),
12851 "narrow keeps the model only: {narrow}"
12852 );
12853 let wide = spans_to_string(&composer_context_line(&state, 140));
12854 assert!(wide.contains("CTX "), "wide carries context usage: {wide}");
12855 }
12856
12857 #[test]
12858 fn model_chips_follow_model_switch() {
12859 let mut state = RenderState::default();
12860 assert_eq!(state.context_window, 128_000, "default before sync");
12861
12862 apply_model_to_chips(&mut state, "google/gemini-2.5-pro", 1_048_576);
12866 assert_eq!(state.header_context.provider, "google");
12867 assert_eq!(state.header_context.model, "google/gemini-2.5-pro");
12868 assert_eq!(
12869 state.header_context.editor_context.as_deref(),
12870 Some("google/gemini-2.5-pro")
12871 );
12872 assert_eq!(state.context_window, 1_048_576);
12873
12874 let wide = spans_to_string(&composer_context_line(&state, 140));
12875 assert!(
12876 wide.contains("CTX 0/1048.5K"),
12877 "CTX chip renders the synced denominator: {wide}"
12878 );
12879
12880 apply_model_to_chips(&mut state, "", 999);
12882 assert_eq!(state.header_context.model, "google/gemini-2.5-pro");
12883
12884 apply_model_to_chips(&mut state, "zai/glm-5.1", 0);
12887 assert_eq!(state.header_context.model, "zai/glm-5.1");
12888 assert_eq!(state.context_window, 1_048_576);
12889 }
12890
12891 #[test]
12892 fn plain_segments_render_in_their_kind_color_not_response() {
12893 let styles = active_styles();
12894 let user_color = color_from_anstyle(styles.user.get_fg_color());
12895 let response = color_from_anstyle(styles.response.get_fg_color());
12896 let line = |kind| TranscriptLine {
12897 kind,
12898 segments: vec![plain_segment("body")],
12899 block_id: 0,
12900 };
12901
12902 let user_line = line(InlineMessageKind::User);
12903 let user = transcript_line_marked(&user_line, &styles, false, false, false, true, 80);
12904 assert_eq!(
12905 user.spans[0].style.fg,
12906 Some(user_color),
12907 "user text must read in the user color — response-ink makes turns indistinguishable"
12908 );
12909
12910 let agent_line = line(InlineMessageKind::Agent);
12911 let agent = transcript_line_marked(&agent_line, &styles, false, false, false, true, 80);
12912 assert_eq!(agent.spans[0].style.fg, Some(response));
12913 }
12914}
12915#[cfg(test)]
12916mod transcript_turn_tests {
12917 use super::*;
12920 fn tl(kind: InlineMessageKind, text: &str, block_id: usize) -> TranscriptLine {
12921 TranscriptLine {
12922 kind,
12923 segments: vec![plain_segment(text)],
12924 block_id,
12925 }
12926 }
12927
12928 fn spans_to_string(line: &Line<'_>) -> String {
12929 line.spans.iter().map(|s| s.content.as_ref()).collect()
12930 }
12931
12932 #[test]
12933 fn user_lines_are_bold_primary_without_prefix() {
12934 let styles = active_styles();
12935 let line = tl(InlineMessageKind::User, "refactor the parser", 0);
12936 let rendered = transcript_line_marked(&line, &styles, false, false, false, true, 80);
12937 assert_eq!(
12938 rendered.spans.len(),
12939 1,
12940 "plain style renders no prefix span"
12941 );
12942 assert_eq!(
12943 spans_to_string(&rendered),
12944 "refactor the parser",
12945 "user text renders as typed, no glyph"
12946 );
12947 assert!(
12948 rendered.spans[0]
12949 .style
12950 .add_modifier
12951 .contains(Modifier::BOLD),
12952 "user body is the only bold transcript text"
12953 );
12954 }
12955
12956 #[test]
12957 fn agent_tool_and_shell_lines_have_no_prefix() {
12958 let styles = active_styles();
12959 for (kind, label) in [
12960 (InlineMessageKind::Agent, "agent"),
12961 (InlineMessageKind::Tool, "tool"),
12962 (InlineMessageKind::Pty, "shell"),
12963 ] {
12964 let line = tl(kind, &format!("{label}-content"), 0);
12965 let rendered = transcript_line_marked(&line, &styles, false, false, false, true, 80);
12966 assert_eq!(
12967 rendered.spans.len(),
12968 1,
12969 "{label} lines carry no marker spans"
12970 );
12971 assert_eq!(spans_to_string(&rendered), format!("{label}-content"));
12972 }
12973 }
12974
12975 #[test]
12976 fn system_labels_render_on_block_start_only() {
12977 let styles = active_styles();
12978 let line = tl(InlineMessageKind::Error, "boom", 0);
12979
12980 let head = transcript_line_marked(&line, &styles, false, false, false, true, 80);
12981 assert_eq!(spans_to_string(&head), "error: boom");
12982
12983 let body = transcript_line_marked(&line, &styles, false, false, false, false, 80);
12984 assert_eq!(
12985 spans_to_string(&body),
12986 "boom",
12987 "continuation lines drop the label"
12988 );
12989 }
12990
12991 #[test]
12992 fn folded_head_keeps_the_block_label() {
12993 let styles = active_styles();
12994 let line = tl(InlineMessageKind::Error, "boom", 0);
12995 let rendered = transcript_line_marked(&line, &styles, true, false, false, false, 80);
12996 assert_eq!(
12997 spans_to_string(&rendered),
12998 "[+] error: boom",
12999 "a collapsed block stays identifiable"
13000 );
13001 }
13002
13003 #[test]
13004 fn transcript_line_marked_clamps_to_width() {
13005 let styles = active_styles();
13008 let big: String = "x".repeat(300);
13009 let line = tl(InlineMessageKind::Agent, &big, 0);
13010 let rendered = transcript_line_marked(&line, &styles, false, false, false, true, 40);
13011 assert!(
13012 rendered.width() <= 40,
13013 "transcript row overflowed the terminal width: rendered.width()={}",
13014 rendered.width()
13015 );
13016 }
13017}
13018
13019#[cfg(test)]
13020mod scrollback_commit_tests {
13021 use super::*;
13027
13028 fn tl(kind: InlineMessageKind, text: &str, block_id: usize) -> TranscriptLine {
13029 TranscriptLine {
13030 kind,
13031 segments: vec![plain_segment(text)],
13032 block_id,
13033 }
13034 }
13035
13036 fn long_transcript() -> Vec<TranscriptLine> {
13039 (0..6)
13040 .flat_map(|b| {
13041 [
13042 tl(InlineMessageKind::Agent, &format!("b{b}-line-one"), b),
13043 tl(InlineMessageKind::Agent, &format!("b{b}-line-two"), b),
13044 ]
13045 })
13046 .collect()
13047 }
13048
13049 #[test]
13050 fn commit_plan_sheds_oldest_blocks_and_keeps_the_tail() {
13051 let state = RenderState {
13052 transcript: long_transcript(),
13053 ..Default::default()
13054 };
13055 let styles = active_styles();
13056 let display = build_transcript_display(&state, &styles, 0, 80);
13057 let plan = scrollback_commit_plan(&display, &state.transcript, 80, 4, None).expect("plan");
13060 assert_eq!(plan.rows, 8, "12 rows total, keep 4 → commit 8");
13061 assert_eq!(plan.new_committed_entries, 8);
13062 }
13063
13064 #[test]
13065 fn commit_plan_never_splits_a_block() {
13066 let transcript: Vec<TranscriptLine> = (0..3)
13069 .flat_map(|b| {
13070 (0..3).map(move |i| tl(InlineMessageKind::Agent, &format!("b{b}-{i}"), b))
13071 })
13072 .collect();
13073 let state = RenderState {
13074 transcript,
13075 ..Default::default()
13076 };
13077 let styles = active_styles();
13078 let display = build_transcript_display(&state, &styles, 0, 80);
13079 let plan = scrollback_commit_plan(&display, &state.transcript, 80, 5, None).expect("plan");
13082 assert_eq!(
13083 plan.new_committed_entries, 3,
13084 "boundary snaps to block start"
13085 );
13086 assert_eq!(plan.rows, 3);
13087 }
13088
13089 #[test]
13090 fn commit_plan_excludes_the_streaming_anchor() {
13091 let state = RenderState {
13092 transcript: long_transcript(),
13093 stream_anchor: Some(4),
13094 ..Default::default()
13095 };
13096 let styles = active_styles();
13097 let display = build_transcript_display(&state, &styles, 0, 80);
13098 let plan = scrollback_commit_plan(&display, &state.transcript, 80, 4, state.stream_anchor)
13099 .expect("plan");
13100 assert!(
13101 plan.new_committed_entries <= 4,
13102 "nothing at/after the anchored (streaming) block commits"
13103 );
13104 }
13105 #[test]
13106 fn committed_entries_floor_the_live_render() {
13107 let mut state = RenderState::default();
13108 state.transcript = long_transcript();
13109 state.committed_entries = 8;
13110 let backend = ratatui::backend::TestBackend::new(80, 24);
13111 let mut terminal = Terminal::new(backend).expect("backend");
13112 terminal
13113 .draw(|f| render_frame(f, &state, &unused_handle()))
13114 .expect("draw");
13115 let buf = terminal.backend().buffer();
13116 let area = buf.area();
13117 let mut rendered = String::new();
13118 for y in 0..area.height {
13119 for x in 0..area.width {
13120 if let Some(cell) = buf.cell((x, y)) {
13121 rendered.push_str(cell.symbol());
13122 }
13123 }
13124 rendered.push('\n');
13125 }
13126 assert!(
13127 !rendered.contains("b0-line-one"),
13128 "committed blocks leave the viewport"
13129 );
13130 assert!(rendered.contains("b5-line"), "the live tail stays");
13131 }
13132
13133 #[test]
13134 fn search_skips_committed_entries() {
13135 let mut state = RenderState::default();
13136 state.transcript = long_transcript();
13137 state.committed_entries = 8;
13138 state.start_search("line-one");
13139 let s = state.search.as_ref().expect("search open");
13140 assert!(
13141 s.matches.iter().all(|&i| i >= 8),
13142 "matches confined to the live region: {:?}",
13143 s.matches
13144 );
13145 assert!(!s.matches.is_empty());
13146 }
13147
13148 fn unused_handle() -> InlineHandle {
13149 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
13150 InlineHandle::new_for_tests(tx)
13151 }
13152
13153 #[test]
13154 fn commit_plan_noop_when_tail_fits() {
13155 let state = RenderState {
13156 transcript: long_transcript(),
13157 ..Default::default()
13158 };
13159 let styles = active_styles();
13160 let display = build_transcript_display(&state, &styles, 0, 80);
13161 assert!(scrollback_commit_plan(&display, &state.transcript, 80, 12, None).is_none());
13162 }
13163
13164 #[test]
13165 fn oversized_block_commits_its_head_at_line_granularity() {
13166 let mut transcript: Vec<TranscriptLine> = (0..30)
13169 .map(|i| tl(InlineMessageKind::Agent, &format!("big-{i:02}"), 0))
13170 .collect();
13171 transcript.push(tl(InlineMessageKind::Agent, "tail-a", 1));
13172 transcript.push(tl(InlineMessageKind::Agent, "tail-b", 1));
13173 let state = RenderState {
13174 transcript,
13175 ..Default::default()
13176 };
13177 let styles = active_styles();
13178 let display = build_transcript_display(&state, &styles, 0, 80);
13179 let plan = scrollback_commit_plan(&display, &state.transcript, 80, 10, None).expect("plan");
13180 assert_eq!(
13181 plan.new_committed_entries, 22,
13182 "32 rows, keep 10 → head 22 rows commit"
13183 );
13184 assert_eq!(plan.rows, 22);
13185 }
13186
13187 #[test]
13188 fn rebuild_only_on_width_change() {
13189 assert!(!should_rebuild_scrollback(80, 80, 24, 30));
13193 assert!(should_rebuild_scrollback(80, 100, 24, 24));
13196 assert!(should_rebuild_scrollback(100, 80, 30, 24));
13198 assert!(!should_rebuild_scrollback(0, 100, 24, 24));
13203 assert!(!should_rebuild_scrollback(0, 80, 24, 24));
13204 }
13205
13206 #[test]
13207 fn force_flush_boundary_is_everything() {
13208 assert_eq!(plan_full_flush(0), 0);
13212 assert_eq!(plan_full_flush(8), 8);
13213 assert_eq!(plan_full_flush(40), 40);
13214 }
13215}
13216
13217#[cfg(test)]
13218mod tool_box_tests {
13219 use super::*;
13223
13224 fn row_text(row: &[InlineSegment]) -> String {
13225 row.iter().map(|s| s.text.as_str()).collect()
13226 }
13227
13228 #[test]
13229 fn korean_rows_keep_the_right_border_aligned() {
13230 let rows = tool_box_rows(
13232 "한글테스트 명령어",
13233 20,
13234 InlineTextStyle::default(),
13235 anstyle::Color::Ansi(anstyle::AnsiColor::White),
13236 );
13237 for row in &rows {
13238 let text = row_text(row);
13239 assert_eq!(text.width(), 20, "row must fill exactly 20 cells: {text:?}");
13240 assert!(text.starts_with('\u{2502}'), "left border: {text:?}");
13241 assert!(text.ends_with('\u{2502}'), "right border: {text:?}");
13242 }
13243 assert!(!rows.is_empty());
13244 assert_eq!(rows.len(), 2, "wraps by display width");
13247 }
13248
13249 #[test]
13250 fn divider_carries_the_label() {
13251 let seg = tool_box_divider(
13252 "Output",
13253 30,
13254 anstyle::Color::Ansi(anstyle::AnsiColor::White),
13255 );
13256 let text = row_text(&seg);
13257 assert!(
13258 text.starts_with("\u{251C}\u{2500} Output"),
13259 "label after ├─: {text:?}"
13260 );
13261
13262 assert!(text.ends_with('\u{2524}'), "closes with ┤: {text:?}");
13263 assert_eq!(text.width(), 30, "divider fills the box width");
13264 }
13265}
13266
13267#[cfg(test)]
13268mod tool_box_width_tests {
13269 use super::*;
13273
13274 #[test]
13275 fn tool_box_width_matches_live_content_width() {
13276 let state = RenderState {
13277 viewport_width: 100,
13278 ..Default::default()
13279 };
13280 assert_eq!(tool_box_width(&state), 98);
13283 }
13284}
13285
13286#[test]
13287fn tool_box_rows_expand_tabs_so_borders_align() {
13288 let chunk = format!("{:>6}\t{}", 1, "[package]");
13293 let rows = tool_box_rows(
13294 &chunk,
13295 176,
13296 InlineTextStyle::default(),
13297 anstyle::Color::Ansi(anstyle::AnsiColor::White),
13298 );
13299 assert_eq!(rows.len(), 1);
13300 for seg in &rows[0] {
13301 assert!(
13302 !seg.text.contains('\t'),
13303 "tabs must be expanded: {:?}",
13304 seg.text
13305 );
13306 }
13307 let built: usize = rows[0]
13308 .iter()
13309 .map(|s| UnicodeWidthStr::width(s.text.as_str()))
13310 .sum();
13311 assert_eq!(built, 176, "built width must equal the box width exactly");
13312}
13313
13314#[cfg(test)]
13315mod contextual_hint_tests {
13316 use super::*;
13320
13321 #[test]
13322 fn brain_chip_lives_on_the_composer_border() {
13323 let mut state = RenderState::default();
13324 state.header_context.provider = "prov".to_string();
13325 state.header_context.model = "prov/m-1".to_string();
13326
13327 let off = spans_to_string_border(&state);
13329 assert!(!off.contains("brain"), "chip hidden when off: {off}");
13330
13331 state.brain = BrainChip::Ok;
13333 let ok = spans_to_string_border(&state);
13334 assert!(ok.contains("brain·ok"), "healthy chip on border: {ok}");
13335 assert!(
13336 ok.trim_end().ends_with("brain·ok"),
13337 "chip is right-aligned: {ok}"
13338 );
13339
13340 state.brain = BrainChip::Down;
13342 let down = spans_to_string_border(&state);
13343 assert!(down.contains("brain·down"), "degraded chip: {down}");
13344 }
13345
13346 #[test]
13347 fn brain_chip_does_not_erase_the_border_rule() {
13348 let backend = ratatui::backend::TestBackend::new(80, 24);
13352 let mut terminal = Terminal::new(backend).expect("backend");
13353 let mut state = RenderState::default();
13354 state.header_context.provider = "prov".to_string();
13355 state.header_context.model = "prov/m-1".to_string();
13356 state.brain = BrainChip::Ok;
13357 terminal
13358 .draw(|f| render_frame(f, &state, &unused_test_handle()))
13359 .expect("draw");
13360 let buf = terminal.backend().buffer();
13361 let border_row = (0..buf.area().height)
13365 .map(|y| {
13366 (0..buf.area().width)
13367 .filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string()))
13368 .collect::<String>()
13369 })
13370 .find(|row| row.contains("MODEL") && row.contains(" | "))
13371 .expect("composer border row rendered");
13372 let rule_count = border_row.chars().filter(|c| *c == '\u{2500}').count();
13373 assert!(
13374 rule_count >= 10,
13375 "the ─ rule must survive right of the facts: {border_row}"
13376 );
13377 assert!(
13378 border_row.contains("brain\u{b7}ok"),
13379 "chip still on the border: {border_row}"
13380 );
13381 }
13382
13383 #[test]
13384 fn run_indicator_stays_up_between_turns() {
13385 let backend = ratatui::backend::TestBackend::new(80, 24);
13390 let mut terminal = Terminal::new(backend).expect("backend");
13391 let state = RenderState {
13392 active_run: Some(RunState {
13393 started_at: std::time::Instant::now(),
13394 turn: 2,
13395 tool_calls: 3,
13396 }),
13397 reasoning_stage: None,
13398 ..Default::default()
13399 };
13400 terminal
13401 .draw(|f| render_frame(f, &state, &unused_test_handle()))
13402 .expect("draw");
13403 let buf = terminal.backend().buffer();
13404 let row: String = (0..buf.area().width)
13405 .filter_map(|x| buf.cell((x, 20)).map(|c| c.symbol().to_string()))
13406 .collect();
13407 assert!(row.contains("RUNNING"), "row stays up between turns: {row}");
13408 assert!(row.contains("working"), "stage fallback label: {row}");
13409 assert!(row.contains("turn 2"), "turn count: {row}");
13410 assert!(row.contains("3 tool calls"), "tool count: {row}");
13411 assert!(row.contains("Esc abort"), "abort hint stays: {row}");
13412 assert!(
13413 RUN_SPINNER.iter().any(|f| row.contains(f)),
13414 "animated spinner frame: {row}"
13415 );
13416 }
13417
13418 #[test]
13419 fn spinner_frame_is_wall_clock_not_draw_count() {
13420 let state = || RenderState {
13425 active_run: Some(RunState::default()),
13426 reasoning_stage: None,
13427 ..Default::default()
13428 };
13429 let spinner_of = |s: &RenderState| -> Option<char> {
13430 let backend = ratatui::backend::TestBackend::new(80, 24);
13431 let mut terminal = Terminal::new(backend).expect("backend");
13432 terminal
13433 .draw(|f| render_frame(f, s, &unused_test_handle()))
13434 .expect("draw");
13435 let buf = terminal.backend().buffer();
13436 let row: String = (0..buf.area().width)
13437 .filter_map(|x| buf.cell((x, 20)).map(|c| c.symbol().to_string()))
13438 .collect();
13439 row.chars()
13440 .find(|c| RUN_SPINNER.iter().any(|f| f.starts_with(*c)))
13441 };
13442 let first = spinner_of(&state());
13445 let second = spinner_of(&state());
13446 assert!(first.is_some(), "spinner renders");
13447 assert_eq!(
13448 first, second,
13449 "back-to-back draws must not advance the spinner"
13450 );
13451 }
13452
13453 #[test]
13454 fn elapsed_formats_minutes_beyond_60s() {
13455 assert_eq!(format_elapsed_secs(59), "59s");
13456 assert_eq!(format_elapsed_secs(60), "1m 00s");
13457 assert_eq!(format_elapsed_secs(125), "2m 05s");
13458 }
13459
13460 #[test]
13461 fn reasoning_row_carries_the_abort_hint() {
13462 let backend = ratatui::backend::TestBackend::new(80, 24);
13463 let mut terminal = Terminal::new(backend).expect("backend");
13464 let state = RenderState {
13465 reasoning_stage: Some("thinking\u{2026}".into()),
13466 ..Default::default()
13467 };
13468 terminal
13469 .draw(|f| render_frame(f, &state, &unused_test_handle()))
13470 .expect("draw");
13471 let buf = terminal.backend().buffer();
13472 let row: String = (0..buf.area().width)
13473 .filter_map(|x| buf.cell((x, 20)).map(|c| c.symbol().to_string()))
13474 .collect();
13475 assert!(
13476 row.contains("Esc abort"),
13477 "streaming shows the contextual abort hint: {row}"
13478 );
13479 }
13480
13481 #[test]
13482 fn pending_quit_owns_the_hint_row() {
13483 let backend = ratatui::backend::TestBackend::new(80, 24);
13484 let mut terminal = Terminal::new(backend).expect("backend");
13485 let state = RenderState {
13486 pending_quit: true,
13487 ..Default::default()
13488 };
13489 terminal
13490 .draw(|f| render_frame(f, &state, &unused_test_handle()))
13491 .expect("draw");
13492 let buf = terminal.backend().buffer();
13493
13494 let row: String = (0..buf.area().width)
13495 .filter_map(|x| buf.cell((x, 20)).map(|c| c.symbol().to_string()))
13496 .collect();
13497 assert!(
13498 row.contains("press Ctrl+C again to quit"),
13499 "armed quit shows its hint: {row}"
13500 );
13501 }
13502
13503 fn spans_to_string_border(state: &RenderState) -> String {
13504 let line = composer_context_line(state, 200);
13505 let mut text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
13506 let used = line.spans.iter().map(|s| s.width()).sum();
13507 if let Some(chip) = composer_brain_chip(state, 200, used) {
13508 assert_eq!(
13509 chip.alignment,
13510 Some(Alignment::Right),
13511 "the chip is its own right-aligned title"
13512 );
13513 text.extend(chip.spans.iter().map(|s| s.content.as_ref()));
13514 }
13515 text
13516 }
13517
13518 fn unused_test_handle() -> InlineHandle {
13519 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
13520 InlineHandle::new_for_tests(tx)
13521 }
13522}
13523
13524#[cfg(test)]
13525mod trailing_breath_tests {
13526 use super::*;
13531
13532 #[test]
13533 fn display_ends_at_the_last_line_no_trailing_blank_item() {
13534 let state = RenderState {
13535 transcript: vec![TranscriptLine {
13536 kind: InlineMessageKind::Agent,
13537 segments: vec![plain_segment("answer")],
13538 block_id: 0,
13539 }],
13540 ..Default::default()
13541 };
13542 let styles = active_styles();
13543 let display = build_transcript_display(&state, &styles, 0, 80);
13544 assert_eq!(display.len(), 1, "the gap is layout, not a display item");
13545 assert!(display[0].line.is_some());
13546 }
13547
13548 #[test]
13549 fn scrollback_area_reserves_one_breath_row_above_the_composer() {
13550 let area = Rect {
13551 x: 0,
13552 y: 0,
13553 width: 100,
13554 height: 30,
13555 };
13556 let layout = super::super::frame_layout::compute_chrome(area);
13557 assert_eq!(
13558 layout.scrollback.bottom() + 1,
13559 layout.prompt.y,
13560 "exactly one row separates the transcript from the composer"
13561 );
13562 assert_eq!(
13563 super::super::frame_layout::scrollback_height(area),
13564 layout.scrollback.height,
13565 "the commit keep-rows must match the rendered area"
13566 );
13567 }
13568}
13569
13570#[cfg(test)]
13571mod nerd_icon_tests {
13572 use super::*;
13576
13577 fn border_text(state: &RenderState) -> String {
13578 let line = composer_context_line(state, 200);
13579 let mut text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
13580 let used = line.spans.iter().map(|s| s.width()).sum();
13581 if let Some(chip) = composer_brain_chip(state, 200, used) {
13582 text.extend(chip.spans.iter().map(|s| s.content.as_ref()));
13583 }
13584 text
13585 }
13586
13587 fn base_state() -> RenderState {
13588 let mut state = RenderState::default();
13589 state.header_context.provider = "prov".to_string();
13590 state.header_context.model = "prov/m-1".to_string();
13591 state.brain = BrainChip::Ok;
13592 state
13593 }
13594
13595 #[test]
13596 fn nerd_mode_replaces_labels_with_private_use_glyphs() {
13597 let mut state = base_state();
13598 state.glyph_set = crate::symbols::GlyphSet::Nerd;
13599 let text = border_text(&state);
13600 assert!(!text.contains("MODEL "), "text label gone: {text}");
13601 assert!(
13602 text.contains(crate::symbols::nerd::MODEL),
13603 "robot glyph for the model: {text}"
13604 );
13605 assert!(
13606 text.contains(crate::symbols::nerd::GIT),
13607 "git glyph present: {text}"
13608 );
13609 assert!(
13610 text.contains(crate::symbols::nerd::BRAIN),
13611 "brain glyph chip: {text}"
13612 );
13613 for ch in text.chars() {
13616 let cp = ch as u32;
13617 let private_use = (0xE000..=0xF8FF).contains(&cp)
13618 || (0xF0000..=0xFFFFD).contains(&cp)
13619 || (0x100000..=0x10FFFD).contains(&cp);
13620 assert!(
13621 !('\u{1F300}'..='\u{1FAFF}').contains(&ch) || !private_use,
13622 "sanity"
13623 );
13624 }
13625 }
13626
13627 #[test]
13628 fn unicode_default_keeps_text_labels() {
13629 let state = base_state();
13630 let text = border_text(&state);
13631 assert!(text.contains("MODEL "), "default keeps text: {text}");
13632 assert!(text.contains("brain\u{b7}ok"), "default chip text: {text}");
13633 }
13634}
13635#[cfg(test)]
13636mod glyph_cycle_tests {
13637 use crate::symbols::GlyphSet;
13638
13639 #[test]
13640 fn glyph_set_cycles_unicode_ascii_nerd() {
13641 assert_eq!(GlyphSet::Unicode.next(), GlyphSet::Ascii);
13642 assert_eq!(GlyphSet::Ascii.next(), GlyphSet::Nerd);
13643 assert_eq!(GlyphSet::Nerd.next(), GlyphSet::Unicode);
13644 }
13645}
13646
13647#[cfg(test)]
13648mod coalesce_draw_tests {
13649 use super::*;
13656 use std::time::{Duration, Instant};
13657
13658 #[test]
13659 fn defer_within_interval() {
13660 let now = Instant::now();
13661 let last = now - Duration::from_millis(10);
13662 assert_eq!(
13663 coalesce_draw(last, false, DRAW_MIN_INTERVAL),
13664 DrawDecision::Defer
13665 );
13666 }
13667
13668 #[test]
13669 fn draw_now_on_priority_even_within_interval() {
13670 let now = Instant::now();
13671 let last = now - Duration::from_millis(10);
13672 assert_eq!(
13673 coalesce_draw(last, true, DRAW_MIN_INTERVAL),
13674 DrawDecision::DrawNow
13675 );
13676 }
13677
13678 #[test]
13679 fn draw_now_when_interval_elapsed() {
13680 let now = Instant::now();
13681 let last = now - Duration::from_millis(60);
13682 assert_eq!(
13683 coalesce_draw(last, false, DRAW_MIN_INTERVAL),
13684 DrawDecision::DrawNow
13685 );
13686 }
13687}
13688#[cfg(test)]
13689mod settings_panel_tests {
13690 use super::*;
13691 use crate::app::agent_session::AgentSessionHandle;
13692 use crate::store::settings::Settings;
13693 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
13694
13695 fn make_session_with_tools() -> AgentSessionHandle {
13696 super::provider_overlay_tests::make_session_with_tools_for_tests()
13697 }
13698 use oxicode_vtui::tui::core::InlineListSelection;
13699 use ratatui::{Terminal, backend::TestBackend};
13700
13701 fn heading(title: &str) -> OverlayListItem {
13702 OverlayListItem {
13703 title: title.into(),
13704 subtitle: None,
13705 badge: None,
13706 indent: 0,
13707 search_value: None,
13708 selection: None,
13709 }
13710 }
13711
13712 fn row(title: &str, badge: &str, selection: Option<InlineListSelection>) -> OverlayListItem {
13713 OverlayListItem {
13714 title: title.into(),
13715 subtitle: None,
13716 badge: Some(badge.into()),
13717 indent: 0,
13718 search_value: None,
13719 selection,
13720 }
13721 }
13722
13723 fn rows_with_positions(terminal: &Terminal<TestBackend>) -> Vec<(u16, String, Vec<u16>)> {
13727 let buf = terminal.backend().buffer();
13728 let area = buf.area();
13729 let mut out = Vec::new();
13730 for y in 0..area.height {
13731 let mut text = String::new();
13732 let mut xs = Vec::new();
13733 for x in 0..area.width {
13734 if let Some(cell) = buf.cell((x, y)) {
13735 text.push_str(cell.symbol());
13736 xs.push(x);
13737 }
13738 }
13739 out.push((y, text, xs));
13740 }
13741 out
13742 }
13743
13744 fn occurrences(rows: &[(u16, String, Vec<u16>)], needle: &str) -> Vec<(u16, usize)> {
13746 let mut hits = Vec::new();
13747 for (y, text, xs) in rows {
13748 let mut from = 0;
13749 while let Some(rel) = text[from..].find(needle) {
13750 let byte_idx = from + rel;
13751 let char_idx = text[..byte_idx].chars().count();
13752 if let Some(&x) = xs.get(char_idx) {
13753 hits.push((*y, x as usize));
13754 }
13755 from = byte_idx + needle.len();
13756 }
13757 }
13758 hits
13759 }
13760
13761 #[test]
13766 fn render_overlay_tabbed_settings_shows_tab_bar_and_sidebar() {
13767 let backend = TestBackend::new(80, 24);
13768 let mut terminal = Terminal::new(backend).unwrap();
13769 let overlay = OverlayState {
13770 title: "Settings".into(),
13771 lines: Vec::new(),
13772 items: vec![
13773 heading("Defaults"),
13774 row(
13775 "Thinking level",
13776 "medium",
13777 Some(InlineListSelection::ConfigAction("ThinkingLevel".into())),
13778 ),
13779 row("Model roles", "0", None),
13780 heading("Pointers"),
13781 row("Theme", "dark", None),
13782 ],
13783 selected: 1,
13784 search: None,
13785 secure_input: None,
13786 tabs: vec!["General".into(), "Model".into(), "Interaction".into()],
13787 active_tab: 1,
13788 sections: vec!["Defaults".into(), "Pointers".into()],
13789 active_section: 0,
13790 key_capture: None,
13791 };
13792 terminal
13793 .draw(|f| render_overlay(f, f.area(), &overlay))
13794 .unwrap();
13795 let rows = rows_with_positions(&terminal);
13796
13797 let general = occurrences(&rows, "General");
13800 let interaction = occurrences(&rows, "Interaction");
13801 assert!(
13802 general
13803 .iter()
13804 .any(|(gy, _)| interaction.iter().any(|(iy, _)| gy == iy)),
13805 "tab bar must list tabs on one row"
13806 );
13807
13808 for name in ["Defaults", "Pointers"] {
13812 let hits = occurrences(&rows, name);
13813 assert!(hits.len() >= 2, "{name} must render in sidebar AND list");
13814 assert!(
13815 hits.iter().any(|(_, x)| *x < 13),
13816 "{name} must render in the sidebar column"
13817 );
13818 assert!(
13819 hits.iter().any(|(_, x)| *x >= 13),
13820 "{name} must render in the item column"
13821 );
13822 }
13823
13824 let buf = terminal.backend().buffer();
13827 let pointers_item_col = occurrences(&rows, "Pointers")
13828 .into_iter()
13829 .find(|(_, x)| *x >= 13)
13830 .expect("items-column Pointers heading");
13831 let cell = buf
13832 .cell((pointers_item_col.1 as u16, pointers_item_col.0))
13833 .expect("cell");
13834 assert!(
13835 cell.modifier.contains(Modifier::DIM),
13836 "out-of-section rows must be dimmed"
13837 );
13838 let defaults_item_col = occurrences(&rows, "Defaults")
13839 .into_iter()
13840 .find(|(_, x)| *x >= 13)
13841 .expect("items-column Defaults heading");
13842 let cell = buf
13843 .cell((defaults_item_col.1 as u16, defaults_item_col.0))
13844 .expect("cell");
13845 assert!(
13846 !cell.modifier.contains(Modifier::DIM),
13847 "active-section rows must not be dimmed"
13848 );
13849 }
13850
13851 #[test]
13856 fn rebound_send_now_combo_submits_immediately() {
13857 let state = Arc::new(parking_lot::Mutex::new(RenderState::default()));
13858 let mut overrides = std::collections::HashMap::new();
13859 overrides.insert("SendNow".to_string(), vec!["Alt+s".to_string()]);
13860 *state.lock().keymap.write() = Keymap::from_settings(&overrides);
13861
13862 let alt_s = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::ALT);
13863 let action = state
13864 .lock()
13865 .keymap
13866 .read()
13867 .resolve(alt_s)
13868 .expect("Alt+s must resolve to SendNow after the rebind");
13869 assert!(matches!(action, GlobalAction::SendNow));
13870 let ctrl_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL);
13874 assert_eq!(state.lock().keymap.read().resolve(ctrl_enter), None);
13875 let ctrl_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
13876 assert!(matches!(
13877 state.lock().keymap.read().resolve(ctrl_p),
13878 Some(GlobalAction::OpenCommandPalette)
13879 ));
13880
13881 state.lock().composer.set_text("send me now");
13882 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
13883 apply_global_action(action, &state, &tx);
13884
13885 assert_eq!(state.lock().composer.text(), "");
13886 match rx.try_recv().expect("interrupt fires first") {
13887 InlineEvent::Interrupt => {}
13888 other => panic!("expected Interrupt first, got {other:?}"),
13889 }
13890 match rx.try_recv().expect("submit fires second") {
13891 InlineEvent::Submit(text) => assert_eq!(&*text, "send me now"),
13892 other => panic!("expected Submit, got {other:?}"),
13893 }
13894 assert!(rx.try_recv().is_err(), "no further events");
13895 }
13896
13897 #[test]
13900 fn settings_selection_variants_round_trip_names() {
13901 assert_eq!(
13902 InlineListSelection::SettingsTab(1),
13903 InlineListSelection::SettingsTab(1)
13904 );
13905 assert_eq!(
13906 InlineListSelection::SettingsSection(0),
13907 InlineListSelection::SettingsSection(0)
13908 );
13909 assert_eq!(
13910 InlineListSelection::SettingKeyCapture("OpenCommandPalette".into()),
13911 InlineListSelection::SettingKeyCapture("OpenCommandPalette".into())
13912 );
13913 assert_eq!(
13914 InlineListSelection::SettingTextEdit("ToolTimeoutSecs".into()),
13915 InlineListSelection::SettingTextEdit("ToolTimeoutSecs".into())
13916 );
13917 assert_eq!(
13918 InlineListSelection::SettingSubmenuOpen("AdvisorSyncBacklog".into()),
13919 InlineListSelection::SettingSubmenuOpen("AdvisorSyncBacklog".into())
13920 );
13921 assert_eq!(
13922 InlineListSelection::SettingMultiselect("DisabledTools".into()),
13923 InlineListSelection::SettingMultiselect("DisabledTools".into())
13924 );
13925 }
13926
13927 #[test]
13940 fn key_capture_appends_combo_and_keeps_default_resolving() {
13941 let real_settings = dirs::home_dir()
13944 .map(|h| h.join(".oxicode").join("settings.json"))
13945 .filter(|p| p.exists());
13946 let real_settings_mtime_before = real_settings
13947 .as_ref()
13948 .and_then(|p| std::fs::metadata(p).ok())
13949 .and_then(|m| m.modified().ok());
13950 let real_settings_sha_before = real_settings
13951 .as_ref()
13952 .and_then(|p| std::fs::read(p).ok())
13953 .map(|b| {
13954 use std::collections::hash_map::DefaultHasher;
13955 use std::hash::{Hash, Hasher};
13956 let mut h = DefaultHasher::new();
13957 b.hash(&mut h);
13958 h.finish()
13959 });
13960
13961 let tmp = tempfile::tempdir().expect("tempdir");
13962 let sandbox = tmp.path().join("settings.json");
13963
13964 let mut state = RenderState::default();
13965 state.settings_override_path = Some(sandbox.clone());
13966 state.overlay = Some(build_key_capture_overlay(
13970 GlobalAction::OpenCommandPalette.name(),
13971 ));
13972 state.settings_map_rows.clear();
13973
13974 let ctrl_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
13976 let alt_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::ALT);
13977 assert_eq!(
13978 state.keymap.read().resolve(ctrl_p),
13979 Some(GlobalAction::OpenCommandPalette)
13980 );
13981 assert_eq!(state.keymap.read().resolve(alt_p), None);
13982
13983 handle_key_capture(&mut state, alt_p);
13985
13986 let keymap = state.keymap.read();
13991 assert_eq!(
13992 keymap.resolve(ctrl_p),
13993 Some(GlobalAction::OpenCommandPalette)
13994 );
13995 assert_eq!(
13996 keymap.resolve(alt_p),
13997 Some(GlobalAction::OpenCommandPalette)
13998 );
13999 drop(keymap);
14000 let overlay = state
14001 .overlay
14002 .as_ref()
14003 .expect("capture commits reopen the panel on Keybindings");
14004 assert!(
14005 overlay.key_capture.is_none(),
14006 "capture prompt must be closed"
14007 );
14008 assert_eq!(
14009 overlay.lines.first().map(String::as_str),
14010 Some("Captured Alt+p for OpenCommandPalette")
14011 );
14012 assert_eq!(state.settings_active_tab, SettingsTab::Keybindings);
14013
14014 let sandbox_contents = std::fs::read_to_string(&sandbox)
14018 .expect("sandbox settings.json must exist after capture");
14019 assert!(
14020 sandbox_contents.contains("OpenCommandPalette"),
14021 "sandbox file must contain the captured keybinding override; got {sandbox_contents}"
14022 );
14023 assert!(
14024 sandbox_contents.contains("Alt+p"),
14025 "sandbox file must contain the captured Alt+p combo; got {sandbox_contents}"
14026 );
14027 if let Some(before) = real_settings_mtime_before {
14028 let after = real_settings
14029 .as_ref()
14030 .and_then(|p| std::fs::metadata(p).ok())
14031 .and_then(|m| m.modified().ok())
14032 .expect("real settings.json must still exist after capture");
14033 assert_eq!(
14034 before, after,
14035 "real ~/.oxicode/settings.json mtime must not change (sandbox leak)"
14036 );
14037 }
14038 if let (Some(before), Some(after)) = (
14039 real_settings_sha_before,
14040 real_settings
14041 .as_ref()
14042 .and_then(|p| std::fs::read(p).ok())
14043 .map(|b| {
14044 use std::collections::hash_map::DefaultHasher;
14045 use std::hash::{Hash, Hasher};
14046 let mut h = DefaultHasher::new();
14047 b.hash(&mut h);
14048 h.finish()
14049 }),
14050 ) {
14051 assert_eq!(
14052 before, after,
14053 "real ~/.oxicode/settings.json content must not change (sandbox leak)"
14054 );
14055 }
14056 }
14057
14058 #[test]
14065 fn remove_keybinding_combo_refuses_to_drop_the_last_combo() {
14066 let mut state = RenderState::default();
14067 let mut settings = Settings::default();
14072 crate::tui_vt::settings_defs::set_action_combos(
14073 &mut settings,
14074 GlobalAction::OpenCommandPalette,
14075 vec!["Ctrl+p".to_string()],
14076 );
14077 *state.keymap.write() = Keymap::from_settings(&settings.keybindings);
14078 assert_eq!(
14079 state
14080 .keymap
14081 .read()
14082 .action_combos(GlobalAction::OpenCommandPalette)
14083 .len(),
14084 1,
14085 "test setup: action must start with exactly one combo"
14086 );
14087
14088 state.settings_active_tab = SettingsTab::Keybindings;
14091
14092 remove_keybinding_combo(&mut state, GlobalAction::OpenCommandPalette, "Ctrl+p");
14093
14094 let ctrl_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
14096 assert_eq!(
14097 state.keymap.read().resolve(ctrl_p),
14098 Some(GlobalAction::OpenCommandPalette),
14099 "the guard must not let OpenCommandPalette go combo-less"
14100 );
14101 assert_eq!(
14102 state
14103 .keymap
14104 .read()
14105 .action_combos(GlobalAction::OpenCommandPalette)
14106 .len(),
14107 1,
14108 "no combo was removed"
14109 );
14110 let overlay = state.overlay.as_ref().expect("reopen leaves the panel up");
14113 assert!(
14114 overlay
14115 .lines
14116 .first()
14117 .map(|l| l.contains("Refusing to remove the last combo"))
14118 .unwrap_or(false),
14119 "panel must explain why the removal was refused; got {:?}",
14120 overlay.lines
14121 );
14122 }
14123
14124 #[test]
14130 fn text_edit_commit_valid_input() {
14131 let tmp = tempfile::tempdir().expect("tempdir");
14132 let sandbox = tmp.path().join("settings.json");
14133 let mut state = RenderState::default();
14134 state.settings_override_path = Some(sandbox.clone());
14135 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
14136 let handle = InlineHandle::new_for_tests(tx);
14137
14138 let (outcome, _msg) = commit_text_edit(
14139 &mut state,
14140 &handle,
14141 None,
14142 SettingKey::SessionHistorySize,
14143 "300".to_string(),
14144 );
14145 assert!(outcome.is_ok(), "valid numeric input must commit");
14146
14147 let contents = std::fs::read_to_string(&sandbox).expect("sandbox written");
14149 let saved: Settings = serde_json::from_str(&contents).expect("sandbox parses");
14150 assert_eq!(
14151 saved.session_history_size, 300,
14152 "sandbox must hold session_history_size=300"
14153 );
14154
14155 let overlay = state.overlay.as_ref().expect("panel reopened");
14157 assert!(
14158 overlay
14159 .lines
14160 .first()
14161 .map(|l| l.contains("300"))
14162 .unwrap_or(false),
14163 "status line must show the new value; got {:?}",
14164 overlay.lines
14165 );
14166 let mut saw_info = false;
14168 while let Ok(cmd) = rx.try_recv() {
14169 if let InlineCommand::AppendLine { kind, .. } = cmd
14170 && matches!(kind, InlineMessageKind::Info)
14171 {
14172 saw_info = true;
14173 }
14174 }
14175 assert!(saw_info, "commit must surface an Info line");
14176 }
14177
14178 #[test]
14182 fn text_edit_commit_invalid_input_is_rejected() {
14183 let tmp = tempfile::tempdir().expect("tempdir");
14184 let sandbox = tmp.path().join("settings.json");
14185 let mut state = RenderState::default();
14186 state.settings_override_path = Some(sandbox.clone());
14187 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
14188 let handle = InlineHandle::new_for_tests(tx);
14189
14190 let (outcome, msg) = commit_text_edit(
14191 &mut state,
14192 &handle,
14193 None,
14194 SettingKey::SessionHistorySize,
14195 "not-a-number".to_string(),
14196 );
14197 assert!(outcome.is_err(), "non-numeric input must be rejected");
14198 assert!(
14199 msg.contains("invalid") || msg.contains("ParseError") || !msg.is_empty(),
14200 "rejection must carry a reason; got {msg}"
14201 );
14202 assert!(
14204 !sandbox.exists(),
14205 "rejected input must not persist anything"
14206 );
14207 let mut saw_error = false;
14209 while let Ok(cmd) = rx.try_recv() {
14210 if let InlineCommand::AppendLine { kind, .. } = cmd
14211 && matches!(kind, InlineMessageKind::Error)
14212 {
14213 saw_error = true;
14214 }
14215 }
14216 assert!(saw_error, "rejection must surface an Error line");
14217 }
14218
14219 #[test]
14224 fn submenu_select_commit_for_sync_backlog() {
14225 let tmp = tempfile::tempdir().expect("tempdir");
14226 let sandbox = tmp.path().join("settings.json");
14227 let mut state = RenderState::default();
14228 state.settings_override_path = Some(sandbox.clone());
14229
14230 open_submenu_select_prompt(&mut state, SettingKey::AdvisorSyncBacklog);
14232 let overlay = state.overlay.as_ref().expect("submenu overlay opens");
14233 assert_eq!(overlay.items.len(), 3, "off/sync/async rows");
14234 let titles: Vec<&str> = overlay.items.iter().map(|i| i.title.as_str()).collect();
14235 assert_eq!(titles, vec!["off", "sync", "async"]);
14236 for item in &overlay.items {
14238 let sel = item.selection.as_ref().expect("row is selectable");
14239 match sel {
14240 InlineListSelection::ConfigAction(p) => {
14241 assert!(
14242 p.starts_with("SubmenuCommit:AdvisorSyncBacklog:"),
14243 "payload must address the key; got {p}"
14244 );
14245 }
14246 other => panic!("expected ConfigAction, got {other:?}"),
14247 }
14248 }
14249
14250 let status = commit_submenu_choice(
14252 &mut state,
14253 SettingKey::AdvisorSyncBacklog,
14254 "async".to_string(),
14255 )
14256 .expect("valid option commits");
14257 assert!(status.contains("async"), "status names the new value");
14258
14259 let contents = std::fs::read_to_string(&sandbox).expect("sandbox written");
14261 assert!(
14262 contents.contains("async"),
14263 "sandbox must hold the async choice: {contents}"
14264 );
14265 let overlay = state.overlay.as_ref().expect("panel reopened");
14267 assert!(
14268 overlay
14269 .lines
14270 .first()
14271 .map(|l| l.contains("async"))
14272 .unwrap_or(false),
14273 "status line must show the new value"
14274 );
14275 }
14276
14277 #[test]
14281 fn multiselect_toggles_tool_and_refuses_essential() {
14282 let session = make_session_with_tools();
14283 let tmp = tempfile::tempdir().expect("tempdir");
14284 let sandbox = tmp.path().join("settings.json");
14285 let mut state = RenderState::default();
14286 state.settings_override_path = Some(sandbox.clone());
14287 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
14288 let handle = InlineHandle::new_for_tests(tx);
14289
14290 open_disabled_tools_multiselect(&mut state, &session);
14293 let overlay = state.overlay.as_ref().expect("multiselect opens");
14294 let titles: Vec<&str> = overlay.items.iter().map(|i| i.title.as_str()).collect();
14295 assert_eq!(titles, vec!["bash", "commit"], "registry tools, sorted");
14296 let bash = &overlay.items[0];
14297 assert_eq!(bash.badge.as_deref(), Some("essential"));
14298 let commit = &overlay.items[1];
14299 assert_eq!(commit.badge.as_deref(), Some("enabled"));
14300
14301 commit_disabled_tool_toggle(
14303 &mut state,
14304 &handle,
14305 &session,
14306 "commit".to_string(),
14307 false, false, );
14310 let saved: Settings = serde_json::from_str(&std::fs::read_to_string(&sandbox).unwrap())
14311 .expect("sandbox parses");
14312 assert!(
14313 saved.disabled_tools.iter().any(|t| t == "commit"),
14314 "toggle must add 'commit' to disabled_tools; got {:?}",
14315 saved.disabled_tools
14316 );
14317
14318 commit_disabled_tool_toggle(
14320 &mut state,
14321 &handle,
14322 &session,
14323 "commit".to_string(),
14324 false, true, );
14327 let saved: Settings = serde_json::from_str(&std::fs::read_to_string(&sandbox).unwrap())
14328 .expect("sandbox parses");
14329 assert!(
14330 !saved.disabled_tools.iter().any(|t| t == "commit"),
14331 "toggle must remove 'commit' from disabled_tools; got {:?}",
14332 saved.disabled_tools
14333 );
14334
14335 let before = std::fs::read_to_string(&sandbox).unwrap();
14337 commit_disabled_tool_toggle(
14338 &mut state,
14339 &handle,
14340 &session,
14341 "bash".to_string(),
14342 true, false, );
14345 let after = std::fs::read_to_string(&sandbox).unwrap();
14346 assert_eq!(before, after, "essential refusal must not write");
14347 let mut saw_refusal = false;
14348 while let Ok(cmd) = rx.try_recv() {
14349 if let InlineCommand::AppendLine { kind, segments } = cmd
14350 && matches!(kind, InlineMessageKind::Error)
14351 && segments.iter().any(|s| s.text.contains("essential"))
14352 {
14353 saw_refusal = true;
14354 }
14355 }
14356 assert!(
14357 saw_refusal,
14358 "essential refusal must surface an Error line mentioning 'essential'"
14359 );
14360 }
14361}
14362
14363#[cfg(test)]
14367mod image_preview_hook_tests {
14368 use super::*;
14369 use base64::{Engine, engine::general_purpose};
14370 use tokio::sync::mpsc;
14371
14372 fn image_result_content(payload: &[u8]) -> String {
14375 let b64 = general_purpose::STANDARD.encode(payload);
14376 format!(
14377 "Generated 1 image(s).\n\nImage 1 ({} bytes, base64):\n{}\n",
14378 payload.len(),
14379 b64
14380 )
14381 }
14382
14383 fn fresh_handle() -> (InlineHandle, mpsc::UnboundedReceiver<InlineCommand>) {
14384 let (tx, rx) = mpsc::unbounded_channel();
14385 (InlineHandle::new_for_tests(tx), rx)
14386 }
14387
14388 fn apply_all(state: &mut RenderState, rx: &mut mpsc::UnboundedReceiver<InlineCommand>) {
14389 while let Ok(cmd) = rx.try_recv() {
14390 apply_command(state, cmd);
14391 }
14392 }
14393
14394 fn transcript_text(state: &RenderState) -> Vec<String> {
14395 state
14396 .transcript
14397 .iter()
14398 .map(|l| {
14399 l.segments
14400 .iter()
14401 .map(|s| s.text.as_str())
14402 .collect::<String>()
14403 })
14404 .collect()
14405 }
14406
14407 #[test]
14411 fn generate_image_result_renders_fallback_row_and_enqueues_live_preview() {
14412 let mut state = RenderState::default();
14413 let (handle, mut rx) = fresh_handle();
14414 let payload = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
14415
14416 map_agent_event(
14417 &handle,
14418 AgentEvent::ToolExecutionStart {
14419 tool_call_id: "img-1".into(),
14420 tool_name: "generate_image".into(),
14421 args: serde_json::json!({"prompt": "a cat"}),
14422 intent: None,
14423 context: None,
14424 },
14425 &mut state,
14426 );
14427 map_agent_event(
14428 &handle,
14429 AgentEvent::ToolExecutionEnd {
14430 tool_call_id: "img-1".into(),
14431 tool_name: "generate_image".into(),
14432 intent: None,
14433 result: oxicode_ai::ToolResult {
14434 tool_call_id: "img-1".into(),
14435 content: image_result_content(&payload),
14436 status: "success".into(),
14437 },
14438 is_error: false,
14439 },
14440 &mut state,
14441 );
14442 apply_all(&mut state, &mut rx);
14443
14444 let texts = transcript_text(&state);
14445 let fallback_idx = texts
14446 .iter()
14447 .position(|t| t.contains("[image: generate_image:"))
14448 .expect("fallback row rendered in the tool box");
14449 assert!(
14450 texts
14451 .iter()
14452 .all(|t| !t.contains(&general_purpose::STANDARD.encode(payload))),
14453 "raw base64 must never render as text"
14454 );
14455
14456 assert_eq!(
14457 state.image_previews.pending_len(),
14458 1,
14459 "decoded PNG enqueued for live placement"
14460 );
14461 let pending = &state.image_previews.pending()[0];
14462 assert_eq!(&*pending.png, &payload, "decoded bytes round-trip");
14463 assert!(
14466 texts[fallback_idx].contains(&pending.label),
14467 "label {label:?} matches the fallback row {row:?}",
14468 label = pending.label,
14469 row = texts[fallback_idx],
14470 );
14471 }
14472
14473 #[test]
14476 fn generate_image_without_payload_keeps_generic_preview() {
14477 let mut state = RenderState::default();
14478 let (handle, mut rx) = fresh_handle();
14479 map_agent_event(
14480 &handle,
14481 AgentEvent::ToolExecutionStart {
14482 tool_call_id: "img-2".into(),
14483 tool_name: "generate_image".into(),
14484 args: serde_json::json!({"prompt": "a cat"}),
14485 intent: None,
14486 context: None,
14487 },
14488 &mut state,
14489 );
14490 map_agent_event(
14491 &handle,
14492 AgentEvent::ToolExecutionEnd {
14493 tool_call_id: "img-2".into(),
14494 tool_name: "generate_image".into(),
14495 intent: None,
14496 result: oxicode_ai::ToolResult {
14497 tool_call_id: "img-2".into(),
14498 content: "Image generation completed but returned no images.".into(),
14499 status: "success".into(),
14500 },
14501 is_error: false,
14502 },
14503 &mut state,
14504 );
14505 apply_all(&mut state, &mut rx);
14506 let texts = transcript_text(&state);
14507 assert!(
14508 texts.iter().any(|t| t.contains("returned no images")),
14509 "generic preview path still renders the summary"
14510 );
14511 assert_eq!(state.image_previews.pending_len(), 0);
14512 }
14513
14514 #[test]
14518 fn live_frame_anchors_and_emits_kitty_sequence() {
14519 use crate::tui_vt::image_preview::{ImagePreviews, ImageSupport};
14520 use ratatui::{Terminal, backend::TestBackend};
14521
14522 let mut state = RenderState::default();
14523 state.image_previews = ImagePreviews::new(ImageSupport::Kitty);
14524 let (handle, mut rx) = fresh_handle();
14525 let payload = [0x89u8, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
14526 map_agent_event(
14527 &handle,
14528 AgentEvent::ToolExecutionStart {
14529 tool_call_id: "img-3".into(),
14530 tool_name: "generate_image".into(),
14531 args: serde_json::json!({"prompt": "a dog"}),
14532 intent: None,
14533 context: None,
14534 },
14535 &mut state,
14536 );
14537 map_agent_event(
14538 &handle,
14539 AgentEvent::ToolExecutionEnd {
14540 tool_call_id: "img-3".into(),
14541 tool_name: "generate_image".into(),
14542 intent: None,
14543 result: oxicode_ai::ToolResult {
14544 tool_call_id: "img-3".into(),
14545 content: image_result_content(&payload),
14546 status: "success".into(),
14547 },
14548 is_error: false,
14549 },
14550 &mut state,
14551 );
14552 apply_all(&mut state, &mut rx);
14553
14554 let backend = TestBackend::new(80, 24);
14557 let mut terminal = Terminal::new(backend).expect("backend");
14558 let (tx, _drain) = mpsc::unbounded_channel();
14559 terminal
14560 .draw(|frame| render_frame(frame, &state, &InlineHandle::new_for_tests(tx)))
14561 .expect("draw");
14562 let buf = terminal.backend().buffer().clone();
14563 let frame_text: String = (0..buf.area().height)
14564 .map(|y| {
14565 (0..buf.area().width)
14566 .filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string()))
14567 .collect::<String>()
14568 })
14569 .collect::<Vec<_>>()
14570 .join("\n");
14571 assert!(
14572 frame_text.contains("[image: generate_image:"),
14573 "live frame paints the fallback row"
14574 );
14575
14576 let seq = state.image_previews.emit_live(state.committed_entries);
14578 assert!(seq.contains("\x1b["));
14579 assert!(seq.contains("\x1b_Ga=t,f=100"), "transmit");
14580 assert!(seq.contains("a=p"), "placement");
14581 assert_eq!(state.image_previews.pending_len(), 0, "placed and consumed");
14582 }
14583
14584 #[test]
14586 fn extract_generated_png_parses_first_image_and_rejects_garbage() {
14587 let payload = vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
14588 assert_eq!(
14589 extract_generated_png(&image_result_content(&payload)),
14590 Some(payload),
14591 "first base64 blob after the marker decodes"
14592 );
14593 let first: Vec<u8> = (1u8..=8).collect();
14596 let second: Vec<u8> = (9u8..=16).collect();
14597 let two = format!(
14598 "Generated 2 image(s).\n\nImage 1 (8 bytes, base64):\n{}\n\nImage 2 (8 bytes, base64):\n{}\n",
14599 general_purpose::STANDARD.encode(&first),
14600 general_purpose::STANDARD.encode(&second),
14601 );
14602 assert_eq!(extract_generated_png(&two), Some(first));
14603 assert_eq!(extract_generated_png("plain text output"), None);
14605 assert_eq!(
14606 extract_generated_png("Image 1 (8 bytes, base64):\n!!!not-base64!!!\n"),
14607 None
14608 );
14609 assert_eq!(
14610 extract_generated_png("Image 1 (2 bytes, base64):\n AQID \n"),
14611 None,
14612 "payloads shorter than a PNG header are rejected"
14613 );
14614 }
14615}