1use super::handlers;
4use super::render;
5use super::slash;
6use super::welcome;
7use crate::app::agent_session::SessionEvent;
8use crate::app::agent_session_runtime::{
9 create_agent_session_from_services, create_agent_session_services,
10 CreateAgentSessionFromServicesOptions, CreateAgentSessionServicesOptions,
11};
12use crate::context::auto_compaction::CompactionReason;
13use crate::util::slash_commands::BUILTIN_SLASH_COMMANDS;
14use anyhow::Result;
15use oxi_agent::AgentEvent;
16use oxi_store::session::SessionManager;
17use oxi_tui::theme::Theme;
18use oxi_tui::widgets::{
19 chat::{ChatMessage, ChatViewState, ContentBlock, MessageRole},
20 footer::FooterState,
21 input::InputState,
22};
23use std::io::{self, Write};
24use std::panic;
25use std::sync::{atomic::Ordering, Arc};
26use tokio::sync::mpsc;
27
28use crossterm::{
29 cursor::Hide,
30 event::{
31 self, DisableBracketedPaste, EnableBracketedPaste, KeyboardEnhancementFlags,
32 PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
33 },
34 execute,
35 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
36};
37use ratatui::{backend::CrosstermBackend, Terminal};
38
39struct Tui {
44 terminal: Terminal<CrosstermBackend<io::Stdout>>,
45 tty_ok: bool,
46}
47
48impl Tui {
49 fn enter() -> Result<Self> {
50 Self::set_panic_hook();
52
53 let tty_ok = enable_raw_mode().is_ok();
54 let mut stdout = io::stdout();
55
56 if tty_ok {
57 let _ = execute!(
58 stdout,
59 EnterAlternateScreen,
60 Hide,
61 EnableBracketedPaste,
62 PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::REPORT_EVENT_TYPES)
63 );
64 let _ = stdout.write_all(b"\x1b[?1000h\x1b[?1006h");
68 let _ = stdout.flush();
69 }
70
71 let backend = CrosstermBackend::new(stdout);
72 let mut terminal = Terminal::new(backend)?;
73 if tty_ok {
74 let _ = terminal.clear();
75 }
76
77 Ok(Self { terminal, tty_ok })
78 }
79
80 fn exit(&mut self) -> Result<()> {
81 if self.tty_ok {
82 let _ = io::stdout().write_all(b"\x1b[?1000l\x1b[?1006l");
84 let _ = io::stdout().flush();
85 execute!(
87 self.terminal.backend_mut(),
88 PopKeyboardEnhancementFlags,
89 DisableBracketedPaste
90 )?;
91 self.terminal.show_cursor()?;
93 execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?;
95 disable_raw_mode()?;
97 }
98 Ok(())
99 }
100
101 fn set_panic_hook() {
102 let original_hook = panic::take_hook();
103 panic::set_hook(Box::new(move |panic_info| {
104 let _ = io::stdout().write_all(b"\x1b[?1000l\x1b[?1006l");
106 let _ = io::stdout().flush();
107 let _ = execute!(io::stdout(), LeaveAlternateScreen);
108 let _ = disable_raw_mode();
109 original_hook(panic_info);
110 }));
111 }
112}
113
114impl std::ops::Deref for Tui {
115 type Target = Terminal<CrosstermBackend<io::Stdout>>;
116 fn deref(&self) -> &Self::Target {
117 &self.terminal
118 }
119}
120
121impl std::ops::DerefMut for Tui {
122 fn deref_mut(&mut self) -> &mut Self::Target {
123 &mut self.terminal
124 }
125}
126
127impl Drop for Tui {
128 fn drop(&mut self) {
129 let _ = self.exit();
130 }
131}
132
133pub(crate) enum UiEvent {
136 AgentStart,
138 AgentEnd,
139
140 #[allow(dead_code)]
142 TurnStart {
143 #[allow(dead_code)]
144 turn_number: u32,
145 },
146 #[allow(dead_code)]
147 TurnEnd {
148 #[allow(dead_code)]
149 turn_number: u32,
150 },
151
152 MessageStart {
155 message: oxi_ai::Message,
156 },
157 MessageUpdate {
160 message: oxi_ai::Message,
161 delta: Option<String>,
162 },
163 MessageEnd {
165 message: oxi_ai::Message,
166 },
167
168 ToolExecutionStart {
170 tool_call_id: String,
171 tool_name: String,
172 args: serde_json::Value,
173 },
174 ToolExecutionEnd {
175 tool_call_id: String,
176 tool_name: String,
177 result: oxi_ai::ToolResult,
178 is_error: bool,
179 },
180
181 Thinking,
183 ThinkingDelta(String),
184 Complete,
185 Error(String),
186
187 CompactionStart {
189 reason: CompactionReason,
190 },
191 CompactionEnd {
192 _reason: CompactionReason,
193 error_message: Option<String>,
194 },
195 RetryStart {
196 attempt: u32,
197 max_attempts: u32,
198 error_message: String,
199 },
200 ModelChanged {
201 model_id: String,
202 },
203 ThinkingLevelChanged {
204 level: String,
205 },
206 QueueUpdate {
207 pending: usize,
208 },
209 TokenUsage {
211 input_tokens: u32,
212 output_tokens: u32,
213 cache_read_tokens: u32,
214 cache_write_tokens: u32,
215 context_window_pct: f32,
216 total_cost: f64,
217 },
218
219 AutoProcessStart {
223 prompt: String,
224 },
225}
226
227pub(super) const SPINNER: &[&str] = &["|", "/", "-", "\\"];
230
231#[derive(Debug, Clone)]
235pub(crate) enum SetupStep {
236 SelectAuthType {
238 auth_type: Option<String>, selected: usize,
240 },
241 SelectProvider {
243 providers: Vec<(String, bool)>, selected: usize,
245 },
246 EnterApiKey {
248 provider: String,
249 key: String,
250 #[allow(dead_code)]
251 masked_cursor: usize,
252 },
253 SelectModel {
255 provider: String,
256 models: Vec<String>,
257 selected: usize,
258 },
259 Done { provider: String, model: String },
261}
262
263#[derive(Debug, Clone)]
265pub(crate) enum AppOverlay {
266 Setup(SetupStep),
268 ModelSelect {
270 models: Vec<String>,
271 filter: String,
272 selected: usize,
273 },
274 ProviderConfig(SetupStep),
276 LogoutSelect {
278 providers: Vec<String>,
279 selected: usize,
280 },
281 ResumeSelect {
283 sessions: Vec<oxi_store::session::SessionInfo>,
284 selected: usize,
285 },
286 #[allow(dead_code)]
288 RoutingStatus {
289 data: oxi_tui::widgets::routing::RoutingStatusData,
290 visible: bool,
291 },
292}
293
294#[derive(Debug, Clone)]
298pub(crate) enum TuiNextAction {
299 SwitchSession(String),
301 NewSession,
303}
304
305pub(crate) struct AppState {
306 pub chat: ChatViewState,
307 pub input: InputState,
308 pub footer_state: FooterState,
309 pub is_agent_busy: bool,
310 pub spinner_frame: usize,
311 pub auto_scroll: bool,
312 pub input_history: Vec<String>,
313 pub history_index: usize,
314 pub saved_input: String,
315 pub slash_completions: Vec<slash::SlashCompletion>,
316 pub slash_completion_index: usize,
317 pub slash_completion_active: bool,
318 pub message_count: usize,
319 pub overlay: Option<AppOverlay>,
321 pub overlay_state: Option<Box<dyn super::overlay::OverlayComponent>>,
324 pub wasm_ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
326 pub session_file_path: Option<String>,
328 pub next_action: Option<TuiNextAction>,
330 pub pending_steering: usize,
332 pub needs_persist: bool,
334 snapshot_text_rendered: usize,
338 snapshot_thinking_rendered: Vec<usize>,
343 snapshot_text_block_created: bool,
350 questionnaire_bridge:
352 Option<std::sync::Arc<oxi_agent::tools::questionnaire::QuestionnaireBridge>>,
353 pub(crate) tool_start_times: std::collections::HashMap<String, std::time::Instant>,
355}
356
357impl AppState {
358 pub fn new() -> Self {
359 Self {
360 chat: ChatViewState::default(),
361 input: InputState::default(),
362 footer_state: FooterState::default(),
363 is_agent_busy: false,
364 spinner_frame: 0,
365 auto_scroll: true,
366 input_history: Vec::new(),
367 history_index: 0,
368 saved_input: String::new(),
369 slash_completions: Vec::new(),
370 slash_completion_index: 0,
371 slash_completion_active: false,
372 message_count: 0,
373 overlay: None,
374 overlay_state: None,
375 wasm_ext: None,
376 session_file_path: None,
377 next_action: None,
378 pending_steering: 0,
379 needs_persist: false,
380 snapshot_text_rendered: 0,
381 snapshot_thinking_rendered: Vec::new(),
382 snapshot_text_block_created: false,
383 questionnaire_bridge: None,
384 tool_start_times: std::collections::HashMap::new(),
385 }
386 }
387
388 pub fn input_value(&self) -> String {
391 self.input.text()
392 }
393
394 pub fn input_clear(&mut self) {
395 self.input.clear();
396 self.clear_slash_completions();
397 }
398
399 pub fn input_set_text(&mut self, text: String) {
400 self.input.set_text(text);
401 }
402
403 pub fn clear_slash_completions(&mut self) {
404 self.slash_completions.clear();
405 self.slash_completion_index = 0;
406 self.slash_completion_active = false;
407 }
408
409 pub fn update_slash_completions(&mut self) {
410 let input_str = self.input_value();
411 let text = input_str.trim();
412 if !text.starts_with('/') || text.contains(' ') {
413 self.clear_slash_completions();
414 return;
415 }
416 let cmd_part = text.split_whitespace().next().unwrap_or("");
417 let query = if cmd_part.len() > 1 {
418 &cmd_part[1..]
419 } else {
420 ""
421 };
422 let mut matches: Vec<slash::SlashCompletion> = BUILTIN_SLASH_COMMANDS
423 .iter()
424 .filter(|cmd| query.is_empty() || cmd.name.starts_with(query))
425 .map(|cmd| slash::SlashCompletion {
426 name: format!("/{}", cmd.name),
427 description: cmd.description.to_string(),
428 })
429 .collect();
430 matches.sort_by(|a, b| a.name.cmp(&b.name));
431 self.slash_completions = matches;
432 self.slash_completion_index = 0;
433 self.slash_completion_active = !self.slash_completions.is_empty();
434 }
435
436 pub fn selected_slash_command(&self) -> Option<&slash::SlashCompletion> {
438 if !self.slash_completion_active || self.slash_completions.is_empty() {
439 return None;
440 }
441 self.slash_completions.get(self.slash_completion_index)
442 }
443
444 pub fn next_slash_completion(&mut self) {
445 if !self.slash_completions.is_empty() {
446 self.slash_completion_index =
447 (self.slash_completion_index + 1) % self.slash_completions.len();
448 }
449 }
450
451 pub fn prev_slash_completion(&mut self) {
452 if !self.slash_completions.is_empty() {
453 if self.slash_completion_index == 0 {
454 self.slash_completion_index = self.slash_completions.len() - 1;
455 } else {
456 self.slash_completion_index -= 1;
457 }
458 }
459 }
460
461 pub fn add_user_message(&mut self, content: String) {
464 self.chat.add_message(ChatMessage {
465 role: MessageRole::User,
466 content_blocks: vec![ContentBlock::Text { content }],
467 timestamp: now_millis(),
468 });
469 self.message_count += 1;
470 }
471
472 pub fn add_system_message(&mut self, content: String) {
473 self.chat.add_message(ChatMessage {
474 role: MessageRole::System,
475 content_blocks: vec![ContentBlock::Text { content }],
476 timestamp: now_millis(),
477 });
478 }
479
480 pub fn start_streaming(&mut self) {
481 self.chat.start_streaming();
482 self.is_agent_busy = true;
483 self.auto_scroll = true;
484 self.snapshot_text_rendered = 0;
485 self.snapshot_thinking_rendered.clear();
486 self.snapshot_text_block_created = false;
487 }
488
489 #[allow(dead_code)]
490 pub fn stream_text_delta(&mut self, _delta: &str) {}
491
492 pub fn update_streaming_message(&mut self, msg: &oxi_ai::Message, _delta: Option<&str>) {
500 if let oxi_ai::Message::Assistant(assistant) = msg {
501 let mut thinking_block_idx: usize = 0;
502 for block in &assistant.content {
503 match block {
504 oxi_ai::ContentBlock::Text(t) => {
505 let text = &t.text;
510 if text.len() >= self.snapshot_text_rendered {
515 let byte_off = text
519 .char_indices()
520 .map(|(i, _)| i)
521 .find(|&i| i >= self.snapshot_text_rendered)
522 .unwrap_or(text.len());
523 let new_text = &text[byte_off..];
524 if !new_text.is_empty() {
525 self.chat.stream_text_delta(new_text);
526 }
527 self.snapshot_text_rendered = text.len();
528 if !text.is_empty() {
529 self.snapshot_text_block_created = true;
530 }
531 }
532 }
533 oxi_ai::ContentBlock::ToolCall(tc) => {
534 let args_str = serde_json::to_string(&tc.arguments)
536 .unwrap_or_else(|_| tc.arguments.to_string());
537 self.chat.stream_tool_call(
538 tc.id.clone(),
539 tc.name.clone(),
540 args_str,
541 oxi_tui::widgets::chat::ToolCallStatus::Requested,
542 );
543 }
544 oxi_ai::ContentBlock::Thinking(t) => {
545 let thinking = &t.thinking;
550 while self.snapshot_thinking_rendered.len() <= thinking_block_idx {
551 self.snapshot_thinking_rendered.push(0);
552 }
553 if thinking.len() > self.snapshot_thinking_rendered[thinking_block_idx] {
554 let prev = self.snapshot_thinking_rendered[thinking_block_idx];
555 let byte_off = thinking
556 .char_indices()
557 .map(|(i, _)| i)
558 .find(|&i| i >= prev)
559 .unwrap_or(thinking.len());
560 let new_thinking = &thinking[byte_off..];
561 if !new_thinking.is_empty() {
562 self.chat.stream_thinking(new_thinking.to_string(), false);
563 }
564 self.snapshot_thinking_rendered[thinking_block_idx] = thinking.len();
565 }
566 thinking_block_idx += 1;
567 }
568 oxi_ai::ContentBlock::Image(img) => {
569 self.chat
570 .stream_image(img.mime_type.clone(), img.data.clone());
571 }
572 oxi_ai::ContentBlock::Unknown(_) => {}
573 }
574 }
575 }
576 }
577
578 pub fn finalize_streaming_message(&mut self, msg: &oxi_ai::Message) {
580 if let oxi_ai::Message::Assistant(assistant) = msg {
581 let usage = &assistant.usage;
583 tracing::info!(
584 "[TOKENS] input={} output={} cache_read={} cache_write={} total={}",
585 usage.input,
586 usage.output,
587 usage.cache_read,
588 usage.cache_write,
589 usage.total_tokens
590 );
591 let context_window_pct = if usage.total_tokens > 0 {
592 (usage.total_tokens as f32 / 200_000.0) * 100.0
593 } else if usage.input > 0 || usage.output > 0 {
594 ((usage.input + usage.output + usage.cache_read + usage.cache_write) as f32
595 / 200_000.0)
596 * 100.0
597 } else {
598 0.0
599 };
600 self.footer_state.data.input_tokens = usage.input as u32;
601 self.footer_state.data.output_tokens = usage.output as u32;
602 self.footer_state.data.cache_read_tokens = usage.cache_read as u32;
603 self.footer_state.data.cache_write_tokens = usage.cache_write as u32;
604 self.footer_state.data.context_window_pct = context_window_pct;
605 self.footer_state.data.total_cost = usage.cost.total();
606 self.footer_state.data.context_tokens =
607 (usage.input + usage.output + usage.cache_read + usage.cache_write) as u32;
608 }
609 }
610
611 pub fn finish_streaming(&mut self) {
612 let was_streaming = self.chat.is_streaming();
613 self.chat.finish_streaming();
614 self.is_agent_busy = false;
615 self.snapshot_text_rendered = 0;
616 self.snapshot_thinking_rendered.clear();
617 self.snapshot_text_block_created = false;
618 if was_streaming {
619 self.message_count += 1;
620 self.chat.refresh_last_code_block();
622 }
623 }
624
625 pub fn cancel_streaming(&mut self) {
626 if self.chat.is_streaming() {
627 self.chat.finish_streaming();
628 self.message_count += 1;
629 }
630 self.is_agent_busy = false;
631 }
632
633 pub fn scroll_up(&mut self, n: u16) {
634 self.chat.scroll_up(n);
635 self.auto_scroll = false;
636 }
637
638 pub fn scroll_down(&mut self, n: u16) {
639 self.chat.scroll_down(n);
640 }
641
642 pub fn ensure_auto_scroll(&mut self, visible_height: u16) {
643 if self.auto_scroll {
644 self.chat.scroll_to_bottom(visible_height);
645 }
646 }
647
648 pub fn messages(&self) -> &[ChatMessage] {
649 &self.chat.messages
650 }
651}
652
653fn now_millis() -> i64 {
654 std::time::SystemTime::now()
655 .duration_since(std::time::UNIX_EPOCH)
656 .unwrap_or_default()
657 .as_millis() as i64
658}
659
660fn get_agent_runtime() -> &'static tokio::runtime::Runtime {
666 use std::sync::OnceLock;
667 static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
668 RUNTIME.get_or_init(|| {
669 tokio::runtime::Builder::new_multi_thread()
670 .worker_threads(2)
671 .enable_all()
672 .build()
673 .expect("Failed to build agent runtime")
674 })
675}
676
677pub async fn run_tui_interactive(app: crate::App) -> Result<()> {
681 run_tui_interactive_impl(app, false).await
682}
683
684pub async fn run_tui_interactive_with_continue(app: crate::App, resume_last: bool) -> Result<()> {
686 run_tui_interactive_impl(app, resume_last).await
687}
688
689async fn run_tui_interactive_impl(app: crate::App, resume_last: bool) -> Result<()> {
690 let settings = app.settings().clone();
692 let mut model_id = app.model_id();
693 let tools = app.agent().tools();
694 let wasm_ext = app.wasm_ext().cloned();
695 let questionnaire_bridge = app.questionnaire_bridge().cloned();
696 let cwd: String = std::env::current_dir()
697 .map(|p| p.to_string_lossy().into_owned())
698 .unwrap_or_else(|_| ".".to_string());
699 let cwd_path = std::env::current_dir().unwrap_or_default();
700 let git_branch = crate::util::git_utils::get_current_branch(&cwd_path);
701
702 let mut session_target: Option<String> = if resume_last {
704 oxi_store::session::find_recent_session_path(&cwd)
705 } else {
706 None
707 };
708
709 let mut tui = Tui::enter()?;
711 let theme = Theme::dark();
712
713 loop {
715 let is_resuming = session_target.is_some();
716
717 let session_manager = match &session_target {
718 Some(path) => SessionManager::open(path, None, Some(&cwd)),
719 None => SessionManager::create(&cwd, None),
720 };
721
722 let services = create_agent_session_services(CreateAgentSessionServicesOptions::new(
723 cwd_path.clone(),
724 ))?;
725 let services = Arc::new(services);
726
727 let create_result =
728 create_agent_session_from_services(CreateAgentSessionFromServicesOptions {
729 services: services.clone(),
730 session_manager,
731 model_id: Some(model_id.clone()),
732 thinking_level: Some(settings.thinking_level),
733 scoped_models: Vec::new(),
734 tool_registry: Some(tools.clone()),
735 })?;
736
737 let agent_session = create_result.session;
738 if let Some(msg) = create_result.model_fallback_message {
739 tracing::warn!("Model fallback: {}", msg);
740 }
741
742 let (session_event_tx, mut session_event_rx) = mpsc::unbounded_channel::<SessionEvent>();
743 agent_session.subscribe(Box::new(move |event| {
744 let _ = session_event_tx.send(event.clone());
745 }));
746
747 let (ui_tx, mut ui_rx) = mpsc::unbounded_channel::<UiEvent>();
748 let (prompt_tx, mut prompt_rx) = mpsc::channel::<String>(16);
749
750 let session_handle = agent_session.clone_handle();
752 let prompt_tx_worker = prompt_tx.clone();
755 let ui_tx_for_thread = ui_tx.clone();
756 let agent_handle = std::thread::spawn(move || {
757 let rt = get_agent_runtime();
758 rt.block_on(async {
759 let local = tokio::task::LocalSet::new();
760 local
761 .run_until(async {
762 while let Some(prompt) = prompt_rx.recv().await {
763 tracing::info!("[TUI] Received prompt, starting agent run");
764 let (event_tx, event_rx) = std::sync::mpsc::channel::<AgentEvent>();
765 let ui_fwd = ui_tx_for_thread.clone();
766 let session_h = session_handle.clone_handle();
767 let forwarder_handle = std::thread::spawn(move || {
770 let mut event_count = 0u32;
771 tracing::info!("[FORWARDER] Thread started, waiting for events");
772 while let Ok(event) = event_rx.recv() {
773 event_count += 1;
774 tracing::info!(
775 "[FORWARDER] Event #{}: {:?}",
776 event_count,
777 std::mem::discriminant(&event)
778 );
779 let ui_event = match event {
780 AgentEvent::AgentStart { .. } => UiEvent::AgentStart,
782 AgentEvent::AgentEnd { .. } => UiEvent::AgentEnd,
783
784 AgentEvent::TurnStart { turn_number } => {
786 UiEvent::TurnStart { turn_number }
787 }
788 AgentEvent::TurnEnd { turn_number, .. } => {
789 UiEvent::TurnEnd { turn_number }
790 }
791
792 AgentEvent::MessageStart { message } => {
796 UiEvent::MessageStart { message }
797 }
798 AgentEvent::MessageUpdate { message, delta } => {
799 UiEvent::MessageUpdate { message, delta }
800 }
801 AgentEvent::MessageEnd { message } => {
802 UiEvent::MessageEnd { message }
803 }
804
805 AgentEvent::ToolExecutionStart {
807 tool_call_id,
808 tool_name,
809 args,
810 } => UiEvent::ToolExecutionStart {
811 tool_call_id,
812 tool_name,
813 args,
814 },
815 AgentEvent::ToolExecutionEnd {
816 tool_call_id,
817 tool_name,
818 result,
819 is_error,
820 } => UiEvent::ToolExecutionEnd {
821 tool_call_id,
822 tool_name,
823 result,
824 is_error,
825 },
826
827 AgentEvent::ToolStart {
830 tool_call_id,
831 tool_name,
832 arguments,
833 } => UiEvent::ToolExecutionStart {
834 tool_call_id,
835 tool_name,
836 args: arguments,
837 },
838 AgentEvent::ToolComplete { result } => {
839 UiEvent::ToolExecutionEnd {
840 tool_call_id: result.tool_call_id.clone(),
841 tool_name: String::new(),
842 result,
843 is_error: false, }
845 }
846 AgentEvent::ToolError {
847 tool_call_id,
848 error,
849 } => UiEvent::ToolExecutionEnd {
850 tool_call_id,
851 tool_name: String::new(),
852 result: oxi_ai::ToolResult {
853 tool_call_id: String::new(),
854 content: error,
855 status: "error".to_string(),
856 },
857 is_error: true,
858 },
859
860 AgentEvent::Start { .. } => {
864 continue;
867 }
868 AgentEvent::Thinking => UiEvent::Thinking,
869 AgentEvent::ThinkingDelta { text } => {
870 UiEvent::ThinkingDelta(text)
871 }
872 AgentEvent::TextChunk { .. } => {
873 continue;
877 }
878 AgentEvent::ToolCall { .. } => {
879 continue;
882 }
883
884 AgentEvent::Complete { .. } => UiEvent::Complete,
886 AgentEvent::Error { message, .. } => {
887 UiEvent::Error(message)
888 }
889
890 AgentEvent::Usage {
892 input_tokens,
893 output_tokens,
894 } => {
895 let _ = ui_fwd.send(UiEvent::TokenUsage {
896 input_tokens: input_tokens as u32,
897 output_tokens: output_tokens as u32,
898 cache_read_tokens: 0,
899 cache_write_tokens: 0,
900 context_window_pct: 0.0,
901 total_cost: 0.0,
902 });
903 continue;
904 }
905
906 AgentEvent::SteeringMessage { .. } => {
908 let steering_q = session_h.steering_queue();
911 let follow_up_q = session_h.follow_up_queue();
912 let pending =
913 steering_q.read().len() + follow_up_q.read().len();
914 let _ = ui_fwd.send(UiEvent::QueueUpdate { pending });
915 continue;
916 }
917 AgentEvent::FollowUpMessage { .. } => {
918 let steering_q = session_h.steering_queue();
919 let follow_up_q = session_h.follow_up_queue();
920 let pending =
921 steering_q.read().len() + follow_up_q.read().len();
922 let _ = ui_fwd.send(UiEvent::QueueUpdate { pending });
923 continue;
924 }
925
926 _ => continue,
928 };
929 tracing::info!("[FORWARDER] Sending UiEvent to ui_fwd");
930 if ui_fwd.send(ui_event).is_err() {
931 tracing::warn!("[FORWARDER] ui_fwd send failed, breaking");
932 break;
933 }
934 tracing::info!("[FORWARDER] UiEvent sent successfully");
935 }
936 tracing::info!("[FORWARDER] Event loop ended");
937 });
938 let sh = session_handle.clone_handle();
939 let agent = sh.agent_ref();
940 sh.reset_should_stop();
941 let steering_q = sh.steering_queue();
942 let follow_up_q = sh.follow_up_queue();
943 let should_stop_flag = sh.should_stop_flag();
944 let hooks = oxi_agent::AgentHooks {
945 should_stop_after_turn: Some(Arc::new(move |_ctx| {
946 should_stop_flag.load(Ordering::SeqCst)
947 })),
948 get_steering_messages: Some(Box::new(move || {
949 steering_q.write().drain(..).collect::<Vec<String>>()
950 })),
951 get_follow_up_messages: Some(Box::new(move || {
952 follow_up_q.write().drain(..).collect::<Vec<String>>()
953 })),
954 tool_execution: oxi_agent::ToolExecutionMode::Sequential,
955 ..Default::default()
956 };
957 agent.set_hooks(hooks);
958 let agent_clone = Arc::clone(&agent);
959 tracing::info!("[AGENT-WORKER] Spawning agent task");
960 let agent_handle = tokio::task::spawn_local(async move {
961 tracing::info!(
962 "[AGENT-WORKER] Agent task started, calling run_with_channel"
963 );
964 let result = agent_clone.run_with_channel(prompt, event_tx).await;
965 if let Err(ref e) = result {
966 tracing::error!("Agent run_with_channel error: {:?}", e);
967 }
968 tracing::info!(
969 "[AGENT-WORKER] Agent run_with_channel completed: {:?}",
970 result
971 );
972 result
973 });
974 let _ = agent_handle.await;
977 let _ = forwarder_handle; let first_pending: Option<String> = {
995 let sq = session_handle.steering_queue();
996 let fq = session_handle.follow_up_queue();
997 let mut sq_guard = sq.write();
998 let mut fq_guard = fq.write();
999 sq_guard.pop_front().or_else(|| fq_guard.pop_front())
1001 };
1002 if let Some(msg) = first_pending {
1003 let remaining = session_handle.pending_message_count();
1004 tracing::info!(
1005 "[AGENT-WORKER] Auto-processing queued message ({} remaining)",
1006 remaining
1007 );
1008 let _ = ui_tx_for_thread.send(UiEvent::QueueUpdate {
1009 pending: remaining,
1010 });
1011 let _ = ui_tx_for_thread.send(UiEvent::AutoProcessStart {
1013 prompt: msg.clone(),
1014 });
1015 let _ = prompt_tx_worker.send(msg).await;
1016 }
1017 }
1018 })
1019 .await;
1020 });
1021 });
1022
1023 let mut state = AppState::new();
1025 state.session_file_path = session_target.clone();
1026
1027 if is_resuming {
1029 if let Some(ref path) = session_target {
1030 let sm = oxi_store::session::SessionManager::open(path, None, Some(&cwd));
1031 let branch = sm.get_branch(None);
1032 for entry in &branch {
1033 match &entry.message {
1034 oxi_store::session::AgentMessage::User { content } => {
1035 state.add_user_message(content.as_str().to_string());
1036 }
1037 oxi_store::session::AgentMessage::Assistant { content, .. } => {
1038 let text: String = content
1039 .iter()
1040 .filter_map(|b| match b {
1041 oxi_store::session::AssistantContentBlock::Text { text } => {
1042 Some(text.as_str())
1043 }
1044 _ => None,
1045 })
1046 .collect::<Vec<_>>()
1047 .join("");
1048 if !text.is_empty() {
1049 state.add_system_message(text);
1050 }
1051 }
1052 _ => {}
1053 }
1054 }
1055 }
1056 }
1057
1058 state.footer_state.data.pwd = Some(cwd.clone());
1060 state.footer_state.data.model_name = model_id.clone();
1061 state.footer_state.data.git_branch = git_branch.clone();
1062 state.footer_state.data.provider_name =
1063 model_id.split('/').next().unwrap_or("").to_string();
1064 state.footer_state.data.version = env!("CARGO_PKG_VERSION").to_string();
1065 state.footer_state.data.thinking_level =
1066 Some(format!("{:?}", settings.thinking_level).to_lowercase());
1067 state.wasm_ext = wasm_ext.clone();
1068 state.questionnaire_bridge = questionnaire_bridge.clone();
1069
1070 if !is_resuming {
1072 let tool_labels: Vec<(String, String)> = {
1073 let registry = tools.clone();
1074 let names = registry.names();
1075 names
1076 .iter()
1077 .filter_map(|name| {
1078 registry
1079 .get(name)
1080 .map(|t| (name.clone(), t.label().to_string()))
1081 })
1082 .collect()
1083 };
1084 let tool_names: Vec<String> = tool_labels.iter().map(|(n, _)| n.clone()).collect();
1085 let skill_names: Vec<String> = {
1086 let sm = crate::skills::SkillManager::load_from_dir(
1087 &crate::skills::SkillManager::skills_dir()
1088 .unwrap_or_else(|_| std::path::PathBuf::from("/dev/null")),
1089 )
1090 .unwrap_or_else(|_| crate::skills::SkillManager::new());
1091 sm.all().iter().map(|s| s.name.clone()).collect()
1092 };
1093 let agents_md_path = welcome::detect_agents_md(&cwd_path);
1094 let project_name = cwd_path
1095 .file_name()
1096 .map(|n| n.to_string_lossy().into_owned())
1097 .unwrap_or_else(|| ".".to_string());
1098 let welcome_info = welcome::WelcomeInfo {
1099 model_id: model_id.clone(),
1100 thinking_level: format!("{:?}", settings.thinking_level).to_lowercase(),
1101 tool_names,
1102 tool_labels,
1103 skill_names,
1104 agents_md_path,
1105 session_type: "new",
1106 git_branch: git_branch.clone(),
1107 project_name,
1108 };
1109 state.chat.add_message(oxi_tui::widgets::chat::ChatMessage {
1110 role: oxi_tui::widgets::chat::MessageRole::System,
1111 content_blocks: vec![oxi_tui::widgets::chat::ContentBlock::Dashboard {
1112 info: welcome::build_dashboard_info(&welcome_info),
1113 }],
1114 timestamp: std::time::SystemTime::now()
1115 .duration_since(std::time::UNIX_EPOCH)
1116 .unwrap_or_default()
1117 .as_millis() as i64,
1118 });
1119 }
1120
1121 let has_model = !model_id.is_empty() && model_id.contains('/');
1123 if !has_model {
1124 let auth = oxi_store::auth_storage::shared_auth_storage();
1125 let providers: Vec<(String, bool)> = oxi_ai::register_builtins::get_builtin_providers()
1126 .iter()
1127 .map(|builtin| {
1128 (
1129 builtin.name.to_string(),
1130 auth.get_api_key(builtin.name).is_some(),
1131 )
1132 })
1133 .collect();
1134 state.overlay = Some(AppOverlay::Setup(SetupStep::SelectProvider {
1135 providers,
1136 selected: 0,
1137 }));
1138 }
1139
1140 let mut running = true;
1142 let mut last_spinner_tick = std::time::Instant::now();
1143 let session_start = std::time::Instant::now();
1144 let poll_timeout = std::time::Duration::from_millis(50);
1145
1146 while running {
1147 let now = std::time::Instant::now();
1148 if now.duration_since(last_spinner_tick).as_millis() >= 80 {
1149 state.spinner_frame = (state.spinner_frame + 1) % SPINNER.len();
1150 state.chat.spinner_frame = state.spinner_frame;
1151 last_spinner_tick = now;
1152 }
1153
1154 state.footer_state.data.session_duration_secs = session_start.elapsed().as_secs();
1156
1157 tui.draw(|f| render::draw(f, &mut state, &theme))?;
1158
1159 if event::poll(poll_timeout)? {
1160 if let Some(action) = handlers::handle_input(
1161 event::read()?,
1162 &mut state,
1163 &agent_session,
1164 &ui_tx,
1165 &prompt_tx,
1166 &mut running,
1167 )
1168 .await
1169 {
1170 match action {
1171 handlers::Action::SendPrompt(value) => {
1172 tracing::info!(
1173 "[TUI] SendPrompt action triggered: {:?}",
1174 &value[..value
1175 .char_indices()
1176 .take(50)
1177 .last()
1178 .map(|(i, c)| i + c.len_utf8())
1179 .unwrap_or(0)]
1180 );
1181 state.add_user_message(value.clone());
1182 state.input_history.insert(0, value.clone());
1183 if state.input_history.len() > 100 {
1184 state.input_history.remove(0);
1185 }
1186 state.history_index = 0;
1187 state.start_streaming();
1188 agent_session.reset_should_stop();
1189 tracing::info!("[TUI] About to send prompt to channel");
1190 let _ = prompt_tx.send(value).await;
1191 tracing::info!("[TUI] Prompt sent to channel");
1192 state.input_clear();
1193 }
1194 handlers::Action::ExecuteSlashCommand(cmd) => {
1195 slash::handle_slash_command(
1196 &cmd,
1197 &agent_session,
1198 &mut state,
1199 &mut running,
1200 );
1201 }
1202 }
1203 }
1204 }
1205
1206 if state.overlay.is_none() && state.overlay_state.is_none() {
1208 if let Some(bridge) = &state.questionnaire_bridge {
1209 if let Some(pending) = bridge.try_take() {
1210 use super::overlay::questionnaire::QuestionnaireOverlay;
1211 state.overlay_state = Some(Box::new(QuestionnaireOverlay::new(
1212 pending.questions,
1213 pending.responder,
1214 )));
1215 tracing::info!("[TUI] Questionnaire overlay opened");
1216 }
1217 }
1218 }
1219
1220 if state.next_action.is_some() {
1222 running = false;
1223 }
1224
1225 while let Ok(ui_event) = ui_rx.try_recv() {
1226 handlers::handle_ui_event(ui_event, &mut state);
1227 if state.needs_persist {
1229 agent_session.persist();
1230 state.needs_persist = false;
1231 }
1232 }
1233 while let Ok(session_event) = session_event_rx.try_recv() {
1234 handlers::handle_session_event(session_event, &ui_tx).await;
1235 }
1236
1237 let chat_visible_height = {
1238 let size = tui.size()?;
1239 size.height.saturating_sub(5)
1240 };
1241 state.ensure_auto_scroll(chat_visible_height);
1242 }
1243
1244 let next_action = state.next_action.take();
1246 drop(prompt_tx);
1247 let _ = agent_handle.join();
1248
1249 match next_action {
1250 Some(TuiNextAction::SwitchSession(path)) => {
1251 tracing::info!("Switching to session: {}", path);
1252 session_target = Some(path);
1253 continue;
1254 }
1255 Some(TuiNextAction::NewSession) => {
1256 tracing::info!("Starting new session");
1257 if let Ok(fresh) = oxi_store::settings::Settings::load() {
1259 if let Some(m) = fresh.effective_model(None) {
1260 if !m.is_empty() {
1261 model_id = if m.contains('/') {
1263 m
1264 } else {
1265 let p = fresh.effective_provider(None).unwrap_or_default();
1266 format!("{}/{}", p, m)
1267 };
1268 }
1269 }
1270 }
1271 session_target = None;
1272 continue;
1273 }
1274 None => break,
1275 }
1276 }
1277
1278 tui.exit()?;
1279 Ok(())
1280}