1use std::collections::{HashSet, VecDeque};
6use std::io::{self, Stdout};
7use std::path::PathBuf;
8use std::sync::Arc;
9use std::time::Duration;
10
11use crate::error::{Error, Result};
12use crate::tui::commands::registry::CommandRegistry;
13use crate::tui::model::{ChatItem, NoticeLevel};
14use crate::tui::theme::Theme;
15use ratatui::backend::CrosstermBackend;
16use ratatui::crossterm::event::{
17 self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyEventKind, MouseEvent,
18 PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
19};
20use ratatui::crossterm::execute;
21use ratatui::crossterm::terminal::{
22 EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
23};
24use ratatui::crossterm::{
25 event::{DisableMouseCapture, EnableMouseCapture},
26 terminal::SetTitle,
27};
28use ratatui::{Frame, Terminal};
29use steer_core::api::Model;
30use steer_core::app::conversation::{AssistantContent, Message, MessageData};
31use steer_core::app::io::{AppCommandSink, AppEventSource};
32use steer_core::app::{AppCommand, AppEvent};
33use steer_core::config::LlmConfigProvider;
34use steer_tools::schema::ToolCall;
35use tokio::sync::mpsc;
36use tokio::task::JoinHandle;
37use tracing::{debug, error, info, warn};
38
39use crate::tui::auth_controller::AuthController;
40use crate::tui::events::pipeline::EventPipeline;
41use crate::tui::events::processors::message::MessageEventProcessor;
42use crate::tui::events::processors::processing_state::ProcessingStateProcessor;
43use crate::tui::events::processors::system::SystemEventProcessor;
44use crate::tui::events::processors::tool::ToolEventProcessor;
45use crate::tui::state::SetupState;
46use crate::tui::state::{ChatStore, ToolCallRegistry};
47
48use crate::tui::chat_viewport::ChatViewport;
49use crate::tui::ui_layout::UiLayout;
50use crate::tui::widgets::InputPanel;
51
52pub mod commands;
53pub mod custom_commands;
54pub mod model;
55pub mod state;
56pub mod theme;
57pub mod widgets;
58
59mod auth_controller;
60mod chat_viewport;
61mod events;
62mod handlers;
63mod ui_layout;
64
65const SPINNER_UPDATE_INTERVAL: Duration = Duration::from_millis(100);
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum InputMode {
71 Simple,
73 VimNormal,
75 VimInsert,
77 BashCommand,
79 AwaitingApproval,
81 ConfirmExit,
83 EditMessageSelection,
85 FuzzyFinder,
87 Setup,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq)]
93enum VimOperator {
94 Delete,
95 Change,
96 Yank,
97}
98
99#[derive(Debug, Default)]
101struct VimState {
102 pending_operator: Option<VimOperator>,
104 pending_g: bool,
106 replace_mode: bool,
108 visual_mode: bool,
110}
111
112pub struct Tui {
114 terminal: Terminal<CrosstermBackend<Stdout>>,
116 terminal_size: (u16, u16),
117 input_mode: InputMode,
119 input_panel_state: crate::tui::widgets::input_panel::InputPanelState,
121 editing_message_id: Option<String>,
123 command_sink: Arc<dyn AppCommandSink>,
125 is_processing: bool,
127 progress_message: Option<String>,
129 spinner_state: usize,
131 current_tool_approval: Option<ToolCall>,
133 current_model: Model,
135 event_pipeline: EventPipeline,
137 chat_store: ChatStore,
139 tool_registry: ToolCallRegistry,
141 chat_viewport: ChatViewport,
143 session_id: String,
145 theme: Theme,
147 setup_state: Option<SetupState>,
149 auth_controller: Option<AuthController>,
151 in_flight_operations: HashSet<uuid::Uuid>,
153 command_registry: CommandRegistry,
155 preferences: steer_core::preferences::Preferences,
157 double_tap_tracker: crate::tui::state::DoubleTapTracker,
159 vim_state: VimState,
161 mode_stack: VecDeque<InputMode>,
163 last_revision: u64,
165}
166
167const MAX_MODE_DEPTH: usize = 8;
168
169impl Tui {
170 fn push_mode(&mut self) {
172 if self.mode_stack.len() == MAX_MODE_DEPTH {
173 self.mode_stack.pop_front(); }
175 self.mode_stack.push_back(self.input_mode);
176 }
177
178 fn pop_mode(&mut self) -> Option<InputMode> {
180 self.mode_stack.pop_back()
181 }
182
183 pub fn switch_mode(&mut self, new_mode: InputMode) {
185 if self.input_mode != new_mode {
186 debug!(
187 "Switching mode from {:?} to {:?}",
188 self.input_mode, new_mode
189 );
190 self.push_mode();
191 self.input_mode = new_mode;
192 }
193 }
194
195 pub fn set_mode(&mut self, new_mode: InputMode) {
197 debug!("Setting mode from {:?} to {:?}", self.input_mode, new_mode);
198 self.input_mode = new_mode;
199 }
200
201 pub fn restore_previous_mode(&mut self) {
203 self.input_mode = self.pop_mode().unwrap_or_else(|| self.default_input_mode());
204 }
205
206 fn default_input_mode(&self) -> InputMode {
208 match self.preferences.ui.editing_mode {
209 steer_core::preferences::EditingMode::Simple => InputMode::Simple,
210 steer_core::preferences::EditingMode::Vim => InputMode::VimNormal,
211 }
212 }
213
214 fn is_text_input_mode(&self) -> bool {
216 matches!(
217 self.input_mode,
218 InputMode::Simple
219 | InputMode::VimInsert
220 | InputMode::BashCommand
221 | InputMode::Setup
222 | InputMode::FuzzyFinder
223 )
224 }
225 pub async fn new(
227 command_sink: Arc<dyn AppCommandSink>,
228 _event_source: Arc<dyn AppEventSource>,
229 current_model: Model,
230 session_id: String,
231 theme: Option<Theme>,
232 ) -> Result<Self> {
233 enable_raw_mode()?;
234 let mut stdout = io::stdout();
235 execute!(
236 stdout,
237 EnterAlternateScreen,
238 EnableBracketedPaste,
239 PushKeyboardEnhancementFlags(
240 ratatui::crossterm::event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
241 ),
242 EnableMouseCapture,
243 SetTitle("Steer")
244 )?;
245
246 let backend = CrosstermBackend::new(stdout);
247 let terminal = Terminal::new(backend)?;
248 let terminal_size = terminal
249 .size()
250 .map(|s| (s.width, s.height))
251 .unwrap_or((80, 24));
252
253 let messages = Self::get_current_conversation(_event_source).await?;
255 info!(
256 "Received {} messages from current conversation",
257 messages.len()
258 );
259
260 let preferences = steer_core::preferences::Preferences::load()
262 .map_err(crate::error::Error::Core)
263 .unwrap_or_default();
264
265 let input_mode = match preferences.ui.editing_mode {
267 steer_core::preferences::EditingMode::Simple => InputMode::Simple,
268 steer_core::preferences::EditingMode::Vim => InputMode::VimNormal,
269 };
270
271 let mut tui = Self {
273 terminal,
274 terminal_size,
275 input_mode,
276 input_panel_state: crate::tui::widgets::input_panel::InputPanelState::new(
277 session_id.clone(),
278 ),
279 editing_message_id: None,
280 command_sink,
281 is_processing: false,
282 progress_message: None,
283 spinner_state: 0,
284 current_tool_approval: None,
285 current_model,
286 event_pipeline: Self::create_event_pipeline(),
287 chat_store: ChatStore::new(),
288 tool_registry: ToolCallRegistry::new(),
289 chat_viewport: ChatViewport::new(),
290 session_id,
291 theme: theme.unwrap_or_default(),
292 setup_state: None,
293 auth_controller: None,
294 in_flight_operations: HashSet::new(),
295 command_registry: CommandRegistry::new(),
296 preferences,
297 double_tap_tracker: crate::tui::state::DoubleTapTracker::new(),
298 vim_state: VimState::default(),
299 mode_stack: VecDeque::new(),
300 last_revision: 0,
301 };
302
303 tui.restore_messages(messages);
305
306 Ok(tui)
307 }
308
309 fn restore_messages(&mut self, messages: Vec<Message>) {
311 let message_count = messages.len();
312 info!("Starting to restore {} messages to TUI", message_count);
313
314 for message in &messages {
316 if let steer_core::app::MessageData::Tool { tool_use_id, .. } = &message.data {
317 debug!(
318 target: "tui.restore",
319 "Found Tool message with tool_use_id={}",
320 tool_use_id
321 );
322 }
323 }
324
325 self.chat_store.ingest_messages(&messages);
326
327 for message in &messages {
330 if let steer_core::app::MessageData::Assistant { content, .. } = &message.data {
331 debug!(
332 target: "tui.restore",
333 "Processing Assistant message id={}",
334 message.id()
335 );
336 for block in content {
337 if let AssistantContent::ToolCall { tool_call } = block {
338 debug!(
339 target: "tui.restore",
340 "Found ToolCall in Assistant message: id={}, name={}, params={}",
341 tool_call.id, tool_call.name, tool_call.parameters
342 );
343
344 self.tool_registry.register_call(tool_call.clone());
346 }
347 }
348 }
349 }
350
351 for message in &messages {
353 if let steer_core::app::MessageData::Tool { tool_use_id, .. } = &message.data {
354 debug!(
355 target: "tui.restore",
356 "Updating registry with Tool result for id={}",
357 tool_use_id
358 );
359 }
361 }
362
363 debug!(
364 target: "tui.restore",
365 "Tool registry state after restoration: {} calls registered",
366 self.tool_registry.metrics().completed_count
367 );
368 info!("Successfully restored {} messages to TUI", message_count);
369 }
370
371 fn push_notice(&mut self, level: crate::tui::model::NoticeLevel, text: String) {
373 use crate::tui::model::{ChatItem, ChatItemData, generate_row_id};
374 self.chat_store.push(ChatItem {
375 parent_chat_item_id: None,
376 data: ChatItemData::SystemNotice {
377 id: generate_row_id(),
378 level,
379 text,
380 ts: time::OffsetDateTime::now_utc(),
381 },
382 });
383 }
384
385 fn push_tui_response(&mut self, command: String, response: String) {
387 use crate::tui::model::{ChatItem, ChatItemData, generate_row_id};
388 self.chat_store.push(ChatItem {
389 parent_chat_item_id: None,
390 data: ChatItemData::TuiCommandResponse {
391 id: generate_row_id(),
392 command,
393 response,
394 ts: time::OffsetDateTime::now_utc(),
395 },
396 });
397 }
398
399 async fn load_file_cache(&mut self) {
401 info!(target: "tui.file_cache", "Requesting workspace files for session {}", self.session_id);
403 if let Err(e) = self
404 .command_sink
405 .send_command(AppCommand::RequestWorkspaceFiles)
406 .await
407 {
408 warn!(target: "tui.file_cache", "Failed to request workspace files: {}", e);
409 }
410 }
411
412 pub fn cleanup_terminal(&mut self) -> Result<()> {
413 execute!(
414 self.terminal.backend_mut(),
415 LeaveAlternateScreen,
416 DisableBracketedPaste,
417 PopKeyboardEnhancementFlags,
418 DisableMouseCapture
419 )?;
420 disable_raw_mode()?;
421 Ok(())
422 }
423
424 pub async fn run(&mut self, mut event_rx: mpsc::Receiver<AppEvent>) -> Result<()> {
425 info!(
427 "Starting TUI run with {} messages in view model",
428 self.chat_store.len()
429 );
430
431 self.load_file_cache().await;
433
434 let (term_event_tx, mut term_event_rx) = mpsc::channel::<Result<Event>>(1);
435 let input_handle: JoinHandle<()> = tokio::spawn(async move {
436 loop {
437 if event::poll(Duration::ZERO).unwrap_or(false) {
439 match event::read() {
440 Ok(evt) => {
441 if term_event_tx.send(Ok(evt)).await.is_err() {
442 break; }
444 }
445 Err(e) if e.kind() == io::ErrorKind::Interrupted => {
446 debug!(target: "tui.input", "Ignoring interrupted syscall");
449 continue;
450 }
451 Err(e) => {
452 warn!(target: "tui.input", "Input error: {}", e);
455 if term_event_tx.send(Err(Error::from(e))).await.is_err() {
456 break; }
458 break;
459 }
460 }
461 } else {
462 tokio::time::sleep(Duration::from_millis(10)).await;
464 }
465 }
466 });
467
468 let mut should_exit = false;
469 let mut needs_redraw = true; let mut last_spinner_char = String::new();
471
472 let mut tick = tokio::time::interval(SPINNER_UPDATE_INTERVAL);
474 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
475
476 while !should_exit {
477 if needs_redraw {
479 self.draw()?;
480 needs_redraw = false;
481 }
482
483 tokio::select! {
484 Some(event_res) = term_event_rx.recv() => {
485 match event_res {
486 Ok(evt) => {
487 match evt {
488 Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
489 match self.handle_key_event(key_event).await {
490 Ok(exit) => {
491 if exit {
492 should_exit = true;
493 }
494 }
495 Err(e) => {
496 use crate::tui::model::{ChatItem, ChatItemData, NoticeLevel, generate_row_id};
498 self.chat_store.push(ChatItem {
499 parent_chat_item_id: None,
500 data: ChatItemData::SystemNotice {
501 id: generate_row_id(),
502 level: NoticeLevel::Error,
503 text: e.to_string(),
504 ts: time::OffsetDateTime::now_utc(),
505 },
506 });
507 }
508 }
509 needs_redraw = true;
510 }
511 Event::Mouse(mouse_event) => {
512 if self.handle_mouse_event(mouse_event)? {
513 needs_redraw = true;
514 }
515 }
516 Event::Resize(width, height) => {
517 self.terminal_size = (width, height);
518 needs_redraw = true;
520 }
521 Event::Paste(data) => {
522 if self.is_text_input_mode() {
524 if self.input_mode == InputMode::Setup {
525 if let Some(setup_state) = &mut self.setup_state {
527 match &setup_state.current_step {
528 crate::tui::state::SetupStep::Authentication(_) => {
529 if setup_state.oauth_state.is_some() {
530 setup_state.oauth_callback_input.push_str(&data);
532 } else {
533 setup_state.api_key_input.push_str(&data);
535 }
536 debug!(target:"tui.run", "Pasted {} chars in Setup mode", data.len());
537 needs_redraw = true;
538 }
539 _ => {
540 }
542 }
543 }
544 } else {
545 let normalized_data =
546 data.replace("\r\n", "\n").replace('\r', "\n");
547 self.input_panel_state.insert_str(&normalized_data);
548 debug!(target:"tui.run", "Pasted {} chars in {:?} mode", normalized_data.len(), self.input_mode);
549 needs_redraw = true;
550 }
551 }
552 }
553 _ => {}
554 }
555 }
556 Err(e) => {
557 error!(target: "tui.run", "Fatal input error: {}. Exiting.", e);
558 should_exit = true;
559 }
560 }
561 }
562 Some(app_event) = event_rx.recv() => {
563 self.handle_app_event(app_event).await;
564 needs_redraw = true;
565 }
566 _ = tick.tick() => {
567 let has_pending_tools = !self.tool_registry.pending_calls().is_empty()
569 || !self.tool_registry.active_calls().is_empty()
570 || self.chat_store.has_pending_tools();
571 let has_in_flight_operations = !self.in_flight_operations.is_empty();
572
573 if self.is_processing || has_pending_tools || has_in_flight_operations {
574 self.spinner_state = self.spinner_state.wrapping_add(1);
575 let ch = get_spinner_char(self.spinner_state);
576 if ch != last_spinner_char {
577 last_spinner_char = ch.to_string();
578 needs_redraw = true;
579 }
580 }
581 }
582 }
583 }
584
585 self.cleanup_terminal()?;
587 input_handle.abort();
588 Ok(())
589 }
590
591 fn handle_mouse_event(&mut self, event: MouseEvent) -> Result<bool> {
593 let needs_redraw = match event.kind {
594 event::MouseEventKind::ScrollUp => {
595 if !self.is_text_input_mode()
597 || (self.input_mode == InputMode::Simple
598 && self.input_panel_state.content().is_empty())
599 {
600 self.chat_viewport.state_mut().scroll_up(3);
601 true
602 } else {
603 false
604 }
605 }
606 event::MouseEventKind::ScrollDown => {
607 if !self.is_text_input_mode()
609 || (self.input_mode == InputMode::Simple
610 && self.input_panel_state.content().is_empty())
611 {
612 self.chat_viewport.state_mut().scroll_down(3);
613 true
614 } else {
615 false
616 }
617 }
618 _ => false,
619 };
620
621 Ok(needs_redraw)
622 }
623
624 fn draw(&mut self) -> Result<()> {
626 self.terminal.draw(|f| {
627 if let Some(setup_state) = &self.setup_state {
629 use crate::tui::widgets::setup::{
630 authentication::AuthenticationWidget, completion::CompletionWidget,
631 provider_selection::ProviderSelectionWidget, welcome::WelcomeWidget,
632 };
633
634 match &setup_state.current_step {
635 crate::tui::state::SetupStep::Welcome => {
636 WelcomeWidget::render(f.area(), f.buffer_mut(), &self.theme);
637 }
638 crate::tui::state::SetupStep::ProviderSelection => {
639 ProviderSelectionWidget::render(
640 f.area(),
641 f.buffer_mut(),
642 setup_state,
643 &self.theme,
644 );
645 }
646 crate::tui::state::SetupStep::Authentication(provider) => {
647 AuthenticationWidget::render(
648 f.area(),
649 f.buffer_mut(),
650 setup_state,
651 *provider,
652 &self.theme,
653 );
654 }
655 crate::tui::state::SetupStep::Completion => {
656 CompletionWidget::render(
657 f.area(),
658 f.buffer_mut(),
659 setup_state,
660 &self.theme,
661 );
662 }
663 }
664 return;
665 }
666
667 let input_mode = self.input_mode;
668 let is_processing = self.is_processing;
669 let spinner_state = self.spinner_state;
670 let current_tool_approval = self.current_tool_approval.as_ref();
671 let current_model_owned = self.current_model;
672
673 let current_revision = self.chat_store.revision();
675 if current_revision != self.last_revision {
676 self.chat_viewport.mark_dirty();
677 self.last_revision = current_revision;
678 }
679
680 let chat_items: Vec<&ChatItem> = self.chat_store.as_items();
682
683 let terminal_size = f.area();
684
685 let input_area_height = self.input_panel_state.required_height(
686 current_tool_approval,
687 terminal_size.width,
688 terminal_size.height,
689 );
690
691 let layout = UiLayout::compute(terminal_size, input_area_height, &self.theme);
692 layout.prepare_background(f, &self.theme);
693
694 self.chat_viewport.rebuild(
695 &chat_items,
696 layout.chat_area.width,
697 self.chat_viewport.state().view_mode,
698 &self.theme,
699 &self.chat_store,
700 );
701
702 let hovered_id = self
703 .input_panel_state
704 .get_hovered_id()
705 .map(|s| s.to_string());
706
707 self.chat_viewport.render(
708 f,
709 layout.chat_area,
710 spinner_state,
711 hovered_id.as_deref(),
712 &self.theme,
713 );
714
715 let input_panel = InputPanel::new(
716 input_mode,
717 current_tool_approval,
718 is_processing,
719 spinner_state,
720 &self.theme,
721 );
722 f.render_stateful_widget(input_panel, layout.input_area, &mut self.input_panel_state);
723
724 layout.render_status_bar(f, ¤t_model_owned, &self.theme);
726
727 let fuzzy_finder_data = if input_mode == InputMode::FuzzyFinder {
729 let results = self.input_panel_state.fuzzy_finder.results().to_vec();
730 let selected = self.input_panel_state.fuzzy_finder.selected_index();
731 let input_height = self.input_panel_state.required_height(
732 current_tool_approval,
733 terminal_size.width,
734 10,
735 );
736 let mode = self.input_panel_state.fuzzy_finder.mode();
737 Some((results, selected, input_height, mode))
738 } else {
739 None
740 };
741
742 if let Some((results, selected_index, input_height, mode)) = fuzzy_finder_data {
744 Self::render_fuzzy_finder_overlay_static(
745 f,
746 &results,
747 selected_index,
748 input_height,
749 mode,
750 &self.theme,
751 &self.command_registry,
752 );
753 }
754 })?;
755 Ok(())
756 }
757
758 fn render_fuzzy_finder_overlay_static(
760 f: &mut Frame,
761 results: &[String],
762 selected_index: usize,
763 input_panel_height: u16,
764 mode: crate::tui::widgets::fuzzy_finder::FuzzyFinderMode,
765 theme: &Theme,
766 command_registry: &CommandRegistry,
767 ) {
768 use ratatui::layout::Rect;
769 use ratatui::style::Style;
770 use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState};
771
772 if results.is_empty() {
775 return; }
777
778 let total_area = f.area();
780
781 let input_panel_y = total_area.height.saturating_sub(input_panel_height + 1); let overlay_height = results.len().min(10) as u16 + 2; let overlay_y = input_panel_y.saturating_sub(overlay_height);
789 let overlay_area = Rect {
790 x: total_area.x,
791 y: overlay_y,
792 width: total_area.width,
793 height: overlay_height,
794 };
795
796 f.render_widget(Clear, overlay_area);
798
799 let items: Vec<ListItem> = match mode {
802 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Files => results
803 .iter()
804 .enumerate()
805 .rev()
806 .map(|(i, path)| {
807 let is_selected = selected_index == i;
808 let style = if is_selected {
809 theme.style(theme::Component::PopupSelection)
810 } else {
811 Style::default()
812 };
813 ListItem::new(path.as_str()).style(style)
814 })
815 .collect(),
816 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Commands => {
817 results
818 .iter()
819 .enumerate()
820 .rev()
821 .map(|(i, cmd_name)| {
822 let is_selected = selected_index == i;
823 let style = if is_selected {
824 theme.style(theme::Component::PopupSelection)
825 } else {
826 Style::default()
827 };
828
829 if let Some(cmd_info) = command_registry.get(cmd_name.as_str()) {
831 let line = format!("/{:<12} {}", cmd_info.name, cmd_info.description);
832 ListItem::new(line).style(style)
833 } else {
834 ListItem::new(format!("/{cmd_name}")).style(style)
835 }
836 })
837 .collect()
838 }
839 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Models
840 | crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Themes => results
841 .iter()
842 .enumerate()
843 .rev()
844 .map(|(i, item)| {
845 let is_selected = selected_index == i;
846 let style = if is_selected {
847 theme.style(theme::Component::PopupSelection)
848 } else {
849 Style::default()
850 };
851 ListItem::new(item.as_str()).style(style)
852 })
853 .collect(),
854 };
855
856 let list_block = Block::default()
858 .borders(Borders::ALL)
859 .border_style(theme.style(theme::Component::PopupBorder))
860 .title(match mode {
861 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Files => " Files ",
862 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Commands => " Commands ",
863 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Models => " Select Model ",
864 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Themes => " Select Theme ",
865 });
866
867 let list = List::new(items)
868 .block(list_block)
869 .highlight_style(theme.style(theme::Component::PopupSelection));
870
871 let mut list_state = ListState::default();
873 let reversed_selection = results
874 .len()
875 .saturating_sub(1)
876 .saturating_sub(selected_index);
877 list_state.select(Some(reversed_selection));
878
879 f.render_stateful_widget(list, overlay_area, &mut list_state);
880 }
881
882 fn create_event_pipeline() -> EventPipeline {
884 EventPipeline::new()
885 .add_processor(Box::new(ProcessingStateProcessor::new()))
886 .add_processor(Box::new(MessageEventProcessor::new()))
887 .add_processor(Box::new(ToolEventProcessor::new()))
888 .add_processor(Box::new(SystemEventProcessor::new()))
889 }
890
891 async fn handle_app_event(&mut self, event: AppEvent) {
892 let mut messages_updated = false;
893
894 match &event {
896 AppEvent::WorkspaceChanged => {
897 self.load_file_cache().await;
898 }
899 AppEvent::WorkspaceFiles { files } => {
900 info!(target: "tui.handle_app_event", "Received workspace files event with {} files", files.len());
902 self.input_panel_state
903 .file_cache
904 .update(files.clone())
905 .await;
906 }
907 _ => {}
908 }
909
910 let mut ctx = crate::tui::events::processor::ProcessingContext {
912 chat_store: &mut self.chat_store,
913 chat_list_state: self.chat_viewport.state_mut(),
914 tool_registry: &mut self.tool_registry,
915 command_sink: &self.command_sink,
916 is_processing: &mut self.is_processing,
917 progress_message: &mut self.progress_message,
918 spinner_state: &mut self.spinner_state,
919 current_tool_approval: &mut self.current_tool_approval,
920 current_model: &mut self.current_model,
921 messages_updated: &mut messages_updated,
922 in_flight_operations: &mut self.in_flight_operations,
923 };
924
925 if let Err(e) = self.event_pipeline.process_event(event, &mut ctx).await {
927 tracing::error!(target: "tui.handle_app_event", "Event processing failed: {}", e);
928 }
929
930 if self.current_tool_approval.is_some() && self.input_mode != InputMode::AwaitingApproval {
934 self.switch_mode(InputMode::AwaitingApproval);
935 } else if self.current_tool_approval.is_none()
936 && self.input_mode == InputMode::AwaitingApproval
937 {
938 self.restore_previous_mode();
939 }
940
941 if messages_updated {
943 if self.chat_viewport.state_mut().is_at_bottom() {
946 self.chat_viewport.state_mut().scroll_to_bottom();
947 }
948 }
949 }
950
951 async fn get_current_conversation(
952 _event_source: Arc<dyn AppEventSource>,
953 ) -> Result<Vec<Message>> {
954 Ok(Vec::new())
956 }
957
958 async fn send_message(&mut self, content: String) -> Result<()> {
959 if content.starts_with('/') {
961 return self.handle_slash_command(content).await;
962 }
963
964 if let Some(message_id_to_edit) = self.editing_message_id.take() {
966 if let Err(e) = self
968 .command_sink
969 .send_command(AppCommand::EditMessage {
970 message_id: message_id_to_edit,
971 new_content: content,
972 })
973 .await
974 {
975 self.push_notice(NoticeLevel::Error, format!("Cannot edit message: {e}"));
976 }
977 } else {
978 if let Err(e) = self
980 .command_sink
981 .send_command(AppCommand::ProcessUserInput(content))
982 .await
983 {
984 self.push_notice(NoticeLevel::Error, format!("Cannot send message: {e}"));
985 }
986 }
987 Ok(())
988 }
989
990 async fn handle_slash_command(&mut self, command_input: String) -> Result<()> {
991 use crate::tui::commands::{AppCommand as TuiAppCommand, TuiCommand, TuiCommandType};
992 use crate::tui::model::NoticeLevel;
993
994 let cmd_name = command_input
996 .trim()
997 .strip_prefix('/')
998 .unwrap_or(command_input.trim());
999
1000 if let Some(cmd_info) = self.command_registry.get(cmd_name) {
1001 if let crate::tui::commands::registry::CommandScope::Custom(custom_cmd) =
1002 &cmd_info.scope
1003 {
1004 let app_cmd = TuiAppCommand::Tui(TuiCommand::Custom(custom_cmd.clone()));
1006 match app_cmd {
1008 TuiAppCommand::Tui(TuiCommand::Custom(custom_cmd)) => {
1009 match custom_cmd {
1011 crate::tui::custom_commands::CustomCommand::Prompt {
1012 prompt, ..
1013 } => {
1014 self.command_sink
1016 .send_command(AppCommand::ProcessUserInput(prompt))
1017 .await?;
1018 } }
1020 }
1021 _ => unreachable!(),
1022 }
1023 return Ok(());
1024 }
1025 }
1026
1027 let app_cmd = match TuiAppCommand::parse(&command_input) {
1029 Ok(cmd) => cmd,
1030 Err(e) => {
1031 self.push_notice(NoticeLevel::Error, e.to_string());
1033 return Ok(());
1034 }
1035 };
1036
1037 match app_cmd {
1039 TuiAppCommand::Tui(tui_cmd) => {
1040 match tui_cmd {
1042 TuiCommand::ReloadFiles => {
1043 self.input_panel_state.file_cache.clear().await;
1045 info!(target: "tui.slash_command", "Cleared file cache, will reload on next access");
1046 if let Err(e) = self
1048 .command_sink
1049 .send_command(AppCommand::RequestWorkspaceFiles)
1050 .await
1051 {
1052 self.push_notice(
1053 NoticeLevel::Error,
1054 format!("Cannot reload files: {e}"),
1055 );
1056 } else {
1057 self.push_tui_response(
1058 TuiCommandType::ReloadFiles.command_name(),
1059 "File cache cleared. Files will be reloaded on next access."
1060 .to_string(),
1061 );
1062 }
1063 }
1064 TuiCommand::Theme(theme_name) => {
1065 if let Some(name) = theme_name {
1066 let loader = theme::ThemeLoader::new();
1068 match loader.load_theme(&name) {
1069 Ok(new_theme) => {
1070 self.theme = new_theme;
1071 self.push_tui_response(
1072 TuiCommandType::Theme.command_name(),
1073 format!("Theme changed to '{name}'"),
1074 );
1075 }
1076 Err(e) => {
1077 self.push_notice(
1078 NoticeLevel::Error,
1079 format!("Failed to load theme '{name}': {e}"),
1080 );
1081 }
1082 }
1083 } else {
1084 let loader = theme::ThemeLoader::new();
1086 let themes = loader.list_themes();
1087 let theme_list = if themes.is_empty() {
1088 "No themes found.".to_string()
1089 } else {
1090 format!("Available themes:\n{}", themes.join("\n"))
1091 };
1092 self.push_tui_response(
1093 TuiCommandType::Theme.command_name(),
1094 theme_list,
1095 );
1096 }
1097 }
1098 TuiCommand::Help(command_name) => {
1099 let help_text = if let Some(cmd_name) = command_name {
1101 if let Some(cmd_info) = self.command_registry.get(&cmd_name) {
1103 format!(
1104 "Command: {}\n\nDescription: {}\n\nUsage: {}",
1105 cmd_info.name, cmd_info.description, cmd_info.usage
1106 )
1107 } else {
1108 format!("Unknown command: {cmd_name}")
1109 }
1110 } else {
1111 let mut help_lines = vec!["Available commands:".to_string()];
1113 for cmd_info in self.command_registry.all_commands() {
1114 help_lines.push(format!(
1115 " {:<20} - {}",
1116 cmd_info.usage, cmd_info.description
1117 ));
1118 }
1119 help_lines.join("\n")
1120 };
1121
1122 self.push_tui_response(TuiCommandType::Help.command_name(), help_text);
1123 }
1124 TuiCommand::Auth => {
1125 let auth_storage =
1128 steer_core::auth::DefaultAuthStorage::new().map_err(|e| {
1129 crate::error::Error::Generic(format!(
1130 "Failed to create auth storage: {e}"
1131 ))
1132 })?;
1133 let auth_providers = LlmConfigProvider::new(Arc::new(auth_storage))
1134 .available_providers()
1135 .await?;
1136
1137 let mut provider_status = std::collections::HashMap::new();
1138 for provider in [
1139 steer_core::api::ProviderKind::Anthropic,
1140 steer_core::api::ProviderKind::OpenAI,
1141 steer_core::api::ProviderKind::Google,
1142 steer_core::api::ProviderKind::XAI,
1143 ] {
1144 let status = if auth_providers.contains(&provider) {
1145 crate::tui::state::AuthStatus::ApiKeySet
1146 } else {
1147 crate::tui::state::AuthStatus::NotConfigured
1148 };
1149 provider_status.insert(provider, status);
1150 }
1151
1152 self.setup_state = Some(
1154 crate::tui::state::SetupState::new_for_auth_command(provider_status),
1155 );
1156 self.set_mode(InputMode::Setup);
1159 self.mode_stack.clear();
1161
1162 self.push_tui_response(
1163 TuiCommandType::Auth.to_string(),
1164 "Entering authentication setup mode...".to_string(),
1165 );
1166 }
1167 TuiCommand::EditingMode(ref mode_name) => {
1168 let response = match mode_name.as_deref() {
1169 None => {
1170 let mode_str = match self.preferences.ui.editing_mode {
1172 steer_core::preferences::EditingMode::Simple => "simple",
1173 steer_core::preferences::EditingMode::Vim => "vim",
1174 };
1175 format!("Current editing mode: {mode_str}")
1176 }
1177 Some("simple") => {
1178 self.preferences.ui.editing_mode =
1179 steer_core::preferences::EditingMode::Simple;
1180 self.set_mode(InputMode::Simple);
1181 self.preferences.save().map_err(crate::error::Error::Core)?;
1182 "Switched to Simple mode".to_string()
1183 }
1184 Some("vim") => {
1185 self.preferences.ui.editing_mode =
1186 steer_core::preferences::EditingMode::Vim;
1187 self.set_mode(InputMode::VimNormal);
1188 self.preferences.save().map_err(crate::error::Error::Core)?;
1189 "Switched to Vim mode (Normal)".to_string()
1190 }
1191 Some(mode) => {
1192 format!("Unknown mode: '{mode}'. Use 'simple' or 'vim'")
1193 }
1194 };
1195
1196 self.push_tui_response(tui_cmd.as_command_str(), response);
1197 }
1198 TuiCommand::Custom(custom_cmd) => {
1199 match custom_cmd {
1201 crate::tui::custom_commands::CustomCommand::Prompt {
1202 prompt, ..
1203 } => {
1204 self.command_sink
1206 .send_command(AppCommand::ProcessUserInput(prompt))
1207 .await?;
1208 } }
1210 }
1211 }
1212 }
1213 TuiAppCommand::Core(core_cmd) => {
1214 if let Err(e) = self
1216 .command_sink
1217 .send_command(AppCommand::ExecuteCommand(core_cmd))
1218 .await
1219 {
1220 self.push_notice(NoticeLevel::Error, e.to_string());
1221 }
1222 }
1223 }
1224
1225 Ok(())
1226 }
1227
1228 fn enter_edit_mode(&mut self, message_id: &str) {
1230 if let Some(item) = self.chat_store.get_by_id(&message_id.to_string()) {
1232 if let crate::tui::model::ChatItemData::Message(message) = &item.data {
1233 if let MessageData::User { content, .. } = &message.data {
1234 let text = content
1236 .iter()
1237 .filter_map(|block| match block {
1238 steer_core::app::conversation::UserContent::Text { text } => {
1239 Some(text.as_str())
1240 }
1241 _ => None,
1242 })
1243 .collect::<Vec<_>>()
1244 .join("\n");
1245
1246 self.input_panel_state
1248 .set_content_from_lines(text.lines().collect::<Vec<_>>());
1249 self.input_mode = match self.preferences.ui.editing_mode {
1251 steer_core::preferences::EditingMode::Simple => InputMode::Simple,
1252 steer_core::preferences::EditingMode::Vim => InputMode::VimInsert,
1253 };
1254
1255 self.editing_message_id = Some(message_id.to_string());
1257 }
1258 }
1259 }
1260 }
1261
1262 fn scroll_to_message_id(&mut self, message_id: &str) {
1264 let mut target_index = None;
1266 for (idx, item) in self.chat_store.items().enumerate() {
1267 if let crate::tui::model::ChatItemData::Message(message) = &item.data {
1268 if message.id() == message_id {
1269 target_index = Some(idx);
1270 break;
1271 }
1272 }
1273 }
1274
1275 if let Some(idx) = target_index {
1276 self.chat_viewport.state_mut().scroll_to_item(idx);
1278 }
1279 }
1280
1281 fn enter_edit_selection_mode(&mut self) {
1283 self.switch_mode(InputMode::EditMessageSelection);
1284
1285 self.input_panel_state
1287 .populate_edit_selection(self.chat_store.iter_items().map(|item| &item.data));
1288
1289 if let Some(id) = self.input_panel_state.get_hovered_id() {
1291 let id = id.to_string();
1292 self.scroll_to_message_id(&id);
1293 }
1294 }
1295}
1296
1297fn get_spinner_char(state: usize) -> &'static str {
1299 const SPINNER_CHARS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1300 SPINNER_CHARS[state % SPINNER_CHARS.len()]
1301}
1302
1303pub fn cleanup_terminal() {
1305 use ratatui::crossterm::{
1306 event::{DisableBracketedPaste, DisableMouseCapture, PopKeyboardEnhancementFlags},
1307 execute,
1308 terminal::{LeaveAlternateScreen, disable_raw_mode},
1309 };
1310 let _ = disable_raw_mode();
1311 let _ = execute!(
1312 std::io::stdout(),
1313 LeaveAlternateScreen,
1314 PopKeyboardEnhancementFlags,
1315 DisableBracketedPaste,
1316 DisableMouseCapture
1317 );
1318}
1319
1320pub fn setup_panic_hook() {
1322 std::panic::set_hook(Box::new(|panic_info| {
1323 cleanup_terminal();
1324 eprintln!("Application panicked:");
1326 eprintln!("{panic_info}");
1327 }));
1328}
1329
1330pub async fn run_tui(
1332 client: std::sync::Arc<steer_grpc::GrpcClientAdapter>,
1333 session_id: Option<String>,
1334 model: steer_core::api::Model,
1335 directory: Option<std::path::PathBuf>,
1336 system_prompt: Option<String>,
1337 theme_name: Option<String>,
1338 force_setup: bool,
1339) -> Result<()> {
1340 use std::collections::HashMap;
1341 use steer_core::app::io::{AppCommandSink, AppEventSource};
1342 use steer_core::session::{SessionConfig, SessionToolConfig};
1343
1344 let loader = theme::ThemeLoader::new();
1346 let theme = if let Some(theme_name) = theme_name {
1347 let path = std::path::Path::new(&theme_name);
1349 let theme_result = if path.is_absolute() || path.exists() {
1350 loader.load_theme_from_path(path)
1352 } else {
1353 loader.load_theme(&theme_name)
1355 };
1356
1357 match theme_result {
1358 Ok(theme) => {
1359 info!("Loaded theme: {}", theme_name);
1360 Some(theme)
1361 }
1362 Err(e) => {
1363 warn!(
1364 "Failed to load theme '{}': {}. Using default theme.",
1365 theme_name, e
1366 );
1367 loader.load_theme("catppuccin-mocha").ok()
1369 }
1370 }
1371 } else {
1372 match loader.load_theme("catppuccin-mocha") {
1374 Ok(theme) => {
1375 info!("Loaded default theme: catppuccin-mocha");
1376 Some(theme)
1377 }
1378 Err(e) => {
1379 warn!(
1380 "Failed to load default theme 'catppuccin-mocha': {}. Using hardcoded default.",
1381 e
1382 );
1383 None
1384 }
1385 }
1386 };
1387
1388 let (session_id, messages) = if let Some(session_id) = session_id {
1390 let (messages, _approved_tools) = client
1392 .activate_session(session_id.clone())
1393 .await
1394 .map_err(Box::new)?;
1395 info!(
1396 "Activated session: {} with {} messages",
1397 session_id,
1398 messages.len()
1399 );
1400 println!("Session ID: {session_id}");
1401 (session_id, messages)
1402 } else {
1403 let mut session_config = SessionConfig {
1405 workspace: if let Some(ref dir) = directory {
1406 steer_core::session::state::WorkspaceConfig::Local { path: dir.clone() }
1407 } else {
1408 steer_core::session::state::WorkspaceConfig::default()
1409 },
1410 tool_config: SessionToolConfig::default(),
1411 system_prompt,
1412 metadata: HashMap::new(),
1413 };
1414
1415 session_config
1417 .metadata
1418 .insert("initial_model".to_string(), model.to_string());
1419
1420 let session_id = client
1421 .create_session(session_config)
1422 .await
1423 .map_err(Box::new)?;
1424 (session_id, vec![])
1425 };
1426
1427 client.start_streaming().await.map_err(Box::new)?;
1428 let event_rx = client.subscribe().await;
1429 let mut tui = Tui::new(
1430 client.clone() as std::sync::Arc<dyn AppCommandSink>,
1431 client.clone() as std::sync::Arc<dyn AppEventSource>,
1432 model,
1433 session_id,
1434 theme.clone(),
1435 )
1436 .await?;
1437
1438 if !messages.is_empty() {
1439 tui.restore_messages(messages);
1440 }
1441
1442 let auth_storage = steer_core::auth::DefaultAuthStorage::new()
1443 .map_err(|e| Error::Generic(format!("Failed to create auth storage: {e}")))?;
1444 let auth_providers = LlmConfigProvider::new(Arc::new(auth_storage))
1445 .available_providers()
1446 .await
1447 .map_err(|e| Error::Generic(format!("Failed to check auth: {e}")))?;
1448
1449 let should_run_setup = force_setup
1450 || (!steer_core::preferences::Preferences::config_path()
1451 .map(|p| p.exists())
1452 .unwrap_or(false)
1453 && auth_providers.is_empty());
1454
1455 if should_run_setup {
1457 let mut provider_status = std::collections::HashMap::new();
1458 for provider in [
1459 steer_core::api::ProviderKind::Anthropic,
1460 steer_core::api::ProviderKind::OpenAI,
1461 steer_core::api::ProviderKind::Google,
1462 steer_core::api::ProviderKind::XAI,
1463 ] {
1464 let status = if auth_providers.contains(&provider) {
1465 crate::tui::state::AuthStatus::ApiKeySet
1466 } else {
1467 crate::tui::state::AuthStatus::NotConfigured
1468 };
1469 provider_status.insert(provider, status);
1470 }
1471
1472 tui.setup_state = Some(crate::tui::state::SetupState::new(provider_status));
1473 tui.input_mode = InputMode::Setup;
1474 }
1475
1476 tui.run(event_rx).await?;
1478
1479 Ok(())
1480}
1481
1482pub async fn run_tui_auth_setup(
1485 client: std::sync::Arc<steer_grpc::GrpcClientAdapter>,
1486 session_id: Option<String>,
1487 model: Option<Model>,
1488 session_db: Option<PathBuf>,
1489 theme_name: Option<String>,
1490) -> Result<()> {
1491 run_tui(
1494 client,
1495 session_id,
1496 model.unwrap_or_default(),
1497 session_db,
1498 None, theme_name,
1500 true, )
1502 .await
1503}
1504
1505#[cfg(test)]
1506mod tests {
1507 use super::*;
1508 use async_trait::async_trait;
1509 use serde_json::json;
1510 use std::sync::Arc;
1511 use steer_core::app::AppCommand;
1512 use steer_core::app::AppEvent;
1513 use steer_core::app::conversation::{AssistantContent, Message, MessageData};
1514 use steer_core::app::io::{AppCommandSink, AppEventSource};
1515 use steer_core::error::Result;
1516 use tokio::sync::mpsc;
1517
1518 struct TerminalCleanupGuard;
1520
1521 impl Drop for TerminalCleanupGuard {
1522 fn drop(&mut self) {
1523 cleanup_terminal();
1524 }
1525 }
1526
1527 struct MockCommandSink;
1529
1530 #[async_trait]
1531 impl AppCommandSink for MockCommandSink {
1532 async fn send_command(&self, _command: AppCommand) -> Result<()> {
1533 Ok(())
1534 }
1535 }
1536
1537 struct MockEventSource;
1538
1539 #[async_trait]
1540 impl AppEventSource for MockEventSource {
1541 async fn subscribe(&self) -> mpsc::Receiver<AppEvent> {
1542 let (_, rx) = mpsc::channel(10);
1543 rx
1544 }
1545 }
1546
1547 #[tokio::test]
1548 #[ignore = "Requires TTY - run with `cargo test -- --ignored` in a terminal"]
1549 async fn test_restore_messages_preserves_tool_call_params() {
1550 let _guard = TerminalCleanupGuard;
1551 let command_sink = Arc::new(MockCommandSink) as Arc<dyn AppCommandSink>;
1553 let event_source = Arc::new(MockEventSource) as Arc<dyn AppEventSource>;
1554 let model = steer_core::api::Model::Claude3_5Sonnet20241022;
1555 let session_id = "test_session_id".to_string();
1556 let mut tui = Tui::new(command_sink, event_source, model, session_id, None)
1557 .await
1558 .unwrap();
1559
1560 let tool_id = "test_tool_123".to_string();
1562 let tool_call = steer_tools::ToolCall {
1563 id: tool_id.clone(),
1564 name: "view".to_string(),
1565 parameters: json!({
1566 "file_path": "/test/file.rs",
1567 "offset": 10,
1568 "limit": 100
1569 }),
1570 };
1571
1572 let assistant_msg = Message {
1573 data: MessageData::Assistant {
1574 content: vec![AssistantContent::ToolCall {
1575 tool_call: tool_call.clone(),
1576 }],
1577 },
1578 id: "msg_assistant".to_string(),
1579 timestamp: 1234567890,
1580 parent_message_id: None,
1581 };
1582
1583 let tool_msg = Message {
1584 data: MessageData::Tool {
1585 tool_use_id: tool_id.clone(),
1586 result: steer_tools::ToolResult::FileContent(
1587 steer_tools::result::FileContentResult {
1588 file_path: "/test/file.rs".to_string(),
1589 content: "file content here".to_string(),
1590 line_count: 1,
1591 truncated: false,
1592 },
1593 ),
1594 },
1595 id: "msg_tool".to_string(),
1596 timestamp: 1234567891,
1597 parent_message_id: Some("msg_assistant".to_string()),
1598 };
1599
1600 let messages = vec![assistant_msg, tool_msg];
1601
1602 tui.restore_messages(messages);
1604
1605 let stored_call = tui
1607 .tool_registry
1608 .get_tool_call(&tool_id)
1609 .expect("Tool call should be in registry");
1610 assert_eq!(stored_call.name, "view");
1611 assert_eq!(stored_call.parameters, tool_call.parameters);
1612 }
1613
1614 #[tokio::test]
1615 #[ignore = "Requires TTY - run with `cargo test -- --ignored` in a terminal"]
1616 async fn test_restore_messages_handles_tool_result_before_assistant() {
1617 let _guard = TerminalCleanupGuard;
1618 let command_sink = Arc::new(MockCommandSink) as Arc<dyn AppCommandSink>;
1620 let event_source = Arc::new(MockEventSource) as Arc<dyn AppEventSource>;
1621 let model = steer_core::api::Model::Claude3_5Sonnet20241022;
1622 let session_id = "test_session_id".to_string();
1623 let mut tui = Tui::new(command_sink, event_source, model, session_id, None)
1624 .await
1625 .unwrap();
1626
1627 let tool_id = "test_tool_456".to_string();
1628 let real_params = json!({
1629 "file_path": "/another/file.rs"
1630 });
1631
1632 let tool_call = steer_tools::ToolCall {
1633 id: tool_id.clone(),
1634 name: "view".to_string(),
1635 parameters: real_params.clone(),
1636 };
1637
1638 let tool_msg = Message {
1640 data: MessageData::Tool {
1641 tool_use_id: tool_id.clone(),
1642 result: steer_tools::ToolResult::FileContent(
1643 steer_tools::result::FileContentResult {
1644 file_path: "/another/file.rs".to_string(),
1645 content: "file content".to_string(),
1646 line_count: 1,
1647 truncated: false,
1648 },
1649 ),
1650 },
1651 id: "msg_tool".to_string(),
1652 timestamp: 1234567890,
1653 parent_message_id: None,
1654 };
1655
1656 let assistant_msg = Message {
1657 data: MessageData::Assistant {
1658 content: vec![AssistantContent::ToolCall {
1659 tool_call: tool_call.clone(),
1660 }],
1661 },
1662 id: "msg_456".to_string(),
1663 timestamp: 1234567891,
1664 parent_message_id: None,
1665 };
1666
1667 let messages = vec![tool_msg, assistant_msg];
1668
1669 tui.restore_messages(messages);
1670
1671 let stored_call = tui
1673 .tool_registry
1674 .get_tool_call(&tool_id)
1675 .expect("Tool call should be in registry");
1676 assert_eq!(stored_call.parameters, real_params);
1677 assert_eq!(stored_call.name, "view");
1678 }
1679}