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