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 seen_tips: std::collections::HashMap<&'static str, u32>,
462 pub file_commands: Vec<FileCommand>,
465 pub secure_input_origin: Option<SecureInputOrigin>,
476 pub session_swapper: Option<Arc<crate::app::agent_session_handle::SessionSwapper>>,
481 pub pending_resume: Option<PathBuf>,
486 pub session_state: Option<crate::SessionState>,
487 pub settings_active_tab: crate::tui_vt::settings_defs::SettingsTab,
491 pub settings_map_rows: Vec<Option<SettingsMapRow>>,
499 pub keymap: Arc<parking_lot::RwLock<crate::tui_vt::keymap::Keymap>>,
505 #[cfg(test)]
512 pub settings_override_path: Option<std::path::PathBuf>,
513
514 pub git_tui: Option<crate::tui_vt::git_tui::GitTuiState>,
519 pub git_tui_viewport: (u16, u16),
523}
524
525impl Default for RenderState {
526 fn default() -> Self {
527 Self {
529 composer: oxicode_textarea::TextArea::new(),
530 transcript: Vec::new(),
531 scroll_offset: usize::MAX,
532 committed_entries: 0,
533 header_context: InlineHeaderContext::default(),
534 input_enabled: false,
535 prompt_prefix: String::new(),
536 placeholder: None,
537 thinking_buffer: String::new(),
538 shutdown_requested: false,
539 message_buffer: String::new(),
540 stream_anchor: None,
541 md_cache: oxicode_vtui::tui::ui::markdown::MdRenderCache::default(),
542 agent_hub_open: false,
543 hub_entries: Vec::new(),
544 hub: None,
545 pending_quit: false,
546 slash_popup: SlashPopup::default(),
547 reasoning_stage: None,
548 active_run: None,
549 stream_reveal: usize::MAX,
550 thinking_level: "medium".to_string(),
551 viewport_width: 80,
552 last_viewport_height: 24,
553 glyph_set: crate::symbols::GlyphSet::default(),
554 image_previews: super::image_preview::ImagePreviews::default(),
555 overlay: None,
556 overlay_model_ids: Vec::new(),
557 overlay_catalog_models: Vec::new(),
558 overlay_providers: Vec::new(),
559 catalog: None,
560 queued_inputs: Vec::new(),
561 queue_panel_open: false,
562 queue_selected: 0,
563 shell_mode: false,
564 follow_ups: Vec::new(),
565 todo_phases: Vec::new(),
566 todo_expanded: false,
567 todo_clear_deadline: None,
568 todo_clear_delay_secs: -1,
569 todo_provider: None,
570 vim_state: crate::tui_vt::vim::VimState::default(),
571 vim_clipboard: String::new(),
572 search: None,
573 block_display: std::collections::HashMap::new(),
574 last_esc_at: None,
575 multiline_mode: false,
576 autonomy_mode: Mode::default(),
577 prompt_history: Vec::new(),
578 history_pos: None,
579 next_block_id: 0,
580 cancel_grace_until: None,
581 confirmation: None,
582 tip: None,
583 cwd: PathBuf::new(),
584 file_search: None,
585 issues_panel: None,
586 issue_store: None,
587 seen_tips: std::collections::HashMap::new(),
588 file_commands: Vec::new(),
589 secure_input_origin: None,
590 session_swapper: None,
591 context_tokens: None,
592 context_window: 128_000,
593 settings_active_tab: crate::tui_vt::settings_defs::SettingsTab::General,
594 settings_map_rows: Vec::new(),
595 keymap: Arc::new(parking_lot::RwLock::new(Keymap::from_settings(
599 &std::collections::HashMap::new(),
600 ))),
601 #[cfg(test)]
602 settings_override_path: None,
603 brain: BrainChip::default(),
604 pending_resume: None,
605 session_state: None,
606 git_tui: None,
607 git_tui_viewport: (80, 24),
608 }
609 }
610}
611
612#[derive(Clone, Debug, PartialEq, Eq)]
616pub enum SecureInputOrigin {
617 SetKey { provider: String },
621 NewlyAdded { provider: String },
625 ModelRoleKey,
628 ModelRoleValue { role: String },
631 TextEdit(crate::tui_vt::settings_defs::SettingKey),
636}
637
638#[derive(Clone, Debug)]
640pub struct SearchState {
641 pub query: String,
642 pub matches: Vec<usize>,
644 pub current: usize,
646}
647
648#[derive(Clone)]
650pub struct SlashPopupItem {
651 pub label: String,
653 pub description: String,
655 pub name: String,
657}
658
659#[derive(Default, Clone)]
664pub struct SlashPopup {
665 pub open: bool,
666 pub items: Vec<SlashPopupItem>,
667 pub selected: usize,
668}
669
670#[derive(Clone, Debug)]
674pub struct OverlayListItem {
675 pub title: String,
676 pub subtitle: Option<String>,
677 pub badge: Option<String>,
678 pub indent: u8,
679 pub search_value: Option<String>,
680 pub selection: Option<oxicode_vtui::tui::core::InlineListSelection>,
682}
683
684#[derive(Clone, Debug, Default)]
693pub struct OverlayState {
694 pub title: String,
695 pub lines: Vec<String>,
696 pub items: Vec<OverlayListItem>,
697 pub selected: usize,
698 pub search: Option<OverlaySearchState>,
699 pub secure_input: Option<OverlaySecureInput>,
700 pub tabs: Vec<String>,
702 pub active_tab: usize,
704 pub sections: Vec<String>,
707 pub active_section: usize,
710 pub key_capture: Option<String>,
716}
717
718#[derive(Clone, Debug)]
726pub struct OverlaySecureInput {
727 pub config: SecurePromptConfig,
728 pub editor: EditBuffer,
729}
730
731#[derive(Clone, Debug)]
735pub struct ModalConfirmation {
736 pub title: String,
737 pub message: String,
738 pub action: ConfirmationAction,
741}
742
743#[derive(Clone, Debug, PartialEq, Eq)]
745pub enum ConfirmationAction {
746 Quit,
748 ClearConversation,
750 RemoveProviderKey(String),
752 CloseIssue(u32),
754}
755
756#[derive(Clone, Debug)]
760pub struct EphemeralTip {
761 pub text: String,
762 pub born_tick: u64,
764 pub ttl_ticks: u64,
766 pub key: &'static str,
769 pub ambient: bool,
773}
774
775#[derive(Clone, Debug)]
777pub struct OverlaySearchState {
778 pub label: String,
779 pub placeholder: Option<String>,
780 pub value: String,
781}
782
783impl RenderState {
784 fn new_with_header(header: InlineHeaderContext) -> Self {
785 let mut s = Self::default();
786 s.header_context = header;
787 s.prompt_prefix = "> ".to_string();
788 s.input_enabled = true;
789 let bindings = crate::store::settings::Settings::load()
792 .unwrap_or_default()
793 .keybindings;
794 *s.keymap.write() = Keymap::from_settings(&bindings);
795 s
796 }
797
798 pub fn swapper(&self) -> Arc<crate::app::agent_session_handle::SessionSwapper> {
803 self.session_swapper
804 .clone()
805 .expect("RenderState::session_swapper must be initialized at TUI startup")
806 }
807
808 fn append_line(&mut self, kind: InlineMessageKind, segments: Vec<InlineSegment>) {
814 let block_id = self.block_id_for_kind(kind);
815 self.transcript
816 .extend(
817 Self::segments_by_explicit_line(segments)
818 .into_iter()
819 .map(|segments| TranscriptLine {
820 kind,
821 segments,
822 block_id,
823 }),
824 );
825 }
826
827 fn append_line_new_block(&mut self, kind: InlineMessageKind, segments: Vec<InlineSegment>) {
831 let block_id = self.fresh_block_id();
832 self.transcript
833 .extend(
834 Self::segments_by_explicit_line(segments)
835 .into_iter()
836 .map(|segments| TranscriptLine {
837 kind,
838 segments,
839 block_id,
840 }),
841 );
842 }
843
844 fn inline_segment(&mut self, kind: InlineMessageKind, segment: InlineSegment) {
847 let mut lines = Self::segments_by_explicit_line(vec![segment]).into_iter();
848
849 let streaming = self.stream_anchor.is_some();
854 if let Some(first) = lines.next() {
855 let merge_ok =
856 streaming && self.transcript.last().is_some_and(|last| last.kind == kind);
857 if merge_ok && let Some(last) = self.transcript.last_mut() {
858 last.segments.extend(first);
859 } else {
860 let block_id = self.fresh_block_id();
863 if self.stream_anchor.is_none() {
864 self.stream_anchor = Some(self.transcript.len());
865 }
866 self.transcript.push(TranscriptLine {
867 kind,
868 segments: first,
869 block_id,
870 });
871 }
872 }
873 let block_id = self
874 .stream_anchor
875 .and_then(|a| self.transcript.get(a))
876 .map(|l| l.block_id)
877 .unwrap_or_else(|| self.fresh_block_id());
878 self.transcript.extend(lines.map(|segments| TranscriptLine {
879 kind,
880 segments,
881 block_id,
882 }));
883 }
884
885 fn segments_by_explicit_line(segments: Vec<InlineSegment>) -> Vec<Vec<InlineSegment>> {
888 if !segments.iter().any(|segment| segment.text.contains('\n')) {
889 return vec![segments];
890 }
891
892 let mut lines = vec![Vec::new()];
893 for segment in segments {
894 let InlineSegment { text, style } = segment;
895 for (index, part) in text.split('\n').enumerate() {
896 if index > 0 {
897 lines.push(Vec::new());
898 }
899 if !part.is_empty()
900 && let Some(line) = lines.last_mut()
901 {
902 line.push(InlineSegment {
903 text: part.to_string(),
904 style: Arc::clone(&style),
905 });
906 }
907 }
908 }
909 lines
910 }
911
912 fn block_id_for_kind(&mut self, kind: InlineMessageKind) -> usize {
915 if let Some(last) = self.transcript.last()
916 && last.kind == kind
917 {
918 return last.block_id;
919 }
920 let id = self.next_block_id;
921 self.next_block_id += 1;
922 id
923 }
924
925 fn fresh_block_id(&mut self) -> usize {
927 let id = self.next_block_id;
928 self.next_block_id += 1;
929 id
930 }
931
932 pub fn start_search(&mut self, query: &str) {
936 let needle = query.to_lowercase();
937 let matches: Vec<usize> = self
938 .transcript
939 .iter()
940 .enumerate()
941 .filter(|(i, _)| *i >= self.committed_entries)
944 .filter(|(_, line)| {
945 line.segments
946 .iter()
947 .any(|s| s.text.to_lowercase().contains(&needle))
948 })
949 .map(|(i, _)| i)
950 .collect();
951 self.search = Some(SearchState {
952 query: query.to_string(),
953 matches,
954 current: 0,
955 });
956 if let Some(s) = &self.search
958 && let Some(&first) = s.matches.first()
959 {
960 self.scroll_offset = first;
961 }
962 }
963
964 pub fn search_next(&mut self) {
966 if let Some(s) = &mut self.search
967 && !s.matches.is_empty()
968 {
969 s.current = (s.current + 1) % s.matches.len();
970 let line = s.matches[s.current];
971 self.scroll_offset = line;
972 }
973 }
974
975 pub fn search_prev(&mut self) {
977 if let Some(s) = &mut self.search
978 && !s.matches.is_empty()
979 {
980 if s.current == 0 {
981 s.current = s.matches.len() - 1;
982 } else {
983 s.current -= 1;
984 }
985 let line = s.matches[s.current];
986 self.scroll_offset = line;
987 }
988 }
989
990 pub fn block_mode(&self, block_id: usize) -> BlockDisplayMode {
996 self.block_display
997 .get(&block_id)
998 .copied()
999 .unwrap_or(BlockDisplayMode::Expanded)
1000 }
1001
1002 pub fn cycle_block_at_view(&mut self) {
1005 let offset = self.effective_offset();
1006 if let Some(line) = self.transcript.get(offset) {
1007 let bid = line.block_id;
1008 let next = match self.block_mode(bid) {
1009 BlockDisplayMode::Collapsed => BlockDisplayMode::Truncated,
1010 BlockDisplayMode::Truncated => BlockDisplayMode::Expanded,
1011 BlockDisplayMode::Expanded => BlockDisplayMode::Collapsed,
1012 };
1013 if next == BlockDisplayMode::Expanded {
1016 self.block_display.remove(&bid);
1017 } else {
1018 self.block_display.insert(bid, next);
1019 }
1020 }
1021 }
1022
1023 pub fn expand_all(&mut self) {
1026 self.block_display.clear();
1027 }
1028
1029 pub fn fold_all(&mut self) {
1031 for bid in self.all_block_ids() {
1032 self.block_display.insert(bid, BlockDisplayMode::Collapsed);
1033 }
1034 }
1035
1036 pub fn truncate_all(&mut self) {
1038 for bid in self.all_block_ids() {
1041 self.block_display.insert(bid, BlockDisplayMode::Truncated);
1042 }
1043 }
1044
1045 fn all_block_ids(&self) -> Vec<usize> {
1047 let mut ids = Vec::new();
1048 let mut prev: Option<usize> = None;
1049 for l in &self.transcript {
1050 if prev != Some(l.block_id) {
1051 ids.push(l.block_id);
1052 prev = Some(l.block_id);
1053 }
1054 }
1055 ids
1056 }
1057
1058 pub fn jump_next_turn(&mut self) {
1062 let offset = self.effective_offset();
1063 let search_after = self
1064 .transcript
1065 .iter()
1066 .enumerate()
1067 .skip(offset + 1)
1068 .find(|(_, l)| l.kind == InlineMessageKind::Agent || l.kind == InlineMessageKind::User);
1069 if let Some((idx, _)) = search_after {
1070 self.scroll_offset = idx;
1071 }
1072 }
1073
1074 pub fn jump_prev_turn(&mut self) {
1076 let offset = self.effective_offset();
1077 let search_before = self
1078 .transcript
1079 .iter()
1080 .enumerate()
1081 .take(offset)
1082 .rev()
1083 .find(|(_, l)| l.kind == InlineMessageKind::User);
1084 if let Some((idx, _)) = search_before {
1085 self.scroll_offset = idx;
1086 }
1087 }
1088
1089 fn effective_offset(&self) -> usize {
1091 if self.scroll_offset == usize::MAX {
1092 self.transcript.len().saturating_sub(1)
1093 } else {
1094 self.scroll_offset
1095 }
1096 }
1097
1098 pub fn drain_queue_head(&mut self) {
1101 if !self.queued_inputs.is_empty() {
1102 self.queued_inputs.remove(0);
1103 }
1104 }
1105
1106 pub fn show_tip(&mut self, key: &'static str, text: &str, ttl: u64, ambient: bool) {
1109 let count = self.seen_tips.entry(key).or_insert(0);
1110 if *count >= SEEN_CAP {
1111 return;
1112 }
1113 *count += 1;
1114 self.tip = Some(EphemeralTip {
1115 text: text.to_string(),
1116 born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
1117 ttl_ticks: ttl,
1118 key,
1119 ambient,
1120 });
1121 }
1122}
1123
1124const SEEN_CAP: u32 = 3;
1126
1127pub async fn run_tui(app: App) -> Result<()> {
1134 let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
1137 let git_branch = crate::util::git_utils::get_current_branch(&cwd);
1138 super::host::activate_theme(app.settings());
1139 let theme_id = oxicode_vtui::theme::active_theme_id();
1141 let validation = oxicode_vtui::theme::validate_theme_contrast(&theme_id);
1142 if validation.warnings.is_empty() {
1143 tracing::debug!("theme '{theme_id}' passed contrast validation");
1144 } else {
1145 for w in &validation.warnings {
1146 tracing::warn!("theme contrast: {w}");
1147 }
1148 }
1149
1150 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
1153 let (evt_tx, mut evt_rx) = tokio::sync::mpsc::unbounded_channel::<InlineEvent>();
1154 let handle = InlineHandle::new_for_tests(cmd_tx);
1155
1156 let session = build_agent_session(&app).await?;
1160 let session_handle = session.clone_handle();
1164
1165 let session_swapper = Arc::new(crate::app::agent_session_handle::SessionSwapper::new(
1169 session_handle.clone(),
1170 ));
1171
1172 let (session_tx, mut session_rx) = tokio::sync::mpsc::unbounded_channel::<SessionEvent>();
1180 let _sub_guard = session.subscribe(Box::new(move |event| {
1181 let _ = session_tx.send(event.clone());
1182 }));
1183
1184 let header = build_header_context(&app, &cwd, git_branch.as_deref());
1186 handle.set_header_context(header.clone());
1187
1188 let mut tui = Tui::enter()?;
1191
1192 handle.set_prompt("> ".to_string(), InlineTextStyle::default());
1196 handle.set_placeholder(Some(
1197 "Describe the task, or type / for commands".to_string(),
1198 ));
1199
1200 let state = Arc::new(parking_lot::Mutex::new(RenderState::new_with_header(
1203 header,
1204 )));
1205 state.lock().cwd = cwd.clone();
1206 state.lock().catalog = Some(app.catalog());
1207 state.lock().file_commands = crate::tui_vt::slash::file_commands::load_file_commands(&cwd);
1208 state.lock().todo_provider = session_handle.todo_provider();
1209 state.lock().todo_clear_delay_secs = app.settings().todo_clear_delay_secs;
1210 state.lock().hub = Some(session_handle.hub_arc());
1211 state.lock().session_swapper = Some(session_swapper.clone());
1212 state.lock().session_state = Some(app.session_state().clone());
1213 state.lock().thinking_level = format!("{:?}", session.thinking_level()).to_ascii_lowercase();
1214 {
1217 let mut s = state.lock();
1218 sync_model_chips(&mut s, &session_handle);
1219 }
1220 state.lock().tip = Some(EphemeralTip {
1223 text: "Press ? for shortcuts | /help for commands".to_string(),
1224 born_tick: 0,
1225 ttl_ticks: 900,
1226 key: "onboarding",
1227 ambient: true,
1228 });
1229 if std::env::var("SSH_CONNECTION").is_ok() {
1231 state.lock().show_tip(
1232 "ssh_wrap",
1233 "Over SSH? Consider tmux to keep sessions alive",
1234 600,
1235 true,
1236 );
1237 }
1238 let mode_handle = app.ask_bridge().map(|b| {
1242 let handle = b.mode_handle();
1243 state.lock().autonomy_mode = Mode::load(&handle);
1244 handle
1245 });
1246 state.lock().glyph_set = app.settings().glyph_set;
1247 state
1250 .lock()
1251 .image_previews
1252 .set_enabled(app.settings().inline_images);
1253 let prompt_queue = Arc::new(PromptQueue::default());
1254 let (issue_action_tx, mut issue_action_rx) =
1259 tokio::sync::mpsc::unbounded_channel::<crate::tui_vt::issues_panel::IssueActionRequest>();
1260 spawn_input_thread(
1261 state.clone(),
1262 evt_tx.clone(),
1263 mode_handle,
1264 prompt_queue.clone(),
1265 issue_action_tx.clone(),
1266 );
1267
1268 spawn_agent_worker(session_swapper.clone(), prompt_queue.clone());
1274 let (brain_tx, mut brain_rx) =
1280 tokio::sync::watch::channel(crate::services::initial_brain_chip(app.settings()));
1281 {
1282 let memory_enabled = app.settings().memory_enabled;
1283 let announce = handle.clone();
1284 tokio::spawn(async move {
1285 let backend = crate::foundation::brain::BrainMemoryBackend::new(
1286 crate::foundation::brain::default_socket_path(),
1287 );
1288 let mut tick = tokio::time::interval(std::time::Duration::from_secs(20));
1289 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1290 let mut auto_revive_attempted = false;
1293 loop {
1294 tick.tick().await;
1295 let mut chip = if !memory_enabled {
1296 BrainChip::Off
1297 } else if !crate::services::brain_socket_present(
1298 &crate::foundation::brain::default_socket_path(),
1299 ) {
1300 BrainChip::Down
1301 } else {
1302 match tokio::time::timeout(
1303 std::time::Duration::from_millis(1500),
1304 backend.ping(),
1305 )
1306 .await
1307 {
1308 Ok(Ok(())) => BrainChip::Ok,
1309 Ok(Err(_)) | Err(_) => BrainChip::Degraded,
1310 }
1311 };
1312 let down = matches!(chip, BrainChip::Down | BrainChip::Degraded);
1316 if crate::foundation::brain_control::should_auto_revive(
1317 memory_enabled,
1318 down,
1319 auto_revive_attempted,
1320 ) {
1321 auto_revive_attempted = true;
1322 let installed = crate::foundation::brain_control::probe_control()
1323 .binary
1324 .is_some();
1325 if installed {
1326 match crate::foundation::brain_control::revive().await {
1327 Ok(msg) => {
1328 announce.append_line(
1329 InlineMessageKind::Info,
1330 vec![plain_segment(format!("brain: daemon was down — {msg}"))],
1331 );
1332 chip = match tokio::time::timeout(
1334 std::time::Duration::from_millis(1500),
1335 backend.ping(),
1336 )
1337 .await
1338 {
1339 Ok(Ok(())) => BrainChip::Ok,
1340 _ => chip,
1341 };
1342 }
1343 Err(e) => {
1344 announce.append_line(
1345 InlineMessageKind::Warning,
1346 vec![plain_segment(format!(
1347 "brain: auto-restart failed — {e} (run /brain for details)"
1348 ))],
1349 );
1350 }
1351 }
1352 }
1353 }
1354 let _ = brain_tx.send(chip);
1355 }
1356 });
1357 }
1358
1359 let result = run_event_loop(
1360 &mut tui.terminal,
1361 &mut cmd_rx,
1362 &mut evt_rx,
1363 &mut session_rx,
1364 &mut brain_rx,
1365 &handle,
1366 &state,
1367 &session_swapper,
1368 &prompt_queue,
1369 &mut issue_action_rx,
1370 )
1371 .await;
1372
1373 handle.shutdown();
1374 drop(tui);
1376
1377 result
1378}
1379
1380#[allow(clippy::too_many_arguments)]
1384async fn run_event_loop(
1385 terminal: &mut Terminal<CrosstermBackend<Stdout>>,
1386 cmd_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineCommand>,
1387 evt_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineEvent>,
1388 session_rx: &mut tokio::sync::mpsc::UnboundedReceiver<SessionEvent>,
1389 brain_rx: &mut tokio::sync::watch::Receiver<BrainChip>,
1390 handle: &InlineHandle,
1391 state: &Arc<parking_lot::Mutex<RenderState>>,
1392 session_swapper: &Arc<crate::app::agent_session_handle::SessionSwapper>,
1393 prompt_queue: &Arc<PromptQueue>,
1394 issue_action_rx: &mut tokio::sync::mpsc::UnboundedReceiver<
1395 crate::tui_vt::issues_panel::IssueActionRequest,
1396 >,
1397) -> Result<()> {
1398 while let Ok(cmd) = cmd_rx.try_recv() {
1401 apply_command(&mut state.lock(), cmd);
1402 }
1403
1404 {
1413 let mut s = state.lock();
1414 match terminal.size() {
1415 Ok(size) => {
1416 s.viewport_width = size.width;
1417 s.last_viewport_height = size.height;
1418 }
1419 Err(_) => {
1420 s.viewport_width = 0;
1421 s.last_viewport_height = 0;
1422 }
1423 }
1424 }
1425
1426 {
1431 let snapshot = state.lock();
1432 let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
1433 if let Err(err) = terminal.draw(|frame| render_frame(frame, &snapshot, handle)) {
1434 tracing::warn!(?err, "initial tui draw failed");
1435 }
1436 let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
1437 }
1438
1439 let mut render_tick = tokio::time::interval(std::time::Duration::from_millis(50));
1445 render_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1446 let mut last_draw = std::time::Instant::now();
1451 let mut priority = false;
1452
1453 loop {
1454 tokio::select! {
1455 biased;
1458
1459 Some(cmd) = cmd_rx.recv() => {
1461 let shutdown = {
1462 let mut s = state.lock();
1463 apply_command(&mut s, cmd)
1464 };
1465 if shutdown {
1466 break;
1467 }
1468 }
1469
1470 Some(event) = session_rx.recv() => {
1472 if let SessionEvent::HandoffComplete { doc_path, auto_continue } = &event {
1474 let mut s = state.lock();
1475 s.transcript.clear();
1476 s.message_buffer.clear();
1477 s.scroll_offset = usize::MAX;
1478 s.append_line(
1479 InlineMessageKind::Info,
1480 vec![plain_segment(format!(
1481 "Handoff written to {}. New session started.",
1482 doc_path
1483 ))],
1484 );
1485 let user_typed = !s.composer.text().trim().is_empty();
1486 s.composer.set_text("");
1487 if *auto_continue && !user_typed {
1488 drop(s);
1489 prompt_queue.enqueue(format!(
1490 "Read the handoff document at {} and continue \
1491 from where the previous session left off.",
1492 doc_path
1493 ));
1494 } else if user_typed {
1495 drop(s);
1496 handle.append_line(
1497 InlineMessageKind::Info,
1498 vec![plain_segment(
1499 "Handoff complete. Auto-continue skipped \
1500 because input was non-empty \u{2014} press \
1501 Enter to submit your message in the new \
1502 session."
1503 .to_string(),
1504 )],
1505 );
1506 }
1507 let session = session_swapper.current();
1508 handle_session_event(&mut state.lock(), handle, &event, Some(&session));
1509 } else {
1510 let session = session_swapper.current();
1517 handle_session_event(&mut state.lock(), handle, &event, Some(&session));
1518 }
1519 }
1520
1521 Some(evt) = evt_rx.recv() => {
1523 let outcome = handle_inline_event(
1524 &mut state.lock(),
1525 handle,
1526 &session_swapper.current(),
1527 prompt_queue,
1528 evt,
1529 );
1530 if outcome == LoopOutcome::Exit {
1531 break;
1532 }
1533 priority = true;
1534 }
1535
1536 Some(req) = issue_action_rx.recv() => {
1539 crate::tui_vt::issues_panel::dispatch_action(req, state.clone());
1540 }
1541
1542 _ = tokio::signal::ctrl_c() => {
1546 let outcome = {
1547 let mut s = state.lock();
1548 handle_interrupt(&mut s, &session_swapper.current(), handle)
1549 };
1550 if outcome == LoopOutcome::Exit {
1551 break;
1552 }
1553 priority = true;
1554 }
1555 changed = brain_rx.changed() => {
1557 if changed.is_ok() {
1558 state.lock().brain = *brain_rx.borrow_and_update();
1559 priority = true;
1560 }
1561 }
1562
1563 _ = render_tick.tick() => {}
1566 }
1567
1568 if coalesce_draw(last_draw, priority, DRAW_MIN_INTERVAL) == DrawDecision::DrawNow {
1572 if let Ok(size) = terminal.size()
1574 && size.width < 40
1575 {
1576 let mut s = state.lock();
1577 if s.tip.is_none() {
1578 s.show_tip(
1579 "small_screen",
1580 "Terminal too narrow \u{2014} resize for full UI",
1581 300,
1582 true,
1583 );
1584 }
1585 }
1586 let mut snapshot = state.lock();
1588 let mut prev_size: Option<(u16, u16)> = None;
1599 if let Ok(size) = terminal.size() {
1600 prev_size = Some((snapshot.viewport_width, snapshot.last_viewport_height));
1601 snapshot.viewport_width = size.width;
1602 snapshot.last_viewport_height = size.height;
1603 }
1604 if let Some((prev_w, prev_h)) = prev_size
1605 && let Ok(size) = terminal.size()
1606 && should_rebuild_scrollback(prev_w, size.width, prev_h, size.height)
1607 {
1608 let _ = execute!(terminal.backend_mut(), crossterm::style::Print("\x1b[3J"));
1611 let _ = terminal.clear();
1612 snapshot.committed_entries = 0;
1613 }
1618 if let Some(provider) = snapshot.todo_provider.as_ref() {
1621 snapshot.todo_phases = refresh_todo_phases(provider, snapshot.hub.as_ref());
1622 }
1623 let clear_delay = snapshot.todo_clear_delay_secs;
1628 sync_todo_clear_timer(&mut snapshot, clear_delay);
1629 advance_stream_reveal(&mut snapshot);
1632 commit_scrollback(terminal, &mut snapshot, false);
1636 let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
1637 let draw_err = terminal
1638 .draw(|frame| render_frame(frame, &snapshot, handle))
1639 .err();
1640 let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
1641 let committed = snapshot.committed_entries;
1649 let image_escapes = snapshot.image_previews.emit_live(committed);
1650 if !image_escapes.is_empty() {
1651 let _ = execute!(
1652 terminal.backend_mut(),
1653 crossterm::style::Print(image_escapes)
1654 );
1655 }
1656 if let Some(err) = draw_err {
1657 tracing::warn!(?err, "tui draw failed");
1658 break;
1659 }
1660 last_draw = std::time::Instant::now();
1663 priority = false;
1664 }
1665 }
1666
1667 const MAX_EXIT_FLUSH_ITERATIONS: usize = 50;
1674 for _ in 0..MAX_EXIT_FLUSH_ITERATIONS {
1675 let mut snapshot = state.lock();
1676 let before = snapshot.committed_entries;
1677 if snapshot.transcript.is_empty() || before >= snapshot.transcript.len() {
1678 break;
1679 }
1680 if let Some(provider) = snapshot.todo_provider.as_ref() {
1683 snapshot.todo_phases = refresh_todo_phases(provider, snapshot.hub.as_ref());
1684 }
1685 let clear_delay = snapshot.todo_clear_delay_secs;
1690 sync_todo_clear_timer(&mut snapshot, clear_delay);
1691 advance_stream_reveal(&mut snapshot);
1694 commit_scrollback(terminal, &mut snapshot, true);
1698 let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
1699 let draw_err = terminal
1700 .draw(|frame| render_frame(frame, &snapshot, handle))
1701 .err();
1702 let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
1703 if let Some(err) = draw_err {
1704 tracing::warn!(?err, "tui draw failed");
1705 break;
1706 }
1707 }
1708
1709 Ok(())
1710}
1711
1712const DRAW_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50);
1717
1718#[derive(Debug, PartialEq, Eq)]
1720enum DrawDecision {
1721 DrawNow,
1723 Defer,
1726}
1727
1728fn coalesce_draw(
1731 last_draw_at: std::time::Instant,
1732 priority: bool,
1733 min_interval: std::time::Duration,
1734) -> DrawDecision {
1735 if priority || last_draw_at.elapsed() >= min_interval {
1736 DrawDecision::DrawNow
1737 } else {
1738 DrawDecision::Defer
1739 }
1740}
1741
1742#[derive(PartialEq, Eq)]
1743enum LoopOutcome {
1744 Continue,
1745 Exit,
1746}
1747
1748#[derive(PartialEq, Eq, Debug)]
1753enum CancelRoute {
1754 Interrupt,
1757 Exit,
1759}
1760
1761fn route_cancel(is_streaming: bool) -> CancelRoute {
1764 if is_streaming {
1765 CancelRoute::Interrupt
1766 } else {
1767 CancelRoute::Exit
1768 }
1769}
1770
1771fn apply_command(state: &mut RenderState, cmd: InlineCommand) -> bool {
1775 match cmd {
1776 InlineCommand::AppendLine { kind, segments } => {
1777 state.append_line(kind, segments);
1778 }
1779 InlineCommand::Inline { kind, segment } => {
1780 state.inline_segment(kind, segment);
1781 }
1782 InlineCommand::BeginStream { .. } => {
1783 state.stream_anchor = None;
1787 }
1788 InlineCommand::EndStream => {
1789 state.stream_anchor = None;
1795 }
1796 InlineCommand::AppendLineBlockStart { kind, segments } => {
1797 state.append_line_new_block(kind, segments);
1798 }
1799 InlineCommand::ReplaceLast { kind, lines, .. } => {
1800 let from = state.stream_anchor.unwrap_or(state.transcript.len());
1808 state.transcript.truncate(from);
1809 for line in lines {
1810 state.append_line(kind, line);
1811 }
1812 state.stream_anchor = Some(from);
1816 }
1817 InlineCommand::AppendPastedMessage { kind, text, .. } => {
1818 state.append_line(kind, vec![plain_segment(text)]);
1819 }
1820 InlineCommand::SetPrompt { prefix, .. } => {
1821 state.prompt_prefix = prefix;
1822 }
1823 InlineCommand::SetPlaceholder { hint, .. } => {
1824 state.placeholder = hint;
1825 }
1826 InlineCommand::SetHeaderContext { context } => {
1827 state.header_context = *context;
1828 }
1829 InlineCommand::SetInputStatus { .. } => {
1830 }
1834 InlineCommand::SetInputEnabled(enabled) => {
1835 state.input_enabled = enabled;
1836 }
1837 InlineCommand::SetCursorVisible(_) | InlineCommand::ForceRedraw => {}
1838 InlineCommand::SetReasoningStage(stage) => {
1839 state.reasoning_stage = stage;
1840 }
1841 InlineCommand::SetVimModeEnabled(enabled) => {
1842 state.vim_state.set_enabled(enabled);
1843 }
1844 InlineCommand::SetQueuedInputs { entries } => {
1845 state.queued_inputs = entries;
1846 }
1847 InlineCommand::ShowOverlay { request } => {
1848 let mut overlay = materialize_overlay(*request);
1849 let mut map_rows = Vec::new();
1856 if overlay.items.iter().any(|it| {
1857 matches!(
1858 it.selection,
1859 Some(InlineListSelection::ConfigAction(_))
1860 | Some(InlineListSelection::SettingsTab(_))
1861 | Some(InlineListSelection::SettingsSection(_))
1862 | Some(InlineListSelection::SettingKeyCapture(_))
1863 | Some(InlineListSelection::SettingTextEdit(_))
1864 | Some(InlineListSelection::SettingSubmenuOpen(_))
1865 | Some(InlineListSelection::SettingMultiselect(_))
1866 )
1867 }) {
1868 let (hydrated, rows) = build_settings_overlay(state.settings_active_tab, None);
1869 overlay = hydrated;
1870 map_rows = rows;
1871 }
1872 state.overlay = Some(overlay);
1873 state.settings_map_rows = map_rows;
1874 }
1875 InlineCommand::CloseOverlay => {
1876 state.overlay = None;
1877 }
1878 InlineCommand::Shutdown => {
1879 state.shutdown_requested = true;
1880 return true;
1881 }
1882 _ => {
1883 tracing::trace!("unhandled InlineCommand (not rendered)");
1886 }
1887 }
1888 false
1889}
1890
1891fn materialize_overlay(request: OverlayRequest) -> OverlayState {
1896 match request {
1897 OverlayRequest::Modal(req) => {
1898 let secure_input = req.secure_prompt.map(|cfg| OverlaySecureInput {
1899 config: cfg,
1900 editor: EditBuffer::new(),
1901 });
1902 OverlayState {
1903 title: req.title,
1904 lines: req.lines,
1905 items: Vec::new(),
1906 selected: 0,
1907 search: None,
1908 secure_input,
1909 ..Default::default()
1910 }
1911 }
1912 OverlayRequest::List(req) => {
1913 let search = req.search.map(|cfg| OverlaySearchState {
1914 label: cfg.label,
1915 placeholder: cfg.placeholder,
1916 value: String::new(),
1917 });
1918 OverlayState {
1919 title: req.title,
1920 lines: req.lines,
1921 items: req.items.into_iter().map(overlay_item_from).collect(),
1922 selected: 0,
1923 search,
1924 secure_input: None,
1925 ..Default::default()
1926 }
1927 }
1928 OverlayRequest::Wizard(req) => {
1929 let step_items = req
1933 .steps
1934 .first()
1935 .map(|s| {
1936 s.items
1937 .iter()
1938 .map(|it| overlay_item_from(it.clone()))
1939 .collect()
1940 })
1941 .unwrap_or_default();
1942 let search = req.search.map(|cfg| OverlaySearchState {
1943 label: cfg.label,
1944 placeholder: cfg.placeholder,
1945 value: String::new(),
1946 });
1947 OverlayState {
1948 title: req.title,
1949 lines: Vec::new(),
1950 items: step_items,
1951 selected: 0,
1952 search,
1953 secure_input: None,
1954 ..Default::default()
1955 }
1956 }
1957 }
1958}
1959fn overlay_item_from(item: InlineListItem) -> OverlayListItem {
1960 OverlayListItem {
1961 title: item.title,
1962 subtitle: item.subtitle,
1963 badge: item.badge,
1964 indent: item.indent,
1965 search_value: item.search_value,
1966 selection: item.selection,
1967 }
1968}
1969
1970const SETTINGS_TABS: &[(SettingsTab, &str)] = &[
1974 (SettingsTab::General, "General"),
1975 (SettingsTab::Model, "Model"),
1976 (SettingsTab::Interaction, "Interaction"),
1977 (SettingsTab::Tools, "Tools"),
1978 (SettingsTab::Ui, "UI"),
1979 (SettingsTab::AdvisorMemory, "Advisor & Memory"),
1980 (SettingsTab::Keybindings, "Keybindings"),
1981 (SettingsTab::Advanced, "Advanced"),
1982];
1983
1984fn build_settings_overlay(
1993 tab: SettingsTab,
1994 keep_search: Option<OverlaySearchState>,
1995) -> (OverlayState, Vec<Option<SettingsMapRow>>) {
1996 let settings = crate::store::settings::Settings::load().unwrap_or_default();
1997 let (items, map_rows) = settings_overlay_items(tab, &settings);
1998 let items: Vec<OverlayListItem> = items.into_iter().map(overlay_item_from).collect();
1999 let mut sections: Vec<String> = Vec::new();
2000 for def in defs_for_tab(tab, &settings) {
2001 if sections.last().map(String::as_str) != Some(def.group) {
2002 sections.push(def.group.to_string());
2003 }
2004 }
2005 let active_tab = SETTINGS_TABS
2006 .iter()
2007 .position(|(t, _)| *t == tab)
2008 .unwrap_or(0);
2009 (
2010 OverlayState {
2011 title: "Settings".into(),
2012 lines: vec!["Browse settings by group; filter with the search bar.".into()],
2013 items,
2014 selected: 0,
2015 search: keep_search.or(Some(OverlaySearchState {
2016 label: "Filter settings".into(),
2017 placeholder: Some("Type to filter".into()),
2018 value: String::new(),
2019 })),
2020 secure_input: None,
2021 tabs: SETTINGS_TABS
2022 .iter()
2023 .map(|(_, name)| name.to_string())
2024 .collect(),
2025 active_tab,
2026 sections,
2027 active_section: 0,
2028 key_capture: None,
2029 },
2030 map_rows,
2031 )
2032}
2033
2034fn reopen_settings_panel(state: &mut RenderState, tab: SettingsTab, status: Option<String>) {
2040 let keep_search = state.overlay.as_ref().and_then(|o| o.search.clone());
2041 state.settings_active_tab = tab;
2042 let (mut overlay, map_rows) = build_settings_overlay(tab, keep_search);
2043 if let Some(status) = status {
2044 if overlay.lines.is_empty() {
2045 overlay.lines.push(status);
2046 } else {
2047 overlay.lines[0] = status;
2048 }
2049 }
2050 state.overlay = Some(overlay);
2051 state.settings_map_rows = map_rows;
2052}
2053
2054fn switch_settings_tab(state: &mut RenderState, tab_idx: usize) {
2060 let Some(&(tab, _)) = SETTINGS_TABS.get(tab_idx) else {
2061 return;
2062 };
2063 let search = state.overlay.as_ref().and_then(|o| o.search.clone());
2064 state.settings_active_tab = tab;
2065 let (overlay, map_rows) = build_settings_overlay(tab, search);
2066 state.overlay = Some(overlay);
2067 state.settings_map_rows = map_rows;
2068}
2069
2070fn jump_settings_section(state: &mut RenderState, section_idx: usize) {
2075 let tab = state.settings_active_tab;
2076 let search = state.overlay.as_ref().and_then(|o| o.search.clone());
2077 let (mut overlay, map_rows) = build_settings_overlay(tab, search);
2078 if let Some(target) = overlay.sections.get(section_idx).cloned() {
2079 let mut in_target = false;
2083 let mut anchor: Option<usize> = None;
2084 for (idx, item) in overlay.items.iter().enumerate() {
2085 let is_heading =
2086 item.selection.is_none() && item.badge.is_none() && item.subtitle.is_none();
2087 if is_heading {
2088 in_target = item.title == target;
2089 } else if in_target && anchor.is_none() {
2090 anchor = Some(idx);
2091 }
2092 }
2093 if let Some(idx) = anchor {
2094 overlay.selected = idx;
2095 overlay.active_section = section_idx;
2096 }
2097 }
2098 state.overlay = Some(overlay);
2099 state.settings_map_rows = map_rows;
2100}
2101
2102fn build_key_capture_overlay(action_name: &str) -> OverlayState {
2109 OverlayState {
2110 title: format!("Keybinding: {action_name}"),
2111 lines: vec![format!(
2112 "Press a key combo for {action_name} (Esc to cancel)\u{2026}"
2113 )],
2114 key_capture: Some(action_name.to_string()),
2115 ..Default::default()
2116 }
2117}
2118
2119fn key_event_to_combo_text(key: KeyEvent) -> Option<(String, KeyCombo)> {
2129 use crossterm::event::KeyCode as Kc;
2130 let mods = key.modifiers & (KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT);
2131 let code = match key.code {
2132 Kc::Char(c) if c.is_ascii_lowercase() && mods.contains(KeyModifiers::SHIFT) => {
2135 Kc::Char(c.to_ascii_uppercase())
2136 }
2137 other => other,
2138 };
2139 let combo = KeyCombo {
2140 code,
2141 modifiers: mods,
2142 };
2143 let text = combo.to_string();
2144 (KeyCombo::parse(&text) == Some(combo.clone())).then_some((text, combo))
2148}
2149
2150fn handle_key_capture(state: &mut RenderState, key: KeyEvent) {
2158 let Some(action_name) = state.overlay.as_ref().and_then(|o| o.key_capture.clone()) else {
2159 return;
2160 };
2161 let Some(action) = GlobalAction::from_name(&action_name) else {
2162 state.overlay = None;
2165 state.settings_map_rows.clear();
2166 return;
2167 };
2168 if key.code == KeyCode::Esc
2170 && !key
2171 .modifiers
2172 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
2173 {
2174 reopen_settings_panel(state, SettingsTab::Keybindings, None);
2175 return;
2176 }
2177 let Some((text, _combo)) = key_event_to_combo_text(key) else {
2178 set_capture_prompt_line(
2179 state,
2180 "That key can't be captured (F-keys and arrows don't round-trip). \
2181 Try another combo, Esc to cancel\u{2026}",
2182 );
2183 return;
2184 };
2185 if !key
2186 .modifiers
2187 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
2188 {
2189 set_capture_prompt_line(
2190 state,
2191 &format!(
2192 "'{text}' has no Ctrl or Alt — it would hijack typing. \
2193 Try another combo, Esc to cancel\u{2026}"
2194 ),
2195 );
2196 return;
2197 }
2198 let mut combos: Vec<String> = state
2201 .keymap
2202 .read()
2203 .action_combos(action)
2204 .iter()
2205 .map(|c| c.to_string())
2206 .collect();
2207 if !combos.iter().any(|c| c == &text) {
2208 combos.push(text.clone());
2209 }
2210 match commit_keybindings(state, action, combos) {
2211 Ok(()) => reopen_settings_panel(
2212 state,
2213 SettingsTab::Keybindings,
2214 Some(format!("Captured {text} for {action_name}")),
2215 ),
2216 Err(e) => set_capture_prompt_line(
2217 state,
2218 &format!("Failed to save keybindings: {e} — Esc to cancel\u{2026}"),
2219 ),
2220 }
2221}
2222
2223fn set_capture_prompt_line(state: &mut RenderState, line: &str) {
2224 if let Some(overlay) = state.overlay.as_mut() {
2225 overlay.lines = vec![line.to_string()];
2226 }
2227}
2228
2229fn commit_keybindings(
2235 state: &mut RenderState,
2236 action: GlobalAction,
2237 combos: Vec<String>,
2238) -> anyhow::Result<()> {
2239 let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
2240 crate::tui_vt::settings_defs::set_action_combos(&mut settings, action, combos);
2241 save_settings_sandboxed(state, &settings)?;
2242 *state.keymap.write() = Keymap::from_settings(&settings.keybindings);
2243 Ok(())
2244}
2245
2246fn save_settings_sandboxed(
2252 state: &RenderState,
2253 settings: &crate::store::settings::Settings,
2254) -> anyhow::Result<()> {
2255 #[cfg(test)]
2256 {
2257 if let Some(path) = state.settings_override_path.as_ref() {
2258 if let Some(parent) = path.parent() {
2259 std::fs::create_dir_all(parent).ok();
2260 }
2261 return settings.save_to(path);
2262 }
2263 }
2264 let _ = state; settings.save()
2266}
2267
2268fn remove_keybinding_combo(state: &mut RenderState, action: GlobalAction, combo: &str) {
2274 let current: Vec<String> = state
2275 .keymap
2276 .read()
2277 .action_combos(action)
2278 .iter()
2279 .map(|c| c.to_string())
2280 .collect();
2281 if current.len() <= 1 {
2282 reopen_settings_panel(
2283 state,
2284 SettingsTab::Keybindings,
2285 Some(format!(
2286 "Refusing to remove the last combo for {} — add another first (Enter on the \
2287 action row)",
2288 action.name()
2289 )),
2290 );
2291 return;
2292 }
2293 let next: Vec<String> = current
2294 .iter()
2295 .filter(|c| c.as_str() != combo)
2296 .cloned()
2297 .collect();
2298 if next.len() == current.len() {
2299 return;
2301 }
2302 match commit_keybindings(state, action, next) {
2303 Ok(()) => reopen_settings_panel(
2304 state,
2305 SettingsTab::Keybindings,
2306 Some(format!("Removed {combo} from {}", action.name())),
2307 ),
2308 Err(e) => reopen_settings_panel(
2309 state,
2310 SettingsTab::Keybindings,
2311 Some(format!("Failed to save keybindings: {e}")),
2312 ),
2313 }
2314}
2315
2316fn open_model_role_value_prompt(state: &mut RenderState, role: &str) {
2324 let current = crate::store::settings::Settings::load()
2325 .map(|s| s.model_roles.get(role).cloned())
2326 .ok()
2327 .flatten();
2328 state.secure_input_origin = Some(SecureInputOrigin::ModelRoleValue {
2329 role: role.to_string(),
2330 });
2331 state.overlay = Some(text_prompt_overlay(
2332 format!("Model for role '{role}'"),
2333 "Enter the model pattern (provider/model). Enter saves, Esc cancels.".into(),
2334 "model",
2335 Some("provider/model".into()),
2336 current.as_deref(),
2337 ));
2338 state.settings_map_rows.clear();
2339}
2340
2341fn open_model_role_key_prompt(state: &mut RenderState) {
2343 state.secure_input_origin = Some(SecureInputOrigin::ModelRoleKey);
2344 state.overlay = Some(text_prompt_overlay(
2345 "New model role".to_string(),
2346 "Name the role (e.g. fast, reviewer). Enter continues, Esc cancels.".into(),
2347 "role",
2348 Some("role name".into()),
2349 None,
2350 ));
2351 state.settings_map_rows.clear();
2352}
2353
2354fn text_prompt_overlay(
2357 title: String,
2358 line: String,
2359 label: &str,
2360 placeholder: Option<String>,
2361 prefill: Option<&str>,
2362) -> OverlayState {
2363 let mut editor = EditBuffer::new();
2364 if let Some(text) = prefill {
2365 let _ = editor.insert_str(text);
2366 }
2367 OverlayState {
2368 title,
2369 lines: vec![line],
2370 secure_input: Some(OverlaySecureInput {
2371 config: SecurePromptConfig {
2372 label: label.to_string(),
2373 placeholder,
2374 mask_input: false,
2375 },
2376 editor,
2377 }),
2378 ..Default::default()
2379 }
2380}
2381
2382fn settings_map_editor_active(state: &RenderState) -> bool {
2385 state.overlay.as_ref().is_some_and(|o| {
2386 o.tabs.len() > 1
2387 && o.key_capture.is_none()
2388 && state.settings_map_rows.len() == o.items.len()
2389 })
2390}
2391
2392fn selected_settings_map_row(state: &RenderState) -> Option<SettingsMapRow> {
2394 if !settings_map_editor_active(state) {
2395 return None;
2396 }
2397 let selected = state.overlay.as_ref().map(|o| o.selected)?;
2398 state.settings_map_rows.get(selected).cloned().flatten()
2399}
2400
2401fn try_edit_model_role(state: &mut RenderState) -> bool {
2404 match selected_settings_map_row(state) {
2405 Some(SettingsMapRow::ModelRole(role)) => {
2406 open_model_role_value_prompt(state, &role);
2407 true
2408 }
2409 _ => false,
2410 }
2411}
2412
2413fn try_remove_settings_map_row(state: &mut RenderState) -> bool {
2417 match selected_settings_map_row(state) {
2418 Some(SettingsMapRow::KeybindingCombo(action, combo)) => {
2419 remove_keybinding_combo(state, action, &combo);
2420 true
2421 }
2422 Some(SettingsMapRow::ModelRole(role)) => {
2423 let status = match crate::store::settings::Settings::load() {
2424 Ok(mut settings) => {
2425 let existed =
2426 crate::tui_vt::settings_defs::remove_model_role(&mut settings, &role);
2427 match settings.save() {
2428 Ok(()) if existed => format!("Removed model role '{role}'"),
2429 Ok(()) => format!("Role '{role}' was already gone"),
2430 Err(e) => format!("Failed to save model roles: {e}"),
2431 }
2432 }
2433 Err(e) => format!("Failed to load settings: {e}"),
2434 };
2435 reopen_settings_panel(state, SettingsTab::Model, Some(status));
2436 true
2437 }
2438 _ => false,
2439 }
2440}
2441
2442fn try_start_new_model_role(state: &mut RenderState) -> bool {
2445 if settings_map_editor_active(state) && state.settings_active_tab == SettingsTab::Model {
2446 open_model_role_key_prompt(state);
2447 true
2448 } else {
2449 false
2450 }
2451}
2452
2453fn open_text_edit_prompt(state: &mut RenderState, key: SettingKey) {
2462 let current = crate::store::settings::Settings::load()
2463 .map(|s| get_display_value(key, &s))
2464 .unwrap_or_default();
2465 state.secure_input_origin = Some(SecureInputOrigin::TextEdit(key));
2466 state.overlay = Some(text_prompt_overlay(
2467 format!("Edit {}", label_for_setting(key)),
2468 format!(
2469 "Enter the new value for {} (Esc to cancel). Empty clears the override when the field supports it.",
2470 label_for_setting(key)
2471 ),
2472 "value",
2473 None,
2474 if current == "default" {
2475 None
2476 } else {
2477 Some(current.as_str())
2478 },
2479 ));
2480}
2481
2482fn open_submenu_select_prompt(state: &mut RenderState, key: SettingKey) {
2486 let Some(options) = submenu_options_for(key) else {
2487 reopen_settings_panel(
2492 state,
2493 state.settings_active_tab,
2494 Some(format!("'{:?}' is not a submenu-select setting", key)),
2495 );
2496 return;
2497 };
2498 let current = crate::store::settings::Settings::load()
2499 .map(|s| get_display_value(key, &s))
2500 .unwrap_or_default();
2501 let items: Vec<OverlayListItem> = options
2502 .iter()
2503 .map(|opt| InlineListItem {
2504 title: (*opt).to_string(),
2505 subtitle: None,
2506 badge: if *opt == current {
2507 Some("current".to_string())
2508 } else {
2509 None
2510 },
2511 indent: 0,
2512 selection: Some(InlineListSelection::ConfigAction(format!(
2513 "SubmenuCommit:{:?}:{}",
2514 key, opt
2515 ))),
2516 search_value: None,
2517 })
2518 .map(overlay_item_from)
2519 .collect();
2520 let label = label_for_setting(key);
2521 state.overlay = Some(OverlayState {
2522 title: format!("Pick value for {label}"),
2523 lines: vec![format!("Esc cancels — current: {current}")],
2524 items,
2525 selected: options.iter().position(|o| *o == current).unwrap_or(0),
2526 ..Default::default()
2527 });
2528 state.settings_map_rows.clear();
2529}
2530
2531fn open_disabled_tools_multiselect(
2536 state: &mut RenderState,
2537 session: &crate::app::agent_session::AgentSessionHandle,
2538) {
2539 let mut tools = session.agent_ref().tools().get_tools();
2540 tools.sort_by(|a, b| a.name().cmp(b.name()));
2541 let settings = crate::store::settings::Settings::load().unwrap_or_default();
2542 let disabled: std::collections::HashSet<String> =
2543 settings.disabled_tools.iter().cloned().collect();
2544 let items: Vec<OverlayListItem> = tools
2545 .iter()
2546 .map(|t| {
2547 let name = t.name();
2548 let is_disabled = disabled.contains(name);
2549 InlineListItem {
2550 title: name.to_string(),
2551 subtitle: Some(t.description().to_string()),
2552 badge: Some(if t.essential() {
2553 if is_disabled {
2554 "essential — locked".to_string()
2555 } else {
2556 "essential".to_string()
2557 }
2558 } else if is_disabled {
2559 "disabled".to_string()
2560 } else {
2561 "enabled".to_string()
2562 }),
2563 indent: 0,
2564 selection: Some(InlineListSelection::ConfigAction(format!(
2565 "DisabledToolToggle:{}",
2566 name
2567 ))),
2568 search_value: None,
2569 }
2570 })
2571 .map(overlay_item_from)
2572 .collect();
2573 let disabled_count = items
2574 .iter()
2575 .filter(|i| {
2576 i.badge
2577 .as_deref()
2578 .map(|b| b == "disabled" || b == "essential — locked")
2579 .unwrap_or(false)
2580 })
2581 .count();
2582 state.overlay = Some(OverlayState {
2583 title: "Disabled tools".into(),
2584 lines: vec![format!(
2585 "{disabled_count} disabled — Enter/Space toggles, Esc closes"
2586 )],
2587 items,
2588 selected: 0,
2589 ..Default::default()
2590 });
2591 state.settings_map_rows.clear();
2592}
2593
2594fn commit_text_edit(
2603 state: &mut RenderState,
2604 handle: &InlineHandle,
2605 session: Option<&crate::app::agent_session::AgentSessionHandle>,
2606 key: SettingKey,
2607 text: String,
2608) -> (anyhow::Result<()>, String) {
2609 let label = label_for_setting(key);
2610 let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
2611 let outcome =
2612 crate::tui_vt::settings_defs::apply_change(key, &mut settings, text.trim().to_string())
2613 .and_then(|_| save_settings_sandboxed(state, &settings));
2614 let new_display = get_display_value(key, &settings);
2615 match &outcome {
2616 Ok(()) => {
2617 if let Some(session) = session {
2618 if let Err(e) = sync_settings_live(state, session, key, &settings) {
2621 handle.append_line(
2622 InlineMessageKind::Error,
2623 vec![plain_segment(format!("{label}: {e}"))],
2624 );
2625 }
2626 }
2627 let status = format!("{label}: {new_display}");
2628 handle.append_line(InlineMessageKind::Info, vec![plain_segment(status.clone())]);
2632 reopen_settings_panel(state, state.settings_active_tab, Some(status.clone()));
2633 (Ok(()), status)
2634 }
2635 Err(e) => {
2636 let msg = format!("{label}: {e}");
2637 handle.append_line(InlineMessageKind::Error, vec![plain_segment(msg.clone())]);
2638 (Err(anyhow::anyhow!("{e}")), msg)
2639 }
2640 }
2641}
2642
2643fn commit_submenu_choice(
2646 state: &mut RenderState,
2647 key: SettingKey,
2648 value: String,
2649) -> anyhow::Result<String> {
2650 let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
2651 crate::tui_vt::settings_defs::apply_change(key, &mut settings, value.clone())?;
2652 save_settings_sandboxed(state, &settings)?;
2653 let new_display = get_display_value(key, &settings);
2654 let label = label_for_setting(key);
2655 let status = format!("{label}: {new_display}");
2656 reopen_settings_panel(state, state.settings_active_tab, Some(status.clone()));
2657 Ok(status)
2658}
2659
2660fn commit_disabled_tool_toggle(
2664 state: &mut RenderState,
2665 handle: &InlineHandle,
2666 session: &crate::app::agent_session::AgentSessionHandle,
2667 tool: String,
2668 essential: bool,
2669 currently_disabled: bool,
2670) {
2671 let label = label_for_setting(SettingKey::DisabledTools);
2672 if essential {
2673 handle.append_line(
2674 InlineMessageKind::Error,
2675 vec![plain_segment(format!(
2676 "'{tool}' is essential and cannot be disabled"
2677 ))],
2678 );
2679 open_disabled_tools_multiselect(state, session);
2682 return;
2683 }
2684 let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
2685 let new_enabled = currently_disabled; crate::tui_vt::settings_defs::toggle_disabled_tool(&mut settings, &tool, new_enabled);
2687 match save_settings_sandboxed(state, &settings) {
2688 Ok(()) => {
2689 let new_state = if new_enabled { "enabled" } else { "disabled" };
2690 handle.append_line(
2691 InlineMessageKind::Info,
2692 vec![plain_segment(format!("{label}: '{tool}' {new_state}"))],
2693 );
2694 open_disabled_tools_multiselect(state, session);
2695 }
2696 Err(e) => {
2697 handle.append_line(
2698 InlineMessageKind::Error,
2699 vec![plain_segment(format!("{label}: failed to save: {e}"))],
2700 );
2701 }
2702 }
2703}
2704
2705fn label_for_setting(key: SettingKey) -> &'static str {
2708 SETTING_DEFS
2709 .iter()
2710 .find(|d| d.key == key)
2711 .map(|d| d.label)
2712 .unwrap_or("setting")
2713}
2714
2715fn parse_setting_key(name: &str) -> Option<SettingKey> {
2720 SETTING_DEFS
2721 .iter()
2722 .map(|d| d.key)
2723 .find(|k| format!("{k:?}") == name)
2724}
2725
2726fn submenu_options_for(key: SettingKey) -> Option<&'static [&'static str]> {
2729 SETTING_DEFS
2730 .iter()
2731 .find(|d| d.key == key)
2732 .and_then(|d| match d.widget {
2733 SettingWidget::SubmenuSelect(opts) => Some(opts),
2734 _ => None,
2735 })
2736}
2737
2738fn item_section_idx(overlay: &OverlayState, idx: usize) -> Option<usize> {
2742 let mut current: Option<String> = None;
2743 for (i, item) in overlay.items.iter().enumerate() {
2744 let is_heading =
2745 item.selection.is_none() && item.badge.is_none() && item.subtitle.is_none();
2746 if is_heading {
2747 current = Some(item.title.clone());
2748 }
2749 if i == idx {
2750 return current
2751 .as_deref()
2752 .and_then(|g| overlay.sections.iter().position(|s| s == g));
2753 }
2754 }
2755 None
2756}
2757
2758fn next_cycle_value(key: SettingKey, s: &crate::store::settings::Settings) -> Option<String> {
2761 match key {
2762 SettingKey::ThinkingLevel => {
2764 const LEVELS: [&str; 6] = ["off", "minimal", "low", "medium", "high", "xhigh"];
2765 let cur = get_display_value(key, s);
2766 let idx = LEVELS.iter().position(|l| *l == cur).unwrap_or(0);
2767 Some(LEVELS[(idx + 1) % LEVELS.len()].to_string())
2768 }
2769 SettingKey::GlyphSet => Some(s.glyph_set.next().to_string()),
2770 SettingKey::EditFormat => Some(
2771 if get_display_value(key, s) == "hashline" {
2772 "str_replace"
2773 } else {
2774 "hashline"
2775 }
2776 .to_string(),
2777 ),
2778 _ => None,
2779 }
2780}
2781
2782fn sync_settings_live(
2792 state: &mut RenderState,
2793 session: &crate::app::agent_session::AgentSessionHandle,
2794 key: SettingKey,
2795 settings: &crate::store::settings::Settings,
2796) -> anyhow::Result<()> {
2797 match key {
2798 SettingKey::ThinkingLevel => {
2799 session.set_thinking_level(settings.thinking_level);
2800 state.thinking_level = get_display_value(key, settings);
2801 }
2802 SettingKey::GlyphSet => state.glyph_set = settings.glyph_set,
2803 SettingKey::AutoCompaction => session.set_auto_compaction(settings.auto_compaction),
2804 SettingKey::AdvisorEnabled if session.is_advisor_enabled() != settings.advisor.enabled => {
2805 session
2806 .set_advisor_enabled(settings.advisor.enabled)
2807 .map_err(|e| {
2808 anyhow::anyhow!(
2809 "failed to {} the advisor live: {e} (saved; restart to apply)",
2810 if settings.advisor.enabled {
2811 "enable"
2812 } else {
2813 "disable"
2814 }
2815 )
2816 })?;
2817 }
2818 _ => {}
2819 }
2820 Ok(())
2821}
2822
2823fn handle_session_event(
2827 state: &mut RenderState,
2828 handle: &InlineHandle,
2829 event: &SessionEvent,
2830 session: Option<&crate::app::agent_session::AgentSessionHandle>,
2831) {
2832 match event {
2833 SessionEvent::Agent(boxed) => {
2834 let event = *boxed.clone();
2835 if let (AgentEvent::Error { message, .. }, Some(session)) = (&event, session)
2836 && is_missing_api_key_error(message)
2837 {
2838 let provider = provider_from_model_id(&session.model_id());
2839 handle.append_line(
2840 InlineMessageKind::Info,
2841 vec![plain_segment(format!(
2842 "Authentication is required for '{provider}'. Enter an API key to continue."
2843 ))],
2844 );
2845 open_secure_prompt(state, handle, SecureInputOrigin::SetKey { provider });
2846 }
2847 map_agent_event(handle, event, state);
2848 }
2849 SessionEvent::CompactionStart { .. } => {
2850 handle.set_reasoning_stage(Some("Compacting\u{2026}".to_string()));
2851 }
2852 SessionEvent::CompactionEnd { error_message, .. } => {
2853 handle.set_reasoning_stage(None);
2854 if let Some(msg) = error_message {
2855 handle.append_line(
2856 InlineMessageKind::Error,
2857 vec![plain_segment(format!("Compaction failed: {msg}"))],
2858 );
2859 }
2860 }
2861 SessionEvent::ThinkingLevelChanged { level } => {
2862 state.thinking_level = format!("{level:?}").to_ascii_lowercase();
2863 }
2864 SessionEvent::QueueUpdate { .. } => {
2865 let pending = state.transcript.len();
2869 handle.set_input_status(
2870 None,
2871 Some(if pending == 0 {
2872 "ready".to_string()
2873 } else {
2874 "queued".to_string()
2875 }),
2876 );
2877 }
2878 SessionEvent::Advisor { body, .. } => {
2879 handle.append_line(InlineMessageKind::Info, vec![plain_segment(body.clone())]);
2880 }
2881 SessionEvent::SessionInfoChanged => {
2882 }
2885 SessionEvent::HandoffComplete { .. } => {
2886 }
2890 SessionEvent::HandoffFailed { error } => {
2891 handle.append_line(
2892 InlineMessageKind::Error,
2893 vec![plain_segment(format!("Handoff failed: {}", error))],
2894 );
2895 }
2896 }
2897}
2898
2899fn is_missing_api_key_error(message: &str) -> bool {
2903 let message = message.to_ascii_lowercase();
2904 message.contains("missing api key") || message.contains("api key is required")
2905}
2906
2907fn provider_from_model_id(model_id: &str) -> String {
2910 model_id
2911 .split_once('/')
2912 .map(|(provider, _)| provider)
2913 .filter(|provider| !provider.is_empty())
2914 .unwrap_or("provider")
2915 .to_string()
2916}
2917
2918pub(crate) fn apply_model_to_chips(state: &mut RenderState, model_id: &str, ctx_window: usize) {
2926 if model_id.is_empty() {
2927 return;
2928 }
2929 state.header_context.provider = provider_from_model_id(model_id);
2930 state.header_context.model = model_id.to_string();
2931 state.header_context.editor_context = Some(model_id.to_string());
2932 if ctx_window > 0 {
2933 state.context_window = ctx_window;
2934 }
2935}
2936
2937pub(crate) fn sync_model_chips(
2939 state: &mut RenderState,
2940 session: &crate::app::agent_session::AgentSessionHandle,
2941) {
2942 apply_model_to_chips(state, &session.model_id(), session.context_window());
2943}
2944fn render_streamed_message(state: &mut RenderState) -> Vec<Vec<InlineSegment>> {
2950 let mut lines = Vec::new();
2951 if !state.thinking_buffer.is_empty() {
2952 let styles = active_styles();
2953 let mut style = InlineTextStyle::default();
2954 style.color = styles.reasoning.get_fg_color();
2955 style.effects |= anstyle::Effects::DIMMED | anstyle::Effects::ITALIC;
2956 for chunk in state.thinking_buffer.split('\n') {
2957 lines.push(vec![InlineSegment {
2958 text: chunk.to_string(),
2959 style: Arc::new(style.clone()),
2960 }]);
2961 }
2962 if !state.message_buffer.is_empty() {
2965 lines.push(vec![plain_segment("")]);
2966 }
2967 }
2968 let body = revealed_stream_body(state).to_string();
2969 if !body.is_empty() {
2970 let (_, content_w) = super::frame_layout::scrollback_geometry(Rect {
2974 x: 0,
2975 y: 0,
2976 width: state.viewport_width,
2977 height: 24,
2978 });
2979 lines.extend(oxicode_vtui::tui::ui::markdown::render_markdown_cached(
2980 &body,
2981 content_w as usize,
2982 &mut state.md_cache,
2983 ));
2984 }
2985 lines
2986}
2987
2988fn revealed_stream_body(state: &RenderState) -> &str {
2991 if state.stream_reveal == usize::MAX {
2992 &state.message_buffer
2993 } else {
2994 let idx = floor_char_boundary(&state.message_buffer, state.stream_reveal);
2995 &state.message_buffer[..idx]
2996 }
2997}
2998
2999fn floor_char_boundary(s: &str, mut i: usize) -> usize {
3002 if i >= s.len() {
3003 return s.len();
3004 }
3005 while !s.is_char_boundary(i) {
3006 i -= 1;
3007 }
3008 i
3009}
3010
3011fn advance_stream_reveal(state: &mut RenderState) -> bool {
3021 if state.stream_anchor.is_none() || state.stream_reveal == usize::MAX {
3022 return false;
3023 }
3024 let len = state.message_buffer.len();
3025 if state.stream_reveal >= len {
3026 state.stream_reveal = len;
3027 return false;
3028 }
3029 let remaining = len - state.stream_reveal;
3030 let step = (remaining / 6).max(8);
3031 let target = (state.stream_reveal + step).min(len);
3032 state.stream_reveal = floor_char_boundary(&state.message_buffer, target);
3033 let lines = render_streamed_message(state);
3036 let from = state.stream_anchor.unwrap_or(state.transcript.len());
3037 state.transcript.truncate(from);
3038 for line in lines {
3039 state.append_line(InlineMessageKind::Agent, line);
3040 }
3041 state.stream_anchor = Some(from);
3042 true
3043}
3044
3045fn tool_args_preview(args: &serde_json::Value) -> String {
3051 use serde_json::Value;
3052 let raw = match args {
3053 Value::Null => return String::new(),
3054 Value::String(s) => s.clone(),
3055 Value::Object(map) => {
3056 if let Some(Value::String(cmd)) = map.get("command") {
3057 cmd.clone()
3058 } else {
3059 map.iter()
3060 .filter_map(|(k, v)| match v {
3061 Value::String(s) => Some(format!("{k}={s}")),
3062 _ => None,
3063 })
3064 .take(3)
3065 .collect::<Vec<_>>()
3066 .join(" ")
3067 }
3068 }
3069 other => other.to_string(),
3070 };
3071 if raw.chars().count() > 72 {
3072 let head: String = raw.chars().take(71).collect();
3073 format!("{head}\u{2026}")
3074 } else {
3075 raw
3076 }
3077}
3078fn tool_box_width(state: &RenderState) -> usize {
3083 let area = Rect {
3084 x: 0,
3085 y: 0,
3086 width: state.viewport_width,
3087 height: 24,
3088 };
3089 let (_x, w) = super::frame_layout::scrollback_geometry(area);
3090 w.max(24) as usize
3091}
3092
3093fn border_segment(text: impl Into<String>, color: anstyle::Color) -> InlineSegment {
3094 let mut style = InlineTextStyle::default();
3095 style.color = Some(color);
3096 InlineSegment {
3097 text: text.into(),
3098 style: Arc::new(style),
3099 }
3100}
3101
3102fn tool_box_top(w: usize, color: anstyle::Color) -> Vec<InlineSegment> {
3104 vec![border_segment(
3105 format!("\u{256D}{}\u{256E}", "\u{2500}".repeat(w.saturating_sub(2))),
3106 color,
3107 )]
3108}
3109
3110fn tool_box_bottom(w: usize, color: anstyle::Color) -> Vec<InlineSegment> {
3112 vec![border_segment(
3113 format!("\u{2570}{}\u{256F}", "\u{2500}".repeat(w.saturating_sub(2))),
3114 color,
3115 )]
3116}
3117
3118fn tool_box_divider(label: &str, w: usize, color: anstyle::Color) -> Vec<InlineSegment> {
3120 let text = format!(" {label} ");
3121 let dashes = w
3122 .saturating_sub(2)
3123 .saturating_sub(text.chars().count())
3124 .saturating_sub(2);
3125 vec![border_segment(
3126 format!(
3127 "\u{251C}\u{2500}{}{}\u{2500}\u{2524}",
3128 text,
3129 "\u{2500}".repeat(dashes)
3130 ),
3131 color,
3132 )]
3133}
3134
3135fn tool_box_rows(
3138 text: &str,
3139 w: usize,
3140 style: InlineTextStyle,
3141 color: anstyle::Color,
3142) -> Vec<Vec<InlineSegment>> {
3143 let inner = w.saturating_sub(4).max(1);
3144 text.split('\n')
3145 .map(|line| expand_tabs(line, TAB_WIDTH))
3146 .flat_map(|line| wrap_by_display_width(&line, inner))
3147 .map(|chunk| {
3148 let pad = inner.saturating_sub(chunk.width());
3151 vec![
3152 border_segment("\u{2502} ", color),
3153 InlineSegment {
3154 text: chunk,
3155 style: Arc::new(style.clone()),
3156 },
3157 border_segment(format!("{} \u{2502}", " ".repeat(pad)), color),
3158 ]
3159 })
3160 .collect()
3161}
3162
3163fn wrap_by_display_width(line: &str, inner: usize) -> Vec<String> {
3166 use unicode_width::UnicodeWidthChar as _;
3167 if line.width() <= inner {
3168 return vec![line.to_string()];
3169 }
3170 let mut out: Vec<String> = Vec::new();
3171 let mut cur = String::new();
3172 let mut cur_w = 0usize;
3173 for ch in line.chars() {
3174 let ch_w = ch.width().unwrap_or(0);
3175 if cur_w + ch_w > inner && !cur.is_empty() {
3176 out.push(std::mem::take(&mut cur));
3177 cur_w = 0;
3178 }
3179 cur.push(ch);
3180 cur_w += ch_w;
3181 }
3182 if !cur.is_empty() {
3183 out.push(cur);
3184 }
3185 out
3186}
3187
3188const TAB_WIDTH: usize = 4;
3194
3195fn expand_tabs(line: &str, tab_width: usize) -> String {
3197 use unicode_width::UnicodeWidthChar as _;
3198 if !line.contains('\t') {
3199 return line.to_string();
3200 }
3201 let mut out = String::with_capacity(line.len() + tab_width);
3202 let mut col = 0usize;
3203 for ch in line.chars() {
3204 if ch == '\t' {
3205 let spaces = tab_width - (col % tab_width);
3206 for _ in 0..spaces {
3207 out.push(' ');
3208 col += 1;
3209 }
3210 } else {
3211 out.push(ch);
3212 col += ch.width().unwrap_or(1).max(1);
3213 }
3214 }
3215 out
3216}
3217
3218fn diff_rows(content: &str) -> Option<Vec<(String, InlineTextStyle)>> {
3221 let lines: Vec<&str> = content.lines().collect();
3222 if !lines.iter().any(|l| l.starts_with("@@")) {
3226 return None;
3227 }
3228 let additions = lines
3229 .iter()
3230 .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
3231 .count();
3232 let deletions = lines
3233 .iter()
3234 .filter(|l| l.starts_with('-') && !l.starts_with("---"))
3235 .count();
3236 if additions + deletions < 2 {
3237 return None;
3238 }
3239
3240 let styles = active_styles();
3241 let green = styles.secondary.get_fg_color();
3242 let red = styles.error.get_fg_color();
3243 const MAX_DIFF_LINES: usize = 30;
3244
3245 let mut rows: Vec<(String, InlineTextStyle)> = Vec::new();
3246 let mut hdr = InlineTextStyle::default();
3247 hdr.effects |= anstyle::Effects::DIMMED;
3248 rows.push((format!("diff +{additions} -{deletions}"), hdr));
3249 for line in lines.iter().take(MAX_DIFF_LINES) {
3250 let mut style = InlineTextStyle::default();
3251 if line.starts_with('+') && !line.starts_with("+++") {
3252 style.color = green;
3253 } else if line.starts_with('-') && !line.starts_with("---") {
3254 style.color = red;
3255 } else {
3256 style.effects |= anstyle::Effects::DIMMED;
3257 }
3258 rows.push(((*line).to_string(), style));
3259 }
3260 if lines.len() > MAX_DIFF_LINES {
3261 let mut more = InlineTextStyle::default();
3262 more.effects |= anstyle::Effects::DIMMED;
3263 rows.push((
3264 format!("\u{2026} +{} lines", lines.len() - MAX_DIFF_LINES),
3265 more,
3266 ));
3267 }
3268 Some(rows)
3269}
3270
3271fn map_agent_event(handle: &InlineHandle, event: AgentEvent, state: &mut RenderState) {
3272 match event {
3273 AgentEvent::TextChunk { text } => {
3274 state.reasoning_stage = Some("generating response".to_string());
3275 state.message_buffer.push_str(&text);
3276 handle.inline(InlineMessageKind::Agent, plain_segment(text));
3277 }
3278 AgentEvent::AgentStart { .. } => {
3279 state.active_run = Some(RunState::default());
3283 }
3284 AgentEvent::MessageStart { .. } => {
3285 if let Some(run) = &mut state.active_run {
3286 run.turn += 1;
3287 }
3288 state.reasoning_stage = Some("generating response".to_string());
3289 state.message_buffer.clear();
3290 state.thinking_buffer.clear();
3291 state.stream_reveal = 0;
3292 handle.begin_stream(InlineMessageKind::Agent);
3297 }
3298 AgentEvent::MessageUpdate { delta, .. } => match &delta {
3299 oxicode_sdk::StreamDelta::Text(text) => {
3300 state.reasoning_stage = Some("generating response".to_string());
3306 state.message_buffer.push_str(text);
3307 handle.replace_last(0, InlineMessageKind::Agent, render_streamed_message(state));
3308 }
3309 oxicode_sdk::StreamDelta::Thinking(text) => {
3310 state.reasoning_stage = Some("thinking\u{2026}".to_string());
3316 state.thinking_buffer.push_str(text);
3317 handle.replace_last(0, InlineMessageKind::Agent, render_streamed_message(state));
3318 }
3319 oxicode_sdk::StreamDelta::Sync => {
3320 if !state.message_buffer.is_empty() || !state.thinking_buffer.is_empty() {
3322 handle.replace_last(
3323 0,
3324 InlineMessageKind::Agent,
3325 render_streamed_message(state),
3326 );
3327 state.message_buffer.clear();
3328 state.thinking_buffer.clear();
3329 state.stream_reveal = 0;
3330 }
3331 }
3332 },
3333 AgentEvent::MessageEnd { message } => {
3334 if state.active_run.is_none() {
3339 state.reasoning_stage = None;
3340 }
3341 if let oxicode_ai::Message::Assistant(a) = &message {
3349 state.thinking_buffer = a
3350 .content
3351 .iter()
3352 .filter_map(|b| b.as_thinking().map(|t| t.thinking.clone()))
3353 .collect();
3354 state.message_buffer = a.text_content();
3355 state.stream_reveal = usize::MAX;
3356 }
3357 if !state.message_buffer.is_empty() || !state.thinking_buffer.is_empty() {
3358 handle.replace_last(0, InlineMessageKind::Agent, render_streamed_message(state));
3359 state.message_buffer.clear();
3360 state.thinking_buffer.clear();
3361 }
3362 handle.end_stream();
3366 }
3367 AgentEvent::ToolExecutionStart {
3368 tool_name, args, ..
3369 } => {
3370 let styles = active_styles();
3373 let border = styles
3374 .tool
3375 .get_fg_color()
3376 .unwrap_or(anstyle::Color::Ansi(anstyle::AnsiColor::White));
3377 let w = tool_box_width(state);
3378 let header = match args.get("command").and_then(|v| v.as_str()) {
3379 Some(cmd) => format!("$ {cmd}"),
3380 None => {
3381 let preview = tool_args_preview(&args);
3382 if preview.is_empty() {
3383 tool_name.clone()
3384 } else {
3385 format!("{tool_name} {preview}")
3386 }
3387 }
3388 };
3389 handle.append_line_block_start(InlineMessageKind::Tool, tool_box_top(w, border));
3390 for row in tool_box_rows(&header, w, InlineTextStyle::default(), border) {
3391 handle.append_line(InlineMessageKind::Tool, row);
3392 }
3393 let stage = format!("tool: {tool_name}");
3394 if let Some(run) = &mut state.active_run {
3395 run.tool_calls += 1;
3396 }
3397 state.reasoning_stage = Some(stage.clone());
3398 handle.set_reasoning_stage(Some(stage));
3399 }
3400 AgentEvent::ToolExecutionEnd {
3401 tool_name,
3402 result,
3403 is_error,
3404 ..
3405 } => {
3406 let styles = active_styles();
3410 let (border, label) = if is_error {
3411 (
3412 styles
3413 .error
3414 .get_fg_color()
3415 .unwrap_or(anstyle::Color::Ansi(anstyle::AnsiColor::White)),
3416 "Error",
3417 )
3418 } else {
3419 (
3420 styles
3421 .tool
3422 .get_fg_color()
3423 .unwrap_or(anstyle::Color::Ansi(anstyle::AnsiColor::White)),
3424 "Output",
3425 )
3426 };
3427 let w = tool_box_width(state);
3428 handle.append_line(InlineMessageKind::Tool, tool_box_divider(label, w, border));
3429 let embedded_png = if tool_name == "generate_image" && !is_error {
3436 extract_generated_png(&result.content)
3437 } else {
3438 None
3439 };
3440 if let Some(png) = embedded_png {
3441 let id = super::image_preview::content_hash_id(&png);
3442 let label = format!("generate_image:{id:08x}");
3443 let mut dim = InlineTextStyle::default();
3444 dim.effects |= anstyle::Effects::DIMMED;
3445 let fallback = super::image_preview::text_fallback(&label);
3446 for row in tool_box_rows(&fallback, w, dim, border) {
3447 handle.append_line(InlineMessageKind::Tool, row);
3448 }
3449 state
3452 .image_previews
3453 .enqueue(id, std::sync::Arc::new(png), label);
3454 } else if let Some(rows) = diff_rows(&result.content) {
3455 for (text, style) in rows {
3456 for row in tool_box_rows(&text, w, style, border) {
3457 handle.append_line(InlineMessageKind::Tool, row);
3458 }
3459 }
3460 } else {
3461 const MAX_BOX_LINES: usize = 12;
3462 let preview = preview_tool_result(&result.content);
3463 let lines: Vec<&str> = preview.split('\n').collect();
3464 let mut dim = InlineTextStyle::default();
3465 dim.effects |= anstyle::Effects::DIMMED;
3466 for line in lines.iter().take(MAX_BOX_LINES) {
3467 for row in tool_box_rows(line, w, dim.clone(), border) {
3468 handle.append_line(InlineMessageKind::Tool, row);
3469 }
3470 }
3471 if lines.len() > MAX_BOX_LINES {
3472 let more = format!("\u{2026} +{} lines", lines.len() - MAX_BOX_LINES);
3473 for row in tool_box_rows(&more, w, dim, border) {
3474 handle.append_line(InlineMessageKind::Tool, row);
3475 }
3476 }
3477 }
3478 handle.append_line(InlineMessageKind::Tool, tool_box_bottom(w, border));
3479 state.reasoning_stage = Some("generating response".to_string());
3480 handle.set_reasoning_stage(Some("generating response".to_string()));
3481 handle.set_input_enabled(true);
3482 }
3483 AgentEvent::Error { message, .. } => {
3484 handle.append_line(InlineMessageKind::Error, vec![plain_segment(message)]);
3485 state.active_run = None;
3486 state.reasoning_stage = None;
3487 handle.set_input_enabled(true);
3488 handle.set_input_status(None, None);
3489 }
3490 AgentEvent::Compaction { .. } => {
3491 }
3494 AgentEvent::Cancelled => {
3495 state.active_run = None;
3496 state.reasoning_stage = None;
3497 handle.set_input_enabled(true);
3498 handle.set_input_status(None, Some("cancelled".to_string()));
3499 }
3500 AgentEvent::AutoRetryStart {
3501 attempt,
3502 max_attempts,
3503 ..
3504 } => {
3505 state.reasoning_stage = Some(format!("retrying {attempt} of {max_attempts}"));
3506 handle.set_input_status(None, Some(format!("retry {attempt}/{max_attempts}")));
3507 }
3508 AgentEvent::TurnEnd { .. } => {
3509 crate::tui_vt::notifications::emit_notification("oxicode", "Response complete");
3513 state.drain_queue_head();
3517 if state.active_run.is_none() {
3521 handle.set_reasoning_stage(None);
3522 }
3523 }
3524 AgentEvent::Usage { input_tokens, .. } => {
3525 state.context_tokens = Some(input_tokens);
3530 }
3531 AgentEvent::AgentEnd { .. } => {
3532 state.active_run = None;
3535 state.reasoning_stage = None;
3536 handle.set_reasoning_stage(None);
3537 }
3538 AgentEvent::TodoReminder { open, attempt, max } => {
3539 let header = format!(
3542 "⚠ {} incomplete todo{} — reminder {attempt}/{max}",
3543 open.len(),
3544 if open.len() == 1 { "" } else { "s" }
3545 );
3546 handle.append_line(InlineMessageKind::Warning, vec![plain_segment(header)]);
3547 for t in &open {
3548 handle.append_line(
3549 InlineMessageKind::Warning,
3550 vec![plain_segment(format!(" ☐ {}", t.content))],
3551 );
3552 }
3553 }
3554 _ => {
3555 tracing::debug!(?event, "ignored AgentEvent variant");
3559 }
3560 }
3561}
3562
3563pub(crate) fn next_provider_actions(has_key: bool, oauth_capable: bool) -> Vec<AuthAction> {
3570 match (has_key, oauth_capable) {
3571 (true, true) => vec![
3572 AuthAction::SetApiKey,
3573 AuthAction::StartOAuth,
3574 AuthAction::RemoveKey,
3575 ],
3576 (true, false) => vec![AuthAction::SetApiKey, AuthAction::RemoveKey],
3577 (false, true) => vec![AuthAction::SetApiKey, AuthAction::StartOAuth],
3578 (false, false) => vec![AuthAction::SetApiKey],
3579 }
3580}
3581
3582pub(crate) fn open_secure_prompt(
3595 state: &mut RenderState,
3596 handle: &InlineHandle,
3597 origin: SecureInputOrigin,
3598) {
3599 let provider = match &origin {
3602 SecureInputOrigin::SetKey { provider } | SecureInputOrigin::NewlyAdded { provider } => {
3603 provider.clone()
3604 }
3605 SecureInputOrigin::ModelRoleKey | SecureInputOrigin::ModelRoleValue { .. } => return,
3606 SecureInputOrigin::TextEdit(_) => return,
3607 };
3608 state.secure_input_origin = Some(origin);
3609 handle.show_modal(
3610 format!("Set API key for {provider}"),
3611 vec![
3612 "Paste the API key. Press Enter to save, Esc to cancel.".into(),
3613 "The key is masked on screen; nothing is logged.".into(),
3614 ],
3615 Some(SecurePromptConfig {
3616 label: format!("{provider} key"),
3617 placeholder: Some("sk-...".into()),
3618 mask_input: true,
3619 }),
3620 );
3621}
3622
3623pub(crate) fn handle_auth_action(
3633 provider: &str,
3634 action: &AuthAction,
3635 auth: &Arc<crate::store::auth_storage::AuthStorage>,
3636 handle: &InlineHandle,
3637 state: &mut RenderState,
3638) -> bool {
3639 match action {
3652 AuthAction::SetApiKey => {
3653 open_secure_prompt(
3654 state,
3655 handle,
3656 SecureInputOrigin::SetKey {
3657 provider: provider.to_string(),
3658 },
3659 );
3660 true
3661 }
3662 AuthAction::StartOAuth => {
3663 let spec = match crate::provider_oauth::spec_for(provider) {
3674 Some(s) => s,
3675 None => {
3676 handle.append_line(
3677 InlineMessageKind::Error,
3678 vec![plain_segment(format!(
3679 "OAuth: no OAuth config for '{provider}'."
3680 ))],
3681 );
3682 return false;
3683 }
3684 };
3685 let provider_owned = provider.to_string();
3690 let tx = handle.clone();
3691 let auth_clone = Arc::clone(auth);
3692 tokio::spawn(async move {
3693 run_oauth_flow(provider_owned, spec, tx, auth_clone).await;
3694 });
3695 false
3696 }
3697 AuthAction::RemoveKey => {
3698 state.confirmation = Some(ModalConfirmation {
3699 title: format!("Remove key for {provider}?"),
3700 message: " y \u{2014} remove key n / x \u{2014} cancel".into(),
3701 action: ConfirmationAction::RemoveProviderKey(provider.to_string()),
3702 });
3703 false
3704 }
3705 }
3706}
3707
3708pub(crate) async fn run_oauth_flow(
3736 provider: String,
3737 spec: crate::provider_oauth::ProviderOAuthSpec,
3738 handle: InlineHandle,
3739 auth: Arc<crate::store::auth_storage::AuthStorage>,
3740) {
3741 use std::time::Duration;
3742 let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0u16)).await {
3751 Ok(l) => l,
3752 Err(e) => {
3753 handle.append_line(
3754 InlineMessageKind::Error,
3755 vec![plain_segment(format!(
3756 "OAuth: could not bind loopback listener for '{provider}': {e}"
3757 ))],
3758 );
3759 return;
3760 }
3761 };
3762 let port = match listener.local_addr() {
3763 Ok(addr) => addr.port(),
3764 Err(e) => {
3765 handle.append_line(
3766 InlineMessageKind::Error,
3767 vec![plain_segment(format!(
3768 "OAuth: could not read loopback port for '{provider}': {e}"
3769 ))],
3770 );
3771 return;
3772 }
3773 };
3774
3775 let (verifier, challenge) = crate::provider_oauth::pkce_pair();
3779 let state_token = crate::provider_oauth::pkce_pair().0; let auth_url = crate::provider_oauth::build_auth_url(&spec, port, &state_token, &challenge);
3785 handle.append_line(
3786 InlineMessageKind::Info,
3787 vec![plain_segment(format!(
3788 "OAuth: opening browser for '{provider}' on http://127.0.0.1:{port}{}",
3789 spec.redirect_path
3790 ))],
3791 );
3792 let callback_timeout = match crate::provider_oauth::open_browser(&auth_url) {
3800 Ok(()) => Duration::from_secs(120),
3801 Err(e) => {
3802 handle.append_line(
3803 InlineMessageKind::Info,
3804 vec![plain_segment(format!(
3805 "OAuth: could not open a browser ({e}).\nOpen this URL manually within 5 minutes:\n {auth_url}"
3806 ))],
3807 );
3808 Duration::from_secs(300)
3809 }
3810 };
3811
3812 let callback = match crate::oauth_listener::await_callback(
3815 listener,
3816 state_token.clone(),
3817 spec.redirect_path.clone(),
3818 callback_timeout,
3819 )
3820 .await
3821 {
3822 Ok(c) => c,
3823 Err(crate::oauth_listener::CallbackError::Timeout) => {
3824 handle.append_line(
3825 InlineMessageKind::Error,
3826 vec![plain_segment(format!(
3827 "OAuth: timed out waiting for '{provider}' callback (after {}s)",
3828 callback_timeout.as_secs()
3829 ))],
3830 );
3831 return;
3832 }
3833 Err(e) => {
3834 handle.append_line(
3835 InlineMessageKind::Error,
3836 vec![plain_segment(format!(
3837 "OAuth: callback failed for '{provider}': {e}"
3838 ))],
3839 );
3840 return;
3841 }
3842 };
3843
3844 let tokens =
3846 match crate::provider_oauth::exchange_code(&spec, port, &callback.code, &verifier).await {
3847 Ok(t) => t,
3848 Err(e) => {
3849 handle.append_line(
3850 InlineMessageKind::Error,
3851 vec![plain_segment(format!(
3852 "OAuth: token exchange failed for '{provider}': {e}"
3853 ))],
3854 );
3855 return;
3856 }
3857 };
3858
3859 let new_expires_at: u64 = tokens.expires_at.max(0) as u64;
3865 let access_token_len = tokens.access_token.chars().count();
3866 auth.set_oauth_full(
3869 &provider,
3870 tokens.access_token,
3871 tokens.refresh_token,
3872 new_expires_at,
3873 if tokens.scopes.is_empty() {
3874 None
3875 } else {
3876 Some(tokens.scopes.join(" "))
3877 },
3878 None,
3879 );
3880 handle.append_line(
3881 InlineMessageKind::Info,
3882 vec![plain_segment(format!(
3883 "OAuth: '{provider}' logged in. Token stored ({} chars).",
3884 access_token_len
3885 ))],
3886 );
3887}
3888
3889fn handle_inline_event(
3891 state: &mut RenderState,
3892 handle: &InlineHandle,
3893 session: &crate::app::agent_session::AgentSessionHandle,
3894 prompt_queue: &Arc<PromptQueue>,
3895 evt: InlineEvent,
3896) -> LoopOutcome {
3897 match evt {
3898 InlineEvent::Submit(text) => {
3899 if let Some(path) = state.pending_resume.take() {
3901 let swapper = state.swapper();
3902 let agent_arc = Arc::clone(&session.agent_arc());
3903 let settings = session.settings_clone();
3904 let session_state = state
3905 .session_state
3906 .clone()
3907 .expect("RenderState::session_state must be initialized at TUI startup");
3908 let path_for_log = path.clone();
3909 let handle = handle.clone();
3910 let swapper_for_swap = swapper.clone();
3911 tokio::spawn(async move {
3912 match crate::app::agent_session::resume_from_file(
3913 agent_arc,
3914 settings,
3915 session_state,
3916 &path,
3917 None,
3918 )
3919 .await
3920 {
3921 Ok(new_session) => {
3922 swapper_for_swap.swap(new_session.clone_handle());
3923 let n = new_session.messages().len();
3924 let id = new_session.session_id();
3925 handle.append_line(
3926 InlineMessageKind::Info,
3927 vec![plain_segment(format!(
3928 "Resumed session {id} ({n} messages)"
3929 ))],
3930 );
3931 }
3932 Err(crate::app::agent_session::ResumeError::FileNotFound(p)) => {
3933 handle.append_line(
3934 InlineMessageKind::Error,
3935 vec![plain_segment(format!("No session file: {}", p.display()))],
3936 );
3937 }
3938 Err(crate::app::agent_session::ResumeError::CwdInvalid(cwd)) => {
3939 handle.append_line(
3940 InlineMessageKind::Error,
3941 vec![plain_segment(format!(
3942 "Cannot resume {}: the session was recorded in `{cwd}`, which no longer exists. \
3943 Use /export to save its content, then /clear.",
3944 path_for_log.display()
3945 ))],
3946 );
3947 }
3948 }
3949 });
3950 return LoopOutcome::Continue;
3951 }
3952 let prompt = text.to_string();
3956 state.composer.set_text("");
3957 if prompt.is_empty() {
3958 return LoopOutcome::Continue;
3959 }
3960 state.pending_quit = false;
3961 if prompt.trim_start().starts_with('/') {
3965 state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
3966 let mut ctx = SlashCtx {
3967 session,
3968 handle,
3969 state,
3970 };
3971 return match SlashRegistry::builtins().dispatch(&prompt, &mut ctx) {
3972 SlashOutcome::Quit => LoopOutcome::Exit,
3973 SlashOutcome::Handled => LoopOutcome::Continue,
3974 SlashOutcome::NotHandled => {
3975 if let Some(expanded) = crate::tui_vt::slash::file_commands::try_expand(
3977 &ctx.state.file_commands,
3978 &prompt,
3979 ) {
3980 prompt_queue.enqueue(expanded);
3983 LoopOutcome::Continue
3984 } else {
3985 ctx.reply(
3986 InlineMessageKind::Error,
3987 format!("Unknown command: {}", prompt.trim()),
3988 );
3989 LoopOutcome::Continue
3990 }
3991 }
3992 };
3993 }
3994 state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
3995 if session.is_streaming() {
3999 state.queued_inputs.push(prompt.clone());
4000 state.show_tip(
4001 "send_now",
4002 "Ctrl+Enter sends now | Ctrl+; manages queue",
4003 240,
4004 true,
4005 );
4006 }
4007 prompt_queue.enqueue(prompt);
4010 }
4011 InlineEvent::Cancel => {
4012 return match route_cancel(session.is_streaming()) {
4018 CancelRoute::Interrupt => handle_interrupt(state, session, handle),
4019 CancelRoute::Exit => LoopOutcome::Exit,
4020 };
4021 }
4022 InlineEvent::Exit => {
4023 return LoopOutcome::Exit;
4024 }
4025 InlineEvent::Interrupt => {
4026 return handle_interrupt(state, session, handle);
4027 }
4028 InlineEvent::ScrollLineUp => {
4029 state.scroll_offset = state.scroll_offset.saturating_add(1);
4030 }
4031 InlineEvent::ScrollLineDown => {
4032 state.scroll_offset = state.scroll_offset.saturating_sub(1);
4033 }
4034 InlineEvent::ScrollPageUp => {
4035 state.scroll_offset = state.scroll_offset.saturating_add(10);
4036 }
4037 InlineEvent::ScrollPageDown => {
4038 state.scroll_offset = state.scroll_offset.saturating_sub(10);
4039 }
4040 InlineEvent::CyclePrimaryAgent => {
4041 let _ = session.cycle_model();
4042 }
4043 InlineEvent::CyclePrimaryAgentPrevious => {
4044 let _ = session.cycle_model();
4047 }
4048 InlineEvent::Overlay(overlay_evt) => {
4049 use oxicode_vtui::tui::core::OverlayEvent;
4050 match overlay_evt {
4051 OverlayEvent::Submitted(sub) => {
4052 let mut opened_new_overlay = false;
4062 if let OverlaySubmission::Selection(InlineListSelection::Model(idx)) = &sub
4064 && idx < &state.overlay_model_ids.len()
4065 {
4066 let model_id = state.overlay_model_ids[*idx].clone();
4067 match session.set_model(&model_id) {
4068 Ok(()) => {
4069 sync_model_chips(state, session);
4070 handle.append_line(
4071 InlineMessageKind::Info,
4072 vec![plain_segment(format!("Switched to {model_id}"))],
4073 );
4074 }
4075 Err(e) => handle.append_line(
4076 InlineMessageKind::Error,
4077 vec![plain_segment(format!("Failed to set model: {e}"))],
4078 ),
4079 }
4080 }
4081 if let OverlaySubmission::Selection(InlineListSelection::Theme(theme_id)) = &sub
4083 {
4084 match oxicode_vtui::theme::set_active_theme(theme_id) {
4085 Ok(()) => {
4086 let label = oxicode_vtui::theme::theme_label(theme_id)
4087 .unwrap_or(theme_id.as_ref())
4088 .to_string();
4089 handle.append_line(
4090 InlineMessageKind::Info,
4091 vec![plain_segment(format!("Theme: {label}"))],
4092 );
4093 }
4094 Err(e) => handle.append_line(
4095 InlineMessageKind::Error,
4096 vec![plain_segment(format!("Unknown theme: {e}"))],
4097 ),
4098 }
4099 }
4100 if let OverlaySubmission::Selection(InlineListSelection::SlashCommand(name)) =
4102 &sub
4103 {
4104 state.composer.set_text(&format!("/{name} "));
4105 }
4106 let synthetic_dispatched = if let OverlaySubmission::Selection(
4117 InlineListSelection::ConfigAction(payload),
4118 ) = &sub
4119 {
4120 if let Some(rest) = payload.strip_prefix("SubmenuCommit:") {
4121 if let Some((key_str, value)) = rest.split_once(':') {
4122 if let Some(key) = parse_setting_key(key_str) {
4123 match commit_submenu_choice(state, key, value.to_string()) {
4124 Ok(status) => {
4125 handle.append_line(
4126 InlineMessageKind::Info,
4127 vec![plain_segment(status.clone())],
4128 );
4129 opened_new_overlay = state.overlay.is_some();
4130 }
4131 Err(e) => handle.append_line(
4132 InlineMessageKind::Error,
4133 vec![plain_segment(format!(
4134 "Failed to save setting: {e}"
4135 ))],
4136 ),
4137 }
4138 } else {
4139 handle.append_line(
4140 InlineMessageKind::Error,
4141 vec![plain_segment(format!(
4142 "Unknown setting key in submenu commit: {key_str}"
4143 ))],
4144 );
4145 }
4146 } else {
4147 handle.append_line(
4148 InlineMessageKind::Error,
4149 vec![plain_segment(format!(
4150 "Malformed submenu commit payload: {payload}"
4151 ))],
4152 );
4153 }
4154 true
4155 } else if let Some(tool) = payload.strip_prefix("DisabledToolToggle:") {
4156 let essential = session
4157 .agent_ref()
4158 .tools()
4159 .get_tools()
4160 .into_iter()
4161 .find(|t| t.name() == tool)
4162 .is_some_and(|t| t.essential());
4163 let currently_disabled = crate::store::settings::Settings::load()
4164 .map(|s| s.disabled_tools.iter().any(|t| t == tool))
4165 .unwrap_or(false);
4166 commit_disabled_tool_toggle(
4167 state,
4168 handle,
4169 session,
4170 tool.to_string(),
4171 essential,
4172 currently_disabled,
4173 );
4174 opened_new_overlay = state.overlay.is_some();
4175 true
4176 } else {
4177 false
4178 }
4179 } else {
4180 false
4181 };
4182 if !synthetic_dispatched
4183 && let OverlaySubmission::Selection(InlineListSelection::ConfigAction(key)) =
4184 &sub
4185 {
4186 let def = SETTING_DEFS.iter().find(|d| format!("{:?}", d.key) == *key);
4187 match def {
4188 Some(def) => {
4189 let mut settings =
4190 crate::store::settings::Settings::load().unwrap_or_default();
4191 let next_value = match def.widget {
4196 SettingWidget::Toggle => Some(
4197 (get_display_value(def.key, &settings) != "true")
4198 .to_string(),
4199 ),
4200 SettingWidget::Cycle => next_cycle_value(def.key, &settings),
4201 _ => None,
4202 };
4203 if let Some(next) = next_value {
4204 match crate::tui_vt::settings_defs::apply_change(
4205 def.key,
4206 &mut settings,
4207 next,
4208 ) {
4209 Ok(()) => match settings.save() {
4210 Ok(()) => {
4211 if let Err(e) = sync_settings_live(
4212 state, session, def.key, &settings,
4213 ) {
4214 handle.append_line(
4219 InlineMessageKind::Error,
4220 vec![plain_segment(format!(
4221 "{}: {e}",
4222 def.label
4223 ))],
4224 );
4225 } else {
4226 handle.append_line(
4227 InlineMessageKind::Info,
4228 vec![plain_segment(format!(
4229 "{}: {}",
4230 def.label,
4231 get_display_value(def.key, &settings)
4232 ))],
4233 );
4234 }
4235 }
4236 Err(e) => handle.append_line(
4237 InlineMessageKind::Error,
4238 vec![plain_segment(format!(
4239 "Failed to save {}: {e}",
4240 def.label
4241 ))],
4242 ),
4243 },
4244 Err(e) => handle.append_line(
4245 InlineMessageKind::Error,
4246 vec![plain_segment(format!(
4247 "Failed to apply {}: {e}",
4248 def.label
4249 ))],
4250 ),
4251 }
4252 }
4253 }
4254 None => handle.append_line(
4255 InlineMessageKind::Error,
4256 vec![plain_segment(format!("Unknown setting: {key}"))],
4257 ),
4258 }
4259 }
4260 if let OverlaySubmission::Selection(InlineListSelection::SettingsTab(idx)) =
4264 &sub
4265 {
4266 switch_settings_tab(state, *idx);
4267 opened_new_overlay = state.overlay.is_some();
4268 }
4269 if let OverlaySubmission::Selection(InlineListSelection::SettingsSection(idx)) =
4273 &sub
4274 {
4275 jump_settings_section(state, *idx);
4276 opened_new_overlay = state.overlay.is_some();
4277 }
4278 if let OverlaySubmission::Selection(InlineListSelection::SettingKeyCapture(
4284 name,
4285 )) = &sub
4286 {
4287 if GlobalAction::from_name(name).is_some() {
4288 state.overlay = Some(build_key_capture_overlay(name));
4289 state.settings_map_rows.clear();
4290 opened_new_overlay = true;
4291 } else {
4292 handle.append_line(
4293 InlineMessageKind::Error,
4294 vec![plain_segment(format!("Unknown keybinding action: {name}"))],
4295 );
4296 }
4297 }
4298 if let OverlaySubmission::Selection(InlineListSelection::SettingTextEdit(
4302 key_name,
4303 )) = &sub
4304 {
4305 if let Some(key) = parse_setting_key(key_name) {
4306 open_text_edit_prompt(state, key);
4307 opened_new_overlay = true;
4308 } else {
4309 handle.append_line(
4310 InlineMessageKind::Error,
4311 vec![plain_segment(format!("Unknown setting key: {key_name}"))],
4312 );
4313 }
4314 }
4315 if let OverlaySubmission::Selection(InlineListSelection::SettingSubmenuOpen(
4320 key_name,
4321 )) = &sub
4322 {
4323 if let Some(key) = parse_setting_key(key_name) {
4324 open_submenu_select_prompt(state, key);
4325 opened_new_overlay = true;
4326 } else {
4327 handle.append_line(
4328 InlineMessageKind::Error,
4329 vec![plain_segment(format!("Unknown setting key: {key_name}"))],
4330 );
4331 }
4332 }
4333 if let OverlaySubmission::Selection(InlineListSelection::SettingMultiselect(
4338 key_name,
4339 )) = &sub
4340 {
4341 if let Some(parsed) = parse_setting_key(key_name) {
4342 if parsed == SettingKey::DisabledTools {
4343 open_disabled_tools_multiselect(state, session);
4344 opened_new_overlay = true;
4345 } else {
4346 handle.append_line(
4347 InlineMessageKind::Error,
4348 vec![plain_segment(format!(
4349 "'{parsed:?}' has no multiselect editor"
4350 ))],
4351 );
4352 }
4353 } else {
4354 handle.append_line(
4355 InlineMessageKind::Error,
4356 vec![plain_segment(format!("Unknown setting key: {key_name}"))],
4357 );
4358 }
4359 }
4360 if let OverlaySubmission::Selection(InlineListSelection::Session(id)) = &sub {
4363 if session.is_streaming() {
4369 handle.append_line(
4370 InlineMessageKind::Error,
4371 vec![plain_segment(
4372 "Cannot resume while agent is running. Use /cancel first.",
4373 )],
4374 );
4375 } else {
4376 let path = crate::tui_vt::slash::registry::sessions_dir()
4377 .join(format!("{id}.jsonl"));
4378 if !path.is_file() {
4379 handle.append_line(
4380 InlineMessageKind::Error,
4381 vec![plain_segment(format!(
4382 "No session file: {}",
4383 path.display()
4384 ))],
4385 );
4386 } else {
4387 state.pending_resume = Some(path);
4388 }
4389 }
4390 }
4391 if let OverlaySubmission::Selection(InlineListSelection::CatalogModel(idx)) =
4393 &sub
4394 && idx < &state.overlay_catalog_models.len()
4395 {
4396 let (provider, model_id) = &state.overlay_catalog_models[*idx];
4397 let full = format!("{provider}/{model_id}");
4398 match session.set_model(&full) {
4399 Ok(()) => {
4400 sync_model_chips(state, session);
4401 handle.append_line(
4402 InlineMessageKind::Info,
4403 vec![plain_segment(format!("Switched to {full}"))],
4404 );
4405 }
4406 Err(e) => handle.append_line(
4407 InlineMessageKind::Error,
4408 vec![plain_segment(format!("Failed to set model: {e}"))],
4409 ),
4410 }
4411 }
4412 if let OverlaySubmission::Selection(InlineListSelection::ProviderRow(idx)) =
4419 &sub
4420 && idx < &state.overlay_providers.len()
4421 {
4422 let name = state.overlay_providers[*idx].clone();
4423 let auth = crate::store::auth_storage::shared_auth_storage();
4424 let has_key = auth.has(&name);
4425 let oauth_capable = crate::provider_oauth::spec_for(&name).is_some();
4426 let actions = next_provider_actions(has_key, oauth_capable);
4427 if actions.len() == 1 {
4428 opened_new_overlay |=
4430 handle_auth_action(&name, &actions[0], &auth, handle, state);
4431 } else {
4432 let items: Vec<InlineListItem> = actions
4434 .iter()
4435 .map(|a| InlineListItem {
4436 title: match a {
4437 AuthAction::SetApiKey => "Set API key".into(),
4438 AuthAction::StartOAuth => "Login with OAuth".into(),
4439 AuthAction::RemoveKey => "Remove key".into(),
4440 },
4441 subtitle: None,
4442 badge: None,
4443 indent: 0,
4444 selection: Some(InlineListSelection::ProviderAction {
4445 provider: name.clone(),
4446 action: a.clone(),
4447 }),
4448 search_value: None,
4449 })
4450 .collect();
4451 handle.show_list_modal(
4452 name.clone(),
4453 vec!["Pick an action".into()],
4454 items,
4455 None,
4456 None,
4457 );
4458 opened_new_overlay = true;
4459 }
4460 }
4461 if let OverlaySubmission::Selection(InlineListSelection::ProviderAction {
4467 provider,
4468 action,
4469 }) = &sub
4470 {
4471 let auth = crate::store::auth_storage::shared_auth_storage();
4472 opened_new_overlay |=
4473 handle_auth_action(provider, action, &auth, handle, state);
4474 }
4475 if let OverlaySubmission::SecureInput(text) = &sub
4483 && let Some(origin) = state.secure_input_origin.take()
4484 {
4485 match origin {
4486 SecureInputOrigin::ModelRoleKey => {
4487 let role = text.trim().to_string();
4491 if role.is_empty() {
4492 handle.append_line(
4493 InlineMessageKind::Error,
4494 vec![plain_segment(
4495 "Model role name can't be empty".to_string(),
4496 )],
4497 );
4498 } else {
4499 open_model_role_value_prompt(state, &role);
4500 opened_new_overlay = true;
4501 }
4502 }
4503 SecureInputOrigin::TextEdit(key) => {
4504 let (_outcome, _msg) = commit_text_edit(
4505 state,
4506 handle,
4507 Some(session),
4508 key,
4509 text.clone(),
4510 );
4511 opened_new_overlay = state.overlay.is_some();
4512 }
4513 SecureInputOrigin::ModelRoleValue { role } => {
4514 let model = text.trim().to_string();
4515 let outcome = if model.is_empty() {
4516 Err("model pattern can't be empty".to_string())
4517 } else {
4518 crate::store::settings::Settings::load()
4519 .map_err(|e| e.to_string())
4520 .and_then(|mut settings| {
4521 crate::tui_vt::settings_defs::set_model_role(
4522 &mut settings,
4523 &role,
4524 model.clone(),
4525 );
4526 settings.save().map_err(|e| e.to_string())
4527 })
4528 };
4529 match outcome {
4530 Ok(()) => {
4531 handle.append_line(
4532 InlineMessageKind::Info,
4533 vec![plain_segment(format!(
4534 "Model role '{role}' \u{2192} {model}"
4535 ))],
4536 );
4537 reopen_settings_panel(
4538 state,
4539 SettingsTab::Model,
4540 Some(format!("Saved '{role}' \u{2192} {model}")),
4541 );
4542 opened_new_overlay = true;
4543 }
4544 Err(e) => handle.append_line(
4545 InlineMessageKind::Error,
4546 vec![plain_segment(format!(
4547 "Failed to save model role '{role}': {e}"
4548 ))],
4549 ),
4550 }
4551 }
4552 origin @ (SecureInputOrigin::SetKey { .. }
4553 | SecureInputOrigin::NewlyAdded { .. }) => {
4554 let provider = match &origin {
4555 SecureInputOrigin::SetKey { provider }
4556 | SecureInputOrigin::NewlyAdded { provider } => {
4557 provider.clone()
4558 }
4559 _ => unreachable!("auth arm only matches auth origins"),
4560 };
4561 let auth = crate::store::auth_storage::shared_auth_storage();
4562 auth.set_api_key(&provider, text.clone());
4563 let refreshed = session.refresh_api_key();
4569 let msg = match origin {
4570 SecureInputOrigin::SetKey { .. } => format!(
4571 "Saved API key for '{provider}'. {}",
4572 match refreshed {
4573 Ok(()) => "Ready to retry your message.",
4574 Err(_) => "Restart this session before retrying.",
4575 }
4576 ),
4577 SecureInputOrigin::NewlyAdded { .. } => format!(
4578 "Added and configured '{provider}'. {}",
4579 match refreshed {
4580 Ok(()) =>
4581 "Use /models to choose a model, or send a message.",
4582 Err(_) => "Restart this session before using it.",
4583 }
4584 ),
4585 SecureInputOrigin::ModelRoleKey
4586 | SecureInputOrigin::ModelRoleValue { .. } => {
4587 unreachable!("auth branch reached with a model-role origin")
4588 }
4589 SecureInputOrigin::TextEdit(_) => {
4590 unreachable!("auth branch reached with a text-edit origin")
4591 }
4592 };
4593 handle
4594 .append_line(InlineMessageKind::Info, vec![plain_segment(msg)]);
4595 }
4596 }
4597 }
4598 state.overlay_catalog_models.clear();
4599 state.overlay_providers.clear();
4600 state.overlay_model_ids.clear();
4601 if !opened_new_overlay {
4602 handle.close_overlay();
4603 }
4604 }
4605 OverlayEvent::Cancelled => {
4606 handle.close_overlay();
4607 }
4608 OverlayEvent::SelectionChanged(_) => {}
4609 }
4610 }
4611 _ => {
4612 }
4616 }
4617 LoopOutcome::Continue
4618}
4619
4620struct StreamingGuard<'a>(&'a std::sync::atomic::AtomicBool);
4627
4628impl Drop for StreamingGuard<'_> {
4629 fn drop(&mut self) {
4630 use std::sync::atomic::Ordering;
4631 self.0.store(false, Ordering::SeqCst);
4632 }
4633}
4634
4635fn handle_interrupt(
4648 state: &mut RenderState,
4649 session: &crate::app::agent_session::AgentSessionHandle,
4650 _handle: &InlineHandle,
4651) -> LoopOutcome {
4652 if state.confirmation.is_some() {
4654 return LoopOutcome::Exit;
4655 }
4656 if state.pending_quit {
4659 state.confirmation = Some(quit_confirmation());
4660 state.pending_quit = false;
4661 return LoopOutcome::Continue;
4662 }
4663 if session.is_streaming() {
4667 let s = session.clone();
4668 tokio::spawn(async move {
4669 s.abort().await;
4670 });
4671 state.pending_quit = true;
4672 } else {
4673 state.confirmation = Some(quit_confirmation());
4674 }
4675 LoopOutcome::Continue
4676}
4677
4678fn quit_confirmation() -> ModalConfirmation {
4680 ModalConfirmation {
4681 title: "Quit oxicode?".into(),
4682 message: " y \u{2014} quit now n / x \u{2014} stay".into(),
4683 action: ConfirmationAction::Quit,
4684 }
4685}
4686
4687pub(super) fn clear_confirmation() -> ModalConfirmation {
4689 ModalConfirmation {
4690 title: "Clear conversation?".into(),
4691 message: " y \u{2014} clear all n / x \u{2014} cancel".into(),
4692 action: ConfirmationAction::ClearConversation,
4693 }
4694}
4695
4696fn apply_global_action(
4709 action: GlobalAction,
4710 state: &Arc<parking_lot::Mutex<RenderState>>,
4711 evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
4712) {
4713 match action {
4714 GlobalAction::Interrupt => {
4718 let _ = evt_tx.send(InlineEvent::Interrupt);
4719 }
4720 GlobalAction::ToggleMultiline => {
4722 let mut s = state.lock();
4723 s.multiline_mode = !s.multiline_mode;
4724 }
4725 GlobalAction::OpenCommandPalette => {
4727 let mut s = state.lock();
4728 s.overlay = Some(build_command_palette());
4729 }
4730 GlobalAction::ToggleQueuePanel => {
4732 let mut s = state.lock();
4733 s.queue_panel_open = !s.queue_panel_open;
4734 if s.queue_panel_open {
4735 s.queue_selected = 0;
4736 }
4737 }
4738 GlobalAction::FoldAll => {
4740 let mut s = state.lock();
4741 s.fold_all();
4742 }
4743 GlobalAction::SendNow => {
4746 let submitted = harvest_and_clear_input(state);
4747 if !submitted.is_empty() {
4748 let _ = evt_tx.send(InlineEvent::Interrupt);
4749 let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
4750 }
4751 }
4752 GlobalAction::Submit => {
4757 let submitted = harvest_and_clear_input(state);
4758 let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
4759 }
4760 GlobalAction::ScrollUp => {
4762 let _ = evt_tx.send(InlineEvent::ScrollPageUp);
4763 }
4764 GlobalAction::ScrollDown => {
4766 let _ = evt_tx.send(InlineEvent::ScrollPageDown);
4767 }
4768 GlobalAction::Clear => {
4773 let mut s = state.lock();
4774 s.fold_all();
4775 }
4776 GlobalAction::Help => {
4780 let mut s = state.lock();
4781 s.overlay = Some(cheatsheet_overlay());
4782 }
4783 GlobalAction::ModelPicker => {
4788 let mut s = state.lock();
4789 s.overlay = Some(build_command_palette());
4790 }
4791 GlobalAction::ToggleThinking => {
4796 let mut s = state.lock();
4797 s.multiline_mode = !s.multiline_mode;
4798 }
4799 }
4800}
4801
4802#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4808pub(crate) enum KeymapDispatch {
4809 Submit,
4810 ScrollPageUp,
4811 ScrollPageDown,
4812 Help,
4813 None,
4814}
4815
4816pub(crate) fn keymap_pre_match(
4832 keymap: &Keymap,
4833 key: &crossterm::event::KeyEvent,
4834 multiline: bool,
4835) -> KeymapDispatch {
4836 let plain_enter_multiline =
4837 key.code == KeyCode::Enter && multiline && !key.modifiers.contains(KeyModifiers::SHIFT);
4838 if keymap.matches(GlobalAction::Submit, key) && !plain_enter_multiline {
4839 return KeymapDispatch::Submit;
4840 }
4841 if keymap.matches(GlobalAction::ScrollUp, key) {
4842 return KeymapDispatch::ScrollPageUp;
4843 }
4844 if keymap.matches(GlobalAction::ScrollDown, key) {
4845 return KeymapDispatch::ScrollPageDown;
4846 }
4847 if keymap.matches(GlobalAction::Help, key) && !matches!(key.code, KeyCode::Char(_)) {
4848 return KeymapDispatch::Help;
4849 }
4850 KeymapDispatch::None
4851}
4852
4853fn harvest_and_clear_input(state: &Arc<parking_lot::Mutex<RenderState>>) -> String {
4858 let mut s = state.lock();
4859 let buf = if s.slash_popup.open && !s.slash_popup.items.is_empty() {
4860 format!("/{}", s.slash_popup.items[s.slash_popup.selected].name)
4861 } else {
4862 let buf = s.composer.text().to_string();
4863 s.composer.set_text("");
4864 buf
4865 };
4866 s.slash_popup = SlashPopup::default();
4867 s.history_pos = None;
4868 if !buf.is_empty() && !buf.starts_with('/') {
4870 s.prompt_history.insert(0, buf.clone());
4871 s.prompt_history.truncate(100);
4872 }
4873 buf
4874}
4875
4876fn cheatsheet_overlay() -> OverlayState {
4880 OverlayState {
4881 title: "Keyboard Shortcuts".into(),
4882 lines: cheatsheet_lines(),
4883 items: vec![],
4884 selected: 0,
4885 search: None,
4886 secure_input: None,
4887 ..Default::default()
4888 }
4889}
4890
4891fn spawn_input_thread(
4892 state: Arc<parking_lot::Mutex<RenderState>>,
4893 evt_tx: tokio::sync::mpsc::UnboundedSender<InlineEvent>,
4894 mode_handle: Option<std::sync::Arc<std::sync::atomic::AtomicU8>>,
4895 prompt_queue: Arc<PromptQueue>,
4896 issue_action_tx: tokio::sync::mpsc::UnboundedSender<
4897 crate::tui_vt::issues_panel::IssueActionRequest,
4898 >,
4899) -> std::thread::JoinHandle<()> {
4900 std::thread::spawn(move || {
4901 loop {
4909 match event::poll(std::time::Duration::from_millis(50)) {
4910 Ok(true) => {}
4911 Ok(false) => continue,
4912 Err(_) => break,
4913 }
4914 let event = match event::read() {
4915 Ok(ev) => ev,
4916 Err(_) => continue,
4917 };
4918
4919 let mut pasted = String::new();
4922 let mut key_event = None;
4923 match event {
4924 Event::Key(k) if k.kind == KeyEventKind::Press => key_event = Some(k),
4925 Event::Paste(p) => pasted = p,
4926 _ => {}
4927 }
4928
4929 if !pasted.is_empty() && state.lock().issues_panel.is_some() {
4935 continue;
4936 }
4937 if !pasted.is_empty() {
4938 let routed_to_secure = {
4942 let mut s = state.lock();
4943 if let Some(overlay) = s.overlay.as_mut() {
4944 if let Some(secure) = overlay.secure_input.as_mut() {
4945 let trimmed = pasted.trim_end_matches('\n');
4949 for ch in trimmed.chars() {
4950 if ch.is_ascii_graphic() || ch == ' ' {
4951 let _ = secure
4952 .editor
4953 .apply(oxicode_textarea::EditCommand::Insert(ch));
4954 }
4955 }
4956 true
4957 } else {
4958 false
4959 }
4960 } else {
4961 false
4962 }
4963 };
4964 if routed_to_secure {
4965 continue;
4966 }
4967 let mut s = state.lock();
4968 s.composer.insert_str(&pasted);
4969 refresh_input_popups(&mut s);
4976 continue;
4977 }
4978 let Some(key) = key_event else { continue };
4979
4980 let keymap = state.lock().keymap.read().clone();
4986
4987 {
4993 let capturing = {
4994 let s = state.lock();
4995 s.overlay.as_ref().is_some_and(|o| o.key_capture.is_some())
4996 };
4997 if capturing {
4998 let mut s = state.lock();
4999 handle_key_capture(&mut s, key);
5000 continue;
5001 }
5002 }
5003
5004 {
5014 let s = state.lock();
5015 if s.confirmation.is_some() {
5016 drop(s);
5017 let interrupt = {
5018 let s = state.lock();
5019 matches!(s.keymap.read().resolve(key), Some(GlobalAction::Interrupt))
5020 };
5021 if interrupt {
5022 let _ = evt_tx.send(InlineEvent::Interrupt);
5023 continue;
5024 }
5025 handle_confirmation_key(&state, &evt_tx, &issue_action_tx, key.code);
5026 continue;
5027 }
5028 }
5029
5030 {
5036 let s = state.lock();
5037 if s.issues_panel.is_some() {
5038 drop(s);
5039 if crate::tui_vt::issues_panel::handle_issues_panel_key(
5040 &state,
5041 &issue_action_tx,
5042 key,
5043 ) {
5044 continue;
5045 }
5046 }
5047 }
5048
5049 {
5056 let action = {
5057 let s = state.lock();
5058 s.keymap.read().resolve(key)
5059 };
5060 if let Some(action) = action {
5061 apply_global_action(action, &state, &evt_tx);
5062 continue;
5063 }
5064 }
5065
5066 {
5072 let s = state.lock();
5073 if s.overlay.is_some() {
5074 drop(s);
5075 if handle_overlay_key(&state, &evt_tx, key.code) {
5076 continue;
5077 }
5078 }
5079 }
5080
5081 {
5085 let s = state.lock();
5086 if s.file_search.is_some() {
5087 drop(s);
5088 if handle_file_search_key(&state, &evt_tx, key.code) {
5089 continue;
5090 }
5091 }
5092 }
5093
5094 if state.lock().git_tui.is_some() && handle_git_tui_key(&state, key.code, key.modifiers)
5099 {
5100 continue;
5101 }
5102
5103 let multiline_mode = state.lock().multiline_mode;
5114 match keymap_pre_match(&keymap, &key, multiline_mode) {
5115 KeymapDispatch::Submit => {
5116 if state.lock().shell_mode {
5119 let submitted = {
5120 let mut s = state.lock();
5121 let buf = s.composer.text().to_string();
5122 s.composer.set_text("");
5123 s.shell_mode = false;
5124 s.history_pos = None;
5125 if !buf.is_empty() {
5126 s.prompt_history.insert(0, buf.clone());
5127 s.prompt_history.truncate(100);
5128 }
5129 buf
5130 };
5131 if !submitted.is_empty() {
5132 let prompt = format!("Run this shell command: `{submitted}`");
5133 let _ = evt_tx.send(InlineEvent::Submit(prompt.into()));
5134 }
5135 continue;
5136 }
5137 let submitted = harvest_and_clear_input(&state);
5138 let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
5139 continue;
5140 }
5141 KeymapDispatch::ScrollPageUp => {
5142 let _ = evt_tx.send(InlineEvent::ScrollPageUp);
5143 continue;
5144 }
5145 KeymapDispatch::ScrollPageDown => {
5146 let _ = evt_tx.send(InlineEvent::ScrollPageDown);
5147 continue;
5148 }
5149 KeymapDispatch::Help => {
5150 let mut s = state.lock();
5151 s.overlay = Some(cheatsheet_overlay());
5152 continue;
5153 }
5154 KeymapDispatch::None => {}
5155 }
5156
5157 match key.code {
5158 KeyCode::BackTab => {
5160 if let Some(h) = &mode_handle {
5161 let new_mode = Mode::load(h).toggle();
5162 h.store(new_mode.as_u8(), std::sync::atomic::Ordering::SeqCst);
5163 let label = new_mode.label();
5164 let detail = if new_mode.is_auto() {
5165 "autonomous — no questions, runs to completion"
5166 } else {
5167 "interactive — may ask questions"
5168 };
5169 let mut s = state.lock();
5170 s.autonomy_mode = new_mode;
5171 s.tip = Some(EphemeralTip {
5172 text: format!("Mode: {label} — {detail}"),
5173 born_tick: 0,
5174 ttl_ticks: 240,
5175 key: "mode_toggle",
5176 ambient: false,
5177 });
5178 }
5179 continue;
5180 }
5181 KeyCode::Enter => {
5182 let multiline = state.lock().multiline_mode;
5193 let shift = key
5194 .modifiers
5195 .contains(crossterm::event::KeyModifiers::SHIFT);
5196 if multiline && !shift {
5197 let mut s = state.lock();
5198 s.composer.insert_str("\n");
5199 }
5200 }
5201 KeyCode::Esc => {
5202 let mut s = state.lock();
5209 if s.shell_mode {
5210 s.shell_mode = false;
5211 s.composer.set_text("");
5212 } else if s.slash_popup.open {
5213 s.slash_popup = SlashPopup::default();
5214 } else if !s.composer.is_empty() {
5215 let now = std::time::Instant::now();
5216 let is_double = s
5217 .last_esc_at
5218 .map(|t| now.duration_since(t).as_millis() < 800)
5219 .unwrap_or(false);
5220 if is_double {
5221 s.composer.set_text("");
5222 s.last_esc_at = None;
5223 } else {
5224 s.last_esc_at = Some(now);
5225 s.tip = Some(EphemeralTip {
5228 text: "Press Esc again to clear input".to_string(),
5229 born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
5230 ttl_ticks: 120,
5231 key: "esc_clear",
5232 ambient: false,
5233 });
5234 }
5235 } else {
5236 let now = std::time::Instant::now();
5237 let in_grace = s.cancel_grace_until.map(|t| t > now).unwrap_or(false);
5238 if in_grace {
5239 } else {
5241 s.cancel_grace_until = Some(now + std::time::Duration::from_secs(1));
5242 s.last_esc_at = None;
5243 drop(s);
5244 let _ = evt_tx.send(InlineEvent::Cancel);
5245 }
5246 }
5247 }
5248 KeyCode::Tab => {
5249 let mut s = state.lock();
5251 if s.slash_popup.open && !s.slash_popup.items.is_empty() {
5252 let name = s.slash_popup.items[s.slash_popup.selected].name.clone();
5253 s.composer.set_text(&format!("/{} ", name));
5254 refresh_input_popups(&mut s);
5255 }
5256 }
5257 KeyCode::Backspace => {
5258 let mut s = state.lock();
5259 s.composer.input(crossterm::event::KeyEvent::new(
5260 KeyCode::Backspace,
5261 KeyModifiers::NONE,
5262 ));
5263 refresh_input_popups(&mut s);
5264 }
5265 KeyCode::Delete => {
5266 let mut s = state.lock();
5267 s.composer.input(crossterm::event::KeyEvent::new(
5268 KeyCode::Delete,
5269 KeyModifiers::NONE,
5270 ));
5271 refresh_input_popups(&mut s);
5272 }
5273 KeyCode::Left => {
5274 let mut s = state.lock();
5275 s.composer.input(crossterm::event::KeyEvent::new(
5276 KeyCode::Left,
5277 KeyModifiers::NONE,
5278 ));
5279 }
5280 KeyCode::Right => {
5281 let mut s = state.lock();
5282 s.composer.input(crossterm::event::KeyEvent::new(
5283 KeyCode::Right,
5284 KeyModifiers::NONE,
5285 ));
5286 }
5287 KeyCode::Up => {
5288 let mut s = state.lock();
5289 if s.slash_popup.open && !s.slash_popup.items.is_empty() {
5290 let len = s.slash_popup.items.len();
5291 s.slash_popup.selected = if s.slash_popup.selected == 0 {
5292 len - 1
5293 } else {
5294 s.slash_popup.selected - 1
5295 };
5296 } else if s.queue_panel_open
5297 && !s.queued_inputs.is_empty()
5298 && s.composer.is_empty()
5299 {
5300 s.queue_selected = if s.queue_selected == 0 {
5301 s.queued_inputs.len() - 1
5302 } else {
5303 s.queue_selected - 1
5304 };
5305 } else if s.composer.is_empty() && !s.prompt_history.is_empty() {
5306 let pos = s.history_pos.unwrap_or(0);
5308 let next = (pos + 1).min(s.prompt_history.len() - 1);
5309 s.history_pos = Some(next);
5310 let entry = s.prompt_history[next].clone();
5311 s.composer.set_text(&entry);
5312 drop(s);
5313 let _ = evt_tx.send(InlineEvent::ScrollLineUp);
5314 }
5315 }
5316 KeyCode::Down => {
5317 let mut s = state.lock();
5318 if s.slash_popup.open && !s.slash_popup.items.is_empty() {
5319 let len = s.slash_popup.items.len();
5320 s.slash_popup.selected = if s.slash_popup.selected + 1 >= len {
5321 0
5322 } else {
5323 s.slash_popup.selected + 1
5324 };
5325 } else if s.queue_panel_open
5326 && !s.queued_inputs.is_empty()
5327 && s.composer.is_empty()
5328 {
5329 s.queue_selected = if s.queue_selected + 1 >= s.queued_inputs.len() {
5330 0
5331 } else {
5332 s.queue_selected + 1
5333 };
5334 } else {
5335 drop(s);
5336 let _ = evt_tx.send(InlineEvent::ScrollLineDown);
5337 }
5338 }
5339 KeyCode::Char(ch) => {
5344 let mut s = state.lock();
5345 if s.file_search.is_some()
5349 && ch == '!'
5350 && s.composer.text()[..s.composer.cursor()].ends_with('@')
5351 {
5352 let cwd = s.cwd.clone();
5353 if let Some(fs) = s.file_search.as_mut() {
5354 fs.toggle_hidden(&cwd);
5355 }
5356 continue;
5357 }
5358 if s.agent_hub_open && ch == 'q' {
5359 s.agent_hub_open = false;
5360 } else if s.vim_state.enabled() && !s.slash_popup.open {
5361 let s = &mut *s;
5364 let vkey =
5365 crossterm::event::KeyEvent::new(KeyCode::Char(ch), key.modifiers);
5366 let mut editor = InputEditor::new(&mut s.composer);
5367 let outcome = crate::tui_vt::vim::handle_key(
5368 &mut s.vim_state,
5369 &mut editor,
5370 &mut s.vim_clipboard,
5371 &vkey,
5372 );
5373 if outcome.handled {
5374 refresh_input_popups(s);
5375 }
5376 } else if s.composer.is_empty() && !s.slash_popup.open {
5377 if ch == '!' && !s.shell_mode {
5379 s.shell_mode = true;
5380 continue;
5381 }
5382 if s.queue_panel_open && !s.queued_inputs.is_empty() {
5386 let idx = s.queue_selected.min(s.queued_inputs.len() - 1);
5387 match ch {
5388 'x' | 'X' => {
5389 let _ = prompt_queue.remove(idx);
5390 s.queued_inputs.remove(idx);
5391 if s.queue_selected >= s.queued_inputs.len()
5392 && !s.queued_inputs.is_empty()
5393 {
5394 s.queue_selected = s.queued_inputs.len() - 1;
5395 }
5396 continue;
5397 }
5398 'e' => {
5399 if let Some(entry) = prompt_queue.remove(idx) {
5400 s.queued_inputs.remove(idx);
5401 s.composer.set_text(&entry);
5402 s.queue_panel_open = false;
5403 continue;
5404 }
5405 }
5406 'J' => {
5407 if prompt_queue.move_by(idx, 1)
5408 && idx + 1 < s.queued_inputs.len()
5409 {
5410 s.queued_inputs.swap(idx, idx + 1);
5411 s.queue_selected = idx + 1;
5412 }
5413 continue;
5414 }
5415 'K' => {
5416 if idx > 0 && prompt_queue.move_by(idx, -1) {
5417 s.queued_inputs.swap(idx, idx - 1);
5418 s.queue_selected = idx - 1;
5419 }
5420 continue;
5421 }
5422 _ => {} }
5424 }
5425 if keymap.matches(GlobalAction::Help, &key) {
5434 s.overlay = Some(cheatsheet_overlay());
5435 } else if matches!(ch, 'e') {
5436 s.cycle_block_at_view();
5437 } else if matches!(ch, 'E') {
5438 s.expand_all();
5439 } else if matches!(ch, 'J') {
5440 s.jump_next_turn();
5441 } else if matches!(ch, 'K') {
5442 s.jump_prev_turn();
5443 } else if matches!(ch, 'n') && s.search.is_some() {
5444 s.search_next();
5445 } else if matches!(ch, 'N') && s.search.is_some() {
5446 s.search_prev();
5447 } else {
5448 s.composer.input(crossterm::event::KeyEvent::new(
5449 KeyCode::Char(ch),
5450 key.modifiers,
5451 ));
5452 refresh_input_popups(&mut s);
5453 }
5454 } else {
5455 s.composer.input(crossterm::event::KeyEvent::new(
5456 KeyCode::Char(ch),
5457 key.modifiers,
5458 ));
5459 refresh_input_popups(&mut s);
5460 }
5461 if s.tip.is_none() && s.composer.text().to_lowercase().contains("plan") {
5463 s.show_tip(
5464 "plan_nudge",
5465 "Try /compact to summarize and plan ahead",
5466 180,
5467 true,
5468 );
5469 }
5470 }
5471 _ => {}
5472 }
5473 }
5474 })
5475}
5476
5477fn handle_confirmation_key(
5481 state: &Arc<parking_lot::Mutex<RenderState>>,
5482 evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
5483 issue_action_tx: &tokio::sync::mpsc::UnboundedSender<
5484 crate::tui_vt::issues_panel::IssueActionRequest,
5485 >,
5486 code: KeyCode,
5487) {
5488 let mut s = state.lock();
5489 let Some(confirm) = s.confirmation.clone() else {
5490 return;
5491 };
5492 match code {
5493 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
5494 s.confirmation = None;
5495 drop(s);
5496 match confirm.action {
5497 ConfirmationAction::Quit => {
5498 let _ = evt_tx.send(InlineEvent::Exit);
5499 }
5500 ConfirmationAction::ClearConversation => {
5501 let _ = evt_tx.send(InlineEvent::Submit("/clear --yes".into()));
5505 }
5506 ConfirmationAction::CloseIssue(id) => {
5507 let (caller, hash, cwd) = {
5508 let s = state.lock();
5509 let hash = s
5510 .issue_store
5511 .as_ref()
5512 .and_then(|store| store.read(id).ok())
5513 .map(|(_, h)| h);
5514 (
5515 oxicode_sdk::liveness::TUI_OWNERSHIP_ID.to_string(),
5516 hash,
5517 s.cwd.clone(),
5518 )
5519 };
5520 let _ = cwd; let _ = issue_action_tx.send(
5522 crate::tui_vt::issues_panel::IssueActionRequest::Close { id, caller, hash },
5523 );
5524 let mut s = state.lock();
5525 if let Some(panel) = s.issues_panel.as_mut() {
5526 panel.pending = true;
5527 }
5528 }
5529 ConfirmationAction::RemoveProviderKey(name) => {
5530 let _ = evt_tx.send(InlineEvent::Submit(
5534 format!("/providers remove {name} --yes").into(),
5535 ));
5536 }
5537 }
5538 }
5539 KeyCode::Char('n')
5540 | KeyCode::Char('N')
5541 | KeyCode::Char('x')
5542 | KeyCode::Char('X')
5543 | KeyCode::Esc => {
5544 s.confirmation = None;
5545 }
5546 _ => {}
5547 }
5548}
5549
5550fn handle_overlay_key(
5555 state: &Arc<parking_lot::Mutex<RenderState>>,
5556 evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
5557 code: KeyCode,
5558) -> bool {
5559 use oxicode_vtui::tui::core::{OverlayEvent, OverlaySubmission};
5560
5561 let mut s = state.lock();
5562 let Some(overlay) = s.overlay.as_mut() else {
5563 return false;
5564 };
5565 if let Some(secure) = overlay.secure_input.as_mut() {
5568 use oxicode_textarea::EditCommand;
5569 match code {
5570 KeyCode::Backspace => {
5571 if secure.editor.cursor_byte() > 0 {
5575 let _ = secure.editor.apply(EditCommand::DeleteGraphemeBackward);
5576 }
5577 }
5578 KeyCode::Left => {
5579 let _ = secure.editor.apply(EditCommand::MoveGraphemeLeft);
5580 }
5581 KeyCode::Right => {
5582 let _ = secure.editor.apply(EditCommand::MoveGraphemeRight);
5583 }
5584 KeyCode::Enter => {
5585 let submission = OverlaySubmission::SecureInput(secure.editor.text().to_string());
5590 drop(s);
5591 state.lock().overlay = None;
5592 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Submitted(submission)));
5593 }
5594 KeyCode::Esc => {
5595 drop(s);
5596 state.lock().overlay = None;
5597 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
5598 }
5599 KeyCode::Char(ch) if ch.is_ascii_graphic() || ch == ' ' => {
5600 let _ = secure.editor.apply(EditCommand::Insert(ch));
5603 }
5604 _ => {} }
5606 return true;
5607 }
5608
5609 let search_empty = s
5616 .overlay
5617 .as_ref()
5618 .and_then(|o| o.search.as_ref())
5619 .is_none_or(|search| search.value.is_empty());
5620 let map_row_consumed = match code {
5621 KeyCode::Enter if try_edit_model_role(&mut s) => true,
5622 KeyCode::Char('d') if search_empty && try_remove_settings_map_row(&mut s) => true,
5623 KeyCode::Char('n') if search_empty && try_start_new_model_role(&mut s) => true,
5624 _ => false,
5625 };
5626 if map_row_consumed {
5627 return true;
5628 }
5629 let Some(overlay) = s.overlay.as_mut() else {
5630 return false;
5631 };
5632
5633 match code {
5634 KeyCode::Esc => {
5635 drop(s);
5637 state.lock().overlay = None;
5638 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
5639 }
5640 KeyCode::Enter => {
5641 let submission = if let Some(item) = overlay.items.get(overlay.selected) {
5644 match item.selection.clone() {
5645 Some(sel) => sel,
5646 None => {
5647 return true;
5653 }
5654 }
5655 } else {
5656 drop(s);
5657 state.lock().overlay = None;
5658 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
5659 return true;
5660 };
5661 let title = overlay.title.clone();
5662 let selected = overlay.selected;
5663 drop(s);
5664 state.lock().overlay = None;
5665 tracing::debug!(
5666 overlay = %title,
5667 selected,
5668 "overlay submitted"
5669 );
5670 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Submitted(
5671 OverlaySubmission::Selection(submission),
5672 )));
5673 }
5674 KeyCode::Up => {
5675 let len = overlay_filtered_indices(overlay).len();
5676 if len == 0 {
5677 return true;
5678 }
5679 let pos = overlay_filtered_indices(overlay)
5680 .iter()
5681 .position(|&i| i == overlay.selected)
5682 .unwrap_or(0);
5683 let new_pos = if pos == 0 { len - 1 } else { pos - 1 };
5684 overlay.selected = overlay_filtered_indices(overlay)[new_pos];
5685 }
5686 KeyCode::Down => {
5687 let filtered = overlay_filtered_indices(overlay);
5688 let len = filtered.len();
5689 if len == 0 {
5690 return true;
5691 }
5692 let pos = filtered
5693 .iter()
5694 .position(|&i| i == overlay.selected)
5695 .unwrap_or(0);
5696 let new_pos = if pos + 1 >= len { 0 } else { pos + 1 };
5697 overlay.selected = filtered[new_pos];
5698 }
5699 KeyCode::Backspace => {
5700 if let Some(search) = overlay.search.as_mut() {
5701 search.value.pop();
5702 overlay.selected = 0;
5703 }
5704 }
5705 KeyCode::Char(ch) => {
5706 if let Some(search) = overlay.search.as_mut() {
5707 search.value.push(ch);
5708 overlay.selected = 0;
5709 }
5710 }
5711 KeyCode::Left | KeyCode::Right => {
5712 let tab_count = overlay.tabs.len();
5716 if tab_count > 1 {
5717 let next = if code == KeyCode::Right {
5718 (overlay.active_tab + 1) % tab_count
5719 } else {
5720 overlay.active_tab.checked_sub(1).unwrap_or(tab_count - 1)
5721 };
5722 switch_settings_tab(&mut s, next);
5723 }
5724 }
5725 _ => {
5726 }
5728 }
5729 true
5730}
5731
5732fn overlay_filtered_indices(overlay: &OverlayState) -> Vec<usize> {
5737 let needle = overlay
5738 .search
5739 .as_ref()
5740 .map(|s| s.value.to_lowercase())
5741 .unwrap_or_default();
5742 if needle.is_empty() {
5743 return (0..overlay.items.len()).collect();
5744 }
5745 overlay
5746 .items
5747 .iter()
5748 .enumerate()
5749 .filter_map(|(idx, item)| {
5750 let title_hit = item.title.to_lowercase().contains(&needle);
5751 let sv_hit = item
5752 .search_value
5753 .as_deref()
5754 .map(|v| v.to_lowercase().contains(&needle))
5755 .unwrap_or(false);
5756 if title_hit || sv_hit { Some(idx) } else { None }
5757 })
5758 .collect()
5759}
5760
5761fn handle_file_search_key(
5767 state: &Arc<parking_lot::Mutex<RenderState>>,
5768 _evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
5769 code: KeyCode,
5770) -> bool {
5771 match code {
5772 KeyCode::Up => {
5773 let mut s = state.lock();
5774 if let Some(fs) = s.file_search.as_mut() {
5775 fs.up();
5776 true
5777 } else {
5778 false
5779 }
5780 }
5781 KeyCode::Down => {
5782 let mut s = state.lock();
5783 if let Some(fs) = s.file_search.as_mut() {
5784 fs.down();
5785 true
5786 } else {
5787 false
5788 }
5789 }
5790 KeyCode::Tab | KeyCode::Enter => {
5791 let mut s = state.lock();
5792 if s.file_search
5793 .as_ref()
5794 .and_then(|fs| fs.selected_result())
5795 .is_some()
5796 {
5797 accept_file_search(&mut s, false);
5798 true
5799 } else {
5800 s.file_search = None;
5802 false
5803 }
5804 }
5805 KeyCode::Esc => {
5806 let mut s = state.lock();
5807 s.file_search = None;
5808 true
5809 }
5810 _ => false,
5811 }
5812}
5813
5814fn handle_git_tui_key(
5822 state: &Arc<parking_lot::Mutex<RenderState>>,
5823 code: KeyCode,
5824 modifiers: KeyModifiers,
5825) -> bool {
5826 use crate::tui_vt::git_tui::{GitKeyAction, match_git_key};
5827 use crossterm::event::KeyEvent;
5828
5829 let mut s = state.lock();
5830 if s.git_tui.is_none() {
5831 return false;
5832 }
5833 let cwd = s.cwd.clone();
5834 let Some(git) = s.git_tui.as_mut() else {
5835 unreachable!("checked above");
5836 };
5837 if git.commit_mode {
5838 match code {
5839 KeyCode::Esc => {
5840 git.commit_mode = false;
5841 git.commit_msg.clear();
5842 return true;
5843 }
5844 KeyCode::Enter => {
5845 if let Err(err) = git.commit(&cwd) {
5846 tracing::warn!(?err, "git commit failed");
5847 s.tip = Some(EphemeralTip {
5849 text: format!("git commit failed: {err}"),
5850 born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
5851 ttl_ticks: 240,
5852 key: "git-commit-error",
5853 ambient: false,
5854 });
5855 }
5856 return true;
5857 }
5858 KeyCode::Backspace => {
5859 git.commit_backspace();
5860 return true;
5861 }
5862 KeyCode::Char(c) => {
5863 if !modifiers.contains(KeyModifiers::CONTROL)
5864 && !modifiers.contains(KeyModifiers::ALT)
5865 {
5866 git.commit_input_char(c);
5867 }
5868 return true;
5869 }
5870 _ => return true, }
5872 }
5873
5874 let key = KeyEvent::new(code, modifiers);
5877 let Some(action) = match_git_key(&key) else {
5878 return true;
5879 };
5880 if matches!(action, GitKeyAction::Close) {
5881 s.git_tui = None;
5883 return true;
5884 }
5885 if let Err(err) = git.apply_action(&cwd, action) {
5886 s.tip = Some(EphemeralTip {
5887 text: format!("/git: {err}"),
5888 born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
5889 ttl_ticks: 240,
5890 key: "git-action-error",
5891 ambient: false,
5892 });
5893 }
5894 true
5895}
5896
5897fn spawn_agent_worker(
5903 session_swapper: Arc<crate::app::agent_session_handle::SessionSwapper>,
5904 prompt_queue: Arc<PromptQueue>,
5905) {
5906 std::thread::spawn(move || {
5907 let runtime = match tokio::runtime::Builder::new_current_thread()
5908 .enable_all()
5909 .build()
5910 {
5911 Ok(rt) => rt,
5912 Err(err) => {
5913 tracing::error!(?err, "failed to build agent worker runtime");
5914 return;
5915 }
5916 };
5917
5918 runtime.block_on(async move {
5919 let local = tokio::task::LocalSet::new();
5920 local
5921 .run_until(async move {
5922 loop {
5923 let prompt = prompt_queue.next().await;
5924 run_one_prompt(&session_swapper.current(), prompt).await;
5925 }
5926 })
5927 .await;
5928 });
5929 });
5930}
5931
5932async fn run_one_prompt(session: &crate::app::agent_session::AgentSessionHandle, prompt: String) {
5933 let session_for_forward = session.clone();
5934 let (event_tx, event_rx) = std::sync::mpsc::channel::<AgentEvent>();
5935
5936 let forwarder = std::thread::spawn(move || {
5940 while let Ok(event) = event_rx.recv() {
5941 session_for_forward.forward_event_to_extensions(&event);
5942 }
5943 });
5944
5945 use std::sync::atomic::Ordering;
5949 session.reset_should_stop();
5950 let streaming = session.streaming_flag();
5951 streaming.store(true, Ordering::SeqCst);
5952 let _stream_guard = StreamingGuard(&streaming);
5953
5954 let agent = session.agent_ref();
5955 let local = tokio::task::LocalSet::new();
5956 let result = local
5957 .run_until(agent.run_with_channel(prompt, event_tx))
5958 .await;
5959
5960 let _ = forwarder.join();
5963 if let Err(err) = result {
5964 tracing::warn!(?err, "agent run failed");
5965 }
5966}
5967
5968fn build_header_context(
5977 app: &App,
5978 cwd: &std::path::Path,
5979 git_branch: Option<&str>,
5980) -> InlineHeaderContext {
5981 let workspace_name = cwd
5982 .file_name()
5983 .map(|n| n.to_string_lossy().into_owned())
5984 .unwrap_or_else(|| "oxicode".to_string());
5985 let model_id = app.model_id();
5986 let provider = model_id
5987 .split_once('/')
5988 .map(|(p, _)| p.to_string())
5989 .unwrap_or_else(|| "Provider".to_string());
5990 let branch = git_branch.unwrap_or("\u{2014}").to_string();
5991 let mut ctx = InlineHeaderContext::default();
5992 ctx.app_name = "oxicode".to_string();
5993 ctx.provider = provider;
5994 ctx.model = model_id.clone();
5995 ctx.git = format!("git: {workspace_name}@{branch}");
5996 ctx.tools = "Tools: ready".to_string();
5997 ctx.search_tools = Some(InlineHeaderStatusBadge {
5998 text: workspace_name,
5999 tone: InlineHeaderStatusTone::Ready,
6000 });
6001 ctx.persistent_memory = Some(InlineHeaderStatusBadge {
6002 text: branch,
6003 tone: InlineHeaderStatusTone::Ready,
6004 });
6005 ctx.editor_context = Some(model_id);
6006 ctx
6007}
6008
6009async fn build_agent_session(app: &App) -> Result<crate::app::agent_session::AgentSession> {
6012 use crate::app::agent_session_runtime::{
6013 CreateAgentSessionFromServicesOptions, CreateAgentSessionServicesOptions,
6014 create_agent_session_from_services, create_agent_session_services,
6015 };
6016 use crate::store::session::SessionManager;
6017
6018 let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
6019 let hook_runner = Arc::clone(&app.oxicode().ports().hooks);
6020 let services = create_agent_session_services(
6021 CreateAgentSessionServicesOptions::new(cwd.clone()),
6022 Some(hook_runner),
6023 )?;
6024 let services = Arc::new(services);
6025
6026 let model_id = app.model_id();
6027 let tools = app.agent_tools();
6028
6029 let session_manager = SessionManager::create(&cwd.to_string_lossy(), None);
6030
6031 let result = create_agent_session_from_services(CreateAgentSessionFromServicesOptions {
6032 services,
6033 session_manager,
6034 model_id: if model_id.is_empty() {
6035 None
6036 } else {
6037 Some(model_id)
6038 },
6039 thinking_level: None,
6040 scoped_models: Vec::new(),
6041 tool_registry: Some(tools),
6042 oxicode: Some(app.oxicode().clone()),
6043 session_state: Some(app.session_state().clone()),
6046 })
6047 .await?;
6048
6049 if let Some(msg) = result.model_fallback_message {
6050 tracing::warn!(message = %msg, "agent session model fallback");
6051 }
6052 Ok(result.session)
6053}
6054
6055fn cheatsheet_lines() -> Vec<String> {
6061 vec![
6062 "".into(),
6063 " Navigation".into(),
6064 " j / ↓ Scroll down".into(),
6065 " k / ↑ Scroll up".into(),
6066 " J (Shift+j) Next turn".into(),
6067 " K (Shift+k) Previous turn".into(),
6068 " PgDn / PgUp Page scroll".into(),
6069 " g / G Top / bottom".into(),
6070 "".into(),
6071 " Blocks".into(),
6072 " e Cycle block (collapse/truncate/expand)".into(),
6073 " E Expand all blocks".into(),
6074 " Ctrl+E Collapse all blocks".into(),
6075 "".into(),
6076 " Search".into(),
6077 " /find <q> Search transcript".into(),
6078 " n / N Next / previous match".into(),
6079 "".into(),
6080 " Commands".into(),
6081 " /theme Cycle color theme".into(),
6082 " /model Pick a model".into(),
6083 " /vim Toggle vim mode".into(),
6084 " /compact Compact context".into(),
6085 " /clear Clear conversation".into(),
6086 " Ctrl+C Cancel run (then y to quit)".into(),
6087 " Ctrl+Enter Send now (abort + submit)".into(),
6088 " Ctrl+M Toggle multiline input".into(),
6089 " Shift+Tab Toggle Auto mode (no questions, runs to end)".into(),
6090 " Ctrl+; Toggle queue panel".into(),
6091 "".into(),
6092 " Special Input".into(),
6093 " @ File picker (fuzzy search)".into(),
6094 " @! Toggle hidden files in picker".into(),
6095 " ! Shell mode (bash command)".into(),
6096 ]
6097}
6098
6099fn build_command_palette() -> OverlayState {
6102 use oxicode_vtui::tui::core::{InlineListItem, InlineListSelection};
6103
6104 let catalog = SlashRegistry::builtin_commands();
6105 let mut items: Vec<InlineListItem> = catalog
6106 .iter()
6107 .map(|(name, desc, aliases)| {
6108 let title = if aliases.is_empty() {
6109 format!("/{name}")
6110 } else {
6111 format!(
6112 "/{name} ({})",
6113 aliases
6114 .iter()
6115 .map(|a| format!("/{a}"))
6116 .collect::<Vec<_>>()
6117 .join(", ")
6118 )
6119 };
6120 InlineListItem {
6121 title,
6122 subtitle: Some(desc.to_string()),
6123 badge: None,
6124 indent: 0,
6125 selection: Some(InlineListSelection::SlashCommand(name.to_string())),
6126 search_value: Some(format!("{name} {desc}")),
6127 }
6128 })
6129 .collect();
6130 items.sort_by(|a, b| a.title.cmp(&b.title));
6131
6132 OverlayState {
6133 title: "Command Palette".into(),
6134 lines: vec!["Type to filter, Enter to select".into()],
6135 items: items
6136 .into_iter()
6137 .map(|item| OverlayListItem {
6138 title: item.title,
6139 subtitle: item.subtitle,
6140 badge: item.badge,
6141 indent: item.indent,
6142 search_value: item.search_value,
6143 selection: item.selection,
6144 })
6145 .collect(),
6146 selected: 0,
6147 search: Some(OverlaySearchState {
6148 label: "search".into(),
6149 placeholder: Some("filter commands\u{2026}".into()),
6150 value: String::new(),
6151 }),
6152 secure_input: None,
6153 ..Default::default()
6154 }
6155}
6156
6157static FRAME_TICK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
6159static ANIMATION_T0: std::sync::LazyLock<std::time::Instant> =
6164 std::sync::LazyLock::new(std::time::Instant::now);
6165
6166fn animation_frame(period_ms: u64) -> u64 {
6170 ANIMATION_T0.elapsed().as_millis() as u64 / period_ms.max(1)
6171}
6172static TITLE_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
6174const TITLE_SPINNER: &[&str] = &["-", "\\", "|", "/"];
6176
6177const RUN_SPINNER: &[&str] = &[
6181 "\u{280B}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283C}", "\u{2834}", "\u{2826}", "\u{2827}",
6182 "\u{2807}", "\u{280F}",
6183];
6184
6185fn format_elapsed_secs(secs: u64) -> String {
6188 if secs < 60 {
6189 format!("{secs}s")
6190 } else {
6191 format!("{}m {:02}s", secs / 60, secs % 60)
6192 }
6193}
6194
6195fn blend_rgb(base: Color, target: Color, ratio: f64) -> Color {
6197 match (base, target) {
6198 (Color::Rgb(br, bg, bb), Color::Rgb(tr, tg, tb)) => {
6199 let r = (br as f64 + (tr as f64 - br as f64) * ratio).round() as u8;
6200 let g = (bg as f64 + (tg as f64 - bg as f64) * ratio).round() as u8;
6201 let b = (bb as f64 + (tb as f64 - bb as f64) * ratio).round() as u8;
6202 Color::Rgb(r, g, b)
6203 }
6204 _ => base,
6205 }
6206}
6207
6208fn accent_color_for_kind(kind: InlineMessageKind, styles: &ThemeStyles) -> Color {
6210 match kind {
6211 InlineMessageKind::User => color_from_anstyle(styles.user.get_fg_color()),
6212 InlineMessageKind::Agent => color_from_anstyle(styles.response.get_fg_color()),
6213 InlineMessageKind::Tool => color_from_anstyle(styles.tool.get_fg_color()),
6214 InlineMessageKind::Error => color_from_anstyle(styles.error.get_fg_color()),
6215 InlineMessageKind::Warning => color_from_anstyle(styles.status.get_fg_color()),
6216 InlineMessageKind::Info => color_from_anstyle(styles.info.get_fg_color()),
6217 InlineMessageKind::Policy => color_from_anstyle(styles.mcp.get_fg_color()),
6218 InlineMessageKind::Pty => color_from_anstyle(styles.pty_output.get_fg_color()),
6219 }
6220}
6221
6222fn render_frame(frame: &mut Frame<'_>, state: &RenderState, _handle: &InlineHandle) {
6228 let area = frame.area();
6229 let bg = active_styles().background;
6234 frame
6235 .buffer_mut()
6236 .set_style(area, Style::default().bg(color_from_anstyle(Some(bg))));
6237 let layout = super::frame_layout::compute_chrome(area);
6238 let tick = FRAME_TICK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6239 {
6241 let running = state.active_run.is_some() || state.reasoning_stage.is_some();
6242 let was_running = TITLE_RUNNING.swap(running, std::sync::atomic::Ordering::Relaxed);
6243 if running || was_running {
6244 let title = if running {
6245 let spin = TITLE_SPINNER[(animation_frame(120) as usize) % TITLE_SPINNER.len()];
6246 let model = state
6247 .header_context
6248 .editor_context
6249 .as_deref()
6250 .unwrap_or("oxicode");
6251 format!("{spin} oxicode \u{2014} {model}")
6252 } else {
6253 "oxicode".to_string()
6254 };
6255 use std::io::Write;
6256 let _ = write!(std::io::stderr(), "\x1b]2;{}\x07", title);
6257 let _ = std::io::stderr().flush();
6258 }
6259 }
6260 if let Some(panel) = &state.issues_panel {
6266 crate::tui_vt::issues_panel::render_issues_panel(frame, area, panel);
6267 if let Some(overlay) = &state.overlay {
6268 render_overlay(frame, area, overlay);
6269 }
6270 if let Some(confirm) = &state.confirmation {
6271 render_confirmation(frame, area, confirm);
6272 }
6273 return;
6274 }
6275 if let Some(git) = &state.git_tui {
6279 crate::tui_vt::git_tui::render::render_overlay_lines(frame, area, git);
6280 } else {
6281 render_transcript(frame, layout.scrollback, state);
6282 let mut pinned_area = layout.scrollback;
6283 if !state.queued_inputs.is_empty() {
6284 let used = render_queue_pane(frame, pinned_area, state);
6285 pinned_area.y = pinned_area.y.saturating_add(used);
6286 pinned_area.height = pinned_area.height.saturating_sub(used);
6287 }
6288 if !state.todo_phases.is_empty() {
6289 if frame.area().height < TODO_COMPACT_ROWS_THRESHOLD {
6290 let line = render_todo_compact_line(&state.todo_phases);
6291 frame.render_widget(
6292 Paragraph::new(vec![line]),
6293 Rect {
6294 height: 1,
6295 ..pinned_area
6296 },
6297 );
6298 } else {
6299 let is_matched = build_matched_closure(state.hub.as_ref());
6300 render_todo_pane(
6301 frame,
6302 pinned_area,
6303 &state.todo_phases,
6304 state.todo_expanded,
6305 is_matched,
6306 );
6307 }
6308 }
6309 if state.active_run.is_some() || state.reasoning_stage.is_some() {
6312 render_reasoning_indicator(frame, layout.prompt, state);
6313 } else if state.pending_quit {
6314 render_pending_quit_hint(frame, layout.prompt);
6315 } else if !state.follow_ups.is_empty() {
6316 render_follow_ups(frame, layout.prompt, &state.follow_ups);
6317 } else {
6318 let occluded = state.overlay.is_some() || state.confirmation.is_some();
6320 if let Some(tip) = &state.tip
6321 && tip_is_visible(tip, tick)
6322 && !(tip.ambient && occluded)
6323 {
6324 render_tip(frame, layout.prompt, &tip.text);
6325 }
6326 }
6327 render_composer(frame, layout.prompt, state);
6328 if state.slash_popup.open {
6329 render_slash_popup(frame, layout.prompt, state);
6330 }
6331 if state.file_search.is_some() {
6332 render_file_search_dropdown(frame, layout.prompt, state);
6333 }
6334 }
6335 if state.agent_hub_open {
6336 render_agent_hub(frame, area, state);
6337 }
6338 if let Some(overlay) = &state.overlay {
6339 render_overlay(frame, area, overlay);
6340 }
6341 if let Some(confirm) = &state.confirmation {
6342 render_confirmation(frame, area, confirm);
6343 }
6344 }
6347fn render_confirmation(frame: &mut Frame, area: Rect, confirm: &ModalConfirmation) {
6349 let styles = active_styles();
6350 let accent = color_from_anstyle(styles.error.get_fg_color());
6351 let inner_w = confirm
6352 .title
6353 .chars()
6354 .count()
6355 .max(confirm.message.chars().count())
6356 .max(36) as u16;
6357 let width = inner_w + 4;
6358 let height = 5;
6359 let x = area.x + area.width.saturating_sub(width) / 2;
6360 let y = area.y + area.height.saturating_sub(height) / 2;
6361 let popup_area = Rect {
6362 x,
6363 y,
6364 width,
6365 height,
6366 };
6367 let block = Block::default()
6368 .borders(Borders::ALL)
6369 .border_type(BorderType::Rounded)
6370 .title(Span::styled(
6371 format!(" {} ", confirm.title),
6372 Style::default().fg(accent).bold(),
6373 ))
6374 .border_style(Style::default().fg(accent));
6375 let msg = Line::styled(
6376 confirm.message.clone(),
6377 Style::default().fg(color_from_anstyle(Some(styles.foreground))),
6378 );
6379 frame.render_widget(
6380 Paragraph::new(vec![Line::default(), msg]).block(block),
6381 popup_area,
6382 );
6383}
6384
6385fn render_agent_hub(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
6389 let rows = state.hub_entries.len() as u16;
6390 let height = rows.saturating_add(4).min(area.height.saturating_sub(1));
6391 let width = area.width.clamp(30, 80);
6392 let rect = Rect {
6393 x: area.x + (area.width.saturating_sub(width)) / 2,
6394 y: area.y + (area.height.saturating_sub(height)) / 2,
6395 width,
6396 height,
6397 };
6398 frame.render_widget(Clear, rect);
6399
6400 let title = Line::from(Span::styled(
6401 " Agent Hub ",
6402 Style::default().add_modifier(Modifier::BOLD),
6403 ));
6404 let block = Block::default().borders(Borders::ALL).title(title);
6405
6406 let items: Vec<ListItem<'_>> = if state.hub_entries.is_empty() {
6407 vec![ListItem::new(Line::from(Span::raw(
6408 "No agents registered.",
6409 )))]
6410 } else {
6411 state
6412 .hub_entries
6413 .iter()
6414 .map(|(id, e)| {
6415 ListItem::new(Line::from(vec![
6416 Span::raw(format!("{:?} ", e.kind)),
6417 Span::raw(e.display_name.clone()),
6418 Span::raw(format!(" — {:?} ({})", e.status, id)),
6419 ]))
6420 })
6421 .collect()
6422 };
6423 frame.render_widget(List::new(items).block(block), rect);
6424}
6425
6426fn render_overlay(frame: &mut Frame<'_>, area: Rect, overlay: &OverlayState) {
6431 let styles = active_styles();
6432 if let Some(secure) = &overlay.secure_input {
6435 let lines_count = overlay.lines.len();
6437 let desired_h = (lines_count as u16).saturating_add(1).saturating_add(2); let height = desired_h.min(area.height.saturating_sub(2));
6439 let width = area.width.clamp(30, 80);
6440 let rect = Rect {
6441 x: area.x + (area.width.saturating_sub(width)) / 2,
6442 y: area.y + (area.height.saturating_sub(height)) / 2,
6443 width,
6444 height,
6445 };
6446 frame.render_widget(Clear, rect);
6447
6448 let title = Line::from(Span::styled(
6449 format!(" {} ", overlay.title),
6450 Style::default()
6451 .fg(color_from_anstyle(styles.primary.get_fg_color()))
6452 .add_modifier(Modifier::BOLD),
6453 ));
6454 let block = Block::default()
6455 .borders(Borders::ALL)
6456 .border_type(BorderType::Plain)
6457 .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
6458 .title(title);
6459 let inner = block.inner(rect);
6460 frame.render_widget(&block, rect);
6461
6462 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
6463
6464 let mut row = inner.top();
6465 for line_text in &overlay.lines {
6466 let row_area = Rect {
6467 x: inner.left(),
6468 y: row,
6469 width: inner.width,
6470 height: 1,
6471 };
6472 let line = Line::from(Span::styled(
6473 line_text.clone(),
6474 Style::default().fg(secondary),
6475 ));
6476 frame.render_widget(Paragraph::new(line), row_area);
6477 row = row.saturating_add(1);
6478 }
6479
6480 let label = &secure.config.label;
6487 let label_prefix = format!("{label}: ");
6488 let prefix_columns = UnicodeWidthStr::width(label_prefix.as_str()) as u16;
6489 let prefix_area = Rect {
6490 x: inner.left(),
6491 y: row,
6492 width: prefix_columns.min(inner.width),
6493 height: 1,
6494 };
6495 frame.render_widget(
6496 Paragraph::new(Line::from(Span::styled(
6497 label_prefix.clone(),
6498 Style::default().fg(secondary),
6499 ))),
6500 prefix_area,
6501 );
6502 let textarea_area = Rect {
6503 x: inner.left().saturating_add(prefix_columns),
6504 y: row,
6505 width: inner.width.saturating_sub(prefix_columns),
6506 height: 1,
6507 };
6508 let inner_left = textarea_area.left();
6509 let inner_right = textarea_area.right().saturating_sub(1);
6510
6511 let value = secure.editor.text();
6512 if value.is_empty() {
6513 if let Some(placeholder) = secure.config.placeholder.as_deref() {
6516 frame.render_widget(
6517 Paragraph::new(Line::from(Span::styled(
6518 placeholder.to_string(),
6519 Style::default().fg(secondary).dim(),
6520 ))),
6521 textarea_area,
6522 );
6523 }
6524 if textarea_area.width > 0 {
6525 frame.set_cursor_position(Position::new(inner_left, row));
6526 }
6527 return;
6528 }
6529
6530 let display_line: Line<'static> = if secure.config.mask_input {
6533 Line::from("*".repeat(value.chars().count()))
6534 } else {
6535 Line::from(value.to_string())
6540 };
6541 let mut ta = TextArea::new();
6542 ta.set_text(value);
6543 ta.replace_range_with_element(
6544 0..value.len(),
6545 value,
6546 MASKED_ELEMENT_KIND,
6547 Some(display_line),
6548 );
6549 ta.set_cursor(secure.editor.cursor_byte());
6553 frame.render_widget_ref(&ta, textarea_area);
6554 if let Some((cx, cy)) = ta.cursor_pos_with_state(textarea_area, TextAreaState::default()) {
6557 let caret_x = cx.min(inner_right);
6558 frame.set_cursor_position(Position::new(caret_x, cy));
6559 }
6560 return;
6561 }
6562 let visible_max = (area.height as usize).saturating_sub(7).max(3);
6565
6566 let filtered: Vec<usize> = match &overlay.search {
6568 Some(search) if !search.value.is_empty() => {
6569 let needle = search.value.to_lowercase();
6570 overlay
6571 .items
6572 .iter()
6573 .enumerate()
6574 .filter_map(|(idx, item)| {
6575 let title_match = item.title.to_lowercase().contains(&needle);
6576 let sv_match = item
6577 .search_value
6578 .as_deref()
6579 .map(|v| v.to_lowercase().contains(&needle))
6580 .unwrap_or(false);
6581 if title_match || sv_match {
6582 Some(idx)
6583 } else {
6584 None
6585 }
6586 })
6587 .collect()
6588 }
6589 _ => (0..overlay.items.len()).collect(),
6590 };
6591
6592 let has_search = overlay.search.is_some();
6593 let has_tabs = overlay.tabs.len() > 1;
6594 let lines_count = overlay.lines.len();
6595 let items_count = filtered.len().min(visible_max);
6596 let height_inner =
6597 (lines_count + items_count + usize::from(has_search) + usize::from(has_tabs)) as u16;
6598 let desired_h = height_inner.saturating_add(3); let height = desired_h.min(area.height.saturating_sub(2));
6600 let width = area.width.clamp(30, 80);
6601 let rect = Rect {
6602 x: area.x + (area.width.saturating_sub(width)) / 2,
6603 y: area.y + (area.height.saturating_sub(height)) / 2,
6604 width,
6605 height,
6606 };
6607 frame.render_widget(Clear, rect);
6608
6609 let title = Line::from(Span::styled(
6610 format!(" {} ", overlay.title),
6611 Style::default()
6612 .fg(color_from_anstyle(styles.primary.get_fg_color()))
6613 .add_modifier(Modifier::BOLD),
6614 ));
6615 let block = Block::default()
6616 .borders(Borders::ALL)
6617 .border_type(BorderType::Plain)
6618 .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
6619 .title(title);
6620 let inner = block.inner(rect);
6621 frame.render_widget(&block, rect);
6622
6623 let primary = color_from_anstyle(styles.primary.get_fg_color());
6624 let fg = color_from_anstyle(Some(styles.foreground));
6625 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
6626
6627 let selected_filtered_pos = filtered
6629 .iter()
6630 .position(|&idx| idx == overlay.selected)
6631 .unwrap_or(0);
6632
6633 let mut row = inner.top();
6634
6635 if has_tabs {
6638 let mut spans: Vec<Span> = Vec::new();
6639 for (i, name) in overlay.tabs.iter().enumerate() {
6640 if i > 0 {
6641 spans.push(Span::raw(" "));
6642 }
6643 let style = if i == overlay.active_tab {
6644 Style::default().fg(primary).add_modifier(Modifier::BOLD)
6645 } else {
6646 Style::default().fg(secondary).add_modifier(Modifier::DIM)
6647 };
6648 spans.push(Span::styled(name.clone(), style));
6649 }
6650 let row_area = Rect {
6651 x: inner.left(),
6652 y: row,
6653 width: inner.width,
6654 height: 1,
6655 };
6656 frame.render_widget(Paragraph::new(Line::from(spans)), row_area);
6657 row = row.saturating_add(1);
6658 }
6659 if let Some(search) = &overlay.search {
6661 let prompt = format!("{}: {}", search.label, search.value);
6662 let line = Line::from(vec![
6663 Span::styled(
6664 format!("{}: ", search.label),
6665 Style::default().fg(secondary),
6666 ),
6667 Span::styled(
6668 if search.value.is_empty() {
6669 search
6670 .placeholder
6671 .clone()
6672 .unwrap_or_else(|| "type to filter\u{2026}".to_string())
6673 } else {
6674 search.value.clone()
6675 },
6676 if search.value.is_empty() {
6677 Style::default().fg(secondary).add_modifier(Modifier::DIM)
6678 } else {
6679 Style::default().fg(fg)
6680 },
6681 ),
6682 ]);
6683 let _ = prompt; let row_area = Rect {
6685 x: inner.left(),
6686 y: row,
6687 width: inner.width,
6688 height: 1,
6689 };
6690 frame.render_widget(Paragraph::new(line), row_area);
6691 row = row.saturating_add(1);
6692 }
6693
6694 for line_text in &overlay.lines {
6696 let row_area = Rect {
6697 x: inner.left(),
6698 y: row,
6699 width: inner.width,
6700 height: 1,
6701 };
6702 let line = Line::from(Span::styled(
6703 line_text.clone(),
6704 Style::default().fg(secondary),
6705 ));
6706 frame.render_widget(Paragraph::new(line), row_area);
6707 row = row.saturating_add(1);
6708 }
6709
6710 let searching = overlay.search.as_ref().is_some_and(|s| !s.value.is_empty());
6716 let use_sidebar = overlay.sections.len() >= 2 && inner.width >= 60 && !searching;
6717 let sidebar_w = if use_sidebar {
6718 let longest = overlay
6719 .sections
6720 .iter()
6721 .map(|s| s.chars().count())
6722 .max()
6723 .unwrap_or(0);
6724 (22usize.min(longest) + 4) as u16
6725 } else {
6726 0
6727 };
6728 let active_section = if use_sidebar {
6731 item_section_idx(overlay, overlay.selected).unwrap_or(overlay.active_section)
6732 } else {
6733 overlay.active_section
6734 };
6735 let list_x = inner.left() + sidebar_w;
6736 let list_w = inner.width.saturating_sub(sidebar_w);
6737 if use_sidebar {
6738 let mut srow = row;
6739 for (i, name) in overlay.sections.iter().enumerate() {
6740 let style = if i == active_section {
6741 Style::default().fg(primary).add_modifier(Modifier::BOLD)
6742 } else {
6743 Style::default().fg(secondary)
6744 };
6745 let marker = if i == active_section { "> " } else { " " };
6746 let row_area = Rect {
6747 x: inner.left(),
6748 y: srow,
6749 width: sidebar_w,
6750 height: 1,
6751 };
6752 frame.render_widget(
6753 Paragraph::new(Line::from(vec![
6754 Span::styled(marker, style),
6755 Span::styled(name.clone(), style),
6756 ])),
6757 row_area,
6758 );
6759 srow = srow.saturating_add(1);
6760 }
6761 }
6762
6763 if filtered.is_empty() {
6765 if overlay.key_capture.is_some() {
6769 return;
6770 }
6771 let row_area = Rect {
6772 x: inner.left(),
6773 y: row,
6774 width: inner.width,
6775 height: 1,
6776 };
6777 let empty_text = if overlay.search.is_some() {
6778 " (no matches)"
6779 } else {
6780 " (no items)"
6781 };
6782 frame.render_widget(
6783 Paragraph::new(Line::from(Span::styled(
6784 empty_text,
6785 Style::default().fg(secondary).add_modifier(Modifier::DIM),
6786 ))),
6787 row_area,
6788 );
6789 } else {
6790 let first_visible = selected_filtered_pos
6791 .saturating_sub(visible_max / 2)
6792 .min(filtered.len().saturating_sub(visible_max));
6793 for &item_idx in filtered.iter().skip(first_visible).take(visible_max) {
6794 let item = &overlay.items[item_idx];
6795 let is_selected = item_idx == overlay.selected;
6796 let marker = if is_selected { "> " } else { " " };
6797 let indent = " ".repeat(item.indent as usize);
6798 let mut item_style = if is_selected {
6799 Style::default().fg(primary).add_modifier(Modifier::BOLD)
6800 } else {
6801 Style::default().fg(fg)
6802 };
6803 if use_sidebar
6806 && item_section_idx(overlay, item_idx).is_some_and(|sec| sec != active_section)
6807 {
6808 item_style = item_style.add_modifier(Modifier::DIM);
6809 }
6810 let mut spans = vec![
6811 Span::styled(marker, item_style),
6812 Span::styled(indent, item_style),
6813 Span::styled(item.title.clone(), item_style),
6814 ];
6815 if let Some(badge) = &item.badge {
6816 spans.push(Span::raw(" "));
6817 spans.push(Span::styled(
6818 badge.clone(),
6819 Style::default().fg(secondary).add_modifier(Modifier::DIM),
6820 ));
6821 }
6822 if let Some(subtitle) = &item.subtitle {
6823 spans.push(Span::raw(" "));
6824 spans.push(Span::styled(
6825 subtitle.clone(),
6826 Style::default().fg(secondary),
6827 ));
6828 }
6829 let line = Line::from(spans);
6830 let row_area = Rect {
6831 x: list_x,
6832 y: row,
6833 width: list_w,
6834 height: 1,
6835 };
6836 frame.render_widget(Paragraph::new(line), row_area);
6837 row = row.saturating_add(1);
6838 }
6839 }
6840
6841 if row < inner.bottom() {
6844 let hint = if overlay.items.iter().any(|item| item.selection.is_some()) {
6845 if has_tabs {
6846 "Enter select | Up/Down move | ←/→ tabs | Esc close"
6847 } else {
6848 "Enter select | Up/Down move | Esc close"
6849 }
6850 } else {
6851 "Esc close"
6852 };
6853 frame.render_widget(
6854 Paragraph::new(Line::from(Span::styled(
6855 hint,
6856 Style::default().fg(secondary).add_modifier(Modifier::DIM),
6857 ))),
6858 Rect {
6859 x: inner.left(),
6860 y: inner.bottom().saturating_sub(1),
6861 width: inner.width,
6862 height: 1,
6863 },
6864 );
6865 }
6866}
6867
6868fn render_transcript(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
6869 if state.transcript.is_empty() {
6870 render_welcome(frame, area, state);
6871 return;
6872 }
6873 let styles = active_styles();
6874 let bg_color = color_from_anstyle(Some(styles.background));
6875
6876 let content_area = Rect {
6880 x: area.x,
6881 y: area.y,
6882 width: area.width,
6883 height: area.height,
6884 };
6885
6886 let display =
6887 build_transcript_display(state, &styles, state.committed_entries, content_area.width);
6888
6889 let total = display.len();
6891 let raw_start = if state.scroll_offset == usize::MAX {
6892 total.saturating_sub(content_area.height as usize)
6893 } else {
6894 display
6895 .iter()
6896 .position(|d| d.source_index >= state.scroll_offset)
6897 .unwrap_or(total.saturating_sub(1))
6898 };
6899 let start = effective_scroll_offset(raw_start, total, content_area.height as usize);
6900
6901 let sticky_first: Option<usize> = display.get(start).and_then(|d| {
6905 let bid = state.transcript.get(d.source_index)?.block_id;
6906 let first_idx = state.transcript.iter().position(|l| l.block_id == bid)?;
6907 (first_idx != d.source_index).then_some(first_idx)
6908 });
6909 let sticky_h: u16 = if sticky_first.is_some() { 1 } else { 0 };
6910 let body_top = content_area.top() + sticky_h;
6911
6912 const FADE_ROWS: usize = 5;
6917 let sticky_opacity: f64 = if let Some(sidx) = sticky_first {
6918 let sticky_bid = state.transcript[sidx].block_id;
6919 let next_offset = display.iter().skip(start).position(|d| {
6922 state
6923 .transcript
6924 .get(d.source_index)
6925 .map(|l| l.block_id != sticky_bid)
6926 .unwrap_or(false)
6927 });
6928 match next_offset {
6929 Some(off) if off <= FADE_ROWS => off as f64 / FADE_ROWS as f64,
6930 _ => 1.0,
6931 }
6932 } else {
6933 1.0
6934 };
6935
6936 if let Some(sidx) = sticky_first {
6939 let tl = &state.transcript[sidx];
6940 let accent_base = accent_color_for_kind(tl.kind, &styles);
6941 let bg_blend = 0.1 * sticky_opacity;
6942 let line =
6943 transcript_line_marked(tl, &styles, false, false, false, true, content_area.width);
6944 let row = Rect {
6945 x: content_area.x,
6946 y: content_area.top(),
6947 width: content_area.width,
6948 height: 1,
6949 };
6950 if bg_blend > 0.01 {
6951 frame.buffer_mut().set_style(
6952 row,
6953 Style::default().bg(blend_rgb(bg_color, accent_base, bg_blend)),
6954 );
6955 }
6956 frame.render_widget(Paragraph::new(line), row);
6957 }
6958 let live_budget = content_area.height.saturating_sub(sticky_h) as usize;
6965 let (alloc_by_block, hidden_count, natural_by_block) =
6966 compute_block_allocations(state, state.committed_entries, live_budget);
6967 let pulse = animation_frame(1000).is_multiple_of(2);
6969 let banner_row_used = hidden_count > 0;
6971 let banner_y = content_area.bottom().saturating_sub(1);
6972 let mut y = body_top;
6974 let width = content_area.width.max(1) as usize;
6975 let image_block_plans: Vec<(usize, u32, u16, usize)> = state
6981 .image_previews
6982 .pending()
6983 .iter()
6984 .filter_map(|p| {
6985 let row_index = state
6988 .transcript
6989 .iter()
6990 .position(|l| l.segments.iter().any(|s| s.text.contains(&p.label)))?;
6991 let bid = state.transcript[row_index].block_id;
6992 let mut block_rows: u16 = 0;
6993 for d in &display {
6994 let Some(l) = state.transcript.get(d.source_index) else {
6995 continue;
6996 };
6997 if l.block_id != bid {
6998 continue;
6999 }
7000 block_rows = block_rows.saturating_add(match &d.line {
7001 None => 1,
7002 Some(line) => {
7003 let lw = line.width();
7004 if lw == 0 {
7005 1
7006 } else {
7007 lw.div_ceil(width).max(1) as u16
7008 }
7009 }
7010 });
7011 }
7012 (block_rows > 0).then_some((bid, p.id, block_rows, row_index))
7013 })
7014 .collect();
7015 let mut current_block: Option<usize> = None;
7016 let mut skipped_blocks: std::collections::HashSet<usize> = std::collections::HashSet::new();
7017 for d in display.into_iter().skip(start) {
7018 if y >= content_area.bottom() {
7019 break;
7020 }
7021 if banner_row_used && y >= banner_y {
7024 break;
7025 }
7026 let d_bid = state.transcript.get(d.source_index).map(|l| l.block_id);
7027 let Some(d_bid) = d_bid else {
7028 continue;
7029 };
7030 if current_block != Some(d_bid) {
7032 if y < content_area.bottom()
7037 && let Some((_, pid, prows, row_index)) =
7038 image_block_plans.iter().find(|(b, _, _, _)| *b == d_bid)
7039 {
7040 let visible_rows = content_area.bottom().saturating_sub(y).max(1);
7041 state.image_previews.record_anchor(
7042 *pid,
7043 content_area.x,
7044 y,
7045 (*prows).min(visible_rows),
7046 *row_index,
7047 );
7048 }
7049 current_block = Some(d_bid);
7050 let alloc = alloc_by_block
7051 .get(&d_bid)
7052 .copied()
7053 .unwrap_or(BlockAlloc { rows: 0 });
7054 let natural = natural_by_block.get(&d_bid).copied().unwrap_or(0);
7061 if alloc.rows < natural {
7062 if skipped_blocks.contains(&d_bid) {
7067 continue;
7068 }
7069 match alloc.rows {
7070 0 => {
7071 skipped_blocks.insert(d_bid);
7072 continue;
7073 }
7074 1 => {
7075 let activity = block_activity(&state.transcript, d_bid);
7076 render_glyph_row(frame, content_area, y, &activity, &styles, pulse);
7077 y += 1;
7078 skipped_blocks.insert(d_bid);
7079 continue;
7080 }
7081 2 => {
7082 let activity = block_activity(&state.transcript, d_bid);
7083 y += render_folded_card(frame, content_area, y, &activity, &styles, pulse);
7084 skipped_blocks.insert(d_bid);
7085 continue;
7086 }
7087 _ => {}
7088 }
7089 }
7090 }
7095 let Some(line) = d.line else {
7096 y += 1;
7097 continue;
7098 };
7099 let text_w = line.width();
7100 let wrapped_h = if text_w == 0 {
7101 1
7102 } else {
7103 text_w.div_ceil(width).max(1) as u16
7104 };
7105 let row = Rect {
7106 x: content_area.x,
7107 y,
7108 width: content_area.width,
7109 height: wrapped_h.min(content_area.bottom().saturating_sub(y)),
7110 };
7111 frame.render_widget(Paragraph::new(line).wrap(Wrap { trim: false }), row);
7112 y += wrapped_h;
7113 }
7114
7115 if banner_row_used {
7119 render_hidden_banner(frame, content_area, banner_y, hidden_count);
7120 }
7121
7122 let _ = (total, sticky_h);
7123}
7124
7125#[derive(Clone)]
7128struct TranscriptDisplayItem<'a> {
7129 source_index: usize,
7130 line: Option<Line<'a>>,
7132}
7133
7134fn block_activity(transcript: &[TranscriptLine], block_id: usize) -> String {
7140 let mut activity = String::new();
7141 for line in transcript.iter().filter(|l| l.block_id == block_id) {
7142 for seg in &line.segments {
7143 let text = seg.text.trim();
7144 if !text.is_empty() {
7145 activity.push_str(text);
7146 break;
7147 }
7148 }
7149 if !activity.is_empty() {
7150 break;
7151 }
7152 }
7153 if !activity.is_empty() {
7154 return activity;
7155 }
7156 transcript
7158 .iter()
7159 .find(|l| l.block_id == block_id)
7160 .map(|l| kind_label(l.kind))
7161 .unwrap_or_else(|| "block".to_string())
7162}
7163
7164fn kind_label(kind: InlineMessageKind) -> String {
7167 match kind {
7168 InlineMessageKind::Agent => "agent".to_string(),
7169 InlineMessageKind::User => "user".to_string(),
7170 InlineMessageKind::Tool => "tool".to_string(),
7171 InlineMessageKind::Error => "error".to_string(),
7172 InlineMessageKind::Warning => "warning".to_string(),
7173 InlineMessageKind::Info => "info".to_string(),
7174 InlineMessageKind::Policy => "policy".to_string(),
7175 InlineMessageKind::Pty => "pty".to_string(),
7176 }
7177}
7178
7179fn block_natural_heights(
7184 transcript: &[TranscriptLine],
7185 mode_for: impl Fn(usize) -> BlockDisplayMode,
7186 from_entry: usize,
7187) -> (Vec<usize>, Vec<usize>) {
7188 let mut block_ids: Vec<usize> = Vec::new();
7189 let mut heights: Vec<usize> = Vec::new();
7190 let mut index_of: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
7191 for item in visible_items(transcript, mode_for) {
7192 let bid = match item {
7193 VisibleItem::Line { source_index, .. } => transcript[source_index].block_id,
7194 VisibleItem::Gap { source_index, .. } => transcript[source_index].block_id,
7195 };
7196 let live_source = match item {
7201 VisibleItem::Line { source_index, .. } => source_index,
7202 VisibleItem::Gap { source_index, .. } => source_index,
7203 };
7204 if live_source < from_entry {
7205 continue;
7206 }
7207 let idx = *index_of.entry(bid).or_insert_with(|| {
7208 block_ids.push(bid);
7209 heights.push(0);
7210 block_ids.len() - 1
7211 });
7212 heights[idx] += 1;
7213 }
7214 (block_ids, heights)
7215}
7216
7217fn compute_block_allocations(
7224 state: &RenderState,
7225 from_entry: usize,
7226 budget: usize,
7227) -> (
7228 std::collections::HashMap<usize, BlockAlloc>,
7229 usize,
7230 std::collections::HashMap<usize, usize>,
7231) {
7232 let (block_ids, heights) =
7233 block_natural_heights(&state.transcript, |bid| state.block_mode(bid), from_entry);
7234 let total_blocks = block_ids.len();
7235 let allocs = allocate_rows(&heights, budget);
7236 let mut by_block: std::collections::HashMap<usize, BlockAlloc> =
7237 std::collections::HashMap::with_capacity(total_blocks);
7238 let mut natural_by_block: std::collections::HashMap<usize, usize> =
7239 std::collections::HashMap::with_capacity(total_blocks);
7240 let mut hidden = 0usize;
7241 for (i, &bid) in block_ids.iter().enumerate() {
7242 let alloc = match state.block_mode(bid) {
7251 BlockDisplayMode::Collapsed => BlockAlloc { rows: heights[i] },
7256 BlockDisplayMode::Truncated | BlockDisplayMode::Expanded => allocs[i],
7257 };
7258 if alloc.rows == 0 {
7259 hidden += 1;
7260 }
7261 natural_by_block.insert(bid, heights[i]);
7262 by_block.insert(bid, alloc);
7263 }
7264 (by_block, hidden, natural_by_block)
7265}
7266
7267fn clamp_fold_text(text: &str, prefix_w: usize, width: usize, ellipsis: &str) -> String {
7273 let budget = width.saturating_sub(prefix_w);
7274 if budget == 0 || width == 0 {
7275 return String::new();
7276 }
7277 let text_w = text.width();
7278 if text_w <= budget {
7279 return text.to_string();
7280 }
7281 let ell_w = ellipsis.width();
7284 let cap = budget.saturating_sub(ell_w);
7285 let mut out = String::new();
7286 let mut used = 0usize;
7287 for ch in text.chars() {
7288 let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
7289 if used + w > cap {
7290 break;
7291 }
7292 out.push(ch);
7293 used += w;
7294 }
7295 out.push_str(ellipsis);
7296 out
7297}
7298
7299fn render_folded_card(
7305 frame: &mut Frame<'_>,
7306 area: Rect,
7307 y: u16,
7308 activity: &str,
7309 styles: &ThemeStyles,
7310 pulse: bool,
7311) -> u16 {
7312 let tool_color = styles
7313 .tool
7314 .get_fg_color()
7315 .or_else(|| styles.secondary.get_fg_color())
7316 .or(styles.response.get_fg_color());
7317 let style = Style::default().fg(color_from_anstyle(tool_color));
7318 let pulse_mark = if pulse { " \u{2022}" } else { "" };
7319 let head_prefix_w = "\u{256D}\u{2500} ".width() + pulse_mark.width();
7323 let head_activity = clamp_fold_text(activity, head_prefix_w, area.width as usize, "\u{2026}");
7324 let head = Line::from(vec![Span::styled(
7325 format!("\u{256D}\u{2500} {head_activity}{pulse_mark}"),
7326 style,
7327 )]);
7328 let tail = Line::from(vec![Span::styled("\u{2570}\u{2500} \u{2026}", style)]);
7329 if y < area.bottom() {
7330 let row = Rect {
7331 x: area.x,
7332 y,
7333 width: area.width,
7334 height: 1,
7335 };
7336 frame.render_widget(Paragraph::new(head), row);
7337 }
7338 let y2 = y.saturating_add(1);
7339 if y2 < area.bottom() {
7340 let row = Rect {
7341 x: area.x,
7342 y: y2,
7343 width: area.width,
7344 height: 1,
7345 };
7346 frame.render_widget(Paragraph::new(tail), row);
7347 }
7348 2
7349}
7350
7351fn render_glyph_row(
7357 frame: &mut Frame<'_>,
7358 area: Rect,
7359 y: u16,
7360 activity: &str,
7361 styles: &ThemeStyles,
7362 pulse: bool,
7363) -> u16 {
7364 let tool_color = styles
7365 .tool
7366 .get_fg_color()
7367 .or_else(|| styles.secondary.get_fg_color())
7368 .or(styles.response.get_fg_color());
7369 let style = Style::default().fg(color_from_anstyle(tool_color));
7370 let pulse_mark = if pulse { " \u{2022}" } else { "" };
7371 let glyph_prefix_w = "\u{25B8} ".width() + pulse_mark.width();
7373 let glyph_activity = clamp_fold_text(activity, glyph_prefix_w, area.width as usize, "\u{2026}");
7374 let line = Line::from(vec![Span::styled(
7375 format!("\u{25B8} {glyph_activity}{pulse_mark}"),
7376 style,
7377 )]);
7378 if y < area.bottom() {
7379 let row = Rect {
7380 x: area.x,
7381 y,
7382 width: area.width,
7383 height: 1,
7384 };
7385 frame.render_widget(Paragraph::new(line), row);
7386 }
7387 1
7388}
7389fn render_hidden_banner(frame: &mut Frame<'_>, area: Rect, y: u16, hidden: usize) -> u16 {
7393 let style = Style::default().fg(color_from_anstyle(active_styles().secondary.get_fg_color()));
7394 let text = if hidden == 1 {
7395 "\u{2026} 1 earlier block hidden".to_string()
7396 } else {
7397 format!("\u{2026} {hidden} earlier blocks hidden")
7398 };
7399 let clamped = clamp_fold_text(&text, 0, area.width as usize, "\u{2026}");
7400 let line = Line::from(vec![Span::styled(clamped, style)]);
7401 if y < area.bottom() {
7402 let row = Rect {
7403 x: area.x,
7404 y,
7405 width: area.width,
7406 height: 1,
7407 };
7408 frame.render_widget(Paragraph::new(line), row);
7409 }
7410 1
7411}
7412fn build_transcript_display<'a>(
7416 state: &'a RenderState,
7417 styles: &'a ThemeStyles,
7418 from_entry: usize,
7419 width: u16,
7420) -> Vec<TranscriptDisplayItem<'a>> {
7421 let search_set: std::collections::HashSet<usize> = state
7422 .search
7423 .as_ref()
7424 .map(|s| s.matches.iter().copied().collect())
7425 .unwrap_or_default();
7426 let current_match = state
7427 .search
7428 .as_ref()
7429 .and_then(|s| (!s.matches.is_empty()).then(|| s.matches[s.current]));
7430
7431 let mut display = Vec::with_capacity(state.transcript.len());
7432 let dim_style = Style::default()
7433 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
7434 .add_modifier(Modifier::DIM);
7435 let mut prev_block: Option<usize> = None;
7436 let mut prev_kind: Option<InlineMessageKind> = None;
7437 for item in visible_items(&state.transcript, |block_id| state.block_mode(block_id)) {
7438 match item {
7439 VisibleItem::Line {
7440 source_index,
7441 folded,
7442 } => {
7443 if source_index < from_entry {
7444 continue;
7445 }
7446 let tl = &state.transcript[source_index];
7447 let is_block_start = prev_block != Some(tl.block_id);
7448 let needs_spacer = is_block_start
7451 && prev_block.is_some()
7452 && (tl.kind == InlineMessageKind::User
7453 || prev_kind == Some(InlineMessageKind::User));
7454 if needs_spacer {
7455 display.push(TranscriptDisplayItem {
7456 source_index,
7457 line: None,
7458 });
7459 }
7460 let is_match = search_set.contains(&source_index);
7461 let line = transcript_line_marked(
7462 tl,
7463 styles,
7464 folded,
7465 is_match,
7466 current_match == Some(source_index),
7467 is_block_start,
7468 width,
7469 );
7470 display.push(TranscriptDisplayItem {
7471 source_index,
7472 line: Some(line),
7473 });
7474 prev_block = Some(tl.block_id);
7475 prev_kind = Some(tl.kind);
7476 }
7477 VisibleItem::Gap {
7478 source_index,
7479 hidden_lines,
7480 } => {
7481 if source_index < from_entry {
7482 continue;
7483 }
7484 let gap = Line::styled(format!(" \u{2026} +{hidden_lines} lines"), dim_style);
7485 display.push(TranscriptDisplayItem {
7486 source_index,
7487 line: Some(gap),
7488 });
7489 }
7490 }
7491 }
7492 display
7493}
7494pub(crate) fn should_rebuild_scrollback(
7507 prev_w: u16,
7508 new_w: u16,
7509 _prev_h: u16,
7510 _new_h: u16,
7511) -> bool {
7512 prev_w != 0 && prev_w != new_w
7513}
7514
7515pub(crate) fn plan_full_flush(display_len: usize) -> usize {
7523 display_len
7524}
7525
7526struct ScrollbackCommit {
7529 rows: u16,
7531 boundary_item: usize,
7533 new_committed_entries: usize,
7535}
7536fn scrollback_commit_plan(
7542 display: &[TranscriptDisplayItem<'_>],
7543 transcript: &[TranscriptLine],
7544 width: usize,
7545 keep_rows: usize,
7546 anchor_entry: Option<usize>,
7547) -> Option<ScrollbackCommit> {
7548 let width = width.max(1);
7549 let mut ends: Vec<usize> = Vec::with_capacity(display.len());
7552 let mut y = 0usize;
7553 for d in display {
7554 let h = match &d.line {
7555 None => 1,
7556 Some(line) => {
7557 let w = line.width();
7558 if w == 0 { 1 } else { w.div_ceil(width).max(1) }
7559 }
7560 };
7561 y += h;
7562 ends.push(y);
7563 }
7564 let total_rows = y;
7565 if total_rows <= keep_rows || display.is_empty() {
7566 return None;
7567 }
7568 let limit = total_rows - keep_rows;
7569
7570 let mut boundary_item = ends.iter().rposition(|&e| e <= limit)? + 1;
7572
7573 if let Some(anchor) = anchor_entry
7576 && let Some(anchor_item) = display.iter().position(|d| d.source_index >= anchor)
7577 {
7578 boundary_item = boundary_item.min(anchor_item);
7579 }
7580
7581 boundary_item = boundary_item.min(display.len().saturating_sub(1));
7583 let bid_of = |i: usize| transcript.get(display[i].source_index).map(|t| t.block_id);
7584 while boundary_item > 0 {
7585 let last = display[boundary_item - 1].source_index;
7586 let next = display[boundary_item].source_index;
7587 let same_block = matches!(
7588 (transcript.get(last), transcript.get(next)),
7589 (Some(a), Some(b)) if a.block_id == b.block_id
7590 );
7591 if !same_block {
7592 break;
7593 }
7594 let block_bid = bid_of(boundary_item);
7601 let block_start = (0..boundary_item)
7602 .rev()
7603 .find(|&i| bid_of(i) != block_bid)
7604 .map_or(0, |i| i + 1);
7605 let block_end = (boundary_item..display.len())
7606 .find(|&i| bid_of(i) != block_bid)
7607 .unwrap_or(display.len());
7608 let before_rows = if block_start == 0 {
7609 0
7610 } else {
7611 ends[block_start - 1]
7612 };
7613 if ends[block_end - 1] - before_rows > keep_rows {
7614 break; }
7616 boundary_item -= 1;
7617 }
7618 if boundary_item == 0 {
7619 return None;
7620 }
7621
7622 let first_live = display[boundary_item].source_index;
7626 let last_committed = display[boundary_item - 1].source_index;
7627 let new_committed_entries = if matches!(
7628 (transcript.get(last_committed), transcript.get(first_live)),
7629 (Some(a), Some(b)) if a.block_id == b.block_id
7630 ) {
7631 first_live
7632 } else {
7633 let live_bid = transcript.get(first_live)?.block_id;
7634 transcript.iter().position(|t| t.block_id == live_bid)?
7635 };
7636
7637 let rows = ends[boundary_item - 1].min(u16::MAX as usize) as u16;
7638 Some(ScrollbackCommit {
7639 rows,
7640 boundary_item,
7641 new_committed_entries,
7642 })
7643}
7644
7645fn render_committed_chunk(
7649 buf: &mut Buffer,
7650 items: &[TranscriptDisplayItem<'_>],
7651 x: u16,
7652 width: u16,
7653) {
7654 use ratatui::widgets::Widget;
7655 let width = width.max(1);
7656 let mut y = 0u16;
7657 for item in items {
7658 let Some(line) = &item.line else {
7659 y += 1;
7660 continue;
7661 };
7662 let text_w = line.width();
7663 let wrapped_h = if text_w == 0 {
7664 1
7665 } else {
7666 text_w.div_ceil(width as usize).max(1) as u16
7667 };
7668 let area = Rect {
7669 x,
7670 y,
7671 width,
7672 height: wrapped_h,
7673 };
7674 Paragraph::new(line.clone())
7675 .wrap(Wrap { trim: false })
7676 .render(area, buf);
7677 y += wrapped_h;
7678 }
7679}
7680
7681fn commit_scrollback(
7690 terminal: &mut Terminal<CrosstermBackend<Stdout>>,
7691 state: &mut RenderState,
7692 force_all: bool,
7693) {
7694 if state.scroll_offset != usize::MAX
7695 || !state.message_buffer.is_empty()
7696 || !state.thinking_buffer.is_empty()
7697 || state.overlay.is_some()
7698 || state.confirmation.is_some()
7699 || state.agent_hub_open
7700 || state.slash_popup.open
7701 || state.file_search.is_some()
7702 || state.search.is_some()
7703 || state.transcript.is_empty()
7704 {
7705 return;
7706 }
7707 let Ok(size) = terminal.size() else {
7708 return;
7709 };
7710 let area = Rect {
7711 x: 0,
7712 y: 0,
7713 width: size.width,
7714 height: size.height,
7715 };
7716 let keep_rows = super::frame_layout::scrollback_height(area) as usize;
7717 if keep_rows == 0 && !force_all {
7718 return;
7719 }
7720 let styles = active_styles();
7721 let (gutter_x, scrollback_w) = super::frame_layout::scrollback_geometry(area);
7722 let content_w = scrollback_w as usize;
7723 let display =
7724 build_transcript_display(state, &styles, state.committed_entries, content_w as u16);
7725 if force_all {
7726 let boundary_item = plan_full_flush(display.len()).min(display.len());
7731 if boundary_item == 0 {
7732 return;
7733 }
7734 let mut total_rows = 0usize;
7737 let width = content_w.max(1);
7738 for d in &display[..boundary_item] {
7739 total_rows += match &d.line {
7740 None => 1,
7741 Some(line) => {
7742 let w = line.width();
7743 if w == 0 { 1 } else { w.div_ceil(width).max(1) }
7744 }
7745 };
7746 }
7747 let rows = total_rows.min(u16::MAX as usize) as u16;
7748 let chunk = &display[..boundary_item];
7749 let res = terminal.insert_before(rows, |buf| {
7750 render_committed_chunk(buf, chunk, gutter_x, content_w as u16);
7751 });
7752 if res.is_ok() {
7753 state.committed_entries = state.transcript.len();
7757 }
7758 return;
7759 }
7760 let Some(plan) = scrollback_commit_plan(
7761 &display,
7762 &state.transcript,
7763 content_w,
7764 keep_rows,
7765 state.stream_anchor,
7766 ) else {
7767 return;
7768 };
7769 let chunk = &display[..plan.boundary_item];
7770 let res = terminal.insert_before(plan.rows, |buf| {
7771 render_committed_chunk(buf, chunk, gutter_x, content_w as u16);
7772 });
7773 if res.is_ok() {
7774 state.committed_entries = plan.new_committed_entries;
7775 }
7776}
7777
7778fn transcript_line_marked<'a>(
7787 line: &'a TranscriptLine,
7788 styles: &'a ThemeStyles,
7789 folded: bool,
7790 is_match: bool,
7791 is_current: bool,
7792 is_block_start: bool,
7793 width: u16,
7794) -> Line<'a> {
7795 let kind_style = match line.kind {
7796 InlineMessageKind::Agent => {
7797 Style::default().fg(color_from_anstyle(styles.response.get_fg_color()))
7798 }
7799 InlineMessageKind::User => {
7800 Style::default().fg(color_from_anstyle(styles.user.get_fg_color()))
7801 }
7802 InlineMessageKind::Tool => {
7803 Style::default().fg(color_from_anstyle(styles.tool.get_fg_color()))
7804 }
7805 InlineMessageKind::Error => {
7806 Style::default().fg(color_from_anstyle(styles.error.get_fg_color()))
7807 }
7808 InlineMessageKind::Warning => {
7809 Style::default().fg(color_from_anstyle(styles.status.get_fg_color()))
7810 }
7811 InlineMessageKind::Info => {
7812 Style::default().fg(color_from_anstyle(styles.info.get_fg_color()))
7813 }
7814 InlineMessageKind::Policy => {
7815 Style::default().fg(color_from_anstyle(styles.mcp.get_fg_color()))
7816 }
7817 InlineMessageKind::Pty => {
7818 Style::default().fg(color_from_anstyle(styles.pty_output.get_fg_color()))
7819 }
7820 };
7821
7822 let severity_label = match line.kind {
7825 InlineMessageKind::Error => Some("error: "),
7826 InlineMessageKind::Warning => Some("warning: "),
7827 InlineMessageKind::Info => Some("info: "),
7828 InlineMessageKind::Policy => Some("policy: "),
7829 _ => None,
7830 };
7831 let mut prefix = String::new();
7832 if folded {
7833 prefix.push_str("[+] ");
7834 }
7835 if let Some(label) = severity_label
7836 && (folded || is_block_start)
7837 {
7838 prefix.push_str(label);
7839 }
7840
7841 let highlight = if is_current {
7843 Some(Style::default().reversed())
7844 } else if is_match {
7845 Some(Style::default().add_modifier(Modifier::UNDERLINED))
7846 } else {
7847 None
7848 };
7849 let prefix_w = prefix.len() as u16;
7855 let budget = width.saturating_sub(prefix_w);
7856 let clamped = clamp_segments_to_width(&line.segments, budget);
7857 let mut spans = Vec::with_capacity(clamped.len() + 1);
7858 if !prefix.is_empty() {
7859 spans.push(Span::styled(prefix, kind_style));
7860 }
7861 for segment in &clamped {
7862 let mut style = segment_style(segment, kind_style, styles);
7863 if line.kind == InlineMessageKind::User {
7865 style = style.add_modifier(Modifier::BOLD);
7866 }
7867 if let Some(h) = highlight {
7868 style = style.patch(h);
7869 }
7870 spans.push(Span::styled(segment.text.clone(), style));
7871 }
7872 Line::from(spans)
7873}
7874
7875pub(crate) fn segment_style(
7876 segment: &InlineSegment,
7877 fallback: Style,
7878 _styles: &ThemeStyles,
7879) -> Style {
7880 let mut style = fallback;
7881 let inline = segment.style.as_ref();
7882 if let Some(color) = inline.color {
7883 style = style.fg(color_from_anstyle(Some(color)));
7884 }
7885 if inline.effects.contains(anstyle::Effects::BOLD) {
7889 style = style.add_modifier(Modifier::BOLD);
7890 }
7891 if inline.effects.contains(anstyle::Effects::ITALIC) {
7892 style = style.add_modifier(Modifier::ITALIC);
7893 }
7894 if inline.effects.contains(anstyle::Effects::UNDERLINE) {
7895 style = style.add_modifier(Modifier::UNDERLINED);
7896 }
7897 if inline.effects.contains(anstyle::Effects::DIMMED) {
7898 style = style.add_modifier(Modifier::DIM);
7899 }
7900 style
7901}
7902fn render_composer(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
7903 let styles = active_styles();
7904 let prefix_style = Style::default()
7905 .fg(color_from_anstyle(styles.primary.get_fg_color()))
7906 .bold();
7907
7908 let prefix = state.prompt_prefix.clone();
7909 let placeholder = state.placeholder.clone();
7910
7911 let mut prefix_w: u16 = 0;
7919 let mut line_spans = Vec::new();
7920 if let Some(label) = state.vim_state.status_label() {
7921 let seg = format!("[{label}] ");
7922 prefix_w = prefix_w.saturating_add(seg.width() as u16);
7923 line_spans.push(Span::styled(
7924 seg,
7925 Style::default()
7926 .fg(color_from_anstyle(styles.tool.get_fg_color()))
7927 .add_modifier(Modifier::BOLD),
7928 ));
7929 }
7930 if state.autonomy_mode.is_auto() {
7931 let seg = "[auto] ";
7932 prefix_w = prefix_w.saturating_add(seg.width() as u16);
7933 line_spans.push(Span::styled(
7934 seg,
7935 Style::default()
7936 .fg(Color::Yellow)
7937 .add_modifier(Modifier::BOLD),
7938 ));
7939 }
7940 prefix_w = prefix_w.saturating_add(UnicodeWidthStr::width(prefix.as_str()) as u16);
7941 line_spans.push(Span::styled(prefix, prefix_style));
7942 if state.shell_mode {
7943 let seg = "! ";
7944 prefix_w = prefix_w.saturating_add(seg.width() as u16);
7945 line_spans.push(Span::styled(
7946 seg,
7947 Style::default()
7948 .fg(Color::Yellow)
7949 .add_modifier(Modifier::BOLD),
7950 ));
7951 }
7952
7953 let context_line = composer_context_line(state, area.width);
7954 let used: usize = context_line.spans.iter().map(|s| s.width()).sum();
7955 let mut block = Block::default()
7956 .borders(Borders::ALL)
7957 .border_type(BorderType::Plain)
7958 .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
7959 .title(context_line);
7963 if let Some(chip) = composer_brain_chip(state, area.width, used) {
7964 block = block.title(chip);
7965 }
7966
7967 let inner = area.inner(Margin::new(1, 1));
7972 let textarea_area = Rect {
7973 x: inner.left().saturating_add(prefix_w),
7974 y: inner.top(),
7975 width: inner.width.saturating_sub(prefix_w),
7976 height: inner.height,
7977 };
7978 if state.composer.is_empty()
7979 && let Some(ph) = placeholder.as_deref()
7980 {
7981 line_spans.push(Span::styled(
7983 ph.to_string(),
7984 Style::default()
7985 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
7986 .dim(),
7987 ));
7988 let paragraph = Paragraph::new(Line::from(line_spans))
7989 .block(block)
7990 .wrap(Wrap { trim: false });
7991 frame.render_widget(paragraph, area);
7992 if state.input_enabled {
7993 frame.set_cursor_position(Position::new(
7996 inner.left().saturating_add(prefix_w),
7997 area.top().saturating_add(1),
7998 ));
7999 }
8000 return;
8001 }
8002 let prefix_area = Rect {
8007 x: inner.left(),
8008 y: inner.top(),
8009 width: prefix_w,
8010 height: inner.height,
8011 };
8012 let frame_paragraph = Paragraph::new(Line::from(Vec::<Span>::new()))
8015 .block(block)
8016 .wrap(Wrap { trim: false });
8017 frame.render_widget(frame_paragraph, area);
8018 frame.render_widget(Paragraph::new(Line::from(line_spans)), prefix_area);
8019 frame.render_widget_ref(&state.composer, textarea_area);
8020
8021 if state.input_enabled
8022 && let Some((cx, cy)) = state
8023 .composer
8024 .cursor_pos_with_state(textarea_area, TextAreaState::default())
8025 {
8026 frame.set_cursor_position(Position::new(cx, cy));
8033 }
8034}
8035
8036fn composer_context_line<'a>(state: &'a RenderState, width: u16) -> Line<'a> {
8043 let styles = active_styles();
8044 let primary = color_from_anstyle(styles.primary.get_fg_color());
8045 let fg = color_from_anstyle(Some(styles.foreground));
8046 let muted = color_from_anstyle(styles.secondary.get_fg_color());
8047 let info = color_from_anstyle(styles.info.get_fg_color());
8048
8049 let model = state
8050 .header_context
8051 .model
8052 .strip_prefix(&format!("{}/", state.header_context.provider))
8053 .unwrap_or(&state.header_context.model);
8054 let workspace = state
8055 .cwd
8056 .file_name()
8057 .map(|name| name.to_string_lossy().into_owned())
8058 .filter(|name| !name.is_empty())
8059 .unwrap_or_else(|| "workspace".to_string());
8060 let branch = state
8061 .header_context
8062 .persistent_memory
8063 .as_ref()
8064 .map(|badge| badge.text.as_str())
8065 .filter(|branch| !branch.is_empty())
8066 .unwrap_or("—");
8067 let context = match state.context_tokens {
8068 Some(used) => {
8069 let percent = used.saturating_mul(100) / state.context_window.max(1);
8070 format!(
8071 "{}/{} {percent}%",
8072 compact_token_count(used),
8073 compact_token_count(state.context_window)
8074 )
8075 }
8076 None => format!("0/{}", compact_token_count(state.context_window)),
8077 };
8078
8079 use crate::symbols::nerd as icons;
8083 let nerd = state.glyph_set == crate::symbols::GlyphSet::Nerd;
8084 let label =
8085 |text: &'static str, icon: &'static str| -> &'static str { if nerd { icon } else { text } };
8086 let mut fields: Vec<(&str, String, Style, u16)> = vec![
8087 (
8088 label("MODEL ", icons::MODEL),
8089 model.to_string(),
8090 Style::default().fg(fg).add_modifier(Modifier::BOLD),
8091 0,
8092 ),
8093 (
8094 label("THINK ", icons::THINK),
8095 state.thinking_level.clone(),
8096 Style::default().fg(info),
8097 58,
8098 ),
8099 (
8100 label("DIR ", icons::DIR),
8101 workspace,
8102 Style::default().fg(fg),
8103 82,
8104 ),
8105 (
8106 label("GIT ", icons::GIT),
8107 branch.to_string(),
8108 Style::default().fg(fg),
8109 104,
8110 ),
8111 (
8112 label("CTX ", icons::CTX),
8113 context,
8114 Style::default().fg(info),
8115 124,
8116 ),
8117 ];
8118 if state.active_run.is_some() || state.reasoning_stage.is_some() {
8119 fields.push((
8120 label("RUN ", icons::RUN),
8121 state
8122 .reasoning_stage
8123 .clone()
8124 .unwrap_or_else(|| "working\u{2026}".to_string()),
8125 Style::default().fg(primary).add_modifier(Modifier::BOLD),
8126 148,
8127 ));
8128 }
8129
8130 let mut spans = Vec::new();
8131 for (i, (label, value, value_style, min_width)) in fields.into_iter().enumerate() {
8132 if width < min_width {
8133 break;
8134 }
8135 if i > 0 {
8136 spans.push(Span::styled(" | ", Style::default().fg(muted)));
8137 }
8138 spans.push(Span::styled(
8139 (*label).to_string(),
8140 Style::default().fg(muted),
8141 ));
8142 spans.push(Span::styled(value, value_style));
8143 }
8144 Line::from(spans)
8145}
8146
8147fn composer_brain_chip<'a>(state: &'a RenderState, width: u16, used: usize) -> Option<Line<'a>> {
8158 let styles = active_styles();
8159 let (chip_label, healthy) = state.brain.chip_label()?;
8160 let chip_color = if healthy {
8161 color_from_anstyle(styles.info.get_fg_color())
8162 } else {
8163 color_from_anstyle(styles.error.get_fg_color())
8164 };
8165 let nerd = state.glyph_set == crate::symbols::GlyphSet::Nerd;
8166 let text = if nerd {
8167 let state_word = chip_label.trim_start_matches("brain\u{b7}");
8168 format!("{} {}", crate::symbols::nerd::BRAIN, state_word)
8169 } else {
8170 chip_label.to_string()
8171 };
8172 let chip = format!(" {text} ");
8173 let usable = width.saturating_sub(2) as usize;
8175 (used + chip.width() < usable)
8176 .then(|| Line::from(Span::styled(chip, Style::default().fg(chip_color))).right_aligned())
8177}
8178
8179fn compact_token_count(tokens: usize) -> String {
8180 if tokens >= 1_000 {
8181 let whole = tokens / 1_000;
8182 let decimal = (tokens % 1_000) / 100;
8183 if decimal == 0 {
8184 format!("{whole}K")
8185 } else {
8186 format!("{whole}.{decimal}K")
8187 }
8188 } else {
8189 tokens.to_string()
8190 }
8191}
8192
8193fn render_welcome(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
8198 let styles = active_styles();
8199 let primary = color_from_anstyle(styles.primary.get_fg_color());
8200 let fg = color_from_anstyle(Some(styles.foreground));
8201 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
8202 let workspace = state
8203 .cwd
8204 .file_name()
8205 .map(|name| name.to_string_lossy().into_owned())
8206 .filter(|name| !name.is_empty())
8207 .unwrap_or_else(|| "workspace".to_string());
8208 let lines = vec![
8209 Line::from(Span::styled(
8210 "OXICODE",
8211 Style::default().fg(primary).add_modifier(Modifier::BOLD),
8212 )),
8213 Line::from(Span::styled(
8214 "Terminal coding assistant",
8215 Style::default().fg(secondary).add_modifier(Modifier::DIM),
8216 )),
8217 Line::from(""),
8218 Line::from(vec![
8219 Span::styled("WORKSPACE ", Style::default().fg(secondary)),
8220 Span::styled(
8221 workspace,
8222 Style::default().fg(fg).add_modifier(Modifier::BOLD),
8223 ),
8224 ]),
8225 Line::from(vec![
8226 Span::styled("MODEL ", Style::default().fg(secondary)),
8227 Span::styled(
8228 format!(
8229 "{} / {}",
8230 state.header_context.provider, state.header_context.model
8231 ),
8232 Style::default().fg(fg),
8233 ),
8234 ]),
8235 Line::from(""),
8236 Line::from(Span::styled(
8237 "Enter send / commands @ attach a file",
8238 Style::default().fg(fg),
8239 )),
8240 Line::from(Span::styled(
8241 "? shortcuts /model change model /help all commands",
8242 Style::default().fg(secondary),
8243 )),
8244 ];
8245 let height = lines.len().min(area.height as usize) as u16;
8246 let card = Rect {
8247 x: area.x,
8248 y: area.y + area.height.saturating_sub(height) / 2,
8249 width: area.width,
8250 height,
8251 };
8252 frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), card);
8253}
8254
8255fn render_reasoning_indicator(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
8263 let styles = active_styles();
8264 let indicator_area = Rect {
8265 x: composer_area.x,
8266 y: composer_area.top().saturating_sub(1),
8267 width: composer_area.width,
8268 height: 1,
8269 };
8270 let primary = color_from_anstyle(styles.primary.get_fg_color());
8271 let muted = color_from_anstyle(styles.secondary.get_fg_color());
8272 let spin = RUN_SPINNER[(animation_frame(80) as usize) % RUN_SPINNER.len()];
8275 let stage = state
8276 .reasoning_stage
8277 .as_deref()
8278 .unwrap_or("working\u{2026}");
8279 let mut spans = vec![
8280 Span::styled(spin, Style::default().fg(primary)),
8281 Span::styled(
8282 " RUNNING",
8283 Style::default().fg(primary).add_modifier(Modifier::BOLD),
8284 ),
8285 Span::styled(" | ", Style::default().fg(muted)),
8286 Span::styled(
8287 stage.to_string(),
8288 Style::default().fg(muted).add_modifier(Modifier::DIM),
8289 ),
8290 ];
8291 if let Some(run) = &state.active_run {
8292 let elapsed = format_elapsed_secs(run.started_at.elapsed().as_secs());
8293 let facts = if run.turn > 0 {
8294 format!(
8295 " \u{b7} turn {} \u{b7} {} tool call{} \u{b7} {elapsed}",
8296 run.turn,
8297 run.tool_calls,
8298 if run.tool_calls == 1 { "" } else { "s" },
8299 )
8300 } else {
8301 format!(" \u{b7} {elapsed}")
8302 };
8303 spans.push(Span::styled(
8304 facts,
8305 Style::default().fg(muted).add_modifier(Modifier::DIM),
8306 ));
8307 }
8308 spans.push(Span::styled(
8311 " Esc abort \u{b7} Ctrl+C quit",
8312 Style::default().fg(muted).add_modifier(Modifier::DIM),
8313 ));
8314 frame.render_widget(Paragraph::new(Line::from(spans)), indicator_area);
8315}
8316
8317fn render_pending_quit_hint(frame: &mut Frame<'_>, composer_area: Rect) {
8321 let styles = active_styles();
8322 let hint_area = Rect {
8323 x: composer_area.x,
8324 y: composer_area.top().saturating_sub(1),
8325 width: composer_area.width,
8326 height: 1,
8327 };
8328 let line = Line::from(Span::styled(
8329 "press Ctrl+C again to quit",
8330 Style::default()
8331 .fg(color_from_anstyle(styles.error.get_fg_color()))
8332 .add_modifier(Modifier::BOLD),
8333 ));
8334 frame.render_widget(Paragraph::new(line), hint_area);
8335}
8336
8337fn render_queue_pane(frame: &mut Frame<'_>, scrollback: Rect, state: &RenderState) -> u16 {
8339 let styles = active_styles();
8340 let entries = &state.queued_inputs;
8341 let interactive = state.queue_panel_open;
8342 let selected = state.queue_selected.min(entries.len().saturating_sub(1));
8343 let height = if interactive {
8344 entries.len() as u16 + 1
8345 } else {
8346 1
8347 };
8348 let area = Rect {
8349 x: scrollback.x,
8350 y: scrollback.y,
8351 width: scrollback.width,
8352 height,
8353 };
8354 let info = color_from_anstyle(styles.info.get_fg_color());
8355 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
8356 let primary = color_from_anstyle(styles.primary.get_fg_color());
8357 if !interactive {
8358 frame.render_widget(
8359 Paragraph::new(Line::from(vec![
8360 Span::styled(
8361 format!("QUEUED {}", entries.len()),
8362 Style::default().fg(primary).add_modifier(Modifier::BOLD),
8363 ),
8364 Span::styled(
8365 " | Ctrl+; manage",
8366 Style::default().fg(secondary).add_modifier(Modifier::DIM),
8367 ),
8368 ])),
8369 area,
8370 );
8371 return height;
8372 }
8373 let items: Vec<Line<'_>> = entries
8374 .iter()
8375 .enumerate()
8376 .map(|(i, e)| {
8377 let prefix = format!("#{} ", i + 1);
8378 let prefix_style = if i == selected {
8379 Style::default().fg(primary).add_modifier(Modifier::BOLD)
8380 } else {
8381 Style::default().fg(info)
8382 };
8383 let text_style = if i == selected {
8384 Style::default().fg(primary).add_modifier(Modifier::BOLD)
8385 } else {
8386 Style::default().fg(secondary)
8387 };
8388 let marker = if i == selected { "> " } else { " " };
8389 Line::from(vec![
8390 Span::styled(prefix, prefix_style),
8391 Span::styled(marker, prefix_style),
8392 Span::styled(e.clone(), text_style),
8393 ])
8394 })
8395 .collect();
8396 frame.render_widget(
8397 Paragraph::new(items).block(Block::default().borders(Borders::TOP).border_style(
8398 Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
8399 )),
8400 area,
8401 );
8402 height
8403}
8404
8405fn format_todo_line(todo: &TodoItem, matched: bool, styles: &ThemeStyles) -> Line<'static> {
8408 let notes_marker = match todo.notes.as_ref().map(|n| n.len()).unwrap_or(0) {
8409 0 => String::new(),
8410 n => format!(" ·{n}"),
8411 };
8412 let (marker, color, strike, suffix) = match todo.status {
8413 TodoStatus::Completed => ("✓", styles.foreground, true, String::new()),
8414 TodoStatus::InProgress => (
8415 "▸",
8416 styles.primary.get_fg_color().unwrap_or(styles.foreground),
8417 false,
8418 String::new(),
8419 ),
8420 TodoStatus::Abandoned => (
8421 "☐",
8422 styles.error.get_fg_color().unwrap_or(styles.foreground),
8423 true,
8424 String::new(),
8425 ),
8426 TodoStatus::Blocked => {
8427 let reason = todo
8428 .block_reason
8429 .as_deref()
8430 .map(|r| format!(" (blocked: {r})"))
8431 .unwrap_or_else(|| " (blocked)".to_string());
8432 (
8433 "☐",
8434 styles.info.get_fg_color().unwrap_or(styles.foreground),
8435 false,
8436 reason,
8437 )
8438 }
8439 TodoStatus::Pending if matched => (
8440 "☐",
8441 styles.primary.get_fg_color().unwrap_or(styles.foreground),
8442 false,
8443 String::new(),
8444 ),
8445 TodoStatus::Pending => (
8446 "☐",
8447 styles.secondary.get_fg_color().unwrap_or(styles.foreground),
8448 false,
8449 String::new(),
8450 ),
8451 };
8452 let mut text_style = Style::default().fg(color_from_anstyle(Some(color)));
8453 if strike {
8454 text_style = text_style.add_modifier(Modifier::CROSSED_OUT);
8455 }
8456 Line::from(vec![
8457 Span::styled(
8458 format!("{marker} "),
8459 Style::default().fg(color_from_anstyle(Some(color))),
8460 ),
8461 Span::styled(
8462 format!("{}{}{}", todo.content, suffix, notes_marker),
8463 text_style,
8464 ),
8465 ])
8466}
8467
8468const TREE_BRANCH: &str = "├─";
8469const TREE_VERTICAL: &str = "│ ";
8470const TREE_HOOK: &str = "└";
8471const SUBSEQUENT_STAGE_CAP: usize = 4;
8472const ACTIVE_TASK_CAP: usize = 5;
8473
8474fn active_phase_index(phases: &[&TodoPhase]) -> usize {
8477 phases
8478 .iter()
8479 .position(|p| {
8480 p.tasks
8481 .iter()
8482 .any(|t| matches!(t.status, TodoStatus::Pending | TodoStatus::InProgress))
8483 })
8484 .unwrap_or_else(|| phases.len().saturating_sub(1))
8485}
8486
8487fn closed_count(tasks: &[TodoItem]) -> usize {
8489 tasks
8490 .iter()
8491 .filter(|t| matches!(t.status, TodoStatus::Completed | TodoStatus::Abandoned))
8492 .count()
8493}
8494
8495fn phase_display_name(name: &str, one_based: usize) -> String {
8497 format!(
8498 "{}. {name}",
8499 oxicode_agent::tools::todo::roman_numeral(one_based)
8500 )
8501}
8502
8503fn render_todo_pane(
8507 frame: &mut Frame<'_>,
8508 area: Rect,
8509 phases: &[TodoPhase],
8510 expanded: bool,
8511 is_matched: impl Fn(&TodoItem) -> bool,
8512) -> u16 {
8513 let phases: Vec<&TodoPhase> = phases.iter().filter(|p| !p.tasks.is_empty()).collect();
8514 if phases.is_empty() {
8515 return 0;
8516 }
8517 let styles = active_styles();
8518 let multi_phase = phases.len() > 1;
8519 let active_idx = active_phase_index(&phases);
8520
8521 let render_tasks = |phase: &TodoPhase| -> Vec<Line<'static>> {
8522 if expanded {
8523 phase
8524 .tasks
8525 .iter()
8526 .map(|t| format_todo_line(t, is_matched(t), &styles))
8527 .collect()
8528 } else {
8529 let sel = oxicode_agent::tools::todo::select_collapsed_todos(
8530 &phase.tasks,
8531 &is_matched,
8532 ACTIVE_TASK_CAP,
8533 );
8534 let mut lines: Vec<Line<'static>> = sel
8535 .items
8536 .iter()
8537 .map(|t| format_todo_line(t, is_matched(t), &styles))
8538 .collect();
8539 if let Some(summary) = sel.summary {
8540 lines.push(Line::from(Span::styled(
8541 summary,
8542 Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
8543 )));
8544 }
8545 lines
8546 }
8547 };
8548
8549 let base_idx = if expanded { 0 } else { active_idx };
8550 let phase_slice: &[&TodoPhase] = if expanded {
8551 &phases[base_idx..]
8552 } else {
8553 &phases[base_idx..(base_idx + 1 + SUBSEQUENT_STAGE_CAP).min(phases.len())]
8554 };
8555 let hidden_stages = phases.len() - base_idx - phase_slice.len();
8556
8557 let mut content_lines: Vec<Line<'static>> = Vec::new();
8558 for (i, phase) in phase_slice.iter().enumerate() {
8559 let one_based = base_idx + i + 1;
8560 let is_active = base_idx + i == active_idx;
8561 let done = closed_count(&phase.tasks);
8562 let header_text = if multi_phase {
8563 format!(
8564 "{} · {done}/{}",
8565 phase_display_name(&phase.name, one_based),
8566 phase.tasks.len()
8567 )
8568 } else {
8569 phase.name.clone()
8570 };
8571 let header_style = if is_active {
8572 Style::default()
8573 .fg(color_from_anstyle(styles.primary.get_fg_color()))
8574 .add_modifier(Modifier::BOLD)
8575 } else {
8576 Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color()))
8577 };
8578 content_lines.push(Line::from(Span::styled(header_text, header_style)));
8579 if is_active || expanded {
8580 content_lines.extend(render_tasks(phase));
8581 }
8582 }
8583 if hidden_stages > 0 {
8584 content_lines.push(Line::from(Span::styled(
8585 format!(
8586 "… {hidden_stages} more stage{}",
8587 if hidden_stages == 1 { "" } else { "s" }
8588 ),
8589 Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
8590 )));
8591 }
8592
8593 let total: usize = phases.iter().map(|p| p.tasks.len()).sum();
8597 let closed: usize = phases.iter().map(|p| closed_count(&p.tasks)).sum();
8598 let path_len = content_lines.len() + 1;
8599 let mut filled = (closed * path_len).checked_div(total).unwrap_or(0);
8600 if closed > 0 {
8601 filled = filled.max(1);
8602 }
8603 if closed < total {
8604 filled = filled.min(path_len.saturating_sub(1));
8605 }
8606
8607 let mut lines: Vec<Line<'static>> = vec![Line::from(Span::styled(
8608 "TODO",
8609 Style::default()
8610 .fg(color_from_anstyle(styles.primary.get_fg_color()))
8611 .add_modifier(Modifier::BOLD),
8612 ))];
8613 for (i, content) in content_lines.into_iter().enumerate() {
8614 let glyph = if i == 0 { TREE_BRANCH } else { TREE_VERTICAL };
8615 let glyph_color = if i < filled {
8616 styles.primary.get_fg_color()
8617 } else {
8618 styles.secondary.get_fg_color()
8619 };
8620 let mut spans = vec![Span::styled(
8621 format!(" {glyph}"),
8622 Style::default().fg(color_from_anstyle(glyph_color)),
8623 )];
8624 spans.extend(content.spans);
8625 lines.push(Line::from(spans));
8626 }
8627 let hook_color = if filled >= path_len {
8630 styles.primary.get_fg_color()
8631 } else {
8632 styles.secondary.get_fg_color()
8633 };
8634 lines.push(Line::from(Span::styled(
8635 format!(" {TREE_HOOK}"),
8636 Style::default().fg(color_from_anstyle(hook_color)),
8637 )));
8638
8639 let height = lines.len() as u16;
8640 frame.render_widget(
8641 Paragraph::new(lines),
8642 Rect {
8643 x: area.x,
8644 y: area.y,
8645 width: area.width,
8646 height,
8647 },
8648 );
8649 height
8650}
8651
8652const TODO_COMPACT_ROWS_THRESHOLD: u16 = 18;
8653
8654fn next_actionable_task(phases: &[TodoPhase]) -> Option<&TodoItem> {
8657 let mut first_pending = None;
8658 for phase in phases {
8659 for task in &phase.tasks {
8660 if task.status == TodoStatus::InProgress {
8661 return Some(task);
8662 }
8663 if first_pending.is_none() && task.status == TodoStatus::Pending {
8664 first_pending = Some(task);
8665 }
8666 }
8667 }
8668 first_pending
8669}
8670
8671fn render_todo_compact_line(phases: &[TodoPhase]) -> Line<'static> {
8674 let styles = active_styles();
8675 let total: usize = phases.iter().map(|p| p.tasks.len()).sum();
8676 let closed: usize = phases.iter().map(|p| closed_count(&p.tasks)).sum();
8677 let mut spans = vec![Span::styled(
8678 format!("TODO {closed}/{total} "),
8679 Style::default()
8680 .fg(color_from_anstyle(styles.primary.get_fg_color()))
8681 .add_modifier(Modifier::BOLD),
8682 )];
8683 match next_actionable_task(phases) {
8684 Some(task) => spans.extend(format_todo_line(task, false, &styles).spans),
8685 None => spans.push(Span::styled(
8686 "✓ done",
8687 Style::default().fg(color_from_anstyle(Some(styles.foreground))),
8688 )),
8689 }
8690 Line::from(spans)
8691}
8692
8693fn refresh_todo_phases(
8698 provider: &std::sync::Arc<dyn TodoStateProvider>,
8699 hub: Option<&crate::app::agent_hub_registry::SharedHubRegistry>,
8700) -> Vec<TodoPhase> {
8701 let phases = provider.get_phases();
8702 let Some(hub) = hub else {
8703 return phases;
8704 };
8705 let completed: Vec<String> = hub
8706 .snapshot()
8707 .into_iter()
8708 .filter(|(_, e)| {
8709 e.kind == oxicode_sdk::HubKind::Subagent && e.status == oxicode_sdk::HubStatus::Idle
8710 })
8711 .filter_map(|(_, e)| e.current_task)
8712 .collect();
8713 let (updated, mutated) =
8714 oxicode_agent::tools::todo::reconcile_with_subagents(&phases, &completed);
8715 if mutated {
8716 provider.set_phases_sync(updated.clone());
8717 }
8718 updated
8719}
8720
8721fn is_todo_list_settled(phases: &[TodoPhase]) -> bool {
8725 let mut seen_task = false;
8726 for phase in phases {
8727 for task in &phase.tasks {
8728 if !matches!(task.status, TodoStatus::Completed | TodoStatus::Abandoned) {
8729 return false;
8730 }
8731 seen_task = true;
8732 }
8733 }
8734 seen_task
8735}
8736
8737fn sync_todo_clear_timer(state: &mut RenderState, delay_secs: i64) {
8742 if delay_secs < 0 || !is_todo_list_settled(&state.todo_phases) {
8743 state.todo_clear_deadline = None;
8744 return;
8745 }
8746 if delay_secs == 0 {
8747 state.todo_phases.clear();
8748 state.todo_clear_deadline = None;
8749 return;
8750 }
8751 let deadline = state.todo_clear_deadline.get_or_insert_with(|| {
8752 std::time::Instant::now() + std::time::Duration::from_secs(delay_secs as u64)
8753 });
8754 if std::time::Instant::now() >= *deadline {
8755 state.todo_phases.clear();
8756 state.todo_clear_deadline = None;
8757 }
8758}
8759
8760fn build_matched_closure(
8764 hub: Option<&crate::app::agent_hub_registry::SharedHubRegistry>,
8765) -> impl Fn(&TodoItem) -> bool + '_ {
8766 let active_descs: Vec<String> = hub
8767 .map(|h| {
8768 h.snapshot()
8769 .into_iter()
8770 .filter(|(_, e)| {
8771 e.kind == oxicode_sdk::HubKind::Subagent
8772 && e.status == oxicode_sdk::HubStatus::Running
8773 })
8774 .filter_map(|(_, e)| e.current_task)
8775 .collect()
8776 })
8777 .unwrap_or_default();
8778 move |t| {
8779 !active_descs.is_empty()
8780 && oxicode_agent::tools::todo::todo_matches_any_description(&t.content, &active_descs)
8781 }
8782}
8783
8784fn render_follow_ups(frame: &mut Frame<'_>, composer_area: Rect, chips: &[String]) {
8786 let styles = active_styles();
8787 let area = Rect {
8788 x: composer_area.x,
8789 y: composer_area.top().saturating_sub(1),
8790 width: composer_area.width,
8791 height: 1,
8792 };
8793 let mut spans = vec![Span::styled(
8794 "Suggestions: ",
8795 Style::default()
8796 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
8797 .add_modifier(Modifier::DIM),
8798 )];
8799 for (i, chip) in chips.iter().enumerate() {
8800 if i > 0 {
8801 spans.push(Span::raw(" "));
8802 }
8803 spans.push(Span::styled(
8804 format!("[{}]", chip),
8805 Style::default().fg(color_from_anstyle(styles.primary.get_fg_color())),
8806 ));
8807 }
8808 frame.render_widget(Paragraph::new(Line::from(spans)), area);
8809}
8810
8811fn tip_is_visible(tip: &EphemeralTip, now_tick: u64) -> bool {
8813 now_tick.saturating_sub(tip.born_tick) < tip.ttl_ticks
8814}
8815
8816fn render_tip(frame: &mut Frame, composer_area: Rect, text: &str) {
8818 let styles = active_styles();
8819 let area = Rect {
8820 x: composer_area.x,
8821 y: composer_area.top().saturating_sub(1),
8822 width: composer_area.width,
8823 height: 1,
8824 };
8825 let line = Line::styled(
8826 format!(" note: {text}"),
8827 Style::default()
8828 .fg(color_from_anstyle(styles.info.get_fg_color()))
8829 .add_modifier(Modifier::DIM),
8830 );
8831 frame.render_widget(Paragraph::new(line), area);
8832}
8833
8834fn render_slash_popup(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
8837 let styles = active_styles();
8838 let items = &state.slash_popup.items;
8839 if items.is_empty() {
8840 return;
8841 }
8842
8843 let max_visible = 7usize;
8844 let visible = items.len().min(max_visible);
8845 let popup_h = visible as u16 + 3; let width = composer_area.width.min(64);
8847 let popup_area = Rect {
8848 x: composer_area.left(),
8849 y: composer_area.top().saturating_sub(popup_h),
8850 width,
8851 height: popup_h,
8852 };
8853 frame.render_widget(Clear, popup_area);
8854
8855 let border_color = color_from_anstyle(styles.secondary.get_fg_color());
8856 let title = Line::from(Span::styled(
8857 " COMMANDS ",
8858 Style::default()
8859 .fg(color_from_anstyle(styles.primary.get_fg_color()))
8860 .add_modifier(Modifier::BOLD),
8861 ));
8862 let block = Block::default()
8863 .borders(Borders::ALL)
8864 .border_type(BorderType::Plain)
8865 .border_style(Style::default().fg(border_color))
8866 .title(title);
8867 let inner = block.inner(popup_area);
8868 frame.render_widget(&block, popup_area);
8869
8870 let max_label = items
8872 .iter()
8873 .take(visible)
8874 .map(|i| i.label.chars().count())
8875 .max()
8876 .unwrap_or(0);
8877
8878 let primary = color_from_anstyle(styles.primary.get_fg_color());
8879 let fg = color_from_anstyle(Some(styles.foreground));
8880 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
8881
8882 for (i, item) in items.iter().take(visible).enumerate() {
8883 let is_selected = i == state.slash_popup.selected;
8884 let y = inner.top() + i as u16;
8885 let row_area = Rect {
8886 x: inner.left(),
8887 y,
8888 width: inner.width,
8889 height: 1,
8890 };
8891
8892 let marker = if is_selected { "> " } else { " " };
8893 let label_style = if is_selected {
8894 Style::default().fg(primary).add_modifier(Modifier::BOLD)
8895 } else {
8896 Style::default().fg(fg)
8897 };
8898 let label_padded = format!("{:<width$}", item.label, width = max_label);
8899 let line = Line::from(vec![
8900 Span::styled(marker, label_style),
8901 Span::styled(label_padded, label_style),
8902 Span::raw(" "),
8903 Span::styled(&item.description, Style::default().fg(secondary)),
8904 ]);
8905 frame.render_widget(Paragraph::new(line), row_area);
8906 }
8907 frame.render_widget(
8908 Paragraph::new(Line::from(Span::styled(
8909 "Enter insert | Up/Down move | Esc close",
8910 Style::default().fg(secondary).add_modifier(Modifier::DIM),
8911 ))),
8912 Rect {
8913 x: inner.left(),
8914 y: inner.bottom().saturating_sub(1),
8915 width: inner.width,
8916 height: 1,
8917 },
8918 );
8919}
8920
8921fn render_file_search_dropdown(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
8925 let styles = active_styles();
8926 let Some(fs) = &state.file_search else {
8927 return;
8928 };
8929 let items = &fs.results;
8930 if items.is_empty() {
8931 return;
8932 }
8933
8934 let max_visible = 10usize;
8935 let visible = items.len().min(max_visible);
8936 let popup_h = visible as u16 + 3; let width = composer_area.width.min(72);
8938 let popup_area = Rect {
8939 x: composer_area.left(),
8940 y: composer_area.top().saturating_sub(popup_h),
8941 width,
8942 height: popup_h,
8943 };
8944 frame.render_widget(Clear, popup_area);
8945
8946 let border_color = color_from_anstyle(styles.secondary.get_fg_color());
8947 let title_str = if fs.hidden_mode {
8948 " FILES: HIDDEN "
8949 } else {
8950 " FILES "
8951 };
8952 let title = Line::from(Span::styled(
8953 title_str,
8954 Style::default()
8955 .fg(color_from_anstyle(styles.primary.get_fg_color()))
8956 .add_modifier(Modifier::BOLD),
8957 ));
8958 let block = Block::default()
8959 .borders(Borders::ALL)
8960 .border_type(BorderType::Plain)
8961 .border_style(Style::default().fg(border_color))
8962 .title(title);
8963 let inner = block.inner(popup_area);
8964 frame.render_widget(&block, popup_area);
8965
8966 let primary = color_from_anstyle(styles.primary.get_fg_color());
8967 let fg = color_from_anstyle(Some(styles.foreground));
8968 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
8969
8970 for (i, result) in items.iter().take(visible).enumerate() {
8971 let is_selected = i == fs.selected;
8972 let y = inner.top() + i as u16;
8973 let row_area = Rect {
8974 x: inner.left(),
8975 y,
8976 width: inner.width,
8977 height: 1,
8978 };
8979
8980 let marker = if is_selected { "> " } else { " " };
8981 let path_style = if is_selected {
8982 Style::default().fg(primary).add_modifier(Modifier::BOLD)
8983 } else {
8984 Style::default().fg(fg)
8985 };
8986 let line = Line::from(vec![
8987 Span::styled(marker, path_style),
8988 Span::styled(&result.path, path_style),
8989 ]);
8990 frame.render_widget(Paragraph::new(line), row_area);
8991 }
8992
8993 if popup_h >= 4 {
8995 let hint_y = inner.bottom().saturating_sub(1);
8996 let hint_area = Rect {
8997 x: inner.left(),
8998 y: hint_y,
8999 width: inner.width,
9000 height: 1,
9001 };
9002 let count = items.len();
9003 let hint = format!("{count} files | Tab accept | Esc cancel");
9004 let _ = secondary; frame.render_widget(
9006 Paragraph::new(Line::from(Span::styled(
9007 hint,
9008 Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
9009 )))
9010 .style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color()))),
9011 hint_area,
9012 );
9013 }
9014}
9015
9016struct InputEditor<'a> {
9028 composer: &'a mut oxicode_textarea::TextArea,
9029}
9030
9031impl<'a> InputEditor<'a> {
9032 fn new(composer: &'a mut oxicode_textarea::TextArea) -> Self {
9033 Self { composer }
9034 }
9035}
9036
9037impl<'a> crate::tui_vt::vim::Editor for InputEditor<'a> {
9038 fn content(&self) -> &str {
9039 self.composer.text()
9040 }
9041 fn cursor(&self) -> usize {
9042 self.composer.cursor()
9043 }
9044 fn set_cursor(&mut self, pos: usize) {
9045 self.composer.set_cursor(pos);
9046 }
9047 fn move_left(&mut self) {
9048 let new_pos = self.composer.cursor().saturating_sub(1);
9051 self.composer.set_cursor(new_pos);
9052 }
9053 fn move_right(&mut self) {
9054 let new_pos = self.composer.cursor().saturating_add(1);
9055 self.composer.set_cursor(new_pos);
9056 }
9057 fn delete_char_forward(&mut self) {
9058 self.composer.input(crossterm::event::KeyEvent::new(
9059 crossterm::event::KeyCode::Delete,
9060 crossterm::event::KeyModifiers::NONE,
9061 ));
9062 }
9063 fn insert_text(&mut self, text: &str) {
9064 self.composer.insert_str(text);
9065 }
9066 fn replace(&mut self, content: String, cursor: usize) {
9067 self.composer.set_text(&content);
9068 self.composer.set_cursor(cursor);
9069 }
9070 fn replace_range(&mut self, start: usize, end: usize, text: &str) {
9071 self.composer.replace_range(start..end, text);
9072 }
9073}
9074
9075pub(crate) fn plain_segment(text: impl Into<String>) -> InlineSegment {
9080 InlineSegment {
9081 text: text.into(),
9082 style: Arc::new(InlineTextStyle::default()),
9083 }
9084}
9085
9086pub(super) fn effective_scroll_offset(offset: usize, total: usize, viewport: usize) -> usize {
9087 if offset == usize::MAX {
9088 return total.saturating_sub(viewport);
9089 }
9090 let max_start = total.saturating_sub(viewport);
9092 offset.min(max_start)
9093}
9094
9095fn slash_filter(token: &str, file_commands: &[FileCommand]) -> Vec<SlashPopupItem> {
9106 let builtins = SlashRegistry::builtin_commands();
9107 let builtin_names: std::collections::HashSet<&str> =
9108 builtins.iter().map(|(n, _, _)| *n).collect();
9109
9110 let mut items: Vec<SlashPopupItem> = builtins
9111 .into_iter()
9112 .filter(|(name, _, aliases)| {
9113 token.is_empty()
9114 || name.starts_with(token)
9115 || aliases.iter().any(|a| a.starts_with(token))
9116 })
9117 .map(|(name, desc, aliases)| {
9118 let mut label = format!("/{name}");
9119 for a in &aliases {
9120 label.push_str(&format!(", /{a}"));
9121 }
9122 SlashPopupItem {
9123 label,
9124 description: desc.to_string(),
9125 name: name.to_string(),
9126 }
9127 })
9128 .collect();
9129
9130 for fc in file_commands {
9132 if builtin_names.contains(fc.name.as_str())
9133 || fc
9134 .aliases
9135 .iter()
9136 .any(|alias| builtin_names.contains(alias.as_str()))
9137 {
9138 continue;
9139 }
9140 if token.is_empty()
9141 || fc.name.starts_with(token)
9142 || fc.aliases.iter().any(|a| a.starts_with(token))
9143 {
9144 let mut label = format!("/{}", fc.name);
9145 for a in &fc.aliases {
9146 label.push_str(&format!(", /{a}"));
9147 }
9148 items.push(SlashPopupItem {
9149 label,
9150 description: fc.description.clone(),
9151 name: fc.name.clone(),
9152 });
9153 }
9154 }
9155
9156 items
9157}
9158
9159fn refresh_slash_popup(state: &mut RenderState) {
9164 let buf = state.composer.text();
9165 let active = buf.starts_with('/') && !buf[1..].contains(' ');
9166 if !active {
9167 state.slash_popup.open = false;
9168 state.slash_popup.items.clear();
9169 state.slash_popup.selected = 0;
9170 return;
9171 }
9172 let token = &buf[1..];
9173 let items = slash_filter(token, &state.file_commands);
9174 state.slash_popup.open = !items.is_empty();
9175 if items.is_empty() {
9176 state.slash_popup.selected = 0;
9177 } else {
9178 state.slash_popup.selected = state.slash_popup.selected.min(items.len() - 1);
9179 }
9180 state.slash_popup.items = items;
9181}
9182fn refresh_input_popups(state: &mut RenderState) {
9186 refresh_slash_popup(state);
9187 refresh_file_search(state);
9188}
9189
9190fn refresh_file_search(state: &mut RenderState) {
9196 use crate::tui_vt::file_search;
9197 if state.slash_popup.open {
9199 state.file_search = None;
9200 return;
9201 }
9202 match file_search::parse_at_cursor(state.composer.text(), state.composer.cursor()) {
9203 Some(token) => match &mut state.file_search {
9204 None => {
9205 let cwd = state.cwd.clone();
9206 state.file_search = Some(file_search::open(&cwd, token.at_offset, false));
9207 }
9208 Some(fs) => {
9209 if fs.query != token.path_query {
9210 fs.refresh(&token.path_query);
9211 }
9212 }
9213 },
9214 None => state.file_search = None,
9215 }
9216}
9217
9218fn accept_file_search(state: &mut RenderState, line_mode: bool) -> bool {
9223 use crate::tui_vt::file_search;
9224 let Some(fs) = &state.file_search else {
9225 return false;
9226 };
9227 let Some(result) = fs.selected_result().cloned() else {
9228 return false;
9229 };
9230 let at_offset = fs.at_offset;
9231 let text = file_search::insertion_text(&result.path, None, line_mode);
9232 let cursor_end = state.composer.cursor();
9233 state.composer.replace_range(
9235 at_offset..cursor_end.min(state.composer.text().len()),
9236 &text,
9237 );
9238 state.composer.set_cursor(at_offset + text.len());
9239 state.file_search = None;
9240 true
9241}
9242
9243fn preview_tool_result(content: &str) -> String {
9244 const MAX: usize = 500;
9245 if content.chars().count() <= MAX {
9246 return content.to_string();
9247 }
9248 let truncated: String = content.chars().take(MAX).collect();
9249 format!("{truncated}\u{2026}")
9250}
9251
9252fn extract_generated_png(content: &str) -> Option<Vec<u8>> {
9259 use base64::{Engine, engine::general_purpose};
9260 const MARKER: &str = "base64):";
9261 let rest = &content[content.find(MARKER)? + MARKER.len()..];
9262 let blob = rest.lines().map(str::trim).find(|l| !l.is_empty())?;
9264 if blob.is_empty() {
9265 return None;
9266 }
9267 let bytes = general_purpose::STANDARD.decode(blob).ok()?;
9268 (bytes.len() >= 8).then_some(bytes)
9271}
9272
9273fn color_from_anstyle(color: Option<anstyle::Color>) -> Color {
9274 match color {
9275 Some(anstyle::Color::Ansi(a)) => ansi_to_ratatui(a),
9276 Some(anstyle::Color::Ansi256(idx)) => Color::Indexed(idx.0),
9277 Some(anstyle::Color::Rgb(rgb)) => Color::Rgb(rgb.0, rgb.1, rgb.2),
9278 None => Color::Reset,
9279 }
9280}
9281fn ansi_to_ratatui(color: anstyle::AnsiColor) -> Color {
9282 use anstyle::AnsiColor as A;
9283 match color {
9284 A::Black => Color::Black,
9285 A::Red => Color::Red,
9286 A::Green => Color::Green,
9287 A::Yellow => Color::Yellow,
9288 A::Blue => Color::Blue,
9289 A::Magenta => Color::Magenta,
9290 A::Cyan => Color::Cyan,
9291 A::White => Color::Gray,
9292 A::BrightBlack => Color::DarkGray,
9293 A::BrightRed => Color::LightRed,
9294 A::BrightGreen => Color::LightGreen,
9295 A::BrightYellow => Color::LightYellow,
9296 A::BrightBlue => Color::LightBlue,
9297 A::BrightMagenta => Color::LightMagenta,
9298 A::BrightCyan => Color::LightCyan,
9299 A::BrightWhite => Color::White,
9300 }
9301}
9302
9303#[allow(dead_code, clippy::declare_interior_mutable_const)]
9306const _ATOMIC_REFS: (AtomicBool, Ordering) = (AtomicBool::new(false), Ordering::SeqCst);
9307
9308#[cfg(test)]
9309mod slash_popup_tests {
9310 use super::*;
9311
9312 #[test]
9313 fn empty_token_lists_all_commands() {
9314 let items = slash_filter("", &[]);
9315 assert!(items.len() >= 7);
9317 assert!(items.iter().any(|i| i.name == "quit"));
9318 assert!(items.iter().any(|i| i.name == "clear"));
9319 assert!(items.iter().any(|i| i.name == "model"));
9320 }
9321
9322 #[test]
9323 fn prefix_filter_matches_name() {
9324 let items = slash_filter("qu", &[]);
9325 assert_eq!(items.len(), 1);
9326 assert_eq!(items[0].name, "quit");
9327 assert!(items[0].label.contains("/quit"));
9328 }
9329
9330 #[test]
9331 fn prefix_filter_matches_alias() {
9332 let items = slash_filter("cl", &[]);
9334 let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
9335 assert!(names.contains(&"clear"));
9336 }
9337
9338 #[test]
9339 fn file_commands_appear_in_filter() {
9340 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
9341 "review",
9342 "---\ndescription: proj cmd\naliases: cr\n---\nbody",
9343 );
9344 let items = slash_filter("", &[fc]);
9345 assert!(items.iter().any(|i| i.name == "review"));
9346 assert!(items.iter().any(|i| i.name == "quit")); }
9348
9349 #[test]
9350 fn file_commands_filtered_by_prefix() {
9351 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
9352 "review",
9353 "---\ndescription: x\n---\nbody",
9354 );
9355 let items = slash_filter("rev", &[fc]);
9356 assert!(items.iter().any(|i| i.name == "review"));
9357 }
9358
9359 #[test]
9360 fn file_commands_shadowed_by_builtins_are_dropped() {
9361 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
9366 "quit",
9367 "---\ndescription: hijack\n---\nbody",
9368 );
9369 let items = slash_filter("", &[fc]);
9370 let quit_count = items.iter().filter(|i| i.name == "quit").count();
9371 assert_eq!(quit_count, 1, "shadowed file command must not appear");
9372 assert!(
9374 items
9375 .iter()
9376 .any(|i| i.name == "quit" && !i.description.contains("hijack"))
9377 );
9378 }
9379
9380 #[test]
9381 fn file_commands_with_builtin_aliases_are_dropped() {
9382 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
9383 "review",
9384 "---\ndescription: hijack\naliases: quit\n---\nbody",
9385 );
9386 let items = slash_filter("", &[fc]);
9387 assert!(!items.iter().any(|item| item.name == "review"));
9388 }
9389
9390 #[test]
9391 fn popup_opens_on_slash() {
9392 let mut state = RenderState::default();
9393 state.composer.set_text("/");
9394 refresh_input_popups(&mut state);
9395 assert!(state.slash_popup.open);
9396 assert!(!state.slash_popup.items.is_empty());
9397 }
9398
9399 #[test]
9400 fn popup_closes_on_space() {
9401 let mut state = RenderState::default();
9402 state.composer.set_text("/quit ");
9403 refresh_input_popups(&mut state);
9404 assert!(!state.slash_popup.open);
9405 }
9406
9407 #[test]
9408 fn popup_closes_on_non_slash() {
9409 let mut state = RenderState::default();
9410 state.composer.set_text("hello");
9411 refresh_input_popups(&mut state);
9412 assert!(!state.slash_popup.open);
9413 }
9414
9415 #[test]
9416 fn popup_filters_as_user_types() {
9417 let mut state = RenderState::default();
9418 state.composer.set_text("/m");
9419 refresh_input_popups(&mut state);
9420 assert!(state.slash_popup.open);
9421 assert!(
9424 state
9425 .slash_popup
9426 .items
9427 .iter()
9428 .all(|i| i.name.starts_with('m'))
9429 );
9430 }
9431
9432 #[test]
9433 fn popup_selection_clamps_on_shrink() {
9434 let mut state = RenderState::default();
9435 state.composer.set_text("/");
9436 refresh_input_popups(&mut state);
9437 let full_count = state.slash_popup.items.len();
9438 state.slash_popup.selected = full_count - 1;
9439 state.composer.set_text("/qu");
9441 refresh_input_popups(&mut state);
9442 assert!(state.slash_popup.selected < state.slash_popup.items.len());
9443 }
9444}
9445
9446#[cfg(test)]
9447mod keymap_dispatch_tests {
9448 use super::*;
9449 use crate::tui_vt::keymap::Keymap;
9450 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
9451
9452 fn press(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
9453 KeyEvent::new(code, mods)
9454 }
9455
9456 fn keymap_with(overrides: &[(&str, &str)]) -> Keymap {
9461 let mut o = std::collections::HashMap::new();
9462 for (action, combo) in overrides {
9463 o.insert(action.to_string(), vec![combo.to_string()]);
9464 }
9465 Keymap::from_settings(&o)
9466 }
9467
9468 fn default_keymap() -> Keymap {
9469 Keymap::from_settings(&std::collections::HashMap::new())
9470 }
9471
9472 #[test]
9473 fn rebound_submit_fires_through_generic_dispatch() {
9474 let km = keymap_with(&[("Submit", "Alt+s")]);
9479 let alt_s = press(KeyCode::Char('s'), KeyModifiers::ALT);
9480 assert_eq!(keymap_pre_match(&km, &alt_s, false), KeymapDispatch::Submit);
9483 assert_eq!(keymap_pre_match(&km, &alt_s, true), KeymapDispatch::Submit);
9484 let enter = press(KeyCode::Enter, KeyModifiers::NONE);
9487 assert_eq!(keymap_pre_match(&km, &enter, false), KeymapDispatch::None);
9488 assert_eq!(keymap_pre_match(&km, &enter, true), KeymapDispatch::None);
9489 }
9490
9491 #[test]
9492 fn default_submit_dispatch_preserves_muscle_memory() {
9493 let km = default_keymap();
9494 let enter = press(KeyCode::Enter, KeyModifiers::NONE);
9495 let shift_enter = press(KeyCode::Enter, KeyModifiers::SHIFT);
9496 assert_eq!(keymap_pre_match(&km, &enter, false), KeymapDispatch::Submit);
9498 assert_eq!(keymap_pre_match(&km, &enter, true), KeymapDispatch::None);
9501 assert_eq!(
9502 keymap_pre_match(&km, &shift_enter, true),
9503 KeymapDispatch::Submit
9504 );
9505 }
9506
9507 #[test]
9508 fn scroll_and_help_dispatch_via_keymap() {
9509 let km = default_keymap();
9510 assert_eq!(
9511 keymap_pre_match(&km, &press(KeyCode::PageUp, KeyModifiers::NONE), false),
9512 KeymapDispatch::ScrollPageUp
9513 );
9514 assert_eq!(
9515 keymap_pre_match(&km, &press(KeyCode::PageDown, KeyModifiers::NONE), false),
9516 KeymapDispatch::ScrollPageDown
9517 );
9518 let km = keymap_with(&[("ScrollUp", "Ctrl+u")]);
9519 assert_eq!(
9520 keymap_pre_match(
9521 &km,
9522 &press(KeyCode::Char('u'), KeyModifiers::CONTROL),
9523 false
9524 ),
9525 KeymapDispatch::ScrollPageUp
9526 );
9527 let km = default_keymap();
9530 assert_eq!(
9531 keymap_pre_match(&km, &press(KeyCode::Char('?'), KeyModifiers::NONE), false),
9532 KeymapDispatch::None
9533 );
9534 let km = keymap_with(&[("Help", "Ctrl+PageUp")]);
9535 assert_eq!(
9536 keymap_pre_match(&km, &press(KeyCode::PageUp, KeyModifiers::CONTROL), false),
9537 KeymapDispatch::Help
9538 );
9539 assert_eq!(
9541 keymap_pre_match(&km, &press(KeyCode::Char('x'), KeyModifiers::NONE), false),
9542 KeymapDispatch::None
9543 );
9544 }
9545}
9546
9547#[cfg(test)]
9548mod render_tests {
9549 use super::*;
9550 use oxicode_vtui::tui::core::{InlineHandle, OverlayEvent};
9551 use ratatui::{Terminal, backend::TestBackend};
9552 use tokio::sync::mpsc;
9553
9554 fn render_frame_to_string(state: &RenderState) -> String {
9559 let backend = TestBackend::new(80, 24);
9560 let mut terminal = Terminal::new(backend).expect("backend");
9561 let (tx, _rx) = mpsc::unbounded_channel();
9562 let handle = InlineHandle::new_for_tests(tx);
9563 terminal
9564 .draw(|f| render_frame(f, state, &handle))
9565 .expect("draw");
9566 let buf = terminal.backend().buffer();
9567 let area = buf.area();
9568 let mut out = String::new();
9569 for y in 0..area.height {
9570 for x in 0..area.width {
9571 if let Some(cell) = buf.cell((x, y)) {
9572 out.push_str(cell.symbol());
9573 }
9574 }
9575 out.push('\n');
9576 }
9577 out
9578 }
9579
9580 #[allow(dead_code)]
9584 fn render_frame_to_string_at(state: &RenderState, width: u16, height: u16) -> String {
9585 let backend = TestBackend::new(width, height);
9586 let mut terminal = Terminal::new(backend).expect("backend");
9587 let (tx, _rx) = mpsc::unbounded_channel();
9588 let handle = InlineHandle::new_for_tests(tx);
9589 terminal
9590 .draw(|f| render_frame(f, state, &handle))
9591 .expect("draw");
9592 let buf = terminal.backend().buffer();
9593 let area = buf.area();
9594 let mut out = String::new();
9595 for y in 0..area.height {
9596 for x in 0..area.width {
9597 if let Some(cell) = buf.cell((x, y)) {
9598 out.push_str(cell.symbol());
9599 }
9600 }
9601 out.push('\n');
9602 }
9603 out
9604 }
9605
9606 fn terminal_caret(state: &RenderState) -> Option<(u16, u16)> {
9609 let backend = TestBackend::new(80, 24);
9610 let mut terminal = Terminal::new(backend).expect("backend");
9611 let (tx, _rx) = mpsc::unbounded_channel();
9612 let handle = InlineHandle::new_for_tests(tx);
9613 terminal
9614 .draw(|f| render_frame(f, state, &handle))
9615 .expect("draw");
9616 terminal
9617 .get_cursor_position()
9618 .ok()
9619 .map(|position| (position.x, position.y))
9620 }
9621
9622 #[test]
9623 fn composer_caret_aligns_after_ascii() {
9624 let mut state = RenderState::default();
9625 state.prompt_prefix = "> ".to_string();
9626 state.input_enabled = true;
9627 let mut composer = oxicode_textarea::TextArea::new();
9628 composer.set_text("hello");
9629 composer.set_cursor(5);
9630 state.composer = composer;
9631 let caret = terminal_caret(&state);
9632 assert_eq!(
9636 caret,
9637 Some((9, 22)),
9638 "ASCII caret must sit right after '> hello'"
9639 );
9640 }
9641
9642 #[test]
9643 fn composer_caret_aligns_after_cjk_display_columns() {
9644 let mut state = RenderState::default();
9645 state.prompt_prefix = "> ".to_string();
9646 state.input_enabled = true;
9647 let body = "안녕";
9648 let mut composer = oxicode_textarea::TextArea::new();
9649 composer.set_text(body);
9650 composer.set_cursor(body.len()); state.composer = composer;
9652 let caret = terminal_caret(&state);
9653 assert_eq!(
9655 caret,
9656 Some((8, 22)),
9657 "CJK caret must sit after 4 display columns (not 6 bytes)"
9658 );
9659 }
9660
9661 #[test]
9662 fn composer_caret_aligns_after_mixed_ascii_cjk() {
9663 let body = "hi안녕";
9664 let mut state = RenderState::default();
9665 state.prompt_prefix = "> ".to_string();
9666 state.input_enabled = true;
9667 let mut composer = oxicode_textarea::TextArea::new();
9668 composer.set_text(body);
9669 composer.set_cursor(body.len()); state.composer = composer;
9671 let caret = terminal_caret(&state);
9672 assert_eq!(
9674 caret,
9675 Some((10, 22)),
9676 "Mixed caret must sit after 6 display columns"
9677 );
9678 }
9679
9680 #[test]
9681 fn agent_session_event_reaches_the_transcript_bridge() {
9682 let (tx, mut rx) = mpsc::unbounded_channel();
9683 let handle = InlineHandle::new_for_tests(tx);
9684 let mut state = RenderState::default();
9685
9686 handle_session_event(
9687 &mut state,
9688 &handle,
9689 &SessionEvent::Agent(Box::new(AgentEvent::TextChunk {
9690 text: "streamed reply".to_string(),
9691 })),
9692 None,
9693 );
9694
9695 let command = rx
9696 .try_recv()
9697 .expect("an agent event must produce a render command");
9698 apply_command(&mut state, command);
9699 assert_eq!(state.transcript.len(), 1);
9700 assert_eq!(state.transcript[0].kind, InlineMessageKind::Agent);
9701 assert_eq!(state.transcript[0].segments[0].text, "streamed reply");
9702 }
9703
9704 #[test]
9705 fn missing_key_errors_are_distinguished_from_other_provider_failures() {
9706 assert!(is_missing_api_key_error(
9707 "Provider stream error: Missing API key — configure a credential"
9708 ));
9709 assert!(!is_missing_api_key_error("Provider returned HTTP 429"));
9710 assert_eq!(
9711 provider_from_model_id("deepseek/deepseek-v4-flash"),
9712 "deepseek"
9713 );
9714 }
9715
9716 #[test]
9717 fn prompt_queue_mutations_change_the_execution_queue() {
9718 let queue = PromptQueue::default();
9719 queue.enqueue("first".to_string());
9720 queue.enqueue("second".to_string());
9721 queue.enqueue("third".to_string());
9722
9723 assert!(queue.move_by(2, -1));
9724 assert_eq!(queue.remove(0).as_deref(), Some("first"));
9725 let pending: Vec<_> = queue.pending.lock().iter().cloned().collect();
9726 assert_eq!(pending, ["third", "second"]);
9727 }
9728
9729 #[test]
9730 fn welcome_screen_shown_when_transcript_empty() {
9731 let state = RenderState::default();
9732 let rendered = render_frame_to_string(&state);
9733 assert!(
9734 rendered.contains("OXICODE") && rendered.contains("WORKSPACE"),
9735 "welcome banner must appear when transcript is empty"
9736 );
9737 }
9738
9739 #[test]
9740 fn composer_is_painted() {
9741 let mut state = RenderState::default();
9745 state.input_enabled = true;
9746 state.prompt_prefix = "> ".to_string();
9747 let rendered = render_frame_to_string(&state);
9748 assert!(
9749 rendered.contains('>'),
9750 "composer prompt prefix must be painted"
9751 );
9752 }
9753
9754 #[test]
9755 fn slash_popup_renders_command_list() {
9756 let mut state = RenderState::default();
9757 state.slash_popup.open = true;
9758 state.slash_popup.items = slash_filter("", &[]);
9759 let rendered = render_frame_to_string(&state);
9760 assert!(rendered.contains("COMMANDS"), "popup title must render");
9761 assert!(rendered.contains("/quit"), "popup must list /quit");
9762 }
9763
9764 #[test]
9765 fn composer_and_popup_render_together() {
9766 let mut state = RenderState::default();
9767 state.prompt_prefix = "> ".to_string();
9768 state.composer.set_text("/qu");
9769 state.slash_popup.open = true;
9770 state.slash_popup.items = slash_filter("qu", &[]);
9771 let rendered = render_frame_to_string(&state);
9772 assert!(rendered.contains("COMMANDS"), "popup must render");
9773 assert!(rendered.contains("/quit"), "popup must list /quit");
9774 assert!(rendered.contains('>'), "composer must still render");
9775 }
9776
9777 #[test]
9778 fn transcript_wraps_long_lines() {
9779 let mut state = RenderState::default();
9787 state.transcript.push(TranscriptLine {
9788 kind: InlineMessageKind::Agent,
9789 segments: vec![plain_segment(
9790 "This is a very long agent response line that should wrap across multiple terminal rows when rendered at a narrow width.".to_string()
9791 )],
9792 block_id: 0,
9793 });
9794 let backend = TestBackend::new(40, 24);
9795 let mut terminal = Terminal::new(backend).expect("backend");
9796 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
9797 let handle = InlineHandle::new_for_tests(tx);
9798 terminal
9799 .draw(|f| render_frame(f, &state, &handle))
9800 .expect("draw");
9801 let buf = terminal.backend().buffer();
9802 let mut full = String::new();
9805 for y in 0..buf.area.height {
9806 for x in 0..buf.area.width {
9807 if let Some(cell) = buf.cell((x, y)) {
9808 full.push_str(cell.symbol());
9809 }
9810 }
9811 full.push('\n');
9812 }
9813 assert!(
9814 !full.contains("wrap"),
9815 "long line is clamped at the viewport edge — content past col 40 must be dropped, not wrapped"
9816 );
9817 assert!(
9820 full.contains("This"),
9821 "the truncated prefix of the clamped line is visible: {full:?}"
9822 );
9823 }
9824
9825 fn sample_overlay_items() -> Vec<OverlayListItem> {
9828 vec![
9829 OverlayListItem {
9830 title: "model-a".to_string(),
9831 subtitle: Some("first".to_string()),
9832 badge: Some("ready".to_string()),
9833 indent: 0,
9834 search_value: None,
9835 selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(0)),
9836 },
9837 OverlayListItem {
9838 title: "model-b".to_string(),
9839 subtitle: None,
9840 badge: None,
9841 indent: 0,
9842 search_value: None,
9843 selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(1)),
9844 },
9845 OverlayListItem {
9846 title: "model-c".to_string(),
9847 subtitle: None,
9848 badge: None,
9849 indent: 0,
9850 search_value: None,
9851 selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(2)),
9852 },
9853 ]
9854 }
9855
9856 #[test]
9857 fn overlay_renders_title_and_items() {
9858 let mut state = RenderState::default();
9859 state.overlay = Some(OverlayState {
9860 title: "Select model".to_string(),
9861 lines: vec!["Pick one".to_string()],
9862 items: sample_overlay_items(),
9863 selected: 0,
9864 search: None,
9865 secure_input: None,
9866 ..Default::default()
9867 });
9868 let rendered = render_frame_to_string(&state);
9869 assert!(
9870 rendered.contains("Select model"),
9871 "overlay title must render"
9872 );
9873 assert!(rendered.contains("model-a"), "first item must render");
9874 assert!(rendered.contains("model-b"), "second item must render");
9875 assert!(rendered.contains("model-c"), "third item must render");
9876 assert!(
9877 rendered.contains("Pick one"),
9878 "descriptive line must render"
9879 );
9880 }
9881
9882 #[test]
9883 fn overlay_search_filters_items() {
9884 let mut state = RenderState::default();
9885 state.overlay = Some(OverlayState {
9886 title: "Select".to_string(),
9887 lines: Vec::new(),
9888 items: sample_overlay_items(),
9889 selected: 0,
9890 search: Some(OverlaySearchState {
9891 label: "filter".to_string(),
9892 placeholder: Some("type".to_string()),
9893 value: "model-b".to_string(),
9894 }),
9895 secure_input: None,
9896 ..Default::default()
9897 });
9898 let rendered = render_frame_to_string(&state);
9899 assert!(rendered.contains("model-b"), "matching item must render");
9900 assert!(
9901 !rendered.contains("model-a"),
9902 "non-matching item must not render (got: {})",
9903 rendered
9904 );
9905 assert!(
9906 !rendered.contains("model-c"),
9907 "non-matching item must not render"
9908 );
9909 }
9910
9911 #[test]
9912 fn overlay_keyboard_nav_moves_selection() {
9913 let mut state = RenderState::default();
9914 state.overlay = Some(OverlayState {
9915 title: "Select".to_string(),
9916 lines: Vec::new(),
9917 items: sample_overlay_items(),
9918 selected: 0,
9919 search: None,
9920 secure_input: None,
9921 ..Default::default()
9922 });
9923 let state_arc = Arc::new(parking_lot::Mutex::new(state));
9924 let (tx, mut _rx) = mpsc::unbounded_channel();
9925
9926 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 0);
9928
9929 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
9931 assert!(consumed, "Down must be consumed while overlay is open");
9932 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 1);
9933
9934 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
9936 assert!(consumed);
9937 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 2);
9938
9939 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
9941 assert!(consumed);
9942 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 0);
9943
9944 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Up);
9946 assert!(consumed);
9947 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 2);
9948
9949 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
9951 assert!(consumed);
9952 assert!(
9953 state_arc.lock().overlay.is_none(),
9954 "overlay must be cleared after Enter"
9955 );
9956 let evt = _rx.try_recv().expect("submit event must arrive");
9957 match evt {
9958 InlineEvent::Overlay(OverlayEvent::Submitted(_)) => {}
9959 other => panic!("expected Submitted overlay event, got {other:?}"),
9960 }
9961 }
9962
9963 #[test]
9964 fn overlay_esc_closes_and_emits_cancelled() {
9965 let mut state = RenderState::default();
9966 state.overlay = Some(OverlayState {
9967 title: "Select".to_string(),
9968 lines: Vec::new(),
9969 items: sample_overlay_items(),
9970 selected: 0,
9971 search: None,
9972 secure_input: None,
9973 ..Default::default()
9974 });
9975 let state_arc = Arc::new(parking_lot::Mutex::new(state));
9976 let (tx, mut rx) = mpsc::unbounded_channel();
9977
9978 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Esc);
9979 assert!(consumed);
9980 assert!(
9981 state_arc.lock().overlay.is_none(),
9982 "overlay must be cleared after Esc"
9983 );
9984 let evt = rx.try_recv().expect("cancel event must arrive");
9985 assert!(
9986 matches!(evt, InlineEvent::Overlay(OverlayEvent::Cancelled)),
9987 "expected Cancelled overlay event"
9988 );
9989 }
9990
9991 #[test]
9992 fn overlay_enter_on_readonly_item_is_noop() {
9993 let mut state = RenderState::default();
9997 state.overlay = Some(OverlayState {
9998 title: "Tools".to_string(),
9999 lines: Vec::new(),
10000 items: vec![OverlayListItem {
10001 title: "read".to_string(),
10002 subtitle: Some("Read a file".to_string()),
10003 badge: None,
10004 indent: 0,
10005 search_value: None,
10006 selection: None,
10007 }],
10008 selected: 0,
10009 search: None,
10010 secure_input: None,
10011 ..Default::default()
10012 });
10013 let state_arc = Arc::new(parking_lot::Mutex::new(state));
10014 let (tx, mut rx) = mpsc::unbounded_channel();
10015
10016 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
10017 assert!(consumed, "Enter must be consumed even on read-only items");
10018 assert!(
10019 state_arc.lock().overlay.is_some(),
10020 "overlay must stay open when Enter hits a read-only item"
10021 );
10022 assert!(
10023 rx.try_recv().is_err(),
10024 "no overlay event must be emitted for a read-only Enter"
10025 );
10026 }
10027
10028 #[test]
10029 fn overlay_chars_route_to_search_field() {
10030 let mut state = RenderState::default();
10031 state.overlay = Some(OverlayState {
10032 title: "Select".to_string(),
10033 lines: Vec::new(),
10034 items: sample_overlay_items(),
10035 selected: 0,
10036 search: Some(OverlaySearchState {
10037 label: "filter".to_string(),
10038 placeholder: None,
10039 value: String::new(),
10040 }),
10041 secure_input: None,
10042 ..Default::default()
10043 });
10044 let state_arc = Arc::new(parking_lot::Mutex::new(state));
10045 let (tx, _rx) = mpsc::unbounded_channel();
10046
10047 handle_overlay_key(&state_arc, &tx, KeyCode::Char('m'));
10048 handle_overlay_key(&state_arc, &tx, KeyCode::Char('o'));
10049 handle_overlay_key(&state_arc, &tx, KeyCode::Backspace);
10050 let value = state_arc
10051 .lock()
10052 .overlay
10053 .as_ref()
10054 .unwrap()
10055 .search
10056 .as_ref()
10057 .unwrap()
10058 .value
10059 .clone();
10060 assert_eq!(value, "m", "Backspace should drop last char");
10061 }
10062
10063 #[test]
10064 fn overlay_key_no_op_when_no_overlay_open() {
10065 let state = RenderState::default();
10066 let state_arc = Arc::new(parking_lot::Mutex::new(state));
10067 let (tx, _rx) = mpsc::unbounded_channel();
10068 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
10069 assert!(
10070 !consumed,
10071 "handle_overlay_key must return false when no overlay is open"
10072 );
10073 }
10074
10075 #[test]
10076 fn apply_command_show_overlay_populates_state() {
10077 use oxicode_vtui::tui::core::{InlineListItem, ListOverlayRequest};
10078 let mut state = RenderState::default();
10079 let items = vec![
10080 InlineListItem {
10081 title: "alpha".to_string(),
10082 subtitle: None,
10083 badge: None,
10084 indent: 0,
10085 selection: None,
10086 search_value: None,
10087 },
10088 InlineListItem {
10089 title: "beta".to_string(),
10090 subtitle: None,
10091 badge: None,
10092 indent: 0,
10093 selection: None,
10094 search_value: None,
10095 },
10096 ];
10097 let request = OverlayRequest::List(ListOverlayRequest {
10098 title: "Pick".to_string(),
10099 lines: vec!["desc".to_string()],
10100 footer_hint: None,
10101 items,
10102 selected: None,
10103 search: None,
10104 hotkeys: Vec::new(),
10105 });
10106 let shutdown = apply_command(
10107 &mut state,
10108 InlineCommand::ShowOverlay {
10109 request: Box::new(request),
10110 },
10111 );
10112 assert!(!shutdown, "ShowOverlay must not request shutdown");
10113 let overlay = state.overlay.as_ref().expect("overlay must be Some");
10114 assert_eq!(overlay.title, "Pick");
10115 assert_eq!(overlay.items.len(), 2);
10116 assert_eq!(overlay.items[0].title, "alpha");
10117 assert_eq!(overlay.items[1].title, "beta");
10118 assert_eq!(overlay.lines.len(), 1);
10119
10120 apply_command(&mut state, InlineCommand::CloseOverlay);
10122 assert!(state.overlay.is_none(), "CloseOverlay must clear state");
10123 }
10124
10125 #[test]
10126 fn materialize_overlay_modal_with_secure_prompt_populates_secure_input() {
10127 use oxicode_vtui::tui::core::{ModalOverlayRequest, SecurePromptConfig};
10128 let request = OverlayRequest::Modal(ModalOverlayRequest {
10129 title: "API key".into(),
10130 lines: vec!["Paste your key".into()],
10131 secure_prompt: Some(SecurePromptConfig {
10132 label: "Key".into(),
10133 placeholder: Some("sk-...".into()),
10134 mask_input: true,
10135 }),
10136 });
10137 let state = materialize_overlay(request);
10138 let secure = state
10139 .secure_input
10140 .expect("secure_input must be Some when secure_prompt is Some");
10141 assert_eq!(secure.config.label, "Key");
10142 assert!(secure.config.mask_input);
10143 assert_eq!(secure.editor.text(), "");
10144 assert_eq!(secure.editor.cursor_byte(), 0);
10145 }
10146
10147 #[test]
10148 fn materialize_overlay_modal_without_secure_prompt_has_none_secure_input() {
10149 use oxicode_vtui::tui::core::ModalOverlayRequest;
10150 let request = OverlayRequest::Modal(ModalOverlayRequest {
10151 title: "Confirm".into(),
10152 lines: vec!["y/n".into()],
10153 secure_prompt: None,
10154 });
10155 let state = materialize_overlay(request);
10156 assert!(
10157 state.secure_input.is_none(),
10158 "secure_input must be None when secure_prompt is None"
10159 );
10160 }
10161
10162 fn three_block_transcript() -> Vec<TranscriptLine> {
10165 vec![
10167 TranscriptLine {
10168 kind: InlineMessageKind::User,
10169 segments: vec![plain_segment("hi")],
10170 block_id: 0,
10171 },
10172 TranscriptLine {
10173 kind: InlineMessageKind::Agent,
10174 segments: vec![plain_segment("hello")],
10175 block_id: 1,
10176 },
10177 TranscriptLine {
10178 kind: InlineMessageKind::Agent,
10179 segments: vec![plain_segment("world")],
10180 block_id: 1,
10181 },
10182 TranscriptLine {
10183 kind: InlineMessageKind::User,
10184 segments: vec![plain_segment("bye")],
10185 block_id: 2,
10186 },
10187 ]
10188 }
10189
10190 #[test]
10191 fn fold_all_collapses_every_block() {
10192 let mut state = RenderState::default();
10193 state.transcript = three_block_transcript();
10194 state.fold_all();
10195 assert_eq!(state.block_display.len(), 3, "3 distinct block ids");
10196 assert_eq!(state.block_mode(0), BlockDisplayMode::Collapsed);
10197 assert_eq!(state.block_mode(1), BlockDisplayMode::Collapsed);
10198 assert_eq!(state.block_mode(2), BlockDisplayMode::Collapsed);
10199 }
10200
10201 #[test]
10202 fn expand_all_after_fold_all_shows_expanded() {
10203 let mut state = RenderState::default();
10204 state.transcript = three_block_transcript();
10205 state.fold_all();
10206 state.expand_all();
10207 assert!(
10208 state.block_display.is_empty(),
10209 "Expanded is the default — no overrides"
10210 );
10211 assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
10212 assert_eq!(state.block_mode(2), BlockDisplayMode::Expanded);
10213 }
10214
10215 #[test]
10216 fn truncate_all_sets_explicit_truncated() {
10217 let mut state = RenderState::default();
10218 state.transcript = three_block_transcript();
10219 state.fold_all();
10220 state.truncate_all();
10221 assert_eq!(state.block_display.len(), 3);
10222 assert_eq!(state.block_mode(1), BlockDisplayMode::Truncated);
10223 }
10224
10225 #[test]
10226 fn default_block_mode_is_expanded() {
10227 let state = RenderState::default();
10228 assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
10229 assert!(state.block_display.is_empty(), "default needs no map entry");
10230 }
10231
10232 #[test]
10233 fn cycle_block_advances_through_three_states() {
10234 let mut state = RenderState::default();
10235 state.transcript = three_block_transcript();
10236 state.scroll_offset = 0; state.cycle_block_at_view();
10239 assert_eq!(state.block_mode(0), BlockDisplayMode::Collapsed);
10240 state.cycle_block_at_view();
10242 assert_eq!(state.block_mode(0), BlockDisplayMode::Truncated);
10243 state.cycle_block_at_view();
10245 assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
10246 assert!(!state.block_display.contains_key(&0));
10247 }
10248
10249 #[test]
10250 fn cancel_grace_field_defaults_none() {
10251 let state = RenderState::default();
10252 assert!(
10253 state.cancel_grace_until.is_none(),
10254 "cancel_grace_until must default to None"
10255 );
10256 }
10257
10258 #[test]
10259 fn cancel_routes_to_interrupt_when_streaming() {
10260 assert_eq!(
10261 route_cancel(true),
10262 CancelRoute::Interrupt,
10263 "Esc while streaming must route through the interrupt path"
10264 );
10265 }
10266
10267 #[test]
10268 fn cancel_routes_to_exit_when_idle() {
10269 assert_eq!(
10270 route_cancel(false),
10271 CancelRoute::Exit,
10272 "Esc while idle must exit immediately (one-press quit)"
10273 );
10274 }
10275 #[test]
10276 fn no_scrollbar_even_when_content_overflows() {
10277 let mut state = RenderState::default();
10282 for i in 0..40u32 {
10283 state.transcript.push(TranscriptLine {
10284 kind: InlineMessageKind::Agent,
10285 segments: vec![plain_segment(format!("line {i}"))],
10286 block_id: i as usize,
10287 });
10288 }
10289 let rendered = render_frame_to_string(&state);
10290 assert!(
10291 !rendered.contains('\u{2588}'),
10292 "no scrollbar thumb (█): native scrollback owns history"
10293 );
10294 }
10295
10296 #[test]
10299 fn confirmation_modal_renders_title() {
10300 let mut state = RenderState::default();
10301 state.confirmation = Some(quit_confirmation());
10302 let rendered = render_frame_to_string(&state);
10303 assert!(
10304 rendered.contains("Quit oxicode?"),
10305 "confirmation title must render"
10306 );
10307 }
10308
10309 #[test]
10310 fn confirmation_yes_sends_exit_and_closes() {
10311 let mut state = RenderState::default();
10312 state.confirmation = Some(quit_confirmation());
10313 let state_arc = Arc::new(parking_lot::Mutex::new(state));
10314 let (tx, mut rx) = mpsc::unbounded_channel();
10315 let (issue_tx, _issue_rx) = mpsc::unbounded_channel();
10316 handle_confirmation_key(&state_arc, &tx, &issue_tx, KeyCode::Char('y'));
10317 assert!(
10318 state_arc.lock().confirmation.is_none(),
10319 "yes must close the modal"
10320 );
10321 let ev = rx.try_recv().expect("yes must send an event");
10322 assert!(matches!(ev, InlineEvent::Exit), "yes must send Exit");
10323 }
10324
10325 #[test]
10326 fn confirmation_no_closes_without_event() {
10327 let mut state = RenderState::default();
10328 state.confirmation = Some(quit_confirmation());
10329 let state_arc = Arc::new(parking_lot::Mutex::new(state));
10330 let (tx, mut rx) = mpsc::unbounded_channel();
10331 let (issue_tx, _issue_rx) = mpsc::unbounded_channel();
10332 handle_confirmation_key(&state_arc, &tx, &issue_tx, KeyCode::Char('n'));
10333 assert!(
10334 state_arc.lock().confirmation.is_none(),
10335 "no must close the modal"
10336 );
10337 assert!(rx.try_recv().is_err(), "no must not send an event");
10338 }
10339 #[test]
10342 fn tip_banner_renders_when_active() {
10343 let mut state = RenderState::default();
10344 let now_tick = FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed);
10345 state.tip = Some(EphemeralTip {
10346 text: "hello-tip-marker".to_string(),
10347 born_tick: now_tick,
10348 ttl_ticks: 100,
10349 key: "test",
10350 ambient: false,
10351 });
10352 let rendered = render_frame_to_string(&state);
10353 assert!(
10354 rendered.contains("hello-tip-marker"),
10355 "active tip must render above the composer"
10356 );
10357 }
10358
10359 #[test]
10360 fn tip_visible_within_ttl_window() {
10361 let tip = EphemeralTip {
10362 text: "x".to_string(),
10363 born_tick: 10,
10364 ttl_ticks: 5,
10365 key: "test",
10366 ambient: false,
10367 };
10368 assert!(tip_is_visible(&tip, 12), "within TTL must be visible");
10369 assert!(
10370 !tip_is_visible(&tip, 15),
10371 "at TTL boundary (born + ttl) must expire"
10372 );
10373 assert!(!tip_is_visible(&tip, 99), "past TTL must expire");
10374 }
10375
10376 #[test]
10379 fn sticky_header_pins_block_head_when_scrolled_into_body() {
10380 let mut state = RenderState::default();
10383 for i in 0..40u32 {
10384 state.transcript.push(TranscriptLine {
10385 kind: InlineMessageKind::Agent,
10386 segments: vec![plain_segment(format!("body-line-{i:02}"))],
10387 block_id: 0,
10388 });
10389 }
10390 state.scroll_offset = 10;
10391 let rendered = render_frame_to_string(&state);
10392 assert!(
10393 rendered.contains("body-line-00"),
10394 "sticky header must pin the block head when scrolled into the body"
10395 );
10396 }
10397
10398 #[test]
10399 fn sticky_header_absent_when_viewport_at_block_head() {
10400 let mut state = RenderState::default();
10402 for i in 0..40u32 {
10403 state.transcript.push(TranscriptLine {
10404 kind: InlineMessageKind::Agent,
10405 segments: vec![plain_segment(format!("head-line-{i:02}"))],
10406 block_id: 0,
10407 });
10408 }
10409 state.scroll_offset = 0;
10410 let rendered = render_frame_to_string(&state);
10411 assert!(rendered.contains("head-line-00"));
10414 }
10415
10416 #[test]
10419 fn turn_end_drains_queue_head() {
10420 let mut state = RenderState::default();
10421 state.queued_inputs = vec!["queued-1".into(), "queued-2".into()];
10422 state.drain_queue_head();
10423 assert_eq!(
10424 state.queued_inputs.len(),
10425 1,
10426 "drain_queue_head must drop the head (now running)"
10427 );
10428 assert_eq!(state.queued_inputs[0], "queued-2");
10429 }
10430
10431 #[test]
10434 fn render_frame_paints_transcript_content() {
10435 let mut state = RenderState::default();
10439 state.transcript.push(TranscriptLine {
10440 kind: InlineMessageKind::Agent,
10441 segments: vec![plain_segment("frame-content-marker-xyz")],
10442 block_id: 0,
10443 });
10444 let rendered = render_frame_to_string(&state);
10445 assert!(
10446 rendered.contains("frame-content-marker-xyz"),
10447 "render_frame must paint transcript content"
10448 );
10449 }
10450
10451 #[test]
10452 fn user_turns_get_one_blank_spacer_row() {
10453 let mut state = RenderState::default();
10454 state.transcript = vec![
10455 TranscriptLine {
10456 kind: InlineMessageKind::Agent,
10457 segments: vec![plain_segment("agent-answer")],
10458 block_id: 0,
10459 },
10460 TranscriptLine {
10461 kind: InlineMessageKind::User,
10462 segments: vec![plain_segment("next-question")],
10463 block_id: 1,
10464 },
10465 ];
10466 let rendered = render_frame_to_string(&state);
10467 let rows: Vec<&str> = rendered.split('\n').collect();
10468 let agent_row = rows
10469 .iter()
10470 .position(|r| r.contains("agent-answer"))
10471 .expect("agent row");
10472 assert!(
10473 rows[agent_row + 1].trim().is_empty(),
10474 "blank spacer between turns: {:?}",
10475 &rows[agent_row..agent_row + 3]
10476 );
10477 assert!(
10478 rows[agent_row + 2].contains("next-question"),
10479 "user line follows the spacer"
10480 );
10481 }
10482
10483 #[test]
10484 fn transcript_snapshot_at_120_cols_matches_role_layout() {
10485 let mut state = RenderState::default();
10486 state.brain = BrainChip::Ok;
10487 state.append_line(
10488 InlineMessageKind::User,
10489 vec![plain_segment("intro message\nsecond line")],
10490 );
10491 state.append_line(
10492 InlineMessageKind::Agent,
10493 vec![plain_segment("answer paragraph line one\nline two")],
10494 );
10495 state.append_line(
10496 InlineMessageKind::User,
10497 vec![plain_segment("follow-up question")],
10498 );
10499 let rendered = render_frame_to_string_at(&state, 120, 24);
10500 let rows: Vec<&str> = rendered.split('\n').collect();
10501 let first_user = rows
10503 .iter()
10504 .position(|row| row.contains("intro message"))
10505 .expect("intro user row");
10506 let continuation = rows
10507 .iter()
10508 .position(|row| row.contains("second line"))
10509 .expect("user continuation visible");
10510 assert_eq!(
10511 continuation,
10512 first_user + 1,
10513 "user continuation on next row"
10514 );
10515 assert!(
10516 !rows[first_user].contains("> "),
10517 "plain style has no prompt glyph: {rows:?}"
10518 );
10519
10520 let agent_row = rows
10523 .iter()
10524 .position(|row| row.contains("answer paragraph"))
10525 .expect("agent row");
10526 assert_eq!(
10527 agent_row,
10528 continuation + 2,
10529 "one blank row separates request from response: {:?}",
10530 &rows[continuation..=agent_row]
10531 );
10532 assert!(
10533 !rows[agent_row].trim_start().starts_with('>'),
10534 "agent rows carry no prompt glyph: {rows:?}"
10535 );
10536
10537 let next_user = rows
10538 .iter()
10539 .position(|row| row.contains("follow-up question"))
10540 .expect("second user row");
10541 assert_eq!(
10542 next_user,
10543 agent_row + 3,
10544 "answer (2 rows) + one blank + next user turn: {:?}",
10545 &rows[agent_row..=next_user]
10546 );
10547
10548 let shortcuts_row = rows
10550 .iter()
10551 .position(|row| row.contains("brain·ok"))
10552 .expect("brain chip on shortcuts row");
10553 assert!(shortcuts_row > next_user, "chip below the chat surface");
10554 }
10555
10556 #[test]
10557 fn response_breathes_after_the_user_request() {
10558 let mut state = RenderState::default();
10559 state.append_line(InlineMessageKind::User, vec![plain_segment("the request")]);
10560 state.append_line(InlineMessageKind::Agent, vec![plain_segment("the answer")]);
10561 let rendered = render_frame_to_string(&state);
10562 let rows: Vec<&str> = rendered.split('\n').collect();
10563 let request_row = rows
10564 .iter()
10565 .position(|r| r.contains("the request"))
10566 .expect("request row");
10567 let answer_row = rows
10568 .iter()
10569 .position(|r| r.contains("the answer"))
10570 .expect("answer row");
10571 assert_eq!(
10572 answer_row,
10573 request_row + 2,
10574 "one blank row must separate request from response: {:?}",
10575 &rows[request_row..=answer_row]
10576 );
10577 }
10578
10579 #[test]
10580 fn assistant_tool_flow_stays_contiguous() {
10581 let mut state = RenderState::default();
10582 state.append_line(InlineMessageKind::Tool, vec![plain_segment("[tool] read")]);
10583 state.append_line(InlineMessageKind::Tool, vec![plain_segment("[done] ok")]);
10584 state.append_line(InlineMessageKind::Agent, vec![plain_segment("the answer")]);
10585 let rendered = render_frame_to_string(&state);
10586 let rows: Vec<&str> = rendered.split('\n').collect();
10587 let tool_row = rows
10588 .iter()
10589 .position(|r| r.contains("[tool] read"))
10590 .expect("tool row");
10591 let answer_row = rows
10592 .iter()
10593 .position(|r| r.contains("the answer"))
10594 .expect("answer row");
10595 assert_eq!(
10596 answer_row,
10597 tool_row + 2,
10598 "tool → answer is one assistant turn — no blank inside it: {:?}",
10599 &rows[tool_row..=answer_row]
10600 );
10601 }
10602
10603 #[test]
10604 fn no_spacer_above_the_first_transcript_line() {
10605 let mut state = RenderState::default();
10606 state.transcript = vec![TranscriptLine {
10607 kind: InlineMessageKind::User,
10608 segments: vec![plain_segment("opening-question")],
10609 block_id: 0,
10610 }];
10611 let rendered = render_frame_to_string(&state);
10612 let rows: Vec<&str> = rendered.split('\n').collect();
10613 let user_row = rows
10614 .iter()
10615 .position(|r| r.contains("opening-question"))
10616 .expect("user row");
10617 assert!(
10618 rows[..user_row].iter().all(|r| r.trim().is_empty()),
10619 "transcript starts at the top with no spacer"
10620 );
10621 }
10622
10623 #[test]
10624 fn long_response_renders_every_line_by_default() {
10625 let mut state = RenderState::default();
10626 let mut segments = Vec::new();
10627 for i in 0..8 {
10628 if i > 0 {
10629 segments.push(plain_segment("\n"));
10630 }
10631 segments.push(plain_segment(format!("line-{i}")));
10632 }
10633 state.append_line(InlineMessageKind::Agent, segments);
10634 let rendered = render_frame_to_string(&state);
10635 assert!(
10636 !rendered.contains("lines"),
10637 "no elision gap by default — full text scrolls instead: {rendered}"
10638 );
10639 for i in 0..8 {
10640 assert!(
10641 rendered.contains(&format!("line-{i}")),
10642 "line-{i} must be reachable by scrolling: {rendered}"
10643 );
10644 }
10645 }
10646 #[test]
10647 fn multiline_user_input_renders_every_explicit_line() {
10648 let mut state = RenderState::default();
10649 state.append_line(
10650 InlineMessageKind::User,
10651 vec![plain_segment("first line\nsecond line")],
10652 );
10653 let rendered = render_frame_to_string(&state);
10654 let rows: Vec<&str> = rendered.split('\n').collect();
10655 let first_row = rows
10656 .iter()
10657 .position(|row| row.contains("first line"))
10658 .expect("first user row");
10659 let second_row = rows
10660 .iter()
10661 .position(|row| row.contains("second line"))
10662 .expect("explicit continuation line is visible");
10663 assert_eq!(
10664 second_row,
10665 first_row + 1,
10666 "explicit newline must occupy the following visual row"
10667 );
10668
10669 assert!(
10670 !rows[second_row].contains("> second line"),
10671 "continuation row must not look like a second user turn"
10672 );
10673 }
10674
10675 #[test]
10676 fn streaming_agent_delta_renders_every_explicit_line() {
10677 let mut state = RenderState::default();
10678 state.inline_segment(
10679 InlineMessageKind::Agent,
10680 plain_segment("first answer\nsecond answer"),
10681 );
10682 let rendered = render_frame_to_string(&state);
10683 let rows: Vec<&str> = rendered.split('\n').collect();
10684 let first_row = rows
10685 .iter()
10686 .position(|row| row.contains("first answer"))
10687 .expect("first agent row");
10688 let second_row = rows
10689 .iter()
10690 .position(|row| row.contains("second answer"))
10691 .expect("second agent row");
10692 assert_eq!(
10693 second_row,
10694 first_row + 1,
10695 "streamed newline must occupy the following visual row"
10696 );
10697 }
10698
10699 #[test]
10700 fn file_search_dropdown_renders_results() {
10701 use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
10702 let mut state = RenderState::default();
10703 state.input_enabled = true;
10704 state.file_search = Some(FileSearchState {
10705 query: "main".into(),
10706 at_offset: 0,
10707 hidden_mode: false,
10708 results: vec![
10709 FileSearchResult {
10710 path: "src/main.rs".into(),
10711 score: 100,
10712 },
10713 FileSearchResult {
10714 path: "tests/main.rs".into(),
10715 score: 50,
10716 },
10717 ],
10718 selected: 0,
10719 index: vec![],
10720 line_mode: false,
10721 });
10722 let rendered = render_frame_to_string(&state);
10723 assert!(rendered.contains("FILES"), "dropdown title must render");
10724 assert!(
10725 rendered.contains("src/main.rs"),
10726 "dropdown must show file paths"
10727 );
10728 }
10729
10730 #[test]
10731 fn file_search_dropdown_hidden_mode_title() {
10732 use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
10733 let mut state = RenderState::default();
10734 state.input_enabled = true;
10735 state.file_search = Some(FileSearchState {
10736 query: "".into(),
10737 at_offset: 0,
10738 hidden_mode: true,
10739 results: vec![FileSearchResult {
10740 path: ".env".into(),
10741 score: 0,
10742 }],
10743 selected: 0,
10744 index: vec![],
10745 line_mode: false,
10746 });
10747 let rendered = render_frame_to_string(&state);
10748 assert!(
10749 rendered.contains("HIDDEN"),
10750 "hidden mode must be indicated in title"
10751 );
10752 }
10753
10754 #[test]
10755 fn file_search_and_composer_render_together() {
10756 use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
10757 let mut state = RenderState::default();
10758 state.input_enabled = true;
10759 state.prompt_prefix = "> ".into();
10760 state.composer.set_text("@main");
10761 state.file_search = Some(FileSearchState {
10762 query: "main".into(),
10763 at_offset: 0,
10764 hidden_mode: false,
10765 results: vec![FileSearchResult {
10766 path: "src/main.rs".into(),
10767 score: 100,
10768 }],
10769 selected: 0,
10770 index: vec![],
10771 line_mode: false,
10772 });
10773 let rendered = render_frame_to_string(&state);
10774 assert!(rendered.contains('>'), "composer must still render");
10776 assert!(
10777 rendered.contains("src/main.rs"),
10778 "dropdown must render alongside composer"
10779 );
10780 }
10781
10782 #[test]
10783 fn format_todo_line_shows_block_reason_and_notes_marker() {
10784 let styles = active_styles();
10785 let todo = TodoItem {
10786 content: "Wire OAuth".into(),
10787 status: TodoStatus::Blocked,
10788 notes: Some(vec!["waiting on vendor".into()]),
10789 block_reason: Some("vendor sandbox pending".into()),
10790 };
10791 let line = format_todo_line(&todo, false, &styles);
10792 let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
10793 assert!(text.contains("Wire OAuth"));
10794 assert!(text.contains("blocked: vendor sandbox pending"));
10795 assert!(text.contains("·1"));
10796 }
10797
10798 #[test]
10799 fn format_todo_line_abandoned_is_strikethrough() {
10800 let styles = active_styles();
10801 let todo = TodoItem {
10802 content: "Drop this".into(),
10803 status: TodoStatus::Abandoned,
10804 notes: None,
10805 block_reason: None,
10806 };
10807 let line = format_todo_line(&todo, false, &styles);
10808 assert!(
10809 line.spans
10810 .iter()
10811 .any(|s| s.style.add_modifier.contains(Modifier::CROSSED_OUT))
10812 );
10813 }
10814
10815 #[test]
10816 fn render_todo_pane_multi_phase_shows_roman_header_and_progress() {
10817 use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10818 let mut state = RenderState::default();
10819 state.todo_phases = vec![
10820 TodoPhase {
10821 name: "Foundation".into(),
10822 tasks: vec![
10823 TodoItem {
10824 content: "a".into(),
10825 status: TodoStatus::Completed,
10826 notes: None,
10827 block_reason: None,
10828 },
10829 TodoItem {
10830 content: "b".into(),
10831 status: TodoStatus::Completed,
10832 notes: None,
10833 block_reason: None,
10834 },
10835 ],
10836 },
10837 TodoPhase {
10838 name: "Auth".into(),
10839 tasks: vec![
10840 TodoItem {
10841 content: "c".into(),
10842 status: TodoStatus::Completed,
10843 notes: None,
10844 block_reason: None,
10845 },
10846 TodoItem {
10847 content: "d".into(),
10848 status: TodoStatus::InProgress,
10849 notes: None,
10850 block_reason: None,
10851 },
10852 TodoItem {
10853 content: "e".into(),
10854 status: TodoStatus::Pending,
10855 notes: None,
10856 block_reason: None,
10857 },
10858 ],
10859 },
10860 ];
10861 let rendered = render_frame_to_string(&state);
10862 assert!(
10863 rendered.contains("II. Auth"),
10864 "multi-phase HUD must show the roman-numeral phase header"
10865 );
10866 assert!(
10867 rendered.contains("1/3"),
10868 "active phase must show its done/total progress"
10869 );
10870 }
10871
10872 #[test]
10873 fn todo_auto_clear_fires_after_delay_when_all_closed() {
10874 use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10875 let mut state = RenderState::default();
10876 state.todo_phases = vec![TodoPhase {
10877 name: "Auth".into(),
10878 tasks: vec![TodoItem {
10879 content: "a".into(),
10880 status: TodoStatus::Completed,
10881 notes: None,
10882 block_reason: None,
10883 }],
10884 }];
10885 sync_todo_clear_timer(&mut state, 0); assert!(state.todo_phases.is_empty());
10887 }
10888
10889 #[test]
10890 fn todo_auto_clear_does_not_fire_while_open_tasks_remain() {
10891 use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10892 let mut state = RenderState::default();
10893 let phases = vec![TodoPhase {
10894 name: "Auth".into(),
10895 tasks: vec![TodoItem {
10896 content: "a".into(),
10897 status: TodoStatus::Pending,
10898 notes: None,
10899 block_reason: None,
10900 }],
10901 }];
10902 state.todo_phases = phases.clone();
10903 sync_todo_clear_timer(&mut state, 0);
10904 assert_eq!(state.todo_phases.len(), phases.len());
10905 assert_eq!(state.todo_phases[0].name, "Auth");
10906 }
10907
10908 #[test]
10909 fn todo_auto_clear_negative_delay_disables_clearing() {
10910 use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10911 let mut state = RenderState::default();
10912 let phases = vec![TodoPhase {
10913 name: "Auth".into(),
10914 tasks: vec![TodoItem {
10915 content: "a".into(),
10916 status: TodoStatus::Completed,
10917 notes: None,
10918 block_reason: None,
10919 }],
10920 }];
10921 state.todo_phases = phases.clone();
10922 sync_todo_clear_timer(&mut state, -1);
10923 assert_eq!(state.todo_phases.len(), phases.len());
10924 }
10925
10926 #[test]
10927 fn render_todo_pane_single_phase_has_no_roman_header() {
10928 use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10929 let mut state = RenderState::default();
10930 state.todo_phases = vec![TodoPhase {
10931 name: "Todos".into(),
10932 tasks: vec![TodoItem {
10933 content: "a".into(),
10934 status: TodoStatus::Pending,
10935 notes: None,
10936 block_reason: None,
10937 }],
10938 }];
10939 let rendered = render_frame_to_string(&state);
10940 assert!(
10941 !rendered.contains("I. Todos"),
10942 "single phase must skip roman header"
10943 );
10944 assert!(rendered.contains("Todos"), "single-phase name must render");
10945 }
10946
10947 #[test]
10948 fn render_todo_compact_line_shows_counts_and_active_task() {
10949 use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10950 let phases = vec![TodoPhase {
10951 name: "Auth".into(),
10952 tasks: vec![
10953 TodoItem {
10954 content: "a".into(),
10955 status: TodoStatus::Completed,
10956 notes: None,
10957 block_reason: None,
10958 },
10959 TodoItem {
10960 content: "b".into(),
10961 status: TodoStatus::InProgress,
10962 notes: None,
10963 block_reason: None,
10964 },
10965 ],
10966 }];
10967 let line = render_todo_compact_line(&phases);
10968 let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
10969 assert!(text.contains("TODO 1/2"));
10970 assert!(text.contains("b"));
10971 }
10972
10973 #[test]
10974 fn render_todo_compact_line_all_done_shows_done_marker() {
10975 use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10976 let phases = vec![TodoPhase {
10977 name: "Auth".into(),
10978 tasks: vec![TodoItem {
10979 content: "a".into(),
10980 status: TodoStatus::Completed,
10981 notes: None,
10982 block_reason: None,
10983 }],
10984 }];
10985 let line = render_todo_compact_line(&phases);
10986 let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
10987 assert!(text.contains("done"));
10988 }
10989
10990 fn test_todo_state() -> std::sync::Arc<crate::store::todo_state::TodoState> {
10991 use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10992 std::sync::Arc::new(crate::store::todo_state::TodoState::with_phases(vec![
10993 TodoPhase {
10994 name: "Auth".into(),
10995 tasks: vec![TodoItem {
10996 content: "implement authentication module".into(),
10997 status: TodoStatus::Pending,
10998 notes: None,
10999 block_reason: None,
11000 }],
11001 },
11002 ]))
11003 }
11004
11005 fn hub_with_subagent(
11006 status: oxicode_sdk::HubStatus,
11007 current_task: Option<&str>,
11008 ) -> std::sync::Arc<crate::app::agent_hub_registry::HubRegistry> {
11009 use crate::app::agent_hub_registry::{HubEntry, HubRegistry};
11010 let hub = HubRegistry::new();
11011 hub.register(
11012 "sub".into(),
11013 HubEntry {
11014 kind: oxicode_sdk::HubKind::Subagent,
11015 status,
11016 display_name: "sub".into(),
11017 current_task: current_task.map(str::to_string),
11018 last_activity_ms: 0,
11019 session_file: None,
11020 },
11021 );
11022 std::sync::Arc::new(hub)
11023 }
11024
11025 #[test]
11026 fn frame_refresh_reconciles_todo_with_completed_subagent() {
11027 let state = test_todo_state();
11028 let provider: std::sync::Arc<dyn TodoStateProvider> =
11029 crate::store::todo_state::provider_from_state(state.clone());
11030 let hub = hub_with_subagent(oxicode_sdk::HubStatus::Idle, Some("authentication module"));
11031 let phases = refresh_todo_phases(&provider, Some(&hub));
11032 assert_eq!(phases[0].tasks[0].status, TodoStatus::Completed);
11033 assert_eq!(state.get_phases()[0].tasks[0].status, TodoStatus::Completed);
11035 }
11036
11037 #[test]
11038 fn matched_closure_lights_pending_todo_for_running_subagent() {
11039 let hub = hub_with_subagent(
11040 oxicode_sdk::HubStatus::Running,
11041 Some("authentication module"),
11042 );
11043 let matched = build_matched_closure(Some(&hub));
11044 let t = oxicode_agent::tools::todo::TodoItem {
11045 content: "implement authentication module".into(),
11046 status: TodoStatus::Pending,
11047 notes: None,
11048 block_reason: None,
11049 };
11050 assert!(matched(&t));
11051 }
11052
11053 #[test]
11054 fn todo_pane_renders_when_items_present() {
11055 let mut state = RenderState::default();
11058 state.todo_phases = vec![oxicode_agent::tools::todo::TodoPhase {
11059 name: "Work".into(),
11060 tasks: vec![
11061 oxicode_agent::tools::todo::TodoItem {
11062 content: "active task".into(),
11063 status: TodoStatus::InProgress,
11064 notes: None,
11065 block_reason: None,
11066 },
11067 oxicode_agent::tools::todo::TodoItem {
11068 content: "open task".into(),
11069 status: TodoStatus::Pending,
11070 notes: None,
11071 block_reason: None,
11072 },
11073 ],
11074 }];
11075 let rendered = render_frame_to_string(&state);
11076 assert!(
11077 rendered.contains("active task"),
11078 "in-progress task must render"
11079 );
11080 assert!(rendered.contains("open task"), "pending task must render");
11081 assert!(rendered.contains("▸"), "in-progress status must render");
11083 }
11084
11085 #[test]
11086 fn todo_pane_hidden_when_empty() {
11087 let state = RenderState::default();
11088 let rendered = render_frame_to_string(&state);
11089 assert!(!rendered.contains("done"), "no completed state when empty");
11091 }
11092
11093 #[test]
11094 fn render_overlay_secure_input_shows_label_mask_value_and_placeholder() {
11095 use ratatui::{Terminal, backend::TestBackend};
11096 let backend = TestBackend::new(80, 24);
11097 let mut terminal = Terminal::new(backend).unwrap();
11098 let overlay = OverlayState {
11099 title: "OpenAI key".into(),
11100 lines: vec!["Paste your API key".into()],
11101 items: Vec::new(),
11102 selected: 0,
11103 search: None,
11104 secure_input: Some(OverlaySecureInput {
11105 config: SecurePromptConfig {
11106 label: "Key".into(),
11107 placeholder: Some("sk-...".into()),
11108 mask_input: true,
11109 },
11110 editor: oxicode_textarea::EditBuffer::from_parts("sk-abc", 6),
11111 }),
11112 ..Default::default()
11113 };
11114 terminal
11115 .draw(|f| render_overlay(f, f.area(), &overlay))
11116 .unwrap();
11117 let buf = terminal.backend().buffer().clone();
11118 let text: String = buf
11120 .content()
11121 .iter()
11122 .map(|c| c.symbol())
11123 .collect::<Vec<_>>()
11124 .join("");
11125 assert!(text.contains("Key:"));
11126 assert!(text.contains("******"));
11127 assert!(!text.contains("sk-abc"));
11128 }
11129
11130 #[test]
11131 fn render_overlay_secure_input_placeholder_when_empty() {
11132 use ratatui::{Terminal, backend::TestBackend};
11133 let backend = TestBackend::new(80, 24);
11134 let mut terminal = Terminal::new(backend).unwrap();
11135 let overlay = OverlayState {
11136 title: "OpenAI key".into(),
11137 lines: vec!["Paste your API key".into()],
11138 items: Vec::new(),
11139 selected: 0,
11140 search: None,
11141 secure_input: Some(OverlaySecureInput {
11142 config: SecurePromptConfig {
11143 label: "Key".into(),
11144 placeholder: Some("sk-...".into()),
11145 mask_input: true,
11146 },
11147 editor: oxicode_textarea::EditBuffer::new(),
11148 }),
11149 ..Default::default()
11150 };
11151 terminal
11152 .draw(|f| render_overlay(f, f.area(), &overlay))
11153 .unwrap();
11154 let buf = terminal.backend().buffer().clone();
11155 let text: String = buf
11156 .content()
11157 .iter()
11158 .map(|c| c.symbol())
11159 .collect::<Vec<_>>()
11160 .join("");
11161 assert!(text.contains("sk-..."));
11162 }
11163 #[test]
11168 fn ladder_collapses_oldest_blocks_to_glyph_row_and_banner() {
11169 let mut state = RenderState::default();
11175 for i in 0..6 {
11176 let bid = i;
11177 state.transcript.push(TranscriptLine {
11178 kind: InlineMessageKind::Tool,
11179 segments: vec![plain_segment(format!("tool-{i}-headline"))],
11180 block_id: bid,
11181 });
11182 state.transcript.push(TranscriptLine {
11183 kind: InlineMessageKind::Tool,
11184 segments: vec![plain_segment(format!("tool-{i}-middle"))],
11185 block_id: bid,
11186 });
11187 state.transcript.push(TranscriptLine {
11188 kind: InlineMessageKind::Tool,
11189 segments: vec![plain_segment(format!("tool-{i}-trailer"))],
11190 block_id: bid,
11191 });
11192 }
11193 let rendered = render_frame_to_string_at(&state, 80, 10);
11196 assert!(
11203 rendered.contains("tool-5")
11204 || rendered.contains("tool-5-headline")
11205 || rendered.contains("\u{25B8}")
11206 || rendered.contains("earlier blocks hidden"),
11207 "live region must surface a recent block or its folded form"
11208 );
11209 }
11210
11211 #[test]
11214 fn ladder_keeps_newest_block_full_under_pressure() {
11215 let mut state = RenderState::default();
11219 for j in 0..2 {
11221 state.transcript.push(TranscriptLine {
11222 kind: InlineMessageKind::Agent,
11223 segments: vec![plain_segment(format!("old-block-line-{j}"))],
11224 block_id: 0,
11225 });
11226 }
11227 for j in 0..2 {
11229 state.transcript.push(TranscriptLine {
11230 kind: InlineMessageKind::Agent,
11231 segments: vec![plain_segment(format!("mid-block-line-{j}"))],
11232 block_id: 1,
11233 });
11234 }
11235 for j in 0..5 {
11237 state.transcript.push(TranscriptLine {
11238 kind: InlineMessageKind::Agent,
11239 segments: vec![plain_segment(format!("new-block-line-{j}"))],
11240 block_id: 2,
11241 });
11242 }
11243 let rendered = render_frame_to_string_at(&state, 80, 12);
11244 assert!(
11246 rendered.contains("new-block-line-0"),
11247 "newest block's leading line must be visible"
11248 );
11249 let old_visible = (0..2).all(|j| rendered.contains(&format!("old-block-line-{j}")));
11253 assert!(
11254 !old_visible,
11255 "oldest block must be folded or hidden when under pressure"
11256 );
11257 }
11258 #[test]
11262 fn long_activity_folded_card_stays_within_width() {
11263 let mut state = RenderState::default();
11264 for i in 0..8 {
11269 let bid = i;
11270 let long = format!("tool-{i}-{}", "x".repeat(120));
11271 state.transcript.push(TranscriptLine {
11272 kind: InlineMessageKind::Tool,
11273 segments: vec![plain_segment(format!("tool-{i}-head"))],
11274 block_id: bid,
11275 });
11276 state.transcript.push(TranscriptLine {
11277 kind: InlineMessageKind::Tool,
11278 segments: vec![plain_segment(format!("tool-{i}-body"))],
11279 block_id: bid,
11280 });
11281 state.transcript.push(TranscriptLine {
11282 kind: InlineMessageKind::Tool,
11283 segments: vec![plain_segment(long)],
11284 block_id: bid,
11285 });
11286 }
11287 let rendered = render_frame_to_string_at(&state, 80, 10);
11288 for line in rendered.split('\n') {
11293 assert!(
11294 line.width() <= 80,
11295 "rendered row exceeded the viewport width: '{}' ({} cells)",
11296 line,
11297 line.width()
11298 );
11299 }
11300 assert!(
11304 rendered.contains('\u{25B8}'),
11305 "glyph rows must be painted even with long activities"
11306 );
11307 }
11308
11309 #[test]
11313 fn manual_collapsed_block_keeps_the_plus_marker() {
11314 let mut state = RenderState::default();
11315 for j in 0..3 {
11317 state.transcript.push(TranscriptLine {
11318 kind: InlineMessageKind::Error,
11319 segments: vec![plain_segment(format!("boom-line-{j}"))],
11320 block_id: 0,
11321 });
11322 }
11323 state.block_display.insert(0, BlockDisplayMode::Collapsed);
11325 state.transcript.push(TranscriptLine {
11327 kind: InlineMessageKind::Agent,
11328 segments: vec![plain_segment("after-collapsed")],
11329 block_id: 1,
11330 });
11331 let rendered = render_frame_to_string_at(&state, 80, 12);
11332 assert!(
11335 rendered.contains("[+] error: boom-line-0"),
11336 "manual Collapsed must keep the [+] marker (got: {rendered:?})"
11337 );
11338 assert!(
11340 !rendered.contains('\u{25B8}'),
11341 "manual Collapsed must NOT be replaced by the ladder glyph"
11342 );
11343 }
11344
11345 #[test]
11349 fn clamp_fold_text_truncates_at_unicode_width() {
11350 let out = clamp_fold_text(&"x".repeat(100), 3, 80, "\u{2026}");
11352 assert!(out.ends_with('\u{2026}'), "ellipsis appended on overflow");
11353 assert!(out.width() <= 80, "clamped to budget: got {}", out.width());
11354 let cjk = "\u{4ECA}\u{65E5}\u{306F}\u{667A}\u{6167}".repeat(20);
11356 let out_cjk = clamp_fold_text(&cjk, 2, 20, "\u{2026}");
11357 assert!(out_cjk.width() <= 20, "CJK clamp: got {}", out_cjk.width());
11358 assert!(out_cjk.ends_with('\u{2026}'));
11359 assert_eq!(clamp_fold_text("short", 0, 80, "\u{2026}"), "short");
11361 assert_eq!(clamp_fold_text("text", 0, 0, "\u{2026}"), "");
11363 }
11364
11365 }
11370
11371#[cfg(test)]
11372mod secure_input_tests {
11373 use super::*;
11374 use oxicode_vtui::tui::core::OverlaySubmission;
11375
11376 #[test]
11377 fn overlay_submission_secure_input_is_routed_to_host() {
11378 let _ = OverlaySubmission::SecureInput("sk-test".into());
11381 let serialized = format!("{:?}", OverlaySubmission::SecureInput("x".into()));
11382 assert!(serialized.contains("SecureInput"));
11383 }
11384
11385 #[test]
11386 fn providers_action_matrix_branches_correctly() {
11387 assert_eq!(
11391 next_provider_actions(true, true),
11392 vec![
11393 AuthAction::SetApiKey,
11394 AuthAction::StartOAuth,
11395 AuthAction::RemoveKey,
11396 ],
11397 "has key + oauth-capable: replace, oauth, remove"
11398 );
11399 assert_eq!(
11400 next_provider_actions(true, false),
11401 vec![AuthAction::SetApiKey, AuthAction::RemoveKey],
11402 "has key, key-only provider: replace, remove"
11403 );
11404 assert_eq!(
11405 next_provider_actions(false, true),
11406 vec![AuthAction::SetApiKey, AuthAction::StartOAuth],
11407 "no key + oauth-capable: set key, oauth"
11408 );
11409 assert_eq!(
11410 next_provider_actions(false, false),
11411 vec![AuthAction::SetApiKey],
11412 "no key + key-only provider: set key only"
11413 );
11414 }
11415
11416 fn render_secure_to_text(secure: &OverlaySecureInput) -> String {
11428 use ratatui::{Terminal, backend::TestBackend};
11429 let backend = TestBackend::new(80, 24);
11430 let mut terminal = Terminal::new(backend).unwrap();
11431 let overlay = OverlayState {
11432 title: "OpenAI key".into(),
11433 lines: vec!["Paste your API key".into()],
11434 items: Vec::new(),
11435 selected: 0,
11436 search: None,
11437 secure_input: Some(secure.clone()),
11438 ..Default::default()
11439 };
11440 terminal
11441 .draw(|f| render_overlay(f, f.area(), &overlay))
11442 .unwrap();
11443 terminal
11444 .backend()
11445 .buffer()
11446 .content()
11447 .iter()
11448 .map(|c| c.symbol())
11449 .collect::<Vec<_>>()
11450 .join("")
11451 }
11452
11453 #[test]
11454 fn masked_render_shows_asterisks_not_value() {
11455 let mut editor = oxicode_textarea::EditBuffer::new();
11459 let _ = editor.insert_str("ABCDE");
11460 let rendered = render_secure_to_text(&OverlaySecureInput {
11461 config: SecurePromptConfig {
11462 label: "Key".into(),
11463 placeholder: Some("sk-...".into()),
11464 mask_input: true,
11465 },
11466 editor,
11467 });
11468 assert!(rendered.contains("*****"), "mask must render asterisks");
11469 assert!(
11470 !rendered.contains("ABCDE"),
11471 "masked render must NEVER carry the real value"
11472 );
11473 assert!(rendered.contains("Key:"), "label prefix must still render");
11474 }
11475
11476 #[test]
11477 fn masked_render_caret_lands_after_mask() {
11478 let mut editor = oxicode_textarea::EditBuffer::new();
11482 let _ = editor.insert_str("ABCD");
11483 let secure = OverlaySecureInput {
11484 config: SecurePromptConfig {
11485 label: "Key".into(),
11486 placeholder: Some("sk-...".into()),
11487 mask_input: true,
11488 },
11489 editor,
11490 };
11491 use ratatui::{Terminal, backend::TestBackend};
11494 let backend = TestBackend::new(80, 24);
11495 let mut terminal = Terminal::new(backend).unwrap();
11496 let overlay = OverlayState {
11497 title: "OpenAI key".into(),
11498 lines: vec!["Paste your API key".into()],
11499 items: Vec::new(),
11500 selected: 0,
11501 search: None,
11502 secure_input: Some(secure.clone()),
11503 ..Default::default()
11504 };
11505 terminal
11506 .draw(|f| render_overlay(f, f.area(), &overlay))
11507 .unwrap();
11508 let value = secure.editor.text();
11511 let mut ta = oxicode_textarea::TextArea::new();
11512 ta.set_text(value);
11513 ta.replace_range_with_element(
11514 0..value.len(),
11515 value,
11516 MASKED_ELEMENT_KIND,
11517 Some(Line::from("*".repeat(value.chars().count()))),
11518 );
11519 ta.set_cursor(secure.editor.cursor_byte());
11520 let caret = ta
11521 .cursor_pos_with_state(
11522 Rect {
11523 x: 0,
11524 y: 0,
11525 width: 80,
11526 height: 24,
11527 },
11528 oxicode_textarea::TextAreaState::default(),
11529 )
11530 .expect("caret must be visible");
11531 assert_eq!(caret.0, 4);
11534 }
11535
11536 #[test]
11537 fn backspace_removes_previous_grapheme() {
11538 let mut editor = oxicode_textarea::EditBuffer::new();
11544 let _ = editor.insert_str("XYZ");
11545 assert_eq!(editor.text(), "XYZ");
11546 assert_eq!(editor.cursor_byte(), 3);
11547 let _ = editor.apply(oxicode_textarea::EditCommand::DeleteGraphemeBackward);
11548 assert_eq!(editor.text(), "XY");
11549 assert_eq!(editor.cursor_byte(), 2);
11550 let _ = editor.apply(oxicode_textarea::EditCommand::DeleteGraphemeBackward);
11551 assert_eq!(editor.text(), "X");
11552 let _ = editor.apply(oxicode_textarea::EditCommand::DeleteGraphemeBackward);
11553 assert_eq!(editor.text(), "");
11554 assert_eq!(editor.cursor_byte(), 0);
11555 }
11556
11557 #[test]
11558 fn empty_editor_renders_placeholder_not_asterisks() {
11559 let rendered = render_secure_to_text(&OverlaySecureInput {
11561 config: SecurePromptConfig {
11562 label: "Key".into(),
11563 placeholder: Some("sk-...".into()),
11564 mask_input: true,
11565 },
11566 editor: oxicode_textarea::EditBuffer::new(),
11567 });
11568 assert!(rendered.contains("sk-..."));
11569 assert!(!rendered.contains("*"));
11570 }
11571
11572 #[test]
11573 fn paste_filter_drops_newline_and_non_ascii_via_edit_command() {
11574 let mut editor = oxicode_textarea::EditBuffer::new();
11579 let pasted = "sk-xyz\nABC\u{1F600}";
11580 let trimmed = pasted.trim_end_matches('\n');
11581 for ch in trimmed.chars() {
11582 if ch.is_ascii_graphic() || ch == ' ' {
11583 let _ = editor.apply(oxicode_textarea::EditCommand::Insert(ch));
11584 }
11585 }
11586 assert_eq!(editor.text(), "sk-xyzABC");
11587 assert_eq!(editor.cursor_byte(), 9);
11588 }
11589}
11590#[cfg(test)]
11601mod provider_overlay_tests {
11602 use super::*;
11603 use crate::app::agent_session::{AgentSession, AgentSessionHandle};
11604 use crate::store::session::SessionManager;
11605 use crate::store::settings::Settings;
11606 use oxicode_agent::{Agent, AgentConfig};
11607 use oxicode_sdk::{Provider, ProviderError, ProviderEvent};
11608 use oxicode_vtui::tui::core::OverlayEvent;
11609 use std::pin::Pin;
11610 use std::sync::Arc;
11611 use std::task::{Context as TaskContext, Poll};
11612
11613 struct EmptyStream;
11616 impl futures::Stream for EmptyStream {
11617 type Item = ProviderEvent;
11618 fn poll_next(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
11619 Poll::Ready(None)
11620 }
11621 }
11622
11623 struct StubProvider;
11624 impl Provider for StubProvider {
11625 fn stream<'a>(
11626 &'a self,
11627 _model: &'a oxicode_sdk::Model,
11628 _context: &'a oxicode_sdk::Context,
11629 _options: Option<oxicode_sdk::StreamOptions>,
11630 ) -> Pin<
11631 Box<
11632 dyn Future<
11633 Output = Result<
11634 Pin<Box<dyn futures::Stream<Item = ProviderEvent> + Send>>,
11635 ProviderError,
11636 >,
11637 > + Send
11638 + 'a,
11639 >,
11640 > {
11641 Box::pin(async move {
11642 Ok::<_, ProviderError>(Box::pin(EmptyStream)
11643 as Pin<Box<dyn futures::Stream<Item = ProviderEvent> + Send>>)
11644 })
11645 }
11646 }
11647
11648 struct StubEssentialTool {
11651 name: &'static str,
11652 essential: bool,
11653 }
11654 #[async_trait::async_trait]
11655 impl oxicode_agent::AgentTool for StubEssentialTool {
11656 fn name(&self) -> &str {
11657 self.name
11658 }
11659 fn label(&self) -> &str {
11660 self.name
11661 }
11662 fn description(&self) -> &str {
11663 "stub tool for multiselect editor tests"
11664 }
11665 fn parameters_schema(&self) -> serde_json::Value {
11666 serde_json::json!({ "type": "object", "properties": {} })
11667 }
11668 fn essential(&self) -> bool {
11669 self.essential
11670 }
11671 async fn execute(
11672 &self,
11673 _id: &str,
11674 _params: serde_json::Value,
11675 _signal: Option<tokio::sync::oneshot::Receiver<()>>,
11676 _ctx: &oxicode_agent::ToolContext,
11677 ) -> Result<oxicode_agent::AgentToolResult, String> {
11678 Ok(oxicode_agent::AgentToolResult::success("ok"))
11679 }
11680 }
11681
11682 fn make_session() -> AgentSessionHandle {
11683 let provider = Arc::new(StubProvider);
11684 let config = AgentConfig::new("anthropic/claude-sonnet-4-20250514");
11685 let agent = Arc::new(Agent::new(
11686 provider,
11687 config,
11688 Arc::new(oxicode_agent::ToolRegistry::new()),
11689 ));
11690 let settings = Settings::default();
11691 let session_manager = SessionManager::in_memory("/tmp/test_providers");
11692 let session = AgentSession::new(
11693 agent,
11694 settings,
11695 session_manager,
11696 "/tmp/test_providers".to_string(),
11697 crate::SessionState::default(),
11698 );
11699 session.clone_handle()
11700 }
11701
11702 pub(super) fn make_session_with_tools_for_tests() -> AgentSessionHandle {
11708 let provider = Arc::new(StubProvider);
11709 let config = AgentConfig::new("anthropic/claude-sonnet-4-20250514");
11710 let registry = oxicode_agent::ToolRegistry::new();
11711 registry.register_arc(Arc::new(StubEssentialTool {
11712 name: "bash",
11713 essential: true,
11714 }));
11715 registry.register_arc(Arc::new(StubEssentialTool {
11716 name: "commit",
11717 essential: false,
11718 }));
11719 let agent = Arc::new(Agent::new(provider, config, Arc::new(registry)));
11720 let settings = Settings::default();
11721 let session_manager = SessionManager::in_memory("/tmp/test_providers");
11722 let session = AgentSession::new(
11723 agent,
11724 settings,
11725 session_manager,
11726 "/tmp/test_providers".to_string(),
11727 crate::SessionState::default(),
11728 );
11729 session.clone_handle()
11730 }
11731
11732 fn summarise(cmds: &[InlineCommand]) -> String {
11735 let mut show = 0;
11736 let mut close = 0;
11737 let mut other = 0;
11738 for c in cmds {
11739 match c {
11740 InlineCommand::ShowOverlay { .. } => show += 1,
11741 InlineCommand::CloseOverlay => close += 1,
11742 _ => other += 1,
11743 }
11744 }
11745 format!("[ShowOverlay={show}, CloseOverlay={close}, other={other}]")
11746 }
11747
11748 #[test]
11755 fn provider_row_opens_action_menu_without_close() {
11756 let session = make_session();
11760 let mut state = RenderState::default();
11761 state.overlay_providers = vec!["openai".to_string()];
11762 state.overlay = Some(OverlayState {
11763 title: "Providers".to_string(),
11764 lines: Vec::new(),
11765 items: Vec::new(),
11766 selected: 0,
11767 search: None,
11768 secure_input: None,
11769 ..Default::default()
11770 });
11771
11772 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
11773 let handle = InlineHandle::new_for_tests(cmd_tx);
11774 let prompt_queue = Arc::new(PromptQueue::default());
11775
11776 let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
11777 InlineListSelection::ProviderRow(0),
11778 )));
11779 let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
11780
11781 let cmds: Vec<InlineCommand> = {
11782 let mut out = Vec::new();
11783 while let Ok(cmd) = cmd_rx.try_recv() {
11784 out.push(cmd);
11785 }
11786 out
11787 };
11788 let show_count = cmds
11789 .iter()
11790 .filter(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
11791 .count();
11792 assert_eq!(
11793 show_count,
11794 1,
11795 "submitting a provider row must ShowOverlay exactly once (commands: {})",
11796 summarise(&cmds)
11797 );
11798
11799 let show_idx = cmds
11803 .iter()
11804 .position(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
11805 .expect("ShowOverlay must be present");
11806 let trailing = &cmds[show_idx + 1..];
11807 assert!(
11808 !trailing
11809 .iter()
11810 .any(|c| matches!(c, InlineCommand::CloseOverlay)),
11811 "no CloseOverlay may follow the action-menu ShowOverlay (commands: {})",
11812 summarise(&cmds)
11813 );
11814
11815 assert!(
11818 state.overlay_providers.is_empty(),
11819 "overlay_providers must be cleared after dispatch (got {:?})",
11820 state.overlay_providers
11821 );
11822 }
11823
11824 #[test]
11831 fn provider_row_set_api_key_opens_secure_prompt_without_close() {
11832 let session = make_session();
11834 let mut state = RenderState::default();
11835 state.overlay_providers = vec!["cerebras".to_string()];
11836 state.overlay = Some(OverlayState {
11837 title: "Providers".to_string(),
11838 lines: Vec::new(),
11839 items: Vec::new(),
11840 selected: 0,
11841 search: None,
11842 secure_input: None,
11843 ..Default::default()
11844 });
11845
11846 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
11847 let handle = InlineHandle::new_for_tests(cmd_tx);
11848 let prompt_queue = Arc::new(PromptQueue::default());
11849
11850 let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
11851 InlineListSelection::ProviderRow(0),
11852 )));
11853 let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
11854
11855 let cmds: Vec<InlineCommand> = {
11856 let mut out = Vec::new();
11857 while let Ok(cmd) = cmd_rx.try_recv() {
11858 out.push(cmd);
11859 }
11860 out
11861 };
11862 let show_count = cmds
11863 .iter()
11864 .filter(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
11865 .count();
11866 assert_eq!(
11867 show_count,
11868 1,
11869 "submitting a provider row must ShowOverlay exactly once (commands: {})",
11870 summarise(&cmds)
11871 );
11872
11873 let show_idx = cmds
11874 .iter()
11875 .position(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
11876 .expect("ShowOverlay must be present");
11877 let trailing = &cmds[show_idx + 1..];
11878 assert!(
11879 !trailing
11880 .iter()
11881 .any(|c| matches!(c, InlineCommand::CloseOverlay)),
11882 "no CloseOverlay may follow the secure-prompt ShowOverlay (commands: {})",
11883 summarise(&cmds)
11884 );
11885
11886 assert_eq!(
11890 state.secure_input_origin,
11891 Some(SecureInputOrigin::SetKey {
11892 provider: "cerebras".to_string(),
11893 }),
11894 "secure_input_origin must be stashed by SetApiKey"
11895 );
11896 }
11897
11898 #[test]
11902 fn catalog_model_selection_still_closes_overlay() {
11903 let session = make_session();
11904 let mut state = RenderState::default();
11905 state.overlay_catalog_models = vec![(
11906 "anthropic".to_string(),
11907 "claude-sonnet-4-20250514".to_string(),
11908 )];
11909 state.overlay = Some(OverlayState {
11910 title: "Models".to_string(),
11911 lines: Vec::new(),
11912 items: Vec::new(),
11913 selected: 0,
11914 search: None,
11915 secure_input: None,
11916 ..Default::default()
11917 });
11918
11919 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
11920 let handle = InlineHandle::new_for_tests(cmd_tx);
11921 let prompt_queue = Arc::new(PromptQueue::default());
11922
11923 let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
11924 InlineListSelection::CatalogModel(0),
11925 )));
11926 let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
11927
11928 let cmds: Vec<InlineCommand> = {
11929 let mut out = Vec::new();
11930 while let Ok(cmd) = cmd_rx.try_recv() {
11931 out.push(cmd);
11932 }
11933 out
11934 };
11935 assert!(
11936 cmds.iter()
11937 .any(|c| matches!(c, InlineCommand::CloseOverlay)),
11938 "catalog model selection must close the overlay (commands: {})",
11939 summarise(&cmds)
11940 );
11941 }
11942
11943 fn secure_input_origin_carries_provider_independently_of_variant() {
11949 let set = SecureInputOrigin::SetKey {
11950 provider: "openai".to_string(),
11951 };
11952 let added = SecureInputOrigin::NewlyAdded {
11953 provider: "minimax".to_string(),
11954 };
11955 let provider_of = |o: &SecureInputOrigin| match o {
11960 SecureInputOrigin::SetKey { provider } | SecureInputOrigin::NewlyAdded { provider } => {
11961 provider.clone()
11962 }
11963 SecureInputOrigin::ModelRoleKey | SecureInputOrigin::ModelRoleValue { .. } => {
11964 unreachable!("model-role origins have no provider")
11965 }
11966 SecureInputOrigin::TextEdit(_) => {
11967 unreachable!("text-edit origin has no provider")
11968 }
11969 };
11970 assert_eq!(provider_of(&set), "openai");
11971 assert_eq!(provider_of(&added), "minimax");
11972 assert_ne!(set, added);
11974 }
11975
11976 #[test]
11985 fn session_picker_resume_refused_while_streaming() {
11986 let session = make_session();
11987 session
11991 .streaming_flag()
11992 .store(true, std::sync::atomic::Ordering::SeqCst);
11993
11994 let mut state = RenderState::default();
11995 assert!(
11997 state.pending_resume.is_none(),
11998 "precondition: pending_resume must start None"
11999 );
12000
12001 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
12002 let handle = InlineHandle::new_for_tests(cmd_tx);
12003 let prompt_queue = Arc::new(PromptQueue::default());
12004
12005 let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
12006 InlineListSelection::Session("some-id".to_string()),
12007 )));
12008 let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
12009
12010 assert!(
12012 state.pending_resume.is_none(),
12013 "streaming session must not enqueue pending_resume (got {:?})",
12014 state.pending_resume
12015 );
12016
12017 let mut cmds: Vec<InlineCommand> = Vec::new();
12019 while let Ok(cmd) = cmd_rx.try_recv() {
12020 cmds.push(cmd);
12021 }
12022 let mut found_error = false;
12023 let mut error_text = String::new();
12024 for cmd in &cmds {
12025 if let InlineCommand::AppendLine { kind, segments } = cmd
12026 && matches!(kind, InlineMessageKind::Error)
12027 {
12028 error_text = segments
12029 .iter()
12030 .map(|s| s.text.as_str())
12031 .collect::<Vec<_>>()
12032 .join("");
12033 found_error = true;
12034 }
12035 }
12036 assert!(
12037 found_error,
12038 "expected an error AppendLine (commands: {})",
12039 summarise(&cmds)
12040 );
12041 assert!(
12042 error_text.contains("Cannot resume while agent is running"),
12043 "error text must match the direct-path wording (got {error_text:?})"
12044 );
12045
12046 session
12049 .streaming_flag()
12050 .store(false, std::sync::atomic::Ordering::SeqCst);
12051 }
12052
12053 #[test]
12057 fn settings_tab_selection_rebuilds_item_list() {
12058 let session = make_session();
12059 let mut state = RenderState::default();
12060 state.overlay = None;
12062
12063 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
12064 let handle = InlineHandle::new_for_tests(cmd_tx);
12065 let prompt_queue = Arc::new(PromptQueue::default());
12066
12067 let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
12068 InlineListSelection::SettingsTab(1),
12069 )));
12070 let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
12071
12072 let overlay = state.overlay.as_ref().expect("panel reopened on tab 1");
12073 assert_eq!(overlay.active_tab, 1);
12074 assert_eq!(overlay.tabs.get(1).map(String::as_str), Some("Model"));
12075 assert_eq!(
12076 overlay.sections,
12077 vec!["Defaults".to_string(), "Pointers".to_string()]
12078 );
12079 let settings = Settings::load().unwrap_or_default();
12081 let expected = settings_overlay_items(SettingsTab::Model, &settings).0;
12082 assert_eq!(overlay.items.len(), expected.len());
12083 assert_eq!(
12084 overlay
12085 .items
12086 .iter()
12087 .map(|i| i.title.clone())
12088 .collect::<Vec<_>>(),
12089 expected.iter().map(|i| i.title.clone()).collect::<Vec<_>>(),
12090 );
12091 assert_eq!(state.settings_active_tab, SettingsTab::Model);
12092 while let Ok(cmd) = cmd_rx.try_recv() {
12095 assert!(
12096 !matches!(cmd, InlineCommand::CloseOverlay),
12097 "tab switch must not close the reopened panel"
12098 );
12099 }
12100 }
12101}
12102#[cfg(test)]
12103mod thinking_stream_tests {
12104 use super::*;
12111 use oxicode_ai::{Api, AssistantMessage, ContentBlock, Message, TextContent};
12112 use oxicode_vtui::tui::core::InlineHandle;
12113 use tokio::sync::mpsc;
12114
12115 fn fresh_handle() -> (InlineHandle, mpsc::UnboundedReceiver<InlineCommand>) {
12116 let (tx, rx) = mpsc::unbounded_channel();
12117 (InlineHandle::new_for_tests(tx), rx)
12118 }
12119
12120 fn assistant() -> Message {
12121 Message::Assistant(AssistantMessage::new(
12122 Api::OpenAiCompletions,
12123 "test",
12124 "test",
12125 ))
12126 }
12127
12128 fn assistant_with_text(text: &str) -> Message {
12129 let mut a = AssistantMessage::new(Api::OpenAiCompletions, "test", "test");
12130 a.content.push(ContentBlock::Text(TextContent::new(text)));
12131 Message::Assistant(a)
12132 }
12133 #[test]
12134 fn thinking_delta_sets_fixed_stage_label_not_raw_text() {
12135 let mut state = RenderState::default();
12136 let (handle, mut cmd_rx) = fresh_handle();
12137
12138 let event = AgentEvent::MessageUpdate {
12142 message: assistant(),
12143 delta: oxicode_sdk::StreamDelta::Thinking("considering options".into()),
12144 };
12145 map_agent_event(&handle, event, &mut state);
12146
12147 assert_eq!(
12148 state.reasoning_stage.as_deref(),
12149 Some("thinking\u{2026}"),
12150 "reasoning stage must show a fixed label, never the streamed fragment"
12151 );
12152 while let Ok(cmd) = cmd_rx.try_recv() {
12153 assert!(
12154 !matches!(cmd, InlineCommand::Inline { .. }),
12155 "thinking must not emit a transcript Inline command"
12156 );
12157 }
12158 }
12159
12160 #[test]
12161 fn thinking_streams_as_dimmed_block_above_the_answer() {
12162 let mut state = RenderState::default();
12163 let (handle, mut rx) = fresh_handle();
12164
12165 map_agent_event(
12166 &handle,
12167 AgentEvent::MessageStart {
12168 message: assistant(),
12169 },
12170 &mut state,
12171 );
12172 map_agent_event(
12173 &handle,
12174 AgentEvent::MessageUpdate {
12175 message: assistant(),
12176 delta: oxicode_sdk::StreamDelta::Thinking("weighing alternatives".into()),
12177 },
12178 &mut state,
12179 );
12180 apply_all(&mut state, &mut rx);
12181 let text: String = state
12182 .transcript
12183 .iter()
12184 .flat_map(|l| l.segments.iter().map(|s| s.text.as_str()))
12185 .collect::<Vec<_>>()
12186 .join(" ");
12187 assert!(
12188 text.contains("weighing alternatives"),
12189 "thinking must render in the transcript: {text}"
12190 );
12191 let dim_italic = state.transcript.iter().any(|l| {
12192 l.segments.iter().any(|s| {
12193 let st = s.style.as_ref();
12194 st.effects.contains(anstyle::Effects::DIMMED)
12195 && st.effects.contains(anstyle::Effects::ITALIC)
12196 })
12197 });
12198 assert!(
12199 dim_italic,
12200 "thinking lines render in the dimmed italic reasoning style"
12201 );
12202
12203 map_agent_event(
12205 &handle,
12206 AgentEvent::MessageUpdate {
12207 message: assistant(),
12208 delta: oxicode_sdk::StreamDelta::Text("the answer".into()),
12209 },
12210 &mut state,
12211 );
12212 apply_all(&mut state, &mut rx);
12213 type_out_stream(&mut state);
12214
12215 let texts: Vec<String> = state
12217 .transcript
12218 .iter()
12219 .map(|l| {
12220 l.segments
12221 .iter()
12222 .map(|s| s.text.as_str())
12223 .collect::<String>()
12224 })
12225 .collect();
12226 let think_idx = texts
12227 .iter()
12228 .position(|t| t.contains("weighing alternatives"))
12229 .expect("thinking line");
12230 let answer_idx = texts
12231 .iter()
12232 .position(|t| t.contains("the answer"))
12233 .expect("answer line");
12234 assert!(
12235 answer_idx > think_idx,
12236 "answer renders below thinking: {texts:?}"
12237 );
12238 assert_eq!(
12239 state.reasoning_stage.as_deref(),
12240 Some("generating response"),
12241 "the stage label moves on once the answer streams"
12242 );
12243 }
12244
12245 #[test]
12246 fn tool_lines_survive_the_full_turn_event_sequence() {
12247 let mut state = RenderState::default();
12248 let (handle, mut rx) = fresh_handle();
12249
12250 map_agent_event(
12253 &handle,
12254 AgentEvent::MessageStart {
12255 message: assistant(),
12256 },
12257 &mut state,
12258 );
12259 map_agent_event(
12260 &handle,
12261 AgentEvent::MessageUpdate {
12262 message: assistant(),
12263 delta: oxicode_sdk::StreamDelta::Text("I will check.".into()),
12264 },
12265 &mut state,
12266 );
12267 map_agent_event(
12268 &handle,
12269 AgentEvent::MessageEnd {
12270 message: assistant(),
12271 },
12272 &mut state,
12273 );
12274 map_agent_event(
12275 &handle,
12276 AgentEvent::ToolExecutionStart {
12277 tool_call_id: "tc1".into(),
12278 tool_name: "bash".into(),
12279 args: serde_json::json!({"command": "echo hi"}),
12280 intent: None,
12281 context: None,
12282 },
12283 &mut state,
12284 );
12285 map_agent_event(
12286 &handle,
12287 AgentEvent::ToolExecutionEnd {
12288 tool_call_id: "tc1".into(),
12289 tool_name: "bash".into(),
12290 intent: None,
12291 result: oxicode_ai::ToolResult {
12292 tool_call_id: "tc1".into(),
12293 content: "ls output".into(),
12294 status: "success".into(),
12295 },
12296 is_error: false,
12297 },
12298 &mut state,
12299 );
12300 map_agent_event(
12301 &handle,
12302 AgentEvent::MessageStart {
12303 message: assistant(),
12304 },
12305 &mut state,
12306 );
12307 map_agent_event(
12308 &handle,
12309 AgentEvent::MessageUpdate {
12310 message: assistant(),
12311 delta: oxicode_sdk::StreamDelta::Text("All done.".into()),
12312 },
12313 &mut state,
12314 );
12315 map_agent_event(
12316 &handle,
12317 AgentEvent::MessageEnd {
12318 message: assistant(),
12319 },
12320 &mut state,
12321 );
12322 apply_all(&mut state, &mut rx);
12323
12324 let text: String = state
12325 .transcript
12326 .iter()
12327 .flat_map(|l| l.segments.iter().map(|s| s.text.as_str()))
12328 .collect::<Vec<_>>()
12329 .join("|");
12330 assert!(
12331 text.contains("$ echo hi"),
12332 "box header shows the shell command: {text}"
12333 );
12334 assert!(
12335 text.contains("Output") && text.contains("ls output"),
12336 "labeled divider separates the call from its output: {text}"
12337 );
12338 assert!(
12339 text.contains("\u{256D}") && text.contains("\u{2570}"),
12340 "rounded top and bottom borders close the box: {text}"
12341 );
12342 let block_ids: std::collections::HashSet<usize> = state
12345 .transcript
12346 .iter()
12347 .filter(|l| l.kind == InlineMessageKind::Tool)
12348 .map(|l| l.block_id)
12349 .collect();
12350 assert_eq!(block_ids.len(), 1, "one tool call = one block");
12351 }
12352 #[test]
12353 fn first_text_delta_overrides_thinking_stage_with_generating_response() {
12354 let mut state = RenderState::default();
12355 let (handle, _cmd_rx) = fresh_handle();
12356
12357 map_agent_event(
12363 &handle,
12364 AgentEvent::MessageUpdate {
12365 message: assistant(),
12366 delta: oxicode_sdk::StreamDelta::Thinking("considering".into()),
12367 },
12368 &mut state,
12369 );
12370 map_agent_event(
12371 &handle,
12372 AgentEvent::MessageUpdate {
12373 message: assistant(),
12374 delta: oxicode_sdk::StreamDelta::Text("hi".into()),
12375 },
12376 &mut state,
12377 );
12378
12379 assert_eq!(
12380 state.reasoning_stage.as_deref(),
12381 Some("generating response"),
12382 "first Text delta must move the stage off `thinking\u{2026}`"
12383 );
12384 }
12385
12386 fn apply_all(state: &mut RenderState, rx: &mut mpsc::UnboundedReceiver<InlineCommand>) {
12387 while let Ok(cmd) = rx.try_recv() {
12388 apply_command(state, cmd);
12389 }
12390 }
12391
12392 #[test]
12393 fn message_end_replaces_the_streamed_block_without_duplicates() {
12394 let mut state = RenderState::default();
12395 let (handle, mut rx) = fresh_handle();
12396
12397 map_agent_event(
12398 &handle,
12399 AgentEvent::MessageStart {
12400 message: assistant(),
12401 },
12402 &mut state,
12403 );
12404 map_agent_event(
12405 &handle,
12406 AgentEvent::MessageUpdate {
12407 message: assistant(),
12408 delta: oxicode_sdk::StreamDelta::Text("para one".into()),
12409 },
12410 &mut state,
12411 );
12412 map_agent_event(
12413 &handle,
12414 AgentEvent::MessageUpdate {
12415 message: assistant(),
12416 delta: oxicode_sdk::StreamDelta::Text("\n\npara two".into()),
12417 },
12418 &mut state,
12419 );
12420 map_agent_event(
12421 &handle,
12422 AgentEvent::MessageEnd {
12423 message: assistant_with_text("para one\n\npara two"),
12424 },
12425 &mut state,
12426 );
12427 apply_all(&mut state, &mut rx);
12428
12429 let text: String = state
12430 .transcript
12431 .iter()
12432 .flat_map(|l| l.segments.iter().map(|s| s.text.as_str()))
12433 .collect::<Vec<_>>()
12434 .join(" ");
12435 assert_eq!(
12436 text.matches("para one").count(),
12437 1,
12438 "the markdown re-render must fully replace the streamed raw lines: {text}"
12439 );
12440 }
12441
12442 #[test]
12443 fn consecutive_messages_stream_into_separate_blocks() {
12444 let mut state = RenderState::default();
12445 let (handle, mut rx) = fresh_handle();
12446
12447 for body in ["first answer", "second answer"] {
12448 map_agent_event(
12449 &handle,
12450 AgentEvent::MessageStart {
12451 message: assistant(),
12452 },
12453 &mut state,
12454 );
12455 map_agent_event(
12456 &handle,
12457 AgentEvent::MessageUpdate {
12458 message: assistant(),
12459 delta: oxicode_sdk::StreamDelta::Text(body.into()),
12460 },
12461 &mut state,
12462 );
12463 map_agent_event(
12464 &handle,
12465 AgentEvent::MessageEnd {
12466 message: assistant_with_text(body),
12467 },
12468 &mut state,
12469 );
12470 }
12471 apply_all(&mut state, &mut rx);
12472
12473 let joined = state
12474 .transcript
12475 .iter()
12476 .map(|l| {
12477 l.segments
12478 .iter()
12479 .map(|s| s.text.as_str())
12480 .collect::<String>()
12481 })
12482 .collect::<Vec<_>>()
12483 .join("|");
12484 assert!(
12485 joined.contains("first answer") && joined.contains("second answer"),
12486 "both messages survive: {joined}"
12487 );
12488 assert!(
12489 !joined.contains("first answersecond answer"),
12490 "a new message must not append into the previous message's line: {joined}"
12491 );
12492 }
12493
12494 #[test]
12495 fn text_deltas_render_markdown_live_not_raw() {
12496 let mut state = RenderState::default();
12497 let (handle, mut rx) = fresh_handle();
12498 map_agent_event(
12499 &handle,
12500 AgentEvent::MessageStart {
12501 message: assistant(),
12502 },
12503 &mut state,
12504 );
12505 map_agent_event(
12506 &handle,
12507 AgentEvent::MessageUpdate {
12508 message: assistant(),
12509 delta: oxicode_sdk::StreamDelta::Text("a **bold** claim".into()),
12510 },
12511 &mut state,
12512 );
12513 apply_all(&mut state, &mut rx);
12514 type_out_stream(&mut state);
12515
12516 let text = state
12517 .transcript
12518 .iter()
12519 .flat_map(|l| l.segments.iter().map(|s| s.text.as_str()))
12520 .collect::<Vec<_>>()
12521 .join(" ");
12522 assert!(
12523 !text.contains("**"),
12524 "the live stream must render markdown, not raw syntax: {text}"
12525 );
12526 assert!(text.contains("bold"), "content survives: {text}");
12527 }
12528
12529 #[test]
12530 fn message_end_does_not_reflow_the_streamed_block() {
12531 let mut state = RenderState::default();
12532 let (handle, mut rx) = fresh_handle();
12533 map_agent_event(
12534 &handle,
12535 AgentEvent::MessageStart {
12536 message: assistant(),
12537 },
12538 &mut state,
12539 );
12540 map_agent_event(
12541 &handle,
12542 AgentEvent::MessageUpdate {
12543 message: assistant(),
12544 delta: oxicode_sdk::StreamDelta::Text("hello **world**".into()),
12545 },
12546 &mut state,
12547 );
12548 apply_all(&mut state, &mut rx);
12549 type_out_stream(&mut state);
12550 let streamed: Vec<String> = state
12551 .transcript
12552 .iter()
12553 .map(|l| {
12554 l.segments
12555 .iter()
12556 .map(|s| s.text.as_str())
12557 .collect::<String>()
12558 })
12559 .collect();
12560
12561 map_agent_event(
12562 &handle,
12563 AgentEvent::MessageEnd {
12564 message: assistant_with_text("hello **world**"),
12565 },
12566 &mut state,
12567 );
12568 apply_all(&mut state, &mut rx);
12569 let final_: Vec<String> = state
12570 .transcript
12571 .iter()
12572 .map(|l| {
12573 l.segments
12574 .iter()
12575 .map(|s| s.text.as_str())
12576 .collect::<String>()
12577 })
12578 .collect();
12579 assert_eq!(
12580 streamed, final_,
12581 "MessageEnd must not re-render what the live stream already shows"
12582 );
12583 }
12584
12585 #[test]
12586 fn final_message_renders_the_authoritative_tail() {
12587 let mut state = RenderState::default();
12593 let (handle, mut rx) = fresh_handle();
12594 map_agent_event(
12595 &handle,
12596 AgentEvent::MessageStart {
12597 message: assistant(),
12598 },
12599 &mut state,
12600 );
12601 map_agent_event(
12602 &handle,
12603 AgentEvent::MessageUpdate {
12604 message: assistant(),
12605 delta: oxicode_sdk::StreamDelta::Text("visible prefix ".into()),
12606 },
12607 &mut state,
12608 );
12609 apply_all(&mut state, &mut rx);
12610 type_out_stream(&mut state);
12611
12612 map_agent_event(
12613 &handle,
12614 AgentEvent::MessageEnd {
12615 message: assistant_with_text("visible prefix HIDDEN-TAIL"),
12616 },
12617 &mut state,
12618 );
12619 apply_all(&mut state, &mut rx);
12620 let text: String = state
12621 .transcript
12622 .iter()
12623 .map(|l| {
12624 l.segments
12625 .iter()
12626 .map(|s| s.text.as_str())
12627 .collect::<String>()
12628 })
12629 .collect();
12630 assert!(
12631 text.contains("HIDDEN-TAIL"),
12632 "the final message is authoritative — its tail must render: {text}"
12633 );
12634 assert_eq!(
12635 text.matches("visible prefix").count(),
12636 1,
12637 "no duplicated block: {text}"
12638 );
12639 }
12640
12641 #[test]
12642 fn streamed_body_renders_only_the_revealed_prefix() {
12643 let mut state = RenderState::default();
12644 state.message_buffer = "hello world".to_string();
12645 state.stream_reveal = 5; let lines = render_streamed_message(&mut state);
12647 let text: String = lines
12648 .iter()
12649 .map(|l| l.iter().map(|s| s.text.as_str()).collect::<String>())
12650 .collect();
12651 assert!(text.contains("hello"), "revealed prefix renders: {text}");
12652 assert!(!text.contains("world"), "unrevealed text waits: {text}");
12653 }
12654
12655 #[test]
12656 fn advance_stream_reveal_types_out_in_bounded_steps() {
12657 let mut state = RenderState::default();
12658 state.stream_anchor = Some(0);
12659 state.message_buffer = "x".repeat(6000);
12660 state.stream_reveal = 0;
12661
12662 assert!(advance_stream_reveal(&mut state), "first tick paints");
12663 assert!(
12664 state.stream_reveal > 0 && state.stream_reveal < 6000,
12665 "bounded step, not a lump: {}",
12666 state.stream_reveal
12667 );
12668 let transcript_after_step: usize = state.transcript.len();
12669 assert!(
12670 transcript_after_step > 0,
12671 "the revealed prefix lands in the transcript"
12672 );
12673
12674 while advance_stream_reveal(&mut state) {}
12675 assert_eq!(
12676 state.stream_reveal, 6000,
12677 "repeated ticks drain the backlog completely"
12678 );
12679 }
12680
12681 fn type_out_stream(state: &mut RenderState) {
12684 while advance_stream_reveal(state) {}
12685 }
12686
12687 #[test]
12688 fn message_end_clears_reasoning_stage() {
12689 let mut state = RenderState::default();
12690 let (handle, _cmd_rx) = fresh_handle();
12691 state.reasoning_stage = Some("thinking\u{2026}".into());
12692
12693 map_agent_event(
12694 &handle,
12695 AgentEvent::MessageEnd {
12696 message: assistant(),
12697 },
12698 &mut state,
12699 );
12700
12701 assert!(
12702 state.reasoning_stage.is_none(),
12703 "MessageEnd must clear the reasoning stage so follow-ups / tips can render"
12704 );
12705 }
12706
12707 #[test]
12708 fn run_tracker_spans_the_whole_tool_loop() {
12709 let mut state = RenderState::default();
12710 let (handle, _cmd_rx) = fresh_handle();
12711
12712 map_agent_event(
12713 &handle,
12714 AgentEvent::AgentStart {
12715 prompts: vec![],
12716 session_id: None,
12717 },
12718 &mut state,
12719 );
12720 assert!(
12721 state.active_run.is_some(),
12722 "AgentStart opens the run tracker"
12723 );
12724
12725 map_agent_event(
12726 &handle,
12727 AgentEvent::MessageStart {
12728 message: assistant(),
12729 },
12730 &mut state,
12731 );
12732 map_agent_event(
12733 &handle,
12734 AgentEvent::ToolExecutionStart {
12735 tool_call_id: "tc-1".into(),
12736 tool_name: "read".into(),
12737 args: serde_json::json!({}),
12738 intent: None,
12739 context: None,
12740 },
12741 &mut state,
12742 );
12743 map_agent_event(
12744 &handle,
12745 AgentEvent::MessageEnd {
12746 message: assistant(),
12747 },
12748 &mut state,
12749 );
12750
12751 let run = state.active_run.as_ref().expect("run stays live");
12754 assert_eq!(run.turn, 1, "MessageStart counts a turn");
12755 assert_eq!(run.tool_calls, 1, "ToolExecutionStart counts a call");
12756
12757 map_agent_event(
12758 &handle,
12759 AgentEvent::AgentEnd {
12760 messages: vec![],
12761 stop_reason: None,
12762 session_id: None,
12763 },
12764 &mut state,
12765 );
12766 assert!(state.active_run.is_none(), "AgentEnd closes the tracker");
12767 assert!(
12768 state.reasoning_stage.is_none(),
12769 "AgentEnd releases the indicator row"
12770 );
12771 }
12772
12773 #[test]
12774 fn message_end_releases_the_stream_anchor() {
12775 let mut state = RenderState::default();
12776 let (handle, mut rx) = fresh_handle();
12777 map_agent_event(
12778 &handle,
12779 AgentEvent::MessageStart {
12780 message: assistant(),
12781 },
12782 &mut state,
12783 );
12784 map_agent_event(
12785 &handle,
12786 AgentEvent::MessageUpdate {
12787 message: assistant(),
12788 delta: oxicode_sdk::StreamDelta::Text("done".into()),
12789 },
12790 &mut state,
12791 );
12792 map_agent_event(
12793 &handle,
12794 AgentEvent::MessageEnd {
12795 message: assistant(),
12796 },
12797 &mut state,
12798 );
12799 while let Ok(cmd) = rx.try_recv() {
12800 apply_command(&mut state, cmd);
12801 }
12802 assert!(
12803 state.stream_anchor.is_none(),
12804 "MessageEnd finalizes the message — the anchor must release so the finished block can commit to scrollback"
12805 );
12806 }
12807}
12808
12809#[cfg(test)]
12810mod composer_border_tests {
12811 use super::*;
12814
12815 fn spans_to_string(line: &Line<'_>) -> String {
12816 line.spans.iter().map(|s| s.content.as_ref()).collect()
12817 }
12818
12819 #[test]
12820 fn composer_border_has_no_app_badge() {
12821 let mut state = RenderState::default();
12822 state.header_context.provider = "prov".to_string();
12823 state.header_context.model = "prov/m-1".to_string();
12824
12825 let text = spans_to_string(&composer_context_line(&state, 200));
12826
12827 assert!(
12828 text.starts_with("MODEL "),
12829 "model leads with no leading separator: {text}"
12830 );
12831 assert!(
12832 text.contains("MODEL m-1"),
12833 "provider prefix is stripped from the model: {text}"
12834 );
12835 }
12836
12837 #[test]
12838 fn composer_border_fields_drop_by_width() {
12839 let mut state = RenderState::default();
12840 state.header_context.provider = "prov".to_string();
12841 state.header_context.model = "prov/m-1".to_string();
12842
12843 let narrow = spans_to_string(&composer_context_line(&state, 60));
12845 assert!(
12846 narrow.contains("MODEL ") && !narrow.contains("CTX "),
12847 "narrow keeps the model only: {narrow}"
12848 );
12849 let wide = spans_to_string(&composer_context_line(&state, 140));
12850 assert!(wide.contains("CTX "), "wide carries context usage: {wide}");
12851 }
12852
12853 #[test]
12854 fn model_chips_follow_model_switch() {
12855 let mut state = RenderState::default();
12856 assert_eq!(state.context_window, 128_000, "default before sync");
12857
12858 apply_model_to_chips(&mut state, "google/gemini-2.5-pro", 1_048_576);
12862 assert_eq!(state.header_context.provider, "google");
12863 assert_eq!(state.header_context.model, "google/gemini-2.5-pro");
12864 assert_eq!(
12865 state.header_context.editor_context.as_deref(),
12866 Some("google/gemini-2.5-pro")
12867 );
12868 assert_eq!(state.context_window, 1_048_576);
12869
12870 let wide = spans_to_string(&composer_context_line(&state, 140));
12871 assert!(
12872 wide.contains("CTX 0/1048.5K"),
12873 "CTX chip renders the synced denominator: {wide}"
12874 );
12875
12876 apply_model_to_chips(&mut state, "", 999);
12878 assert_eq!(state.header_context.model, "google/gemini-2.5-pro");
12879
12880 apply_model_to_chips(&mut state, "zai/glm-5.1", 0);
12883 assert_eq!(state.header_context.model, "zai/glm-5.1");
12884 assert_eq!(state.context_window, 1_048_576);
12885 }
12886
12887 #[test]
12888 fn plain_segments_render_in_their_kind_color_not_response() {
12889 let styles = active_styles();
12890 let user_color = color_from_anstyle(styles.user.get_fg_color());
12891 let response = color_from_anstyle(styles.response.get_fg_color());
12892 let line = |kind| TranscriptLine {
12893 kind,
12894 segments: vec![plain_segment("body")],
12895 block_id: 0,
12896 };
12897
12898 let user_line = line(InlineMessageKind::User);
12899 let user = transcript_line_marked(&user_line, &styles, false, false, false, true, 80);
12900 assert_eq!(
12901 user.spans[0].style.fg,
12902 Some(user_color),
12903 "user text must read in the user color — response-ink makes turns indistinguishable"
12904 );
12905
12906 let agent_line = line(InlineMessageKind::Agent);
12907 let agent = transcript_line_marked(&agent_line, &styles, false, false, false, true, 80);
12908 assert_eq!(agent.spans[0].style.fg, Some(response));
12909 }
12910}
12911#[cfg(test)]
12912mod transcript_turn_tests {
12913 use super::*;
12916 fn tl(kind: InlineMessageKind, text: &str, block_id: usize) -> TranscriptLine {
12917 TranscriptLine {
12918 kind,
12919 segments: vec![plain_segment(text)],
12920 block_id,
12921 }
12922 }
12923
12924 fn spans_to_string(line: &Line<'_>) -> String {
12925 line.spans.iter().map(|s| s.content.as_ref()).collect()
12926 }
12927
12928 #[test]
12929 fn user_lines_are_bold_primary_without_prefix() {
12930 let styles = active_styles();
12931 let line = tl(InlineMessageKind::User, "refactor the parser", 0);
12932 let rendered = transcript_line_marked(&line, &styles, false, false, false, true, 80);
12933 assert_eq!(
12934 rendered.spans.len(),
12935 1,
12936 "plain style renders no prefix span"
12937 );
12938 assert_eq!(
12939 spans_to_string(&rendered),
12940 "refactor the parser",
12941 "user text renders as typed, no glyph"
12942 );
12943 assert!(
12944 rendered.spans[0]
12945 .style
12946 .add_modifier
12947 .contains(Modifier::BOLD),
12948 "user body is the only bold transcript text"
12949 );
12950 }
12951
12952 #[test]
12953 fn agent_tool_and_shell_lines_have_no_prefix() {
12954 let styles = active_styles();
12955 for (kind, label) in [
12956 (InlineMessageKind::Agent, "agent"),
12957 (InlineMessageKind::Tool, "tool"),
12958 (InlineMessageKind::Pty, "shell"),
12959 ] {
12960 let line = tl(kind, &format!("{label}-content"), 0);
12961 let rendered = transcript_line_marked(&line, &styles, false, false, false, true, 80);
12962 assert_eq!(
12963 rendered.spans.len(),
12964 1,
12965 "{label} lines carry no marker spans"
12966 );
12967 assert_eq!(spans_to_string(&rendered), format!("{label}-content"));
12968 }
12969 }
12970
12971 #[test]
12972 fn system_labels_render_on_block_start_only() {
12973 let styles = active_styles();
12974 let line = tl(InlineMessageKind::Error, "boom", 0);
12975
12976 let head = transcript_line_marked(&line, &styles, false, false, false, true, 80);
12977 assert_eq!(spans_to_string(&head), "error: boom");
12978
12979 let body = transcript_line_marked(&line, &styles, false, false, false, false, 80);
12980 assert_eq!(
12981 spans_to_string(&body),
12982 "boom",
12983 "continuation lines drop the label"
12984 );
12985 }
12986
12987 #[test]
12988 fn folded_head_keeps_the_block_label() {
12989 let styles = active_styles();
12990 let line = tl(InlineMessageKind::Error, "boom", 0);
12991 let rendered = transcript_line_marked(&line, &styles, true, false, false, false, 80);
12992 assert_eq!(
12993 spans_to_string(&rendered),
12994 "[+] error: boom",
12995 "a collapsed block stays identifiable"
12996 );
12997 }
12998
12999 #[test]
13000 fn transcript_line_marked_clamps_to_width() {
13001 let styles = active_styles();
13004 let big: String = "x".repeat(300);
13005 let line = tl(InlineMessageKind::Agent, &big, 0);
13006 let rendered = transcript_line_marked(&line, &styles, false, false, false, true, 40);
13007 assert!(
13008 rendered.width() <= 40,
13009 "transcript row overflowed the terminal width: rendered.width()={}",
13010 rendered.width()
13011 );
13012 }
13013}
13014
13015#[cfg(test)]
13016mod scrollback_commit_tests {
13017 use super::*;
13023
13024 fn tl(kind: InlineMessageKind, text: &str, block_id: usize) -> TranscriptLine {
13025 TranscriptLine {
13026 kind,
13027 segments: vec![plain_segment(text)],
13028 block_id,
13029 }
13030 }
13031
13032 fn long_transcript() -> Vec<TranscriptLine> {
13035 (0..6)
13036 .flat_map(|b| {
13037 [
13038 tl(InlineMessageKind::Agent, &format!("b{b}-line-one"), b),
13039 tl(InlineMessageKind::Agent, &format!("b{b}-line-two"), b),
13040 ]
13041 })
13042 .collect()
13043 }
13044
13045 #[test]
13046 fn commit_plan_sheds_oldest_blocks_and_keeps_the_tail() {
13047 let state = RenderState {
13048 transcript: long_transcript(),
13049 ..Default::default()
13050 };
13051 let styles = active_styles();
13052 let display = build_transcript_display(&state, &styles, 0, 80);
13053 let plan = scrollback_commit_plan(&display, &state.transcript, 80, 4, None).expect("plan");
13056 assert_eq!(plan.rows, 8, "12 rows total, keep 4 → commit 8");
13057 assert_eq!(plan.new_committed_entries, 8);
13058 }
13059
13060 #[test]
13061 fn commit_plan_never_splits_a_block() {
13062 let transcript: Vec<TranscriptLine> = (0..3)
13065 .flat_map(|b| {
13066 (0..3).map(move |i| tl(InlineMessageKind::Agent, &format!("b{b}-{i}"), b))
13067 })
13068 .collect();
13069 let state = RenderState {
13070 transcript,
13071 ..Default::default()
13072 };
13073 let styles = active_styles();
13074 let display = build_transcript_display(&state, &styles, 0, 80);
13075 let plan = scrollback_commit_plan(&display, &state.transcript, 80, 5, None).expect("plan");
13078 assert_eq!(
13079 plan.new_committed_entries, 3,
13080 "boundary snaps to block start"
13081 );
13082 assert_eq!(plan.rows, 3);
13083 }
13084
13085 #[test]
13086 fn commit_plan_excludes_the_streaming_anchor() {
13087 let state = RenderState {
13088 transcript: long_transcript(),
13089 stream_anchor: Some(4),
13090 ..Default::default()
13091 };
13092 let styles = active_styles();
13093 let display = build_transcript_display(&state, &styles, 0, 80);
13094 let plan = scrollback_commit_plan(&display, &state.transcript, 80, 4, state.stream_anchor)
13095 .expect("plan");
13096 assert!(
13097 plan.new_committed_entries <= 4,
13098 "nothing at/after the anchored (streaming) block commits"
13099 );
13100 }
13101 #[test]
13102 fn committed_entries_floor_the_live_render() {
13103 let mut state = RenderState::default();
13104 state.transcript = long_transcript();
13105 state.committed_entries = 8;
13106 let backend = ratatui::backend::TestBackend::new(80, 24);
13107 let mut terminal = Terminal::new(backend).expect("backend");
13108 terminal
13109 .draw(|f| render_frame(f, &state, &unused_handle()))
13110 .expect("draw");
13111 let buf = terminal.backend().buffer();
13112 let area = buf.area();
13113 let mut rendered = String::new();
13114 for y in 0..area.height {
13115 for x in 0..area.width {
13116 if let Some(cell) = buf.cell((x, y)) {
13117 rendered.push_str(cell.symbol());
13118 }
13119 }
13120 rendered.push('\n');
13121 }
13122 assert!(
13123 !rendered.contains("b0-line-one"),
13124 "committed blocks leave the viewport"
13125 );
13126 assert!(rendered.contains("b5-line"), "the live tail stays");
13127 }
13128
13129 #[test]
13130 fn search_skips_committed_entries() {
13131 let mut state = RenderState::default();
13132 state.transcript = long_transcript();
13133 state.committed_entries = 8;
13134 state.start_search("line-one");
13135 let s = state.search.as_ref().expect("search open");
13136 assert!(
13137 s.matches.iter().all(|&i| i >= 8),
13138 "matches confined to the live region: {:?}",
13139 s.matches
13140 );
13141 assert!(!s.matches.is_empty());
13142 }
13143
13144 fn unused_handle() -> InlineHandle {
13145 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
13146 InlineHandle::new_for_tests(tx)
13147 }
13148
13149 #[test]
13150 fn commit_plan_noop_when_tail_fits() {
13151 let state = RenderState {
13152 transcript: long_transcript(),
13153 ..Default::default()
13154 };
13155 let styles = active_styles();
13156 let display = build_transcript_display(&state, &styles, 0, 80);
13157 assert!(scrollback_commit_plan(&display, &state.transcript, 80, 12, None).is_none());
13158 }
13159
13160 #[test]
13161 fn oversized_block_commits_its_head_at_line_granularity() {
13162 let mut transcript: Vec<TranscriptLine> = (0..30)
13165 .map(|i| tl(InlineMessageKind::Agent, &format!("big-{i:02}"), 0))
13166 .collect();
13167 transcript.push(tl(InlineMessageKind::Agent, "tail-a", 1));
13168 transcript.push(tl(InlineMessageKind::Agent, "tail-b", 1));
13169 let state = RenderState {
13170 transcript,
13171 ..Default::default()
13172 };
13173 let styles = active_styles();
13174 let display = build_transcript_display(&state, &styles, 0, 80);
13175 let plan = scrollback_commit_plan(&display, &state.transcript, 80, 10, None).expect("plan");
13176 assert_eq!(
13177 plan.new_committed_entries, 22,
13178 "32 rows, keep 10 → head 22 rows commit"
13179 );
13180 assert_eq!(plan.rows, 22);
13181 }
13182
13183 #[test]
13184 fn rebuild_only_on_width_change() {
13185 assert!(!should_rebuild_scrollback(80, 80, 24, 30));
13189 assert!(should_rebuild_scrollback(80, 100, 24, 24));
13192 assert!(should_rebuild_scrollback(100, 80, 30, 24));
13194 assert!(!should_rebuild_scrollback(0, 100, 24, 24));
13199 assert!(!should_rebuild_scrollback(0, 80, 24, 24));
13200 }
13201
13202 #[test]
13203 fn force_flush_boundary_is_everything() {
13204 assert_eq!(plan_full_flush(0), 0);
13208 assert_eq!(plan_full_flush(8), 8);
13209 assert_eq!(plan_full_flush(40), 40);
13210 }
13211}
13212
13213#[cfg(test)]
13214mod tool_box_tests {
13215 use super::*;
13219
13220 fn row_text(row: &[InlineSegment]) -> String {
13221 row.iter().map(|s| s.text.as_str()).collect()
13222 }
13223
13224 #[test]
13225 fn korean_rows_keep_the_right_border_aligned() {
13226 let rows = tool_box_rows(
13228 "한글테스트 명령어",
13229 20,
13230 InlineTextStyle::default(),
13231 anstyle::Color::Ansi(anstyle::AnsiColor::White),
13232 );
13233 for row in &rows {
13234 let text = row_text(row);
13235 assert_eq!(text.width(), 20, "row must fill exactly 20 cells: {text:?}");
13236 assert!(text.starts_with('\u{2502}'), "left border: {text:?}");
13237 assert!(text.ends_with('\u{2502}'), "right border: {text:?}");
13238 }
13239 assert!(!rows.is_empty());
13240 assert_eq!(rows.len(), 2, "wraps by display width");
13243 }
13244
13245 #[test]
13246 fn divider_carries_the_label() {
13247 let seg = tool_box_divider(
13248 "Output",
13249 30,
13250 anstyle::Color::Ansi(anstyle::AnsiColor::White),
13251 );
13252 let text = row_text(&seg);
13253 assert!(
13254 text.starts_with("\u{251C}\u{2500} Output"),
13255 "label after ├─: {text:?}"
13256 );
13257
13258 assert!(text.ends_with('\u{2524}'), "closes with ┤: {text:?}");
13259 assert_eq!(text.width(), 30, "divider fills the box width");
13260 }
13261}
13262
13263#[cfg(test)]
13264mod tool_box_width_tests {
13265 use super::*;
13269
13270 #[test]
13271 fn tool_box_width_matches_live_content_width() {
13272 let state = RenderState {
13273 viewport_width: 100,
13274 ..Default::default()
13275 };
13276 assert_eq!(tool_box_width(&state), 98);
13279 }
13280}
13281
13282#[test]
13283fn tool_box_rows_expand_tabs_so_borders_align() {
13284 let chunk = format!("{:>6}\t{}", 1, "[package]");
13289 let rows = tool_box_rows(
13290 &chunk,
13291 176,
13292 InlineTextStyle::default(),
13293 anstyle::Color::Ansi(anstyle::AnsiColor::White),
13294 );
13295 assert_eq!(rows.len(), 1);
13296 for seg in &rows[0] {
13297 assert!(
13298 !seg.text.contains('\t'),
13299 "tabs must be expanded: {:?}",
13300 seg.text
13301 );
13302 }
13303 let built: usize = rows[0]
13304 .iter()
13305 .map(|s| UnicodeWidthStr::width(s.text.as_str()))
13306 .sum();
13307 assert_eq!(built, 176, "built width must equal the box width exactly");
13308}
13309
13310#[cfg(test)]
13311mod contextual_hint_tests {
13312 use super::*;
13316
13317 #[test]
13318 fn brain_chip_lives_on_the_composer_border() {
13319 let mut state = RenderState::default();
13320 state.header_context.provider = "prov".to_string();
13321 state.header_context.model = "prov/m-1".to_string();
13322
13323 let off = spans_to_string_border(&state);
13325 assert!(!off.contains("brain"), "chip hidden when off: {off}");
13326
13327 state.brain = BrainChip::Ok;
13329 let ok = spans_to_string_border(&state);
13330 assert!(ok.contains("brain·ok"), "healthy chip on border: {ok}");
13331 assert!(
13332 ok.trim_end().ends_with("brain·ok"),
13333 "chip is right-aligned: {ok}"
13334 );
13335
13336 state.brain = BrainChip::Down;
13338 let down = spans_to_string_border(&state);
13339 assert!(down.contains("brain·down"), "degraded chip: {down}");
13340 }
13341
13342 #[test]
13343 fn brain_chip_does_not_erase_the_border_rule() {
13344 let backend = ratatui::backend::TestBackend::new(80, 24);
13348 let mut terminal = Terminal::new(backend).expect("backend");
13349 let mut state = RenderState::default();
13350 state.header_context.provider = "prov".to_string();
13351 state.header_context.model = "prov/m-1".to_string();
13352 state.brain = BrainChip::Ok;
13353 terminal
13354 .draw(|f| render_frame(f, &state, &unused_test_handle()))
13355 .expect("draw");
13356 let buf = terminal.backend().buffer();
13357 let border_row = (0..buf.area().height)
13361 .map(|y| {
13362 (0..buf.area().width)
13363 .filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string()))
13364 .collect::<String>()
13365 })
13366 .find(|row| row.contains("MODEL") && row.contains(" | "))
13367 .expect("composer border row rendered");
13368 let rule_count = border_row.chars().filter(|c| *c == '\u{2500}').count();
13369 assert!(
13370 rule_count >= 10,
13371 "the ─ rule must survive right of the facts: {border_row}"
13372 );
13373 assert!(
13374 border_row.contains("brain\u{b7}ok"),
13375 "chip still on the border: {border_row}"
13376 );
13377 }
13378
13379 #[test]
13380 fn run_indicator_stays_up_between_turns() {
13381 let backend = ratatui::backend::TestBackend::new(80, 24);
13386 let mut terminal = Terminal::new(backend).expect("backend");
13387 let state = RenderState {
13388 active_run: Some(RunState {
13389 started_at: std::time::Instant::now(),
13390 turn: 2,
13391 tool_calls: 3,
13392 }),
13393 reasoning_stage: None,
13394 ..Default::default()
13395 };
13396 terminal
13397 .draw(|f| render_frame(f, &state, &unused_test_handle()))
13398 .expect("draw");
13399 let buf = terminal.backend().buffer();
13400 let row: String = (0..buf.area().width)
13401 .filter_map(|x| buf.cell((x, 20)).map(|c| c.symbol().to_string()))
13402 .collect();
13403 assert!(row.contains("RUNNING"), "row stays up between turns: {row}");
13404 assert!(row.contains("working"), "stage fallback label: {row}");
13405 assert!(row.contains("turn 2"), "turn count: {row}");
13406 assert!(row.contains("3 tool calls"), "tool count: {row}");
13407 assert!(row.contains("Esc abort"), "abort hint stays: {row}");
13408 assert!(
13409 RUN_SPINNER.iter().any(|f| row.contains(f)),
13410 "animated spinner frame: {row}"
13411 );
13412 }
13413
13414 #[test]
13415 fn spinner_frame_is_wall_clock_not_draw_count() {
13416 let state = || RenderState {
13421 active_run: Some(RunState::default()),
13422 reasoning_stage: None,
13423 ..Default::default()
13424 };
13425 let spinner_of = |s: &RenderState| -> Option<char> {
13426 let backend = ratatui::backend::TestBackend::new(80, 24);
13427 let mut terminal = Terminal::new(backend).expect("backend");
13428 terminal
13429 .draw(|f| render_frame(f, s, &unused_test_handle()))
13430 .expect("draw");
13431 let buf = terminal.backend().buffer();
13432 let row: String = (0..buf.area().width)
13433 .filter_map(|x| buf.cell((x, 20)).map(|c| c.symbol().to_string()))
13434 .collect();
13435 row.chars()
13436 .find(|c| RUN_SPINNER.iter().any(|f| f.starts_with(*c)))
13437 };
13438 let first = spinner_of(&state());
13441 let second = spinner_of(&state());
13442 assert!(first.is_some(), "spinner renders");
13443 assert_eq!(
13444 first, second,
13445 "back-to-back draws must not advance the spinner"
13446 );
13447 }
13448
13449 #[test]
13450 fn elapsed_formats_minutes_beyond_60s() {
13451 assert_eq!(format_elapsed_secs(59), "59s");
13452 assert_eq!(format_elapsed_secs(60), "1m 00s");
13453 assert_eq!(format_elapsed_secs(125), "2m 05s");
13454 }
13455
13456 #[test]
13457 fn reasoning_row_carries_the_abort_hint() {
13458 let backend = ratatui::backend::TestBackend::new(80, 24);
13459 let mut terminal = Terminal::new(backend).expect("backend");
13460 let state = RenderState {
13461 reasoning_stage: Some("thinking\u{2026}".into()),
13462 ..Default::default()
13463 };
13464 terminal
13465 .draw(|f| render_frame(f, &state, &unused_test_handle()))
13466 .expect("draw");
13467 let buf = terminal.backend().buffer();
13468 let row: String = (0..buf.area().width)
13469 .filter_map(|x| buf.cell((x, 20)).map(|c| c.symbol().to_string()))
13470 .collect();
13471 assert!(
13472 row.contains("Esc abort"),
13473 "streaming shows the contextual abort hint: {row}"
13474 );
13475 }
13476
13477 #[test]
13478 fn pending_quit_owns_the_hint_row() {
13479 let backend = ratatui::backend::TestBackend::new(80, 24);
13480 let mut terminal = Terminal::new(backend).expect("backend");
13481 let state = RenderState {
13482 pending_quit: true,
13483 ..Default::default()
13484 };
13485 terminal
13486 .draw(|f| render_frame(f, &state, &unused_test_handle()))
13487 .expect("draw");
13488 let buf = terminal.backend().buffer();
13489
13490 let row: String = (0..buf.area().width)
13491 .filter_map(|x| buf.cell((x, 20)).map(|c| c.symbol().to_string()))
13492 .collect();
13493 assert!(
13494 row.contains("press Ctrl+C again to quit"),
13495 "armed quit shows its hint: {row}"
13496 );
13497 }
13498
13499 fn spans_to_string_border(state: &RenderState) -> String {
13500 let line = composer_context_line(state, 200);
13501 let mut text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
13502 let used = line.spans.iter().map(|s| s.width()).sum();
13503 if let Some(chip) = composer_brain_chip(state, 200, used) {
13504 assert_eq!(
13505 chip.alignment,
13506 Some(Alignment::Right),
13507 "the chip is its own right-aligned title"
13508 );
13509 text.extend(chip.spans.iter().map(|s| s.content.as_ref()));
13510 }
13511 text
13512 }
13513
13514 fn unused_test_handle() -> InlineHandle {
13515 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
13516 InlineHandle::new_for_tests(tx)
13517 }
13518}
13519
13520#[cfg(test)]
13521mod trailing_breath_tests {
13522 use super::*;
13527
13528 #[test]
13529 fn display_ends_at_the_last_line_no_trailing_blank_item() {
13530 let state = RenderState {
13531 transcript: vec![TranscriptLine {
13532 kind: InlineMessageKind::Agent,
13533 segments: vec![plain_segment("answer")],
13534 block_id: 0,
13535 }],
13536 ..Default::default()
13537 };
13538 let styles = active_styles();
13539 let display = build_transcript_display(&state, &styles, 0, 80);
13540 assert_eq!(display.len(), 1, "the gap is layout, not a display item");
13541 assert!(display[0].line.is_some());
13542 }
13543
13544 #[test]
13545 fn scrollback_area_reserves_one_breath_row_above_the_composer() {
13546 let area = Rect {
13547 x: 0,
13548 y: 0,
13549 width: 100,
13550 height: 30,
13551 };
13552 let layout = super::super::frame_layout::compute_chrome(area);
13553 assert_eq!(
13554 layout.scrollback.bottom() + 1,
13555 layout.prompt.y,
13556 "exactly one row separates the transcript from the composer"
13557 );
13558 assert_eq!(
13559 super::super::frame_layout::scrollback_height(area),
13560 layout.scrollback.height,
13561 "the commit keep-rows must match the rendered area"
13562 );
13563 }
13564}
13565
13566#[cfg(test)]
13567mod nerd_icon_tests {
13568 use super::*;
13572
13573 fn border_text(state: &RenderState) -> String {
13574 let line = composer_context_line(state, 200);
13575 let mut text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
13576 let used = line.spans.iter().map(|s| s.width()).sum();
13577 if let Some(chip) = composer_brain_chip(state, 200, used) {
13578 text.extend(chip.spans.iter().map(|s| s.content.as_ref()));
13579 }
13580 text
13581 }
13582
13583 fn base_state() -> RenderState {
13584 let mut state = RenderState::default();
13585 state.header_context.provider = "prov".to_string();
13586 state.header_context.model = "prov/m-1".to_string();
13587 state.brain = BrainChip::Ok;
13588 state
13589 }
13590
13591 #[test]
13592 fn nerd_mode_replaces_labels_with_private_use_glyphs() {
13593 let mut state = base_state();
13594 state.glyph_set = crate::symbols::GlyphSet::Nerd;
13595 let text = border_text(&state);
13596 assert!(!text.contains("MODEL "), "text label gone: {text}");
13597 assert!(
13598 text.contains(crate::symbols::nerd::MODEL),
13599 "robot glyph for the model: {text}"
13600 );
13601 assert!(
13602 text.contains(crate::symbols::nerd::GIT),
13603 "git glyph present: {text}"
13604 );
13605 assert!(
13606 text.contains(crate::symbols::nerd::BRAIN),
13607 "brain glyph chip: {text}"
13608 );
13609 for ch in text.chars() {
13612 let cp = ch as u32;
13613 let private_use = (0xE000..=0xF8FF).contains(&cp)
13614 || (0xF0000..=0xFFFFD).contains(&cp)
13615 || (0x100000..=0x10FFFD).contains(&cp);
13616 assert!(
13617 !('\u{1F300}'..='\u{1FAFF}').contains(&ch) || !private_use,
13618 "sanity"
13619 );
13620 }
13621 }
13622
13623 #[test]
13624 fn unicode_default_keeps_text_labels() {
13625 let state = base_state();
13626 let text = border_text(&state);
13627 assert!(text.contains("MODEL "), "default keeps text: {text}");
13628 assert!(text.contains("brain\u{b7}ok"), "default chip text: {text}");
13629 }
13630}
13631#[cfg(test)]
13632mod glyph_cycle_tests {
13633 use crate::symbols::GlyphSet;
13634
13635 #[test]
13636 fn glyph_set_cycles_unicode_ascii_nerd() {
13637 assert_eq!(GlyphSet::Unicode.next(), GlyphSet::Ascii);
13638 assert_eq!(GlyphSet::Ascii.next(), GlyphSet::Nerd);
13639 assert_eq!(GlyphSet::Nerd.next(), GlyphSet::Unicode);
13640 }
13641}
13642
13643#[cfg(test)]
13644mod coalesce_draw_tests {
13645 use super::*;
13652 use std::time::{Duration, Instant};
13653
13654 #[test]
13655 fn defer_within_interval() {
13656 let now = Instant::now();
13657 let last = now - Duration::from_millis(10);
13658 assert_eq!(
13659 coalesce_draw(last, false, DRAW_MIN_INTERVAL),
13660 DrawDecision::Defer
13661 );
13662 }
13663
13664 #[test]
13665 fn draw_now_on_priority_even_within_interval() {
13666 let now = Instant::now();
13667 let last = now - Duration::from_millis(10);
13668 assert_eq!(
13669 coalesce_draw(last, true, DRAW_MIN_INTERVAL),
13670 DrawDecision::DrawNow
13671 );
13672 }
13673
13674 #[test]
13675 fn draw_now_when_interval_elapsed() {
13676 let now = Instant::now();
13677 let last = now - Duration::from_millis(60);
13678 assert_eq!(
13679 coalesce_draw(last, false, DRAW_MIN_INTERVAL),
13680 DrawDecision::DrawNow
13681 );
13682 }
13683}
13684#[cfg(test)]
13685mod settings_panel_tests {
13686 use super::*;
13687 use crate::app::agent_session::AgentSessionHandle;
13688 use crate::store::settings::Settings;
13689 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
13690
13691 fn make_session_with_tools() -> AgentSessionHandle {
13692 super::provider_overlay_tests::make_session_with_tools_for_tests()
13693 }
13694 use oxicode_vtui::tui::core::InlineListSelection;
13695 use ratatui::{Terminal, backend::TestBackend};
13696
13697 fn heading(title: &str) -> OverlayListItem {
13698 OverlayListItem {
13699 title: title.into(),
13700 subtitle: None,
13701 badge: None,
13702 indent: 0,
13703 search_value: None,
13704 selection: None,
13705 }
13706 }
13707
13708 fn row(title: &str, badge: &str, selection: Option<InlineListSelection>) -> OverlayListItem {
13709 OverlayListItem {
13710 title: title.into(),
13711 subtitle: None,
13712 badge: Some(badge.into()),
13713 indent: 0,
13714 search_value: None,
13715 selection,
13716 }
13717 }
13718
13719 fn rows_with_positions(terminal: &Terminal<TestBackend>) -> Vec<(u16, String, Vec<u16>)> {
13723 let buf = terminal.backend().buffer();
13724 let area = buf.area();
13725 let mut out = Vec::new();
13726 for y in 0..area.height {
13727 let mut text = String::new();
13728 let mut xs = Vec::new();
13729 for x in 0..area.width {
13730 if let Some(cell) = buf.cell((x, y)) {
13731 text.push_str(cell.symbol());
13732 xs.push(x);
13733 }
13734 }
13735 out.push((y, text, xs));
13736 }
13737 out
13738 }
13739
13740 fn occurrences(rows: &[(u16, String, Vec<u16>)], needle: &str) -> Vec<(u16, usize)> {
13742 let mut hits = Vec::new();
13743 for (y, text, xs) in rows {
13744 let mut from = 0;
13745 while let Some(rel) = text[from..].find(needle) {
13746 let byte_idx = from + rel;
13747 let char_idx = text[..byte_idx].chars().count();
13748 if let Some(&x) = xs.get(char_idx) {
13749 hits.push((*y, x as usize));
13750 }
13751 from = byte_idx + needle.len();
13752 }
13753 }
13754 hits
13755 }
13756
13757 #[test]
13762 fn render_overlay_tabbed_settings_shows_tab_bar_and_sidebar() {
13763 let backend = TestBackend::new(80, 24);
13764 let mut terminal = Terminal::new(backend).unwrap();
13765 let overlay = OverlayState {
13766 title: "Settings".into(),
13767 lines: Vec::new(),
13768 items: vec![
13769 heading("Defaults"),
13770 row(
13771 "Thinking level",
13772 "medium",
13773 Some(InlineListSelection::ConfigAction("ThinkingLevel".into())),
13774 ),
13775 row("Model roles", "0", None),
13776 heading("Pointers"),
13777 row("Theme", "dark", None),
13778 ],
13779 selected: 1,
13780 search: None,
13781 secure_input: None,
13782 tabs: vec!["General".into(), "Model".into(), "Interaction".into()],
13783 active_tab: 1,
13784 sections: vec!["Defaults".into(), "Pointers".into()],
13785 active_section: 0,
13786 key_capture: None,
13787 };
13788 terminal
13789 .draw(|f| render_overlay(f, f.area(), &overlay))
13790 .unwrap();
13791 let rows = rows_with_positions(&terminal);
13792
13793 let general = occurrences(&rows, "General");
13796 let interaction = occurrences(&rows, "Interaction");
13797 assert!(
13798 general
13799 .iter()
13800 .any(|(gy, _)| interaction.iter().any(|(iy, _)| gy == iy)),
13801 "tab bar must list tabs on one row"
13802 );
13803
13804 for name in ["Defaults", "Pointers"] {
13808 let hits = occurrences(&rows, name);
13809 assert!(hits.len() >= 2, "{name} must render in sidebar AND list");
13810 assert!(
13811 hits.iter().any(|(_, x)| *x < 13),
13812 "{name} must render in the sidebar column"
13813 );
13814 assert!(
13815 hits.iter().any(|(_, x)| *x >= 13),
13816 "{name} must render in the item column"
13817 );
13818 }
13819
13820 let buf = terminal.backend().buffer();
13823 let pointers_item_col = occurrences(&rows, "Pointers")
13824 .into_iter()
13825 .find(|(_, x)| *x >= 13)
13826 .expect("items-column Pointers heading");
13827 let cell = buf
13828 .cell((pointers_item_col.1 as u16, pointers_item_col.0))
13829 .expect("cell");
13830 assert!(
13831 cell.modifier.contains(Modifier::DIM),
13832 "out-of-section rows must be dimmed"
13833 );
13834 let defaults_item_col = occurrences(&rows, "Defaults")
13835 .into_iter()
13836 .find(|(_, x)| *x >= 13)
13837 .expect("items-column Defaults heading");
13838 let cell = buf
13839 .cell((defaults_item_col.1 as u16, defaults_item_col.0))
13840 .expect("cell");
13841 assert!(
13842 !cell.modifier.contains(Modifier::DIM),
13843 "active-section rows must not be dimmed"
13844 );
13845 }
13846
13847 #[test]
13852 fn rebound_send_now_combo_submits_immediately() {
13853 let state = Arc::new(parking_lot::Mutex::new(RenderState::default()));
13854 let mut overrides = std::collections::HashMap::new();
13855 overrides.insert("SendNow".to_string(), vec!["Alt+s".to_string()]);
13856 *state.lock().keymap.write() = Keymap::from_settings(&overrides);
13857
13858 let alt_s = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::ALT);
13859 let action = state
13860 .lock()
13861 .keymap
13862 .read()
13863 .resolve(alt_s)
13864 .expect("Alt+s must resolve to SendNow after the rebind");
13865 assert!(matches!(action, GlobalAction::SendNow));
13866 let ctrl_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL);
13870 assert_eq!(state.lock().keymap.read().resolve(ctrl_enter), None);
13871 let ctrl_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
13872 assert!(matches!(
13873 state.lock().keymap.read().resolve(ctrl_p),
13874 Some(GlobalAction::OpenCommandPalette)
13875 ));
13876
13877 state.lock().composer.set_text("send me now");
13878 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
13879 apply_global_action(action, &state, &tx);
13880
13881 assert_eq!(state.lock().composer.text(), "");
13882 match rx.try_recv().expect("interrupt fires first") {
13883 InlineEvent::Interrupt => {}
13884 other => panic!("expected Interrupt first, got {other:?}"),
13885 }
13886 match rx.try_recv().expect("submit fires second") {
13887 InlineEvent::Submit(text) => assert_eq!(&*text, "send me now"),
13888 other => panic!("expected Submit, got {other:?}"),
13889 }
13890 assert!(rx.try_recv().is_err(), "no further events");
13891 }
13892
13893 #[test]
13896 fn settings_selection_variants_round_trip_names() {
13897 assert_eq!(
13898 InlineListSelection::SettingsTab(1),
13899 InlineListSelection::SettingsTab(1)
13900 );
13901 assert_eq!(
13902 InlineListSelection::SettingsSection(0),
13903 InlineListSelection::SettingsSection(0)
13904 );
13905 assert_eq!(
13906 InlineListSelection::SettingKeyCapture("OpenCommandPalette".into()),
13907 InlineListSelection::SettingKeyCapture("OpenCommandPalette".into())
13908 );
13909 assert_eq!(
13910 InlineListSelection::SettingTextEdit("ToolTimeoutSecs".into()),
13911 InlineListSelection::SettingTextEdit("ToolTimeoutSecs".into())
13912 );
13913 assert_eq!(
13914 InlineListSelection::SettingSubmenuOpen("AdvisorSyncBacklog".into()),
13915 InlineListSelection::SettingSubmenuOpen("AdvisorSyncBacklog".into())
13916 );
13917 assert_eq!(
13918 InlineListSelection::SettingMultiselect("DisabledTools".into()),
13919 InlineListSelection::SettingMultiselect("DisabledTools".into())
13920 );
13921 }
13922
13923 #[test]
13936 fn key_capture_appends_combo_and_keeps_default_resolving() {
13937 let real_settings = dirs::home_dir()
13940 .map(|h| h.join(".oxicode").join("settings.json"))
13941 .filter(|p| p.exists());
13942 let real_settings_mtime_before = real_settings
13943 .as_ref()
13944 .and_then(|p| std::fs::metadata(p).ok())
13945 .and_then(|m| m.modified().ok());
13946 let real_settings_sha_before = real_settings
13947 .as_ref()
13948 .and_then(|p| std::fs::read(p).ok())
13949 .map(|b| {
13950 use std::collections::hash_map::DefaultHasher;
13951 use std::hash::{Hash, Hasher};
13952 let mut h = DefaultHasher::new();
13953 b.hash(&mut h);
13954 h.finish()
13955 });
13956
13957 let tmp = tempfile::tempdir().expect("tempdir");
13958 let sandbox = tmp.path().join("settings.json");
13959
13960 let mut state = RenderState::default();
13961 state.settings_override_path = Some(sandbox.clone());
13962 state.overlay = Some(build_key_capture_overlay(
13966 GlobalAction::OpenCommandPalette.name(),
13967 ));
13968 state.settings_map_rows.clear();
13969
13970 let ctrl_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
13972 let alt_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::ALT);
13973 assert_eq!(
13974 state.keymap.read().resolve(ctrl_p),
13975 Some(GlobalAction::OpenCommandPalette)
13976 );
13977 assert_eq!(state.keymap.read().resolve(alt_p), None);
13978
13979 handle_key_capture(&mut state, alt_p);
13981
13982 let keymap = state.keymap.read();
13987 assert_eq!(
13988 keymap.resolve(ctrl_p),
13989 Some(GlobalAction::OpenCommandPalette)
13990 );
13991 assert_eq!(
13992 keymap.resolve(alt_p),
13993 Some(GlobalAction::OpenCommandPalette)
13994 );
13995 drop(keymap);
13996 let overlay = state
13997 .overlay
13998 .as_ref()
13999 .expect("capture commits reopen the panel on Keybindings");
14000 assert!(
14001 overlay.key_capture.is_none(),
14002 "capture prompt must be closed"
14003 );
14004 assert_eq!(
14005 overlay.lines.first().map(String::as_str),
14006 Some("Captured Alt+p for OpenCommandPalette")
14007 );
14008 assert_eq!(state.settings_active_tab, SettingsTab::Keybindings);
14009
14010 let sandbox_contents = std::fs::read_to_string(&sandbox)
14014 .expect("sandbox settings.json must exist after capture");
14015 assert!(
14016 sandbox_contents.contains("OpenCommandPalette"),
14017 "sandbox file must contain the captured keybinding override; got {sandbox_contents}"
14018 );
14019 assert!(
14020 sandbox_contents.contains("Alt+p"),
14021 "sandbox file must contain the captured Alt+p combo; got {sandbox_contents}"
14022 );
14023 if let Some(before) = real_settings_mtime_before {
14024 let after = real_settings
14025 .as_ref()
14026 .and_then(|p| std::fs::metadata(p).ok())
14027 .and_then(|m| m.modified().ok())
14028 .expect("real settings.json must still exist after capture");
14029 assert_eq!(
14030 before, after,
14031 "real ~/.oxicode/settings.json mtime must not change (sandbox leak)"
14032 );
14033 }
14034 if let (Some(before), Some(after)) = (
14035 real_settings_sha_before,
14036 real_settings
14037 .as_ref()
14038 .and_then(|p| std::fs::read(p).ok())
14039 .map(|b| {
14040 use std::collections::hash_map::DefaultHasher;
14041 use std::hash::{Hash, Hasher};
14042 let mut h = DefaultHasher::new();
14043 b.hash(&mut h);
14044 h.finish()
14045 }),
14046 ) {
14047 assert_eq!(
14048 before, after,
14049 "real ~/.oxicode/settings.json content must not change (sandbox leak)"
14050 );
14051 }
14052 }
14053
14054 #[test]
14061 fn remove_keybinding_combo_refuses_to_drop_the_last_combo() {
14062 let mut state = RenderState::default();
14063 let mut settings = Settings::default();
14068 crate::tui_vt::settings_defs::set_action_combos(
14069 &mut settings,
14070 GlobalAction::OpenCommandPalette,
14071 vec!["Ctrl+p".to_string()],
14072 );
14073 *state.keymap.write() = Keymap::from_settings(&settings.keybindings);
14074 assert_eq!(
14075 state
14076 .keymap
14077 .read()
14078 .action_combos(GlobalAction::OpenCommandPalette)
14079 .len(),
14080 1,
14081 "test setup: action must start with exactly one combo"
14082 );
14083
14084 state.settings_active_tab = SettingsTab::Keybindings;
14087
14088 remove_keybinding_combo(&mut state, GlobalAction::OpenCommandPalette, "Ctrl+p");
14089
14090 let ctrl_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
14092 assert_eq!(
14093 state.keymap.read().resolve(ctrl_p),
14094 Some(GlobalAction::OpenCommandPalette),
14095 "the guard must not let OpenCommandPalette go combo-less"
14096 );
14097 assert_eq!(
14098 state
14099 .keymap
14100 .read()
14101 .action_combos(GlobalAction::OpenCommandPalette)
14102 .len(),
14103 1,
14104 "no combo was removed"
14105 );
14106 let overlay = state.overlay.as_ref().expect("reopen leaves the panel up");
14109 assert!(
14110 overlay
14111 .lines
14112 .first()
14113 .map(|l| l.contains("Refusing to remove the last combo"))
14114 .unwrap_or(false),
14115 "panel must explain why the removal was refused; got {:?}",
14116 overlay.lines
14117 );
14118 }
14119
14120 #[test]
14126 fn text_edit_commit_valid_input() {
14127 let tmp = tempfile::tempdir().expect("tempdir");
14128 let sandbox = tmp.path().join("settings.json");
14129 let mut state = RenderState::default();
14130 state.settings_override_path = Some(sandbox.clone());
14131 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
14132 let handle = InlineHandle::new_for_tests(tx);
14133
14134 let (outcome, _msg) = commit_text_edit(
14135 &mut state,
14136 &handle,
14137 None,
14138 SettingKey::SessionHistorySize,
14139 "300".to_string(),
14140 );
14141 assert!(outcome.is_ok(), "valid numeric input must commit");
14142
14143 let contents = std::fs::read_to_string(&sandbox).expect("sandbox written");
14145 let saved: Settings = serde_json::from_str(&contents).expect("sandbox parses");
14146 assert_eq!(
14147 saved.session_history_size, 300,
14148 "sandbox must hold session_history_size=300"
14149 );
14150
14151 let overlay = state.overlay.as_ref().expect("panel reopened");
14153 assert!(
14154 overlay
14155 .lines
14156 .first()
14157 .map(|l| l.contains("300"))
14158 .unwrap_or(false),
14159 "status line must show the new value; got {:?}",
14160 overlay.lines
14161 );
14162 let mut saw_info = false;
14164 while let Ok(cmd) = rx.try_recv() {
14165 if let InlineCommand::AppendLine { kind, .. } = cmd
14166 && matches!(kind, InlineMessageKind::Info)
14167 {
14168 saw_info = true;
14169 }
14170 }
14171 assert!(saw_info, "commit must surface an Info line");
14172 }
14173
14174 #[test]
14178 fn text_edit_commit_invalid_input_is_rejected() {
14179 let tmp = tempfile::tempdir().expect("tempdir");
14180 let sandbox = tmp.path().join("settings.json");
14181 let mut state = RenderState::default();
14182 state.settings_override_path = Some(sandbox.clone());
14183 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
14184 let handle = InlineHandle::new_for_tests(tx);
14185
14186 let (outcome, msg) = commit_text_edit(
14187 &mut state,
14188 &handle,
14189 None,
14190 SettingKey::SessionHistorySize,
14191 "not-a-number".to_string(),
14192 );
14193 assert!(outcome.is_err(), "non-numeric input must be rejected");
14194 assert!(
14195 msg.contains("invalid") || msg.contains("ParseError") || !msg.is_empty(),
14196 "rejection must carry a reason; got {msg}"
14197 );
14198 assert!(
14200 !sandbox.exists(),
14201 "rejected input must not persist anything"
14202 );
14203 let mut saw_error = false;
14205 while let Ok(cmd) = rx.try_recv() {
14206 if let InlineCommand::AppendLine { kind, .. } = cmd
14207 && matches!(kind, InlineMessageKind::Error)
14208 {
14209 saw_error = true;
14210 }
14211 }
14212 assert!(saw_error, "rejection must surface an Error line");
14213 }
14214
14215 #[test]
14220 fn submenu_select_commit_for_sync_backlog() {
14221 let tmp = tempfile::tempdir().expect("tempdir");
14222 let sandbox = tmp.path().join("settings.json");
14223 let mut state = RenderState::default();
14224 state.settings_override_path = Some(sandbox.clone());
14225
14226 open_submenu_select_prompt(&mut state, SettingKey::AdvisorSyncBacklog);
14228 let overlay = state.overlay.as_ref().expect("submenu overlay opens");
14229 assert_eq!(overlay.items.len(), 3, "off/sync/async rows");
14230 let titles: Vec<&str> = overlay.items.iter().map(|i| i.title.as_str()).collect();
14231 assert_eq!(titles, vec!["off", "sync", "async"]);
14232 for item in &overlay.items {
14234 let sel = item.selection.as_ref().expect("row is selectable");
14235 match sel {
14236 InlineListSelection::ConfigAction(p) => {
14237 assert!(
14238 p.starts_with("SubmenuCommit:AdvisorSyncBacklog:"),
14239 "payload must address the key; got {p}"
14240 );
14241 }
14242 other => panic!("expected ConfigAction, got {other:?}"),
14243 }
14244 }
14245
14246 let status = commit_submenu_choice(
14248 &mut state,
14249 SettingKey::AdvisorSyncBacklog,
14250 "async".to_string(),
14251 )
14252 .expect("valid option commits");
14253 assert!(status.contains("async"), "status names the new value");
14254
14255 let contents = std::fs::read_to_string(&sandbox).expect("sandbox written");
14257 assert!(
14258 contents.contains("async"),
14259 "sandbox must hold the async choice: {contents}"
14260 );
14261 let overlay = state.overlay.as_ref().expect("panel reopened");
14263 assert!(
14264 overlay
14265 .lines
14266 .first()
14267 .map(|l| l.contains("async"))
14268 .unwrap_or(false),
14269 "status line must show the new value"
14270 );
14271 }
14272
14273 #[test]
14277 fn multiselect_toggles_tool_and_refuses_essential() {
14278 let session = make_session_with_tools();
14279 let tmp = tempfile::tempdir().expect("tempdir");
14280 let sandbox = tmp.path().join("settings.json");
14281 let mut state = RenderState::default();
14282 state.settings_override_path = Some(sandbox.clone());
14283 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
14284 let handle = InlineHandle::new_for_tests(tx);
14285
14286 open_disabled_tools_multiselect(&mut state, &session);
14289 let overlay = state.overlay.as_ref().expect("multiselect opens");
14290 let titles: Vec<&str> = overlay.items.iter().map(|i| i.title.as_str()).collect();
14291 assert_eq!(titles, vec!["bash", "commit"], "registry tools, sorted");
14292 let bash = &overlay.items[0];
14293 assert_eq!(bash.badge.as_deref(), Some("essential"));
14294 let commit = &overlay.items[1];
14295 assert_eq!(commit.badge.as_deref(), Some("enabled"));
14296
14297 commit_disabled_tool_toggle(
14299 &mut state,
14300 &handle,
14301 &session,
14302 "commit".to_string(),
14303 false, false, );
14306 let saved: Settings = serde_json::from_str(&std::fs::read_to_string(&sandbox).unwrap())
14307 .expect("sandbox parses");
14308 assert!(
14309 saved.disabled_tools.iter().any(|t| t == "commit"),
14310 "toggle must add 'commit' to disabled_tools; got {:?}",
14311 saved.disabled_tools
14312 );
14313
14314 commit_disabled_tool_toggle(
14316 &mut state,
14317 &handle,
14318 &session,
14319 "commit".to_string(),
14320 false, true, );
14323 let saved: Settings = serde_json::from_str(&std::fs::read_to_string(&sandbox).unwrap())
14324 .expect("sandbox parses");
14325 assert!(
14326 !saved.disabled_tools.iter().any(|t| t == "commit"),
14327 "toggle must remove 'commit' from disabled_tools; got {:?}",
14328 saved.disabled_tools
14329 );
14330
14331 let before = std::fs::read_to_string(&sandbox).unwrap();
14333 commit_disabled_tool_toggle(
14334 &mut state,
14335 &handle,
14336 &session,
14337 "bash".to_string(),
14338 true, false, );
14341 let after = std::fs::read_to_string(&sandbox).unwrap();
14342 assert_eq!(before, after, "essential refusal must not write");
14343 let mut saw_refusal = false;
14344 while let Ok(cmd) = rx.try_recv() {
14345 if let InlineCommand::AppendLine { kind, segments } = cmd
14346 && matches!(kind, InlineMessageKind::Error)
14347 && segments.iter().any(|s| s.text.contains("essential"))
14348 {
14349 saw_refusal = true;
14350 }
14351 }
14352 assert!(
14353 saw_refusal,
14354 "essential refusal must surface an Error line mentioning 'essential'"
14355 );
14356 }
14357}
14358
14359#[cfg(test)]
14363mod image_preview_hook_tests {
14364 use super::*;
14365 use base64::{Engine, engine::general_purpose};
14366 use tokio::sync::mpsc;
14367
14368 fn image_result_content(payload: &[u8]) -> String {
14371 let b64 = general_purpose::STANDARD.encode(payload);
14372 format!(
14373 "Generated 1 image(s).\n\nImage 1 ({} bytes, base64):\n{}\n",
14374 payload.len(),
14375 b64
14376 )
14377 }
14378
14379 fn fresh_handle() -> (InlineHandle, mpsc::UnboundedReceiver<InlineCommand>) {
14380 let (tx, rx) = mpsc::unbounded_channel();
14381 (InlineHandle::new_for_tests(tx), rx)
14382 }
14383
14384 fn apply_all(state: &mut RenderState, rx: &mut mpsc::UnboundedReceiver<InlineCommand>) {
14385 while let Ok(cmd) = rx.try_recv() {
14386 apply_command(state, cmd);
14387 }
14388 }
14389
14390 fn transcript_text(state: &RenderState) -> Vec<String> {
14391 state
14392 .transcript
14393 .iter()
14394 .map(|l| {
14395 l.segments
14396 .iter()
14397 .map(|s| s.text.as_str())
14398 .collect::<String>()
14399 })
14400 .collect()
14401 }
14402
14403 #[test]
14407 fn generate_image_result_renders_fallback_row_and_enqueues_live_preview() {
14408 let mut state = RenderState::default();
14409 let (handle, mut rx) = fresh_handle();
14410 let payload = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
14411
14412 map_agent_event(
14413 &handle,
14414 AgentEvent::ToolExecutionStart {
14415 tool_call_id: "img-1".into(),
14416 tool_name: "generate_image".into(),
14417 args: serde_json::json!({"prompt": "a cat"}),
14418 intent: None,
14419 context: None,
14420 },
14421 &mut state,
14422 );
14423 map_agent_event(
14424 &handle,
14425 AgentEvent::ToolExecutionEnd {
14426 tool_call_id: "img-1".into(),
14427 tool_name: "generate_image".into(),
14428 intent: None,
14429 result: oxicode_ai::ToolResult {
14430 tool_call_id: "img-1".into(),
14431 content: image_result_content(&payload),
14432 status: "success".into(),
14433 },
14434 is_error: false,
14435 },
14436 &mut state,
14437 );
14438 apply_all(&mut state, &mut rx);
14439
14440 let texts = transcript_text(&state);
14441 let fallback_idx = texts
14442 .iter()
14443 .position(|t| t.contains("[image: generate_image:"))
14444 .expect("fallback row rendered in the tool box");
14445 assert!(
14446 texts
14447 .iter()
14448 .all(|t| !t.contains(&general_purpose::STANDARD.encode(payload))),
14449 "raw base64 must never render as text"
14450 );
14451
14452 assert_eq!(
14453 state.image_previews.pending_len(),
14454 1,
14455 "decoded PNG enqueued for live placement"
14456 );
14457 let pending = &state.image_previews.pending()[0];
14458 assert_eq!(&*pending.png, &payload, "decoded bytes round-trip");
14459 assert!(
14462 texts[fallback_idx].contains(&pending.label),
14463 "label {label:?} matches the fallback row {row:?}",
14464 label = pending.label,
14465 row = texts[fallback_idx],
14466 );
14467 }
14468
14469 #[test]
14472 fn generate_image_without_payload_keeps_generic_preview() {
14473 let mut state = RenderState::default();
14474 let (handle, mut rx) = fresh_handle();
14475 map_agent_event(
14476 &handle,
14477 AgentEvent::ToolExecutionStart {
14478 tool_call_id: "img-2".into(),
14479 tool_name: "generate_image".into(),
14480 args: serde_json::json!({"prompt": "a cat"}),
14481 intent: None,
14482 context: None,
14483 },
14484 &mut state,
14485 );
14486 map_agent_event(
14487 &handle,
14488 AgentEvent::ToolExecutionEnd {
14489 tool_call_id: "img-2".into(),
14490 tool_name: "generate_image".into(),
14491 intent: None,
14492 result: oxicode_ai::ToolResult {
14493 tool_call_id: "img-2".into(),
14494 content: "Image generation completed but returned no images.".into(),
14495 status: "success".into(),
14496 },
14497 is_error: false,
14498 },
14499 &mut state,
14500 );
14501 apply_all(&mut state, &mut rx);
14502 let texts = transcript_text(&state);
14503 assert!(
14504 texts.iter().any(|t| t.contains("returned no images")),
14505 "generic preview path still renders the summary"
14506 );
14507 assert_eq!(state.image_previews.pending_len(), 0);
14508 }
14509
14510 #[test]
14514 fn live_frame_anchors_and_emits_kitty_sequence() {
14515 use crate::tui_vt::image_preview::{ImagePreviews, ImageSupport};
14516 use ratatui::{Terminal, backend::TestBackend};
14517
14518 let mut state = RenderState::default();
14519 state.image_previews = ImagePreviews::new(ImageSupport::Kitty);
14520 let (handle, mut rx) = fresh_handle();
14521 let payload = [0x89u8, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
14522 map_agent_event(
14523 &handle,
14524 AgentEvent::ToolExecutionStart {
14525 tool_call_id: "img-3".into(),
14526 tool_name: "generate_image".into(),
14527 args: serde_json::json!({"prompt": "a dog"}),
14528 intent: None,
14529 context: None,
14530 },
14531 &mut state,
14532 );
14533 map_agent_event(
14534 &handle,
14535 AgentEvent::ToolExecutionEnd {
14536 tool_call_id: "img-3".into(),
14537 tool_name: "generate_image".into(),
14538 intent: None,
14539 result: oxicode_ai::ToolResult {
14540 tool_call_id: "img-3".into(),
14541 content: image_result_content(&payload),
14542 status: "success".into(),
14543 },
14544 is_error: false,
14545 },
14546 &mut state,
14547 );
14548 apply_all(&mut state, &mut rx);
14549
14550 let backend = TestBackend::new(80, 24);
14553 let mut terminal = Terminal::new(backend).expect("backend");
14554 let (tx, _drain) = mpsc::unbounded_channel();
14555 terminal
14556 .draw(|frame| render_frame(frame, &state, &InlineHandle::new_for_tests(tx)))
14557 .expect("draw");
14558 let buf = terminal.backend().buffer().clone();
14559 let frame_text: String = (0..buf.area().height)
14560 .map(|y| {
14561 (0..buf.area().width)
14562 .filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string()))
14563 .collect::<String>()
14564 })
14565 .collect::<Vec<_>>()
14566 .join("\n");
14567 assert!(
14568 frame_text.contains("[image: generate_image:"),
14569 "live frame paints the fallback row"
14570 );
14571
14572 let seq = state.image_previews.emit_live(state.committed_entries);
14574 assert!(seq.contains("\x1b["));
14575 assert!(seq.contains("\x1b_Ga=t,f=100"), "transmit");
14576 assert!(seq.contains("a=p"), "placement");
14577 assert_eq!(state.image_previews.pending_len(), 0, "placed and consumed");
14578 }
14579
14580 #[test]
14582 fn extract_generated_png_parses_first_image_and_rejects_garbage() {
14583 let payload = vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
14584 assert_eq!(
14585 extract_generated_png(&image_result_content(&payload)),
14586 Some(payload),
14587 "first base64 blob after the marker decodes"
14588 );
14589 let first: Vec<u8> = (1u8..=8).collect();
14592 let second: Vec<u8> = (9u8..=16).collect();
14593 let two = format!(
14594 "Generated 2 image(s).\n\nImage 1 (8 bytes, base64):\n{}\n\nImage 2 (8 bytes, base64):\n{}\n",
14595 general_purpose::STANDARD.encode(&first),
14596 general_purpose::STANDARD.encode(&second),
14597 );
14598 assert_eq!(extract_generated_png(&two), Some(first));
14599 assert_eq!(extract_generated_png("plain text output"), None);
14601 assert_eq!(
14602 extract_generated_png("Image 1 (8 bytes, base64):\n!!!not-base64!!!\n"),
14603 None
14604 );
14605 assert_eq!(
14606 extract_generated_png("Image 1 (2 bytes, base64):\n AQID \n"),
14607 None,
14608 "payloads shorter than a PNG header are rejected"
14609 );
14610 }
14611}