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, TuiCommandResponse};
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::{AppCommand, AppEvent};
32use steer_core::config::LlmConfigProvider;
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::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
65#[cfg(test)]
66mod test_utils;
67
68const SPINNER_UPDATE_INTERVAL: Duration = Duration::from_millis(100);
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum InputMode {
74 Simple,
76 VimNormal,
78 VimInsert,
80 BashCommand,
82 AwaitingApproval,
84 ConfirmExit,
86 EditMessageSelection,
88 FuzzyFinder,
90 Setup,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq)]
96enum VimOperator {
97 Delete,
98 Change,
99 Yank,
100}
101
102#[derive(Debug, Default)]
104struct VimState {
105 pending_operator: Option<VimOperator>,
107 pending_g: bool,
109 replace_mode: bool,
111 visual_mode: bool,
113}
114
115pub struct Tui {
117 terminal: Terminal<CrosstermBackend<Stdout>>,
119 terminal_size: (u16, u16),
120 input_mode: InputMode,
122 input_panel_state: crate::tui::widgets::input_panel::InputPanelState,
124 editing_message_id: Option<String>,
126 client: AgentClient,
128 is_processing: bool,
130 progress_message: Option<String>,
132 spinner_state: usize,
134 current_tool_approval: Option<ToolCall>,
136 current_model: Model,
138 event_pipeline: EventPipeline,
140 chat_store: ChatStore,
142 tool_registry: ToolCallRegistry,
144 chat_viewport: ChatViewport,
146 session_id: String,
148 theme: Theme,
150 setup_state: Option<SetupState>,
152 auth_controller: Option<AuthController>,
154 in_flight_operations: HashSet<uuid::Uuid>,
156 command_registry: CommandRegistry,
158 preferences: steer_core::preferences::Preferences,
160 double_tap_tracker: crate::tui::state::DoubleTapTracker,
162 vim_state: VimState,
164 mode_stack: VecDeque<InputMode>,
166 last_revision: u64,
168}
169
170const MAX_MODE_DEPTH: usize = 8;
171
172impl Tui {
173 fn push_mode(&mut self) {
175 if self.mode_stack.len() == MAX_MODE_DEPTH {
176 self.mode_stack.pop_front(); }
178 self.mode_stack.push_back(self.input_mode);
179 }
180
181 fn pop_mode(&mut self) -> Option<InputMode> {
183 self.mode_stack.pop_back()
184 }
185
186 pub fn switch_mode(&mut self, new_mode: InputMode) {
188 if self.input_mode != new_mode {
189 debug!(
190 "Switching mode from {:?} to {:?}",
191 self.input_mode, new_mode
192 );
193 self.push_mode();
194 self.input_mode = new_mode;
195 }
196 }
197
198 pub fn set_mode(&mut self, new_mode: InputMode) {
200 debug!("Setting mode from {:?} to {:?}", self.input_mode, new_mode);
201 self.input_mode = new_mode;
202 }
203
204 pub fn restore_previous_mode(&mut self) {
206 self.input_mode = self.pop_mode().unwrap_or_else(|| self.default_input_mode());
207 }
208
209 fn default_input_mode(&self) -> InputMode {
211 match self.preferences.ui.editing_mode {
212 steer_core::preferences::EditingMode::Simple => InputMode::Simple,
213 steer_core::preferences::EditingMode::Vim => InputMode::VimNormal,
214 }
215 }
216
217 fn is_text_input_mode(&self) -> bool {
219 matches!(
220 self.input_mode,
221 InputMode::Simple
222 | InputMode::VimInsert
223 | InputMode::BashCommand
224 | InputMode::Setup
225 | InputMode::FuzzyFinder
226 )
227 }
228 pub async fn new(
230 client: AgentClient,
231 current_model: Model,
232 session_id: String,
233 theme: Option<Theme>,
234 ) -> Result<Self> {
235 enable_raw_mode()?;
236 let mut stdout = io::stdout();
237 execute!(
238 stdout,
239 EnterAlternateScreen,
240 EnableBracketedPaste,
241 PushKeyboardEnhancementFlags(
242 ratatui::crossterm::event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
243 ),
244 EnableMouseCapture,
245 SetTitle("Steer")
246 )?;
247
248 let backend = CrosstermBackend::new(stdout);
249 let terminal = Terminal::new(backend)?;
250 let terminal_size = terminal
251 .size()
252 .map(|s| (s.width, s.height))
253 .unwrap_or((80, 24));
254
255 let preferences = steer_core::preferences::Preferences::load()
257 .map_err(crate::error::Error::Core)
258 .unwrap_or_default();
259
260 let input_mode = match preferences.ui.editing_mode {
262 steer_core::preferences::EditingMode::Simple => InputMode::Simple,
263 steer_core::preferences::EditingMode::Vim => InputMode::VimNormal,
264 };
265
266 let tui = Self {
268 terminal,
269 terminal_size,
270 input_mode,
271 input_panel_state: crate::tui::widgets::input_panel::InputPanelState::new(
272 session_id.clone(),
273 ),
274 editing_message_id: None,
275 client,
276 is_processing: false,
277 progress_message: None,
278 spinner_state: 0,
279 current_tool_approval: None,
280 current_model,
281 event_pipeline: Self::create_event_pipeline(),
282 chat_store: ChatStore::new(),
283 tool_registry: ToolCallRegistry::new(),
284 chat_viewport: ChatViewport::new(),
285 session_id,
286 theme: theme.unwrap_or_default(),
287 setup_state: None,
288 auth_controller: None,
289 in_flight_operations: HashSet::new(),
290 command_registry: CommandRegistry::new(),
291 preferences,
292 double_tap_tracker: crate::tui::state::DoubleTapTracker::new(),
293 vim_state: VimState::default(),
294 mode_stack: VecDeque::new(),
295 last_revision: 0,
296 };
297
298 Ok(tui)
299 }
300
301 fn restore_messages(&mut self, messages: Vec<Message>) {
303 let message_count = messages.len();
304 info!("Starting to restore {} messages to TUI", message_count);
305
306 for message in &messages {
308 if let steer_core::app::MessageData::Tool { tool_use_id, .. } = &message.data {
309 debug!(
310 target: "tui.restore",
311 "Found Tool message with tool_use_id={}",
312 tool_use_id
313 );
314 }
315 }
316
317 self.chat_store.ingest_messages(&messages);
318
319 for message in &messages {
322 if let steer_core::app::MessageData::Assistant { content, .. } = &message.data {
323 debug!(
324 target: "tui.restore",
325 "Processing Assistant message id={}",
326 message.id()
327 );
328 for block in content {
329 if let AssistantContent::ToolCall { tool_call } = block {
330 debug!(
331 target: "tui.restore",
332 "Found ToolCall in Assistant message: id={}, name={}, params={}",
333 tool_call.id, tool_call.name, tool_call.parameters
334 );
335
336 self.tool_registry.register_call(tool_call.clone());
338 }
339 }
340 }
341 }
342
343 for message in &messages {
345 if let steer_core::app::MessageData::Tool { tool_use_id, .. } = &message.data {
346 debug!(
347 target: "tui.restore",
348 "Updating registry with Tool result for id={}",
349 tool_use_id
350 );
351 }
353 }
354
355 debug!(
356 target: "tui.restore",
357 "Tool registry state after restoration: {} calls registered",
358 self.tool_registry.metrics().completed_count
359 );
360 info!("Successfully restored {} messages to TUI", message_count);
361 }
362
363 fn push_notice(&mut self, level: crate::tui::model::NoticeLevel, text: String) {
365 use crate::tui::model::{ChatItem, ChatItemData, generate_row_id};
366 self.chat_store.push(ChatItem {
367 parent_chat_item_id: None,
368 data: ChatItemData::SystemNotice {
369 id: generate_row_id(),
370 level,
371 text,
372 ts: time::OffsetDateTime::now_utc(),
373 },
374 });
375 }
376
377 fn push_tui_response(&mut self, command: String, response: TuiCommandResponse) {
379 use crate::tui::model::{ChatItem, ChatItemData, generate_row_id};
380 self.chat_store.push(ChatItem {
381 parent_chat_item_id: None,
382 data: ChatItemData::TuiCommandResponse {
383 id: generate_row_id(),
384 command,
385 response,
386 ts: time::OffsetDateTime::now_utc(),
387 },
388 });
389 }
390
391 async fn load_file_cache(&mut self) {
393 info!(target: "tui.file_cache", "Requesting workspace files for session {}", self.session_id);
395 if let Err(e) = self
396 .client
397 .send_command(AppCommand::RequestWorkspaceFiles)
398 .await
399 {
400 warn!(target: "tui.file_cache", "Failed to request workspace files: {}", e);
401 }
402 }
403
404 pub fn cleanup_terminal(&mut self) -> Result<()> {
405 execute!(
406 self.terminal.backend_mut(),
407 LeaveAlternateScreen,
408 DisableBracketedPaste,
409 PopKeyboardEnhancementFlags,
410 DisableMouseCapture
411 )?;
412 disable_raw_mode()?;
413 Ok(())
414 }
415
416 pub async fn run(&mut self, mut event_rx: mpsc::Receiver<AppEvent>) -> Result<()> {
417 info!(
419 "Starting TUI run with {} messages in view model",
420 self.chat_store.len()
421 );
422
423 self.load_file_cache().await;
425
426 let (term_event_tx, mut term_event_rx) = mpsc::channel::<Result<Event>>(1);
427 let input_handle: JoinHandle<()> = tokio::spawn(async move {
428 loop {
429 if event::poll(Duration::ZERO).unwrap_or(false) {
431 match event::read() {
432 Ok(evt) => {
433 if term_event_tx.send(Ok(evt)).await.is_err() {
434 break; }
436 }
437 Err(e) if e.kind() == io::ErrorKind::Interrupted => {
438 debug!(target: "tui.input", "Ignoring interrupted syscall");
441 continue;
442 }
443 Err(e) => {
444 warn!(target: "tui.input", "Input error: {}", e);
447 if term_event_tx.send(Err(Error::from(e))).await.is_err() {
448 break; }
450 break;
451 }
452 }
453 } else {
454 tokio::time::sleep(Duration::from_millis(10)).await;
456 }
457 }
458 });
459
460 let mut should_exit = false;
461 let mut needs_redraw = true; let mut last_spinner_char = String::new();
463
464 let mut tick = tokio::time::interval(SPINNER_UPDATE_INTERVAL);
466 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
467
468 while !should_exit {
469 if needs_redraw {
471 self.draw()?;
472 needs_redraw = false;
473 }
474
475 tokio::select! {
476 Some(event_res) = term_event_rx.recv() => {
477 match event_res {
478 Ok(evt) => {
479 match evt {
480 Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
481 match self.handle_key_event(key_event).await {
482 Ok(exit) => {
483 if exit {
484 should_exit = true;
485 }
486 }
487 Err(e) => {
488 use crate::tui::model::{ChatItem, ChatItemData, NoticeLevel, generate_row_id};
490 self.chat_store.push(ChatItem {
491 parent_chat_item_id: None,
492 data: ChatItemData::SystemNotice {
493 id: generate_row_id(),
494 level: NoticeLevel::Error,
495 text: e.to_string(),
496 ts: time::OffsetDateTime::now_utc(),
497 },
498 });
499 }
500 }
501 needs_redraw = true;
502 }
503 Event::Mouse(mouse_event) => {
504 if self.handle_mouse_event(mouse_event)? {
505 needs_redraw = true;
506 }
507 }
508 Event::Resize(width, height) => {
509 self.terminal_size = (width, height);
510 needs_redraw = true;
512 }
513 Event::Paste(data) => {
514 if self.is_text_input_mode() {
516 if self.input_mode == InputMode::Setup {
517 if let Some(setup_state) = &mut self.setup_state {
519 match &setup_state.current_step {
520 crate::tui::state::SetupStep::Authentication(_) => {
521 if setup_state.oauth_state.is_some() {
522 setup_state.oauth_callback_input.push_str(&data);
524 } else {
525 setup_state.api_key_input.push_str(&data);
527 }
528 debug!(target:"tui.run", "Pasted {} chars in Setup mode", data.len());
529 needs_redraw = true;
530 }
531 _ => {
532 }
534 }
535 }
536 } else {
537 let normalized_data =
538 data.replace("\r\n", "\n").replace('\r', "\n");
539 self.input_panel_state.insert_str(&normalized_data);
540 debug!(target:"tui.run", "Pasted {} chars in {:?} mode", normalized_data.len(), self.input_mode);
541 needs_redraw = true;
542 }
543 }
544 }
545 _ => {}
546 }
547 }
548 Err(e) => {
549 error!(target: "tui.run", "Fatal input error: {}. Exiting.", e);
550 should_exit = true;
551 }
552 }
553 }
554 Some(app_event) = event_rx.recv() => {
555 self.handle_app_event(app_event).await;
556 needs_redraw = true;
557 }
558 _ = tick.tick() => {
559 let has_pending_tools = !self.tool_registry.pending_calls().is_empty()
561 || !self.tool_registry.active_calls().is_empty()
562 || self.chat_store.has_pending_tools();
563 let has_in_flight_operations = !self.in_flight_operations.is_empty();
564
565 if self.is_processing || has_pending_tools || has_in_flight_operations {
566 self.spinner_state = self.spinner_state.wrapping_add(1);
567 let ch = get_spinner_char(self.spinner_state);
568 if ch != last_spinner_char {
569 last_spinner_char = ch.to_string();
570 needs_redraw = true;
571 }
572 }
573 }
574 }
575 }
576
577 self.cleanup_terminal()?;
579 input_handle.abort();
580 Ok(())
581 }
582
583 fn handle_mouse_event(&mut self, event: MouseEvent) -> Result<bool> {
585 let needs_redraw = match event.kind {
586 event::MouseEventKind::ScrollUp => {
587 if !self.is_text_input_mode()
589 || (self.input_mode == InputMode::Simple
590 && self.input_panel_state.content().is_empty())
591 {
592 self.chat_viewport.state_mut().scroll_up(3);
593 true
594 } else {
595 false
596 }
597 }
598 event::MouseEventKind::ScrollDown => {
599 if !self.is_text_input_mode()
601 || (self.input_mode == InputMode::Simple
602 && self.input_panel_state.content().is_empty())
603 {
604 self.chat_viewport.state_mut().scroll_down(3);
605 true
606 } else {
607 false
608 }
609 }
610 _ => false,
611 };
612
613 Ok(needs_redraw)
614 }
615
616 fn draw(&mut self) -> Result<()> {
618 self.terminal.draw(|f| {
619 if let Some(setup_state) = &self.setup_state {
621 use crate::tui::widgets::setup::{
622 authentication::AuthenticationWidget, completion::CompletionWidget,
623 provider_selection::ProviderSelectionWidget, welcome::WelcomeWidget,
624 };
625
626 match &setup_state.current_step {
627 crate::tui::state::SetupStep::Welcome => {
628 WelcomeWidget::render(f.area(), f.buffer_mut(), &self.theme);
629 }
630 crate::tui::state::SetupStep::ProviderSelection => {
631 ProviderSelectionWidget::render(
632 f.area(),
633 f.buffer_mut(),
634 setup_state,
635 &self.theme,
636 );
637 }
638 crate::tui::state::SetupStep::Authentication(provider) => {
639 AuthenticationWidget::render(
640 f.area(),
641 f.buffer_mut(),
642 setup_state,
643 *provider,
644 &self.theme,
645 );
646 }
647 crate::tui::state::SetupStep::Completion => {
648 CompletionWidget::render(
649 f.area(),
650 f.buffer_mut(),
651 setup_state,
652 &self.theme,
653 );
654 }
655 }
656 return;
657 }
658
659 let input_mode = self.input_mode;
660 let is_processing = self.is_processing;
661 let spinner_state = self.spinner_state;
662 let current_tool_approval = self.current_tool_approval.as_ref();
663 let current_model_owned = self.current_model;
664
665 let current_revision = self.chat_store.revision();
667 if current_revision != self.last_revision {
668 self.chat_viewport.mark_dirty();
669 self.last_revision = current_revision;
670 }
671
672 let chat_items: Vec<&ChatItem> = self.chat_store.as_items();
674
675 let terminal_size = f.area();
676
677 let input_area_height = self.input_panel_state.required_height(
678 current_tool_approval,
679 terminal_size.width,
680 terminal_size.height,
681 );
682
683 let layout = UiLayout::compute(terminal_size, input_area_height, &self.theme);
684 layout.prepare_background(f, &self.theme);
685
686 self.chat_viewport.rebuild(
687 &chat_items,
688 layout.chat_area.width,
689 self.chat_viewport.state().view_mode,
690 &self.theme,
691 &self.chat_store,
692 );
693
694 let hovered_id = self
695 .input_panel_state
696 .get_hovered_id()
697 .map(|s| s.to_string());
698
699 self.chat_viewport.render(
700 f,
701 layout.chat_area,
702 spinner_state,
703 hovered_id.as_deref(),
704 &self.theme,
705 );
706
707 let input_panel = InputPanel::new(
708 input_mode,
709 current_tool_approval,
710 is_processing,
711 spinner_state,
712 &self.theme,
713 );
714 f.render_stateful_widget(input_panel, layout.input_area, &mut self.input_panel_state);
715
716 layout.render_status_bar(f, ¤t_model_owned, &self.theme);
718
719 let fuzzy_finder_data = if input_mode == InputMode::FuzzyFinder {
721 let results = self.input_panel_state.fuzzy_finder.results().to_vec();
722 let selected = self.input_panel_state.fuzzy_finder.selected_index();
723 let input_height = self.input_panel_state.required_height(
724 current_tool_approval,
725 terminal_size.width,
726 10,
727 );
728 let mode = self.input_panel_state.fuzzy_finder.mode();
729 Some((results, selected, input_height, mode))
730 } else {
731 None
732 };
733
734 if let Some((results, selected_index, input_height, mode)) = fuzzy_finder_data {
736 Self::render_fuzzy_finder_overlay_static(
737 f,
738 &results,
739 selected_index,
740 input_height,
741 mode,
742 &self.theme,
743 &self.command_registry,
744 );
745 }
746 })?;
747 Ok(())
748 }
749
750 fn render_fuzzy_finder_overlay_static(
752 f: &mut Frame,
753 results: &[crate::tui::widgets::fuzzy_finder::PickerItem],
754 selected_index: usize,
755 input_panel_height: u16,
756 mode: crate::tui::widgets::fuzzy_finder::FuzzyFinderMode,
757 theme: &Theme,
758 command_registry: &CommandRegistry,
759 ) {
760 use ratatui::layout::Rect;
761 use ratatui::style::Style;
762 use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState};
763
764 if results.is_empty() {
767 return; }
769
770 let total_area = f.area();
772
773 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);
781 let overlay_area = Rect {
782 x: total_area.x,
783 y: overlay_y,
784 width: total_area.width,
785 height: overlay_height,
786 };
787
788 f.render_widget(Clear, overlay_area);
790
791 let items: Vec<ListItem> = match mode {
794 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Files => results
795 .iter()
796 .enumerate()
797 .rev()
798 .map(|(i, item)| {
799 let is_selected = selected_index == i;
800 let style = if is_selected {
801 theme.style(theme::Component::PopupSelection)
802 } else {
803 Style::default()
804 };
805 ListItem::new(item.label.as_str()).style(style)
806 })
807 .collect(),
808 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Commands => {
809 results
810 .iter()
811 .enumerate()
812 .rev()
813 .map(|(i, item)| {
814 let is_selected = selected_index == i;
815 let style = if is_selected {
816 theme.style(theme::Component::PopupSelection)
817 } else {
818 Style::default()
819 };
820
821 let label = &item.label;
823 if let Some(cmd_info) = command_registry.get(label.as_str()) {
824 let line = format!("/{:<12} {}", cmd_info.name, cmd_info.description);
825 ListItem::new(line).style(style)
826 } else {
827 ListItem::new(format!("/{label}")).style(style)
828 }
829 })
830 .collect()
831 }
832 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Models
833 | crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Themes => results
834 .iter()
835 .enumerate()
836 .rev()
837 .map(|(i, item)| {
838 let is_selected = selected_index == i;
839 let style = if is_selected {
840 theme.style(theme::Component::PopupSelection)
841 } else {
842 Style::default()
843 };
844 ListItem::new(item.label.as_str()).style(style)
845 })
846 .collect(),
847 };
848
849 let list_block = Block::default()
851 .borders(Borders::ALL)
852 .border_style(theme.style(theme::Component::PopupBorder))
853 .title(match mode {
854 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Files => " Files ",
855 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Commands => " Commands ",
856 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Models => " Select Model ",
857 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Themes => " Select Theme ",
858 });
859
860 let list = List::new(items)
861 .block(list_block)
862 .highlight_style(theme.style(theme::Component::PopupSelection));
863
864 let mut list_state = ListState::default();
866 let reversed_selection = results
867 .len()
868 .saturating_sub(1)
869 .saturating_sub(selected_index);
870 list_state.select(Some(reversed_selection));
871
872 f.render_stateful_widget(list, overlay_area, &mut list_state);
873 }
874
875 fn create_event_pipeline() -> EventPipeline {
877 EventPipeline::new()
878 .add_processor(Box::new(ProcessingStateProcessor::new()))
879 .add_processor(Box::new(MessageEventProcessor::new()))
880 .add_processor(Box::new(ToolEventProcessor::new()))
881 .add_processor(Box::new(SystemEventProcessor::new()))
882 }
883
884 async fn handle_app_event(&mut self, event: AppEvent) {
885 let mut messages_updated = false;
886
887 match &event {
889 AppEvent::WorkspaceChanged => {
890 self.load_file_cache().await;
891 }
892 AppEvent::WorkspaceFiles { files } => {
893 info!(target: "tui.handle_app_event", "Received workspace files event with {} files", files.len());
895 self.input_panel_state
896 .file_cache
897 .update(files.clone())
898 .await;
899 }
900 _ => {}
901 }
902
903 let mut ctx = crate::tui::events::processor::ProcessingContext {
905 chat_store: &mut self.chat_store,
906 chat_list_state: self.chat_viewport.state_mut(),
907 tool_registry: &mut self.tool_registry,
908 client: &self.client,
909 is_processing: &mut self.is_processing,
910 progress_message: &mut self.progress_message,
911 spinner_state: &mut self.spinner_state,
912 current_tool_approval: &mut self.current_tool_approval,
913 current_model: &mut self.current_model,
914 messages_updated: &mut messages_updated,
915 in_flight_operations: &mut self.in_flight_operations,
916 };
917
918 if let Err(e) = self.event_pipeline.process_event(event, &mut ctx).await {
920 tracing::error!(target: "tui.handle_app_event", "Event processing failed: {}", e);
921 }
922
923 if self.current_tool_approval.is_some() && self.input_mode != InputMode::AwaitingApproval {
927 self.switch_mode(InputMode::AwaitingApproval);
928 } else if self.current_tool_approval.is_none()
929 && self.input_mode == InputMode::AwaitingApproval
930 {
931 self.restore_previous_mode();
932 }
933
934 if messages_updated {
936 if self.chat_viewport.state_mut().is_at_bottom() {
939 self.chat_viewport.state_mut().scroll_to_bottom();
940 }
941 }
942 }
943
944 async fn send_message(&mut self, content: String) -> Result<()> {
945 if content.starts_with('/') {
947 return self.handle_slash_command(content).await;
948 }
949
950 if let Some(message_id_to_edit) = self.editing_message_id.take() {
952 if let Err(e) = self
954 .client
955 .send_command(AppCommand::EditMessage {
956 message_id: message_id_to_edit,
957 new_content: content,
958 })
959 .await
960 {
961 self.push_notice(NoticeLevel::Error, format!("Cannot edit message: {e}"));
962 }
963 } else {
964 if let Err(e) = self
966 .client
967 .send_command(AppCommand::ProcessUserInput(content))
968 .await
969 {
970 self.push_notice(NoticeLevel::Error, format!("Cannot send message: {e}"));
971 }
972 }
973 Ok(())
974 }
975
976 async fn handle_slash_command(&mut self, command_input: String) -> Result<()> {
977 use crate::tui::commands::{AppCommand as TuiAppCommand, TuiCommand, TuiCommandType};
978 use crate::tui::model::NoticeLevel;
979
980 let cmd_name = command_input
982 .trim()
983 .strip_prefix('/')
984 .unwrap_or(command_input.trim());
985
986 if let Some(cmd_info) = self.command_registry.get(cmd_name) {
987 if let crate::tui::commands::registry::CommandScope::Custom(custom_cmd) =
988 &cmd_info.scope
989 {
990 let app_cmd = TuiAppCommand::Tui(TuiCommand::Custom(custom_cmd.clone()));
992 match app_cmd {
994 TuiAppCommand::Tui(TuiCommand::Custom(custom_cmd)) => {
995 match custom_cmd {
997 crate::tui::custom_commands::CustomCommand::Prompt {
998 prompt, ..
999 } => {
1000 self.client
1002 .send_command(AppCommand::ProcessUserInput(prompt))
1003 .await?
1004 } }
1006 }
1007 _ => unreachable!(),
1008 }
1009 return Ok(());
1010 }
1011 }
1012
1013 let app_cmd = match TuiAppCommand::parse(&command_input) {
1015 Ok(cmd) => cmd,
1016 Err(e) => {
1017 self.push_notice(NoticeLevel::Error, e.to_string());
1019 return Ok(());
1020 }
1021 };
1022
1023 match app_cmd {
1025 TuiAppCommand::Tui(tui_cmd) => {
1026 match tui_cmd {
1028 TuiCommand::ReloadFiles => {
1029 self.input_panel_state.file_cache.clear().await;
1031 info!(target: "tui.slash_command", "Cleared file cache, will reload on next access");
1032 if let Err(e) = self
1034 .client
1035 .send_command(AppCommand::RequestWorkspaceFiles)
1036 .await
1037 {
1038 self.push_notice(
1039 NoticeLevel::Error,
1040 format!("Cannot reload files: {e}"),
1041 );
1042 } else {
1043 self.push_tui_response(
1044 TuiCommandType::ReloadFiles.command_name(),
1045 TuiCommandResponse::Text(
1046 "File cache cleared. Files will be reloaded on next access."
1047 .to_string(),
1048 ),
1049 );
1050 }
1051 }
1052 TuiCommand::Theme(theme_name) => {
1053 if let Some(name) = theme_name {
1054 let loader = theme::ThemeLoader::new();
1056 match loader.load_theme(&name) {
1057 Ok(new_theme) => {
1058 self.theme = new_theme;
1059 self.push_tui_response(
1060 TuiCommandType::Theme.command_name(),
1061 TuiCommandResponse::Theme { name: name.clone() },
1062 );
1063 }
1064 Err(e) => {
1065 self.push_notice(
1066 NoticeLevel::Error,
1067 format!("Failed to load theme '{name}': {e}"),
1068 );
1069 }
1070 }
1071 } else {
1072 let loader = theme::ThemeLoader::new();
1074 let themes = loader.list_themes();
1075 self.push_tui_response(
1076 TuiCommandType::Theme.command_name(),
1077 TuiCommandResponse::ListThemes(themes),
1078 );
1079 }
1080 }
1081 TuiCommand::Help(command_name) => {
1082 let help_text = if let Some(cmd_name) = command_name {
1084 if let Some(cmd_info) = self.command_registry.get(&cmd_name) {
1086 format!(
1087 "Command: {}\n\nDescription: {}\n\nUsage: {}",
1088 cmd_info.name, cmd_info.description, cmd_info.usage
1089 )
1090 } else {
1091 format!("Unknown command: {cmd_name}")
1092 }
1093 } else {
1094 let mut help_lines = vec!["Available commands:".to_string()];
1096 for cmd_info in self.command_registry.all_commands() {
1097 help_lines.push(format!(
1098 " {:<20} - {}",
1099 cmd_info.usage, cmd_info.description
1100 ));
1101 }
1102 help_lines.join("\n")
1103 };
1104
1105 self.push_tui_response(
1106 TuiCommandType::Help.command_name(),
1107 TuiCommandResponse::Text(help_text),
1108 );
1109 }
1110 TuiCommand::Auth => {
1111 let auth_storage =
1114 steer_core::auth::DefaultAuthStorage::new().map_err(|e| {
1115 crate::error::Error::Generic(format!(
1116 "Failed to create auth storage: {e}"
1117 ))
1118 })?;
1119 let auth_providers = LlmConfigProvider::new(Arc::new(auth_storage))
1120 .available_providers()
1121 .await?;
1122
1123 let mut provider_status = std::collections::HashMap::new();
1124 for provider in [
1125 steer_core::api::ProviderKind::Anthropic,
1126 steer_core::api::ProviderKind::OpenAI,
1127 steer_core::api::ProviderKind::Google,
1128 steer_core::api::ProviderKind::XAI,
1129 ] {
1130 let status = if auth_providers.contains(&provider) {
1131 crate::tui::state::AuthStatus::ApiKeySet
1132 } else {
1133 crate::tui::state::AuthStatus::NotConfigured
1134 };
1135 provider_status.insert(provider, status);
1136 }
1137
1138 self.setup_state = Some(
1140 crate::tui::state::SetupState::new_for_auth_command(provider_status),
1141 );
1142 self.set_mode(InputMode::Setup);
1145 self.mode_stack.clear();
1147
1148 self.push_tui_response(
1149 TuiCommandType::Auth.to_string(),
1150 TuiCommandResponse::Text(
1151 "Entering authentication setup mode...".to_string(),
1152 ),
1153 );
1154 }
1155 TuiCommand::EditingMode(ref mode_name) => {
1156 let response = match mode_name.as_deref() {
1157 None => {
1158 let mode_str = self.preferences.ui.editing_mode.to_string();
1160 format!("Current editing mode: {mode_str}")
1161 }
1162 Some("simple") => {
1163 self.preferences.ui.editing_mode =
1164 steer_core::preferences::EditingMode::Simple;
1165 self.set_mode(InputMode::Simple);
1166 self.preferences.save().map_err(crate::error::Error::Core)?;
1167 "Switched to Simple mode".to_string()
1168 }
1169 Some("vim") => {
1170 self.preferences.ui.editing_mode =
1171 steer_core::preferences::EditingMode::Vim;
1172 self.set_mode(InputMode::VimNormal);
1173 self.preferences.save().map_err(crate::error::Error::Core)?;
1174 "Switched to Vim mode (Normal)".to_string()
1175 }
1176 Some(mode) => {
1177 format!("Unknown mode: '{mode}'. Use 'simple' or 'vim'")
1178 }
1179 };
1180
1181 self.push_tui_response(
1182 tui_cmd.as_command_str(),
1183 TuiCommandResponse::Text(response),
1184 );
1185 }
1186 TuiCommand::Mcp => {
1187 let servers = self.client.get_mcp_servers().await?;
1188 self.push_tui_response(
1189 tui_cmd.as_command_str(),
1190 TuiCommandResponse::ListMcpServers(servers),
1191 );
1192 }
1193 TuiCommand::Custom(custom_cmd) => {
1194 match custom_cmd {
1196 crate::tui::custom_commands::CustomCommand::Prompt {
1197 prompt, ..
1198 } => {
1199 self.client
1201 .send_command(AppCommand::ProcessUserInput(prompt))
1202 .await?;
1203 } }
1205 }
1206 }
1207 }
1208 TuiAppCommand::Core(core_cmd) => {
1209 if let Err(e) = self
1211 .client
1212 .send_command(AppCommand::ExecuteCommand(core_cmd))
1213 .await
1214 {
1215 self.push_notice(NoticeLevel::Error, e.to_string());
1216 }
1217 }
1218 }
1219
1220 Ok(())
1221 }
1222
1223 fn enter_edit_mode(&mut self, message_id: &str) {
1225 if let Some(item) = self.chat_store.get_by_id(&message_id.to_string()) {
1227 if let crate::tui::model::ChatItemData::Message(message) = &item.data {
1228 if let MessageData::User { content, .. } = &message.data {
1229 let text = content
1231 .iter()
1232 .filter_map(|block| match block {
1233 steer_core::app::conversation::UserContent::Text { text } => {
1234 Some(text.as_str())
1235 }
1236 _ => None,
1237 })
1238 .collect::<Vec<_>>()
1239 .join("\n");
1240
1241 self.input_panel_state
1243 .set_content_from_lines(text.lines().collect::<Vec<_>>());
1244 self.input_mode = match self.preferences.ui.editing_mode {
1246 steer_core::preferences::EditingMode::Simple => InputMode::Simple,
1247 steer_core::preferences::EditingMode::Vim => InputMode::VimInsert,
1248 };
1249
1250 self.editing_message_id = Some(message_id.to_string());
1252 }
1253 }
1254 }
1255 }
1256
1257 fn scroll_to_message_id(&mut self, message_id: &str) {
1259 let mut target_index = None;
1261 for (idx, item) in self.chat_store.items().enumerate() {
1262 if let crate::tui::model::ChatItemData::Message(message) = &item.data {
1263 if message.id() == message_id {
1264 target_index = Some(idx);
1265 break;
1266 }
1267 }
1268 }
1269
1270 if let Some(idx) = target_index {
1271 self.chat_viewport.state_mut().scroll_to_item(idx);
1273 }
1274 }
1275
1276 fn enter_edit_selection_mode(&mut self) {
1278 self.switch_mode(InputMode::EditMessageSelection);
1279
1280 self.input_panel_state
1282 .populate_edit_selection(self.chat_store.iter_items());
1283
1284 if let Some(id) = self.input_panel_state.get_hovered_id() {
1286 let id = id.to_string();
1287 self.scroll_to_message_id(&id);
1288 }
1289 }
1290}
1291
1292fn get_spinner_char(state: usize) -> &'static str {
1294 const SPINNER_CHARS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1295 SPINNER_CHARS[state % SPINNER_CHARS.len()]
1296}
1297
1298pub fn cleanup_terminal() {
1300 use ratatui::crossterm::{
1301 event::{DisableBracketedPaste, DisableMouseCapture, PopKeyboardEnhancementFlags},
1302 execute,
1303 terminal::{LeaveAlternateScreen, disable_raw_mode},
1304 };
1305 let _ = disable_raw_mode();
1306 let _ = execute!(
1307 std::io::stdout(),
1308 LeaveAlternateScreen,
1309 PopKeyboardEnhancementFlags,
1310 DisableBracketedPaste,
1311 DisableMouseCapture
1312 );
1313}
1314
1315pub fn setup_panic_hook() {
1317 std::panic::set_hook(Box::new(|panic_info| {
1318 cleanup_terminal();
1319 eprintln!("Application panicked:");
1321 eprintln!("{panic_info}");
1322 }));
1323}
1324
1325pub async fn run_tui(
1327 client: steer_grpc::AgentClient,
1328 session_id: Option<String>,
1329 model: steer_core::api::Model,
1330 directory: Option<std::path::PathBuf>,
1331 system_prompt: Option<String>,
1332 theme_name: Option<String>,
1333 force_setup: bool,
1334) -> Result<()> {
1335 use std::collections::HashMap;
1336 use steer_core::app::io::AppEventSource;
1337 use steer_core::session::{SessionConfig, SessionToolConfig};
1338
1339 let loader = theme::ThemeLoader::new();
1341 let theme = if let Some(theme_name) = theme_name {
1342 let path = std::path::Path::new(&theme_name);
1344 let theme_result = if path.is_absolute() || path.exists() {
1345 loader.load_theme_from_path(path)
1347 } else {
1348 loader.load_theme(&theme_name)
1350 };
1351
1352 match theme_result {
1353 Ok(theme) => {
1354 info!("Loaded theme: {}", theme_name);
1355 Some(theme)
1356 }
1357 Err(e) => {
1358 warn!(
1359 "Failed to load theme '{}': {}. Using default theme.",
1360 theme_name, e
1361 );
1362 loader.load_theme("catppuccin-mocha").ok()
1364 }
1365 }
1366 } else {
1367 match loader.load_theme("catppuccin-mocha") {
1369 Ok(theme) => {
1370 info!("Loaded default theme: catppuccin-mocha");
1371 Some(theme)
1372 }
1373 Err(e) => {
1374 warn!(
1375 "Failed to load default theme 'catppuccin-mocha': {}. Using hardcoded default.",
1376 e
1377 );
1378 None
1379 }
1380 }
1381 };
1382
1383 let (session_id, messages) = if let Some(session_id) = session_id {
1385 let (messages, _approved_tools) = client
1387 .activate_session(session_id.clone())
1388 .await
1389 .map_err(Box::new)?;
1390 info!(
1391 "Activated session: {} with {} messages",
1392 session_id,
1393 messages.len()
1394 );
1395 println!("Session ID: {session_id}");
1396 (session_id, messages)
1397 } else {
1398 let session_config = SessionConfig {
1400 workspace: if let Some(ref dir) = directory {
1401 steer_core::session::state::WorkspaceConfig::Local { path: dir.clone() }
1402 } else {
1403 steer_core::session::state::WorkspaceConfig::default()
1404 },
1405 tool_config: SessionToolConfig::default(),
1406 system_prompt,
1407 metadata: HashMap::new(),
1408 };
1409
1410 let session_id = client
1411 .create_session(session_config)
1412 .await
1413 .map_err(Box::new)?;
1414 (session_id, vec![])
1415 };
1416
1417 client.start_streaming().await.map_err(Box::new)?;
1418 let event_rx = client.subscribe().await;
1419 let mut tui = Tui::new(client, model, session_id, theme.clone()).await?;
1420
1421 if !messages.is_empty() {
1422 tui.restore_messages(messages);
1423 }
1424
1425 let auth_storage = steer_core::auth::DefaultAuthStorage::new()
1426 .map_err(|e| Error::Generic(format!("Failed to create auth storage: {e}")))?;
1427 let auth_providers = LlmConfigProvider::new(Arc::new(auth_storage))
1428 .available_providers()
1429 .await
1430 .map_err(|e| Error::Generic(format!("Failed to check auth: {e}")))?;
1431
1432 let should_run_setup = force_setup
1433 || (!steer_core::preferences::Preferences::config_path()
1434 .map(|p| p.exists())
1435 .unwrap_or(false)
1436 && auth_providers.is_empty());
1437
1438 if should_run_setup {
1440 let mut provider_status = std::collections::HashMap::new();
1441 for provider in [
1442 steer_core::api::ProviderKind::Anthropic,
1443 steer_core::api::ProviderKind::OpenAI,
1444 steer_core::api::ProviderKind::Google,
1445 steer_core::api::ProviderKind::XAI,
1446 ] {
1447 let status = if auth_providers.contains(&provider) {
1448 crate::tui::state::AuthStatus::ApiKeySet
1449 } else {
1450 crate::tui::state::AuthStatus::NotConfigured
1451 };
1452 provider_status.insert(provider, status);
1453 }
1454
1455 tui.setup_state = Some(crate::tui::state::SetupState::new(provider_status));
1456 tui.input_mode = InputMode::Setup;
1457 }
1458
1459 tui.run(event_rx).await?;
1461
1462 Ok(())
1463}
1464
1465pub async fn run_tui_auth_setup(
1468 client: steer_grpc::AgentClient,
1469 session_id: Option<String>,
1470 model: Option<Model>,
1471 session_db: Option<PathBuf>,
1472 theme_name: Option<String>,
1473) -> Result<()> {
1474 run_tui(
1477 client,
1478 session_id,
1479 model.unwrap_or_default(),
1480 session_db,
1481 None, theme_name,
1483 true, )
1485 .await
1486}
1487
1488#[cfg(test)]
1489mod tests {
1490 use crate::tui::test_utils::local_client_and_server;
1491
1492 use super::*;
1493
1494 use serde_json::json;
1495
1496 use steer_core::app::conversation::{AssistantContent, Message, MessageData};
1497 use tempfile::tempdir;
1498
1499 struct TerminalCleanupGuard;
1501
1502 impl Drop for TerminalCleanupGuard {
1503 fn drop(&mut self) {
1504 cleanup_terminal();
1505 }
1506 }
1507
1508 #[tokio::test]
1509 #[ignore = "Requires TTY - run with `cargo test -- --ignored` in a terminal"]
1510 async fn test_restore_messages_preserves_tool_call_params() {
1511 let _guard = TerminalCleanupGuard;
1512 let path = tempdir().unwrap().path().to_path_buf();
1514 let (client, _server_handle) = local_client_and_server(Some(path)).await;
1515 let model = steer_core::api::Model::Claude3_5Sonnet20241022;
1516 let session_id = "test_session_id".to_string();
1517 let mut tui = Tui::new(client, model, session_id, None).await.unwrap();
1518
1519 let tool_id = "test_tool_123".to_string();
1521 let tool_call = steer_tools::ToolCall {
1522 id: tool_id.clone(),
1523 name: "view".to_string(),
1524 parameters: json!({
1525 "file_path": "/test/file.rs",
1526 "offset": 10,
1527 "limit": 100
1528 }),
1529 };
1530
1531 let assistant_msg = Message {
1532 data: MessageData::Assistant {
1533 content: vec![AssistantContent::ToolCall {
1534 tool_call: tool_call.clone(),
1535 }],
1536 },
1537 id: "msg_assistant".to_string(),
1538 timestamp: 1234567890,
1539 parent_message_id: None,
1540 };
1541
1542 let tool_msg = Message {
1543 data: MessageData::Tool {
1544 tool_use_id: tool_id.clone(),
1545 result: steer_tools::ToolResult::FileContent(
1546 steer_tools::result::FileContentResult {
1547 file_path: "/test/file.rs".to_string(),
1548 content: "file content here".to_string(),
1549 line_count: 1,
1550 truncated: false,
1551 },
1552 ),
1553 },
1554 id: "msg_tool".to_string(),
1555 timestamp: 1234567891,
1556 parent_message_id: Some("msg_assistant".to_string()),
1557 };
1558
1559 let messages = vec![assistant_msg, tool_msg];
1560
1561 tui.restore_messages(messages);
1563
1564 let stored_call = tui
1566 .tool_registry
1567 .get_tool_call(&tool_id)
1568 .expect("Tool call should be in registry");
1569 assert_eq!(stored_call.name, "view");
1570 assert_eq!(stored_call.parameters, tool_call.parameters);
1571 }
1572
1573 #[tokio::test]
1574 #[ignore = "Requires TTY - run with `cargo test -- --ignored` in a terminal"]
1575 async fn test_restore_messages_handles_tool_result_before_assistant() {
1576 let _guard = TerminalCleanupGuard;
1577 let path = tempdir().unwrap().path().to_path_buf();
1579 let (client, _server_handle) = local_client_and_server(Some(path)).await;
1580 let model = steer_core::api::Model::Claude3_5Sonnet20241022;
1581 let session_id = "test_session_id".to_string();
1582 let mut tui = Tui::new(client, model, session_id, None).await.unwrap();
1583
1584 let tool_id = "test_tool_456".to_string();
1585 let real_params = json!({
1586 "file_path": "/another/file.rs"
1587 });
1588
1589 let tool_call = steer_tools::ToolCall {
1590 id: tool_id.clone(),
1591 name: "view".to_string(),
1592 parameters: real_params.clone(),
1593 };
1594
1595 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: "/another/file.rs".to_string(),
1602 content: "file content".to_string(),
1603 line_count: 1,
1604 truncated: false,
1605 },
1606 ),
1607 },
1608 id: "msg_tool".to_string(),
1609 timestamp: 1234567890,
1610 parent_message_id: None,
1611 };
1612
1613 let assistant_msg = Message {
1614 data: MessageData::Assistant {
1615 content: vec![AssistantContent::ToolCall {
1616 tool_call: tool_call.clone(),
1617 }],
1618 },
1619 id: "msg_456".to_string(),
1620 timestamp: 1234567891,
1621 parent_message_id: None,
1622 };
1623
1624 let messages = vec![tool_msg, assistant_msg];
1625
1626 tui.restore_messages(messages);
1627
1628 let stored_call = tui
1630 .tool_registry
1631 .get_tool_call(&tool_id)
1632 .expect("Tool call should be in registry");
1633 assert_eq!(stored_call.parameters, real_params);
1634 assert_eq!(stored_call.name, "view");
1635 }
1636}