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