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 ratatui::backend::CrosstermBackend;
17use ratatui::crossterm::event::{self, Event, KeyEventKind, MouseEvent};
18use ratatui::{Frame, Terminal};
19use steer_core::app::conversation::{AssistantContent, Message, MessageData};
20use steer_core::app::io::AppEventSource;
21use steer_core::app::{AppCommand, AppEvent};
22
23use steer_core::config::model::ModelId;
24use steer_grpc::AgentClient;
25use steer_tools::schema::ToolCall;
26use tokio::sync::mpsc;
27use tokio::task::JoinHandle;
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 (term_event_tx, term_event_rx) = mpsc::channel::<Result<Event>>(1);
417 let input_handle: JoinHandle<()> = tokio::spawn(async move {
418 loop {
419 if event::poll(Duration::ZERO).unwrap_or(false) {
421 match event::read() {
422 Ok(evt) => {
423 if term_event_tx.send(Ok(evt)).await.is_err() {
424 break; }
426 }
427 Err(e) if e.kind() == io::ErrorKind::Interrupted => {
428 debug!(target: "tui.input", "Ignoring interrupted syscall");
431 continue;
432 }
433 Err(e) => {
434 warn!(target: "tui.input", "Input error: {}", e);
437 if term_event_tx.send(Err(Error::from(e))).await.is_err() {
438 break; }
440 break;
441 }
442 }
443 } else {
444 tokio::time::sleep(Duration::from_millis(10)).await;
446 }
447 }
448 });
449
450 let run_result = self
452 .run_event_loop(event_rx, term_event_rx, update_rx)
453 .await;
454
455 input_handle.abort();
457
458 run_result
459 }
460
461 async fn run_event_loop(
462 &mut self,
463 mut event_rx: mpsc::Receiver<AppEvent>,
464 mut term_event_rx: mpsc::Receiver<Result<Event>>,
465 mut update_rx: mpsc::Receiver<UpdateStatus>,
466 ) -> Result<()> {
467 let mut should_exit = false;
468 let mut needs_redraw = true; let mut last_spinner_char = String::new();
470
471 let mut tick = tokio::time::interval(SPINNER_UPDATE_INTERVAL);
473 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
474
475 while !should_exit {
476 if needs_redraw {
478 self.draw()?;
479 needs_redraw = false;
480 }
481
482 tokio::select! {
483 status = update_rx.recv() => {
484 if let Some(status) = status {
485 self.update_status = status;
486 needs_redraw = true;
487 }
488 }
489 event_res = term_event_rx.recv() => {
490 match event_res {
491 Some(Ok(evt)) => {
492 match evt {
493 Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
494 match self.handle_key_event(key_event).await {
495 Ok(exit) => {
496 if exit {
497 should_exit = true;
498 }
499 }
500 Err(e) => {
501 use crate::tui::model::{ChatItem, ChatItemData, NoticeLevel, generate_row_id};
503 self.chat_store.push(ChatItem {
504 parent_chat_item_id: None,
505 data: ChatItemData::SystemNotice {
506 id: generate_row_id(),
507 level: NoticeLevel::Error,
508 text: e.to_string(),
509 ts: time::OffsetDateTime::now_utc(),
510 },
511 });
512 }
513 }
514 needs_redraw = true;
515 }
516 Event::Mouse(mouse_event) => {
517 if self.handle_mouse_event(mouse_event)? {
518 needs_redraw = true;
519 }
520 }
521 Event::Resize(width, height) => {
522 self.terminal_size = (width, height);
523 needs_redraw = true;
525 }
526 Event::Paste(data) => {
527 if self.is_text_input_mode() {
529 if self.input_mode == InputMode::Setup {
530 if let Some(setup_state) = &mut self.setup_state {
532 match &setup_state.current_step {
533 crate::tui::state::SetupStep::Authentication(_) => {
534 if setup_state.oauth_state.is_some() {
535 setup_state.oauth_callback_input.push_str(&data);
537 } else {
538 setup_state.api_key_input.push_str(&data);
540 }
541 debug!(target:"tui.run", "Pasted {} chars in Setup mode", data.len());
542 needs_redraw = true;
543 }
544 _ => {
545 }
547 }
548 }
549 } else {
550 let normalized_data =
551 data.replace("\r\n", "\n").replace('\r', "\n");
552 self.input_panel_state.insert_str(&normalized_data);
553 debug!(target:"tui.run", "Pasted {} chars in {:?} mode", normalized_data.len(), self.input_mode);
554 needs_redraw = true;
555 }
556 }
557 }
558 _ => {}
559 }
560 }
561 Some(Err(e)) => {
562 error!(target: "tui.run", "Fatal input error: {}. Exiting.", e);
563 should_exit = true;
564 }
565 None => {
566 should_exit = true;
568 }
569 }
570 }
571 app_event_opt = event_rx.recv() => {
572 match app_event_opt {
573 Some(app_event) => {
574 self.handle_app_event(app_event).await;
575 needs_redraw = true;
576 }
577 None => {
578 should_exit = true;
579 }
580 }
581 }
582 _ = tick.tick() => {
583 let has_pending_tools = !self.tool_registry.pending_calls().is_empty()
585 || !self.tool_registry.active_calls().is_empty()
586 || self.chat_store.has_pending_tools();
587 let has_in_flight_operations = !self.in_flight_operations.is_empty();
588
589 if self.is_processing || has_pending_tools || has_in_flight_operations {
590 self.spinner_state = self.spinner_state.wrapping_add(1);
591 let ch = get_spinner_char(self.spinner_state);
592 if ch != last_spinner_char {
593 last_spinner_char = ch.to_string();
594 needs_redraw = true;
595 }
596 }
597 }
598 }
599 }
600
601 Ok(())
602 }
603
604 fn handle_mouse_event(&mut self, event: MouseEvent) -> Result<bool> {
606 let needs_redraw = match event.kind {
607 event::MouseEventKind::ScrollUp => {
608 if !self.is_text_input_mode()
610 || (self.input_mode == InputMode::Simple
611 && self.input_panel_state.content().is_empty())
612 {
613 self.chat_viewport.state_mut().scroll_up(3);
614 true
615 } else {
616 false
617 }
618 }
619 event::MouseEventKind::ScrollDown => {
620 if !self.is_text_input_mode()
622 || (self.input_mode == InputMode::Simple
623 && self.input_panel_state.content().is_empty())
624 {
625 self.chat_viewport.state_mut().scroll_down(3);
626 true
627 } else {
628 false
629 }
630 }
631 _ => false,
632 };
633
634 Ok(needs_redraw)
635 }
636
637 fn draw(&mut self) -> Result<()> {
639 self.terminal.draw(|f| {
640 if let Some(setup_state) = &self.setup_state {
642 use crate::tui::widgets::setup::{
643 authentication::AuthenticationWidget, completion::CompletionWidget,
644 provider_selection::ProviderSelectionWidget, welcome::WelcomeWidget,
645 };
646
647 match &setup_state.current_step {
648 crate::tui::state::SetupStep::Welcome => {
649 WelcomeWidget::render(f.area(), f.buffer_mut(), &self.theme);
650 }
651 crate::tui::state::SetupStep::ProviderSelection => {
652 ProviderSelectionWidget::render(
653 f.area(),
654 f.buffer_mut(),
655 setup_state,
656 &self.theme,
657 );
658 }
659 crate::tui::state::SetupStep::Authentication(provider_id) => {
660 AuthenticationWidget::render(
661 f.area(),
662 f.buffer_mut(),
663 setup_state,
664 provider_id.clone(),
665 &self.theme,
666 );
667 }
668 crate::tui::state::SetupStep::Completion => {
669 CompletionWidget::render(
670 f.area(),
671 f.buffer_mut(),
672 setup_state,
673 &self.theme,
674 );
675 }
676 }
677 return;
678 }
679
680 let input_mode = self.input_mode;
681 let is_processing = self.is_processing;
682 let spinner_state = self.spinner_state;
683 let current_tool_approval = self.current_tool_approval.as_ref();
684 let current_model_owned = self.current_model.clone();
685
686 let current_revision = self.chat_store.revision();
688 if current_revision != self.last_revision {
689 self.chat_viewport.mark_dirty();
690 self.last_revision = current_revision;
691 }
692
693 let chat_items: Vec<&ChatItem> = self.chat_store.as_items();
695
696 let terminal_size = f.area();
697
698 let input_area_height = self.input_panel_state.required_height(
699 current_tool_approval,
700 terminal_size.width,
701 terminal_size.height,
702 );
703
704 let layout = UiLayout::compute(terminal_size, input_area_height, &self.theme);
705 layout.prepare_background(f, &self.theme);
706
707 self.chat_viewport.rebuild(
708 &chat_items,
709 layout.chat_area.width,
710 self.chat_viewport.state().view_mode,
711 &self.theme,
712 &self.chat_store,
713 );
714
715 let hovered_id = self
716 .input_panel_state
717 .get_hovered_id()
718 .map(|s| s.to_string());
719
720 self.chat_viewport.render(
721 f,
722 layout.chat_area,
723 spinner_state,
724 hovered_id.as_deref(),
725 &self.theme,
726 );
727
728 let input_panel = InputPanel::new(
729 input_mode,
730 current_tool_approval,
731 is_processing,
732 spinner_state,
733 &self.theme,
734 );
735 f.render_stateful_widget(input_panel, layout.input_area, &mut self.input_panel_state);
736
737 let update_badge = match &self.update_status {
738 UpdateStatus::Available(info) => {
739 crate::tui::widgets::status_bar::UpdateBadge::Available {
740 latest: &info.latest,
741 }
742 }
743 _ => crate::tui::widgets::status_bar::UpdateBadge::None,
744 };
745 layout.render_status_bar(f, ¤t_model_owned, &self.theme, update_badge);
746
747 let fuzzy_finder_data = if input_mode == InputMode::FuzzyFinder {
749 let results = self.input_panel_state.fuzzy_finder.results().to_vec();
750 let selected = self.input_panel_state.fuzzy_finder.selected_index();
751 let input_height = self.input_panel_state.required_height(
752 current_tool_approval,
753 terminal_size.width,
754 10,
755 );
756 let mode = self.input_panel_state.fuzzy_finder.mode();
757 Some((results, selected, input_height, mode))
758 } else {
759 None
760 };
761
762 if let Some((results, selected_index, input_height, mode)) = fuzzy_finder_data {
764 Self::render_fuzzy_finder_overlay_static(
765 f,
766 &results,
767 selected_index,
768 input_height,
769 mode,
770 &self.theme,
771 &self.command_registry,
772 );
773 }
774 })?;
775 Ok(())
776 }
777
778 fn render_fuzzy_finder_overlay_static(
780 f: &mut Frame,
781 results: &[crate::tui::widgets::fuzzy_finder::PickerItem],
782 selected_index: usize,
783 input_panel_height: u16,
784 mode: crate::tui::widgets::fuzzy_finder::FuzzyFinderMode,
785 theme: &Theme,
786 command_registry: &CommandRegistry,
787 ) {
788 use ratatui::layout::Rect;
789 use ratatui::style::Style;
790 use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState};
791
792 if results.is_empty() {
795 return; }
797
798 let total_area = f.area();
800
801 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);
809 let overlay_area = Rect {
810 x: total_area.x,
811 y: overlay_y,
812 width: total_area.width,
813 height: overlay_height,
814 };
815
816 f.render_widget(Clear, overlay_area);
818
819 let items: Vec<ListItem> = match mode {
822 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Files => results
823 .iter()
824 .enumerate()
825 .rev()
826 .map(|(i, item)| {
827 let is_selected = selected_index == i;
828 let style = if is_selected {
829 theme.style(theme::Component::PopupSelection)
830 } else {
831 Style::default()
832 };
833 ListItem::new(item.label.as_str()).style(style)
834 })
835 .collect(),
836 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Commands => {
837 results
838 .iter()
839 .enumerate()
840 .rev()
841 .map(|(i, item)| {
842 let is_selected = selected_index == i;
843 let style = if is_selected {
844 theme.style(theme::Component::PopupSelection)
845 } else {
846 Style::default()
847 };
848
849 let label = &item.label;
851 if let Some(cmd_info) = command_registry.get(label.as_str()) {
852 let line = format!("/{:<12} {}", cmd_info.name, cmd_info.description);
853 ListItem::new(line).style(style)
854 } else {
855 ListItem::new(format!("/{label}")).style(style)
856 }
857 })
858 .collect()
859 }
860 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Models
861 | crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Themes => results
862 .iter()
863 .enumerate()
864 .rev()
865 .map(|(i, item)| {
866 let is_selected = selected_index == i;
867 let style = if is_selected {
868 theme.style(theme::Component::PopupSelection)
869 } else {
870 Style::default()
871 };
872 ListItem::new(item.label.as_str()).style(style)
873 })
874 .collect(),
875 };
876
877 let list_block = Block::default()
879 .borders(Borders::ALL)
880 .border_style(theme.style(theme::Component::PopupBorder))
881 .title(match mode {
882 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Files => " Files ",
883 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Commands => " Commands ",
884 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Models => " Select Model ",
885 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Themes => " Select Theme ",
886 });
887
888 let list = List::new(items)
889 .block(list_block)
890 .highlight_style(theme.style(theme::Component::PopupSelection));
891
892 let mut list_state = ListState::default();
894 let reversed_selection = results
895 .len()
896 .saturating_sub(1)
897 .saturating_sub(selected_index);
898 list_state.select(Some(reversed_selection));
899
900 f.render_stateful_widget(list, overlay_area, &mut list_state);
901 }
902
903 fn create_event_pipeline() -> EventPipeline {
905 EventPipeline::new()
906 .add_processor(Box::new(ProcessingStateProcessor::new()))
907 .add_processor(Box::new(MessageEventProcessor::new()))
908 .add_processor(Box::new(ToolEventProcessor::new()))
909 .add_processor(Box::new(SystemEventProcessor::new()))
910 }
911
912 async fn handle_app_event(&mut self, event: AppEvent) {
913 let mut messages_updated = false;
914
915 match &event {
917 AppEvent::WorkspaceChanged => {
918 self.load_file_cache().await;
919 }
920 AppEvent::WorkspaceFiles { files } => {
921 info!(target: "tui.handle_app_event", "Received workspace files event with {} files", files.len());
923 self.input_panel_state
924 .file_cache
925 .update(files.clone())
926 .await;
927 }
928 _ => {}
929 }
930
931 let mut ctx = crate::tui::events::processor::ProcessingContext {
933 chat_store: &mut self.chat_store,
934 chat_list_state: self.chat_viewport.state_mut(),
935 tool_registry: &mut self.tool_registry,
936 client: &self.client,
937 is_processing: &mut self.is_processing,
938 progress_message: &mut self.progress_message,
939 spinner_state: &mut self.spinner_state,
940 current_tool_approval: &mut self.current_tool_approval,
941 current_model: &mut self.current_model,
942 messages_updated: &mut messages_updated,
943 in_flight_operations: &mut self.in_flight_operations,
944 };
945
946 if let Err(e) = self.event_pipeline.process_event(event, &mut ctx).await {
948 tracing::error!(target: "tui.handle_app_event", "Event processing failed: {}", e);
949 }
950
951 if self.current_tool_approval.is_some() && self.input_mode != InputMode::AwaitingApproval {
955 self.switch_mode(InputMode::AwaitingApproval);
956 } else if self.current_tool_approval.is_none()
957 && self.input_mode == InputMode::AwaitingApproval
958 {
959 self.restore_previous_mode();
960 }
961
962 if messages_updated {
964 if self.chat_viewport.state_mut().is_at_bottom() {
967 self.chat_viewport.state_mut().scroll_to_bottom();
968 }
969 }
970 }
971
972 async fn send_message(&mut self, content: String) -> Result<()> {
973 if content.starts_with('/') {
975 return self.handle_slash_command(content).await;
976 }
977
978 if let Some(message_id_to_edit) = self.editing_message_id.take() {
980 if let Err(e) = self
982 .client
983 .send_command(AppCommand::EditMessage {
984 message_id: message_id_to_edit,
985 new_content: content,
986 })
987 .await
988 {
989 self.push_notice(NoticeLevel::Error, format!("Cannot edit message: {e}"));
990 }
991 } else {
992 if let Err(e) = self
994 .client
995 .send_command(AppCommand::ProcessUserInput(content))
996 .await
997 {
998 self.push_notice(NoticeLevel::Error, format!("Cannot send message: {e}"));
999 }
1000 }
1001 Ok(())
1002 }
1003
1004 async fn handle_slash_command(&mut self, command_input: String) -> Result<()> {
1005 use crate::tui::commands::{AppCommand as TuiAppCommand, TuiCommand, TuiCommandType};
1006 use crate::tui::model::NoticeLevel;
1007
1008 let cmd_name = command_input
1010 .trim()
1011 .strip_prefix('/')
1012 .unwrap_or(command_input.trim());
1013
1014 if let Some(cmd_info) = self.command_registry.get(cmd_name) {
1015 if let crate::tui::commands::registry::CommandScope::Custom(custom_cmd) =
1016 &cmd_info.scope
1017 {
1018 let app_cmd = TuiAppCommand::Tui(TuiCommand::Custom(custom_cmd.clone()));
1020 match app_cmd {
1022 TuiAppCommand::Tui(TuiCommand::Custom(custom_cmd)) => {
1023 match custom_cmd {
1025 crate::tui::custom_commands::CustomCommand::Prompt {
1026 prompt, ..
1027 } => {
1028 self.client
1030 .send_command(AppCommand::ProcessUserInput(prompt))
1031 .await?
1032 } }
1034 }
1035 _ => unreachable!(),
1036 }
1037 return Ok(());
1038 }
1039 }
1040
1041 let app_cmd = match TuiAppCommand::parse(&command_input) {
1043 Ok(cmd) => cmd,
1044 Err(e) => {
1045 self.push_notice(NoticeLevel::Error, e.to_string());
1047 return Ok(());
1048 }
1049 };
1050
1051 match app_cmd {
1053 TuiAppCommand::Tui(tui_cmd) => {
1054 match tui_cmd {
1056 TuiCommand::ReloadFiles => {
1057 self.input_panel_state.file_cache.clear().await;
1059 info!(target: "tui.slash_command", "Cleared file cache, will reload on next access");
1060 if let Err(e) = self
1062 .client
1063 .send_command(AppCommand::RequestWorkspaceFiles)
1064 .await
1065 {
1066 self.push_notice(
1067 NoticeLevel::Error,
1068 format!("Cannot reload files: {e}"),
1069 );
1070 } else {
1071 self.push_tui_response(
1072 TuiCommandType::ReloadFiles.command_name(),
1073 TuiCommandResponse::Text(
1074 "File cache cleared. Files will be reloaded on next access."
1075 .to_string(),
1076 ),
1077 );
1078 }
1079 }
1080 TuiCommand::Theme(theme_name) => {
1081 if let Some(name) = theme_name {
1082 let loader = theme::ThemeLoader::new();
1084 match loader.load_theme(&name) {
1085 Ok(new_theme) => {
1086 self.theme = new_theme;
1087 self.push_tui_response(
1088 TuiCommandType::Theme.command_name(),
1089 TuiCommandResponse::Theme { name: name.clone() },
1090 );
1091 }
1092 Err(e) => {
1093 self.push_notice(
1094 NoticeLevel::Error,
1095 format!("Failed to load theme '{name}': {e}"),
1096 );
1097 }
1098 }
1099 } else {
1100 let loader = theme::ThemeLoader::new();
1102 let themes = loader.list_themes();
1103 self.push_tui_response(
1104 TuiCommandType::Theme.command_name(),
1105 TuiCommandResponse::ListThemes(themes),
1106 );
1107 }
1108 }
1109 TuiCommand::Help(command_name) => {
1110 let help_text = if let Some(cmd_name) = command_name {
1112 if let Some(cmd_info) = self.command_registry.get(&cmd_name) {
1114 format!(
1115 "Command: {}\n\nDescription: {}\n\nUsage: {}",
1116 cmd_info.name, cmd_info.description, cmd_info.usage
1117 )
1118 } else {
1119 format!("Unknown command: {cmd_name}")
1120 }
1121 } else {
1122 let mut help_lines = vec!["Available commands:".to_string()];
1124 for cmd_info in self.command_registry.all_commands() {
1125 help_lines.push(format!(
1126 " {:<20} - {}",
1127 cmd_info.usage, cmd_info.description
1128 ));
1129 }
1130 help_lines.join("\n")
1131 };
1132
1133 self.push_tui_response(
1134 TuiCommandType::Help.command_name(),
1135 TuiCommandResponse::Text(help_text),
1136 );
1137 }
1138 TuiCommand::Auth => {
1139 let providers = self.client.list_providers().await.map_err(|e| {
1143 crate::error::Error::Generic(format!(
1144 "Failed to list providers from server: {e}"
1145 ))
1146 })?;
1147 let statuses =
1148 self.client
1149 .get_provider_auth_status(None)
1150 .await
1151 .map_err(|e| {
1152 crate::error::Error::Generic(format!(
1153 "Failed to get provider auth status: {e}"
1154 ))
1155 })?;
1156
1157 let mut provider_status = std::collections::HashMap::new();
1159
1160 use steer_grpc::proto::provider_auth_status::Status;
1161 let mut status_map = std::collections::HashMap::new();
1162 for s in statuses {
1163 status_map.insert(s.provider_id.clone(), s.status);
1164 }
1165
1166 let registry =
1168 std::sync::Arc::new(RemoteProviderRegistry::from_proto(providers));
1169
1170 for p in registry.all() {
1171 let status = match status_map.get(&p.id).copied() {
1172 Some(v) if v == Status::AuthStatusOauth as i32 => {
1173 crate::tui::state::AuthStatus::OAuthConfigured
1174 }
1175 Some(v) if v == Status::AuthStatusApiKey as i32 => {
1176 crate::tui::state::AuthStatus::ApiKeySet
1177 }
1178 _ => crate::tui::state::AuthStatus::NotConfigured,
1179 };
1180 provider_status.insert(
1181 steer_core::config::provider::ProviderId(p.id.clone()),
1182 status,
1183 );
1184 }
1185
1186 self.setup_state =
1188 Some(crate::tui::state::SetupState::new_for_auth_command(
1189 registry,
1190 provider_status,
1191 ));
1192 self.set_mode(InputMode::Setup);
1195 self.mode_stack.clear();
1197
1198 self.push_tui_response(
1199 TuiCommandType::Auth.to_string(),
1200 TuiCommandResponse::Text(
1201 "Entering authentication setup mode...".to_string(),
1202 ),
1203 );
1204 }
1205 TuiCommand::EditingMode(ref mode_name) => {
1206 let response = match mode_name.as_deref() {
1207 None => {
1208 let mode_str = self.preferences.ui.editing_mode.to_string();
1210 format!("Current editing mode: {mode_str}")
1211 }
1212 Some("simple") => {
1213 self.preferences.ui.editing_mode =
1214 steer_core::preferences::EditingMode::Simple;
1215 self.set_mode(InputMode::Simple);
1216 self.preferences.save().map_err(crate::error::Error::Core)?;
1217 "Switched to Simple mode".to_string()
1218 }
1219 Some("vim") => {
1220 self.preferences.ui.editing_mode =
1221 steer_core::preferences::EditingMode::Vim;
1222 self.set_mode(InputMode::VimNormal);
1223 self.preferences.save().map_err(crate::error::Error::Core)?;
1224 "Switched to Vim mode (Normal)".to_string()
1225 }
1226 Some(mode) => {
1227 format!("Unknown mode: '{mode}'. Use 'simple' or 'vim'")
1228 }
1229 };
1230
1231 self.push_tui_response(
1232 tui_cmd.as_command_str(),
1233 TuiCommandResponse::Text(response),
1234 );
1235 }
1236 TuiCommand::Mcp => {
1237 let servers = self.client.get_mcp_servers().await?;
1238 self.push_tui_response(
1239 tui_cmd.as_command_str(),
1240 TuiCommandResponse::ListMcpServers(servers),
1241 );
1242 }
1243 TuiCommand::Custom(custom_cmd) => {
1244 match custom_cmd {
1246 crate::tui::custom_commands::CustomCommand::Prompt {
1247 prompt, ..
1248 } => {
1249 self.client
1251 .send_command(AppCommand::ProcessUserInput(prompt))
1252 .await?;
1253 } }
1255 }
1256 }
1257 }
1258 TuiAppCommand::Core(core_cmd) => {
1259 if let Err(e) = self
1261 .client
1262 .send_command(AppCommand::ExecuteCommand(core_cmd))
1263 .await
1264 {
1265 self.push_notice(NoticeLevel::Error, e.to_string());
1266 }
1267 }
1268 }
1269
1270 Ok(())
1271 }
1272
1273 fn enter_edit_mode(&mut self, message_id: &str) {
1275 if let Some(item) = self.chat_store.get_by_id(&message_id.to_string()) {
1277 if let crate::tui::model::ChatItemData::Message(message) = &item.data {
1278 if let MessageData::User { content, .. } = &message.data {
1279 let text = content
1281 .iter()
1282 .filter_map(|block| match block {
1283 steer_core::app::conversation::UserContent::Text { text } => {
1284 Some(text.as_str())
1285 }
1286 _ => None,
1287 })
1288 .collect::<Vec<_>>()
1289 .join("\n");
1290
1291 self.input_panel_state
1293 .set_content_from_lines(text.lines().collect::<Vec<_>>());
1294 self.input_mode = match self.preferences.ui.editing_mode {
1296 steer_core::preferences::EditingMode::Simple => InputMode::Simple,
1297 steer_core::preferences::EditingMode::Vim => InputMode::VimInsert,
1298 };
1299
1300 self.editing_message_id = Some(message_id.to_string());
1302 }
1303 }
1304 }
1305 }
1306
1307 fn scroll_to_message_id(&mut self, message_id: &str) {
1309 let mut target_index = None;
1311 for (idx, item) in self.chat_store.items().enumerate() {
1312 if let crate::tui::model::ChatItemData::Message(message) = &item.data {
1313 if message.id() == message_id {
1314 target_index = Some(idx);
1315 break;
1316 }
1317 }
1318 }
1319
1320 if let Some(idx) = target_index {
1321 self.chat_viewport.state_mut().scroll_to_item(idx);
1323 }
1324 }
1325
1326 fn enter_edit_selection_mode(&mut self) {
1328 self.switch_mode(InputMode::EditMessageSelection);
1329
1330 self.input_panel_state
1332 .populate_edit_selection(self.chat_store.iter_items());
1333
1334 if let Some(id) = self.input_panel_state.get_hovered_id() {
1336 let id = id.to_string();
1337 self.scroll_to_message_id(&id);
1338 }
1339 }
1340}
1341
1342fn get_spinner_char(state: usize) -> &'static str {
1344 const SPINNER_CHARS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1345 SPINNER_CHARS[state % SPINNER_CHARS.len()]
1346}
1347
1348impl Drop for Tui {
1349 fn drop(&mut self) {
1350 crate::tui::terminal::cleanup_with_writer(self.terminal.backend_mut());
1352 }
1353}
1354
1355pub fn setup_panic_hook() {
1357 std::panic::set_hook(Box::new(|panic_info| {
1358 cleanup();
1359 eprintln!("Application panicked:");
1361 eprintln!("{panic_info}");
1362 }));
1363}
1364
1365pub async fn run_tui(
1367 client: steer_grpc::AgentClient,
1368 session_id: Option<String>,
1369 model: steer_core::config::model::ModelId,
1370 directory: Option<std::path::PathBuf>,
1371 system_prompt: Option<String>,
1372 theme_name: Option<String>,
1373 force_setup: bool,
1374) -> Result<()> {
1375 use std::collections::HashMap;
1376 use steer_core::session::{SessionConfig, SessionToolConfig};
1377
1378 let loader = theme::ThemeLoader::new();
1380 let theme = if let Some(theme_name) = theme_name {
1381 let path = std::path::Path::new(&theme_name);
1383 let theme_result = if path.is_absolute() || path.exists() {
1384 loader.load_theme_from_path(path)
1386 } else {
1387 loader.load_theme(&theme_name)
1389 };
1390
1391 match theme_result {
1392 Ok(theme) => {
1393 info!("Loaded theme: {}", theme_name);
1394 Some(theme)
1395 }
1396 Err(e) => {
1397 warn!(
1398 "Failed to load theme '{}': {}. Using default theme.",
1399 theme_name, e
1400 );
1401 loader.load_theme("catppuccin-mocha").ok()
1403 }
1404 }
1405 } else {
1406 match loader.load_theme("catppuccin-mocha") {
1408 Ok(theme) => {
1409 info!("Loaded default theme: catppuccin-mocha");
1410 Some(theme)
1411 }
1412 Err(e) => {
1413 warn!(
1414 "Failed to load default theme 'catppuccin-mocha': {}. Using hardcoded default.",
1415 e
1416 );
1417 None
1418 }
1419 }
1420 };
1421
1422 let (session_id, messages) = if let Some(session_id) = session_id {
1424 let (messages, _approved_tools) = client
1426 .activate_session(session_id.clone())
1427 .await
1428 .map_err(Box::new)?;
1429 info!(
1430 "Activated session: {} with {} messages",
1431 session_id,
1432 messages.len()
1433 );
1434 println!("Session ID: {session_id}");
1435 (session_id, messages)
1436 } else {
1437 let mut session_config = SessionConfig {
1439 workspace: if let Some(ref dir) = directory {
1440 steer_core::session::state::WorkspaceConfig::Local { path: dir.clone() }
1441 } else {
1442 steer_core::session::state::WorkspaceConfig::default()
1443 },
1444 tool_config: SessionToolConfig::default(),
1445 system_prompt,
1446 metadata: HashMap::new(),
1447 };
1448
1449 session_config.metadata.insert(
1451 "initial_model".to_string(),
1452 format!("{}/{}", model.0.storage_key(), model.1),
1453 );
1454
1455 let session_id = client
1456 .create_session(session_config)
1457 .await
1458 .map_err(Box::new)?;
1459 (session_id, vec![])
1460 };
1461
1462 client.start_streaming().await.map_err(Box::new)?;
1463 let event_rx = client.subscribe().await;
1464 let mut tui = Tui::new(client, model.clone(), session_id.clone(), theme.clone()).await?;
1465
1466 struct TuiCleanupGuard;
1468 impl Drop for TuiCleanupGuard {
1469 fn drop(&mut self) {
1470 cleanup();
1471 }
1472 }
1473 let _cleanup_guard = TuiCleanupGuard;
1474
1475 if !messages.is_empty() {
1476 tui.restore_messages(messages.clone());
1477 }
1478
1479 let statuses = tui
1481 .client
1482 .get_provider_auth_status(None)
1483 .await
1484 .map_err(|e| Error::Generic(format!("Failed to get provider auth status: {e}")))?;
1485
1486 use steer_grpc::proto::provider_auth_status::Status as AuthStatusProto;
1487 let has_any_auth = statuses.iter().any(|s| {
1488 s.status == AuthStatusProto::AuthStatusOauth as i32
1489 || s.status == AuthStatusProto::AuthStatusApiKey as i32
1490 });
1491
1492 let should_run_setup = force_setup
1493 || (!steer_core::preferences::Preferences::config_path()
1494 .map(|p| p.exists())
1495 .unwrap_or(false)
1496 && !has_any_auth);
1497
1498 if should_run_setup {
1500 let providers =
1502 tui.client.list_providers().await.map_err(|e| {
1503 Error::Generic(format!("Failed to list providers from server: {e}"))
1504 })?;
1505 let registry = std::sync::Arc::new(RemoteProviderRegistry::from_proto(providers));
1506
1507 let mut status_map = std::collections::HashMap::new();
1509 for s in statuses {
1510 status_map.insert(s.provider_id.clone(), s.status);
1511 }
1512
1513 let mut provider_status = std::collections::HashMap::new();
1514 use steer_grpc::proto::provider_auth_status::Status as AuthStatusProto;
1515 for p in registry.all() {
1516 let status = match status_map.get(&p.id).copied() {
1517 Some(v) if v == AuthStatusProto::AuthStatusOauth as i32 => {
1518 crate::tui::state::AuthStatus::OAuthConfigured
1519 }
1520 Some(v) if v == AuthStatusProto::AuthStatusApiKey as i32 => {
1521 crate::tui::state::AuthStatus::ApiKeySet
1522 }
1523 _ => crate::tui::state::AuthStatus::NotConfigured,
1524 };
1525 provider_status.insert(
1526 steer_core::config::provider::ProviderId(p.id.clone()),
1527 status,
1528 );
1529 }
1530
1531 tui.setup_state = Some(crate::tui::state::SetupState::new(
1532 registry,
1533 provider_status,
1534 ));
1535 tui.input_mode = InputMode::Setup;
1536 }
1537
1538 tui.run(event_rx).await
1540}
1541
1542pub async fn run_tui_auth_setup(
1545 client: steer_grpc::AgentClient,
1546 session_id: Option<String>,
1547 model: Option<ModelId>,
1548 session_db: Option<PathBuf>,
1549 theme_name: Option<String>,
1550) -> Result<()> {
1551 run_tui(
1554 client,
1555 session_id,
1556 model.unwrap_or(steer_core::config::model::builtin::claude_3_7_sonnet_20250219()),
1557 session_db,
1558 None, theme_name,
1560 true, )
1562 .await
1563}
1564
1565#[cfg(test)]
1566mod tests {
1567 use crate::tui::test_utils::local_client_and_server;
1568
1569 use super::*;
1570
1571 use serde_json::json;
1572
1573 use steer_core::app::conversation::{AssistantContent, Message, MessageData};
1574 use tempfile::tempdir;
1575
1576 struct TerminalCleanupGuard;
1578
1579 impl Drop for TerminalCleanupGuard {
1580 fn drop(&mut self) {
1581 cleanup();
1582 }
1583 }
1584
1585 #[tokio::test]
1586 #[ignore = "Requires TTY - run with `cargo test -- --ignored` in a terminal"]
1587 async fn test_restore_messages_preserves_tool_call_params() {
1588 let _guard = TerminalCleanupGuard;
1589 let path = tempdir().unwrap().path().to_path_buf();
1591 let (client, _server_handle) = local_client_and_server(Some(path)).await;
1592 let model = steer_core::config::model::builtin::claude_3_5_sonnet_20241022();
1593 let session_id = "test_session_id".to_string();
1594 let mut tui = Tui::new(client, model, session_id, None).await.unwrap();
1595
1596 let tool_id = "test_tool_123".to_string();
1598 let tool_call = steer_tools::ToolCall {
1599 id: tool_id.clone(),
1600 name: "view".to_string(),
1601 parameters: json!({
1602 "file_path": "/test/file.rs",
1603 "offset": 10,
1604 "limit": 100
1605 }),
1606 };
1607
1608 let assistant_msg = Message {
1609 data: MessageData::Assistant {
1610 content: vec![AssistantContent::ToolCall {
1611 tool_call: tool_call.clone(),
1612 }],
1613 },
1614 id: "msg_assistant".to_string(),
1615 timestamp: 1234567890,
1616 parent_message_id: None,
1617 };
1618
1619 let tool_msg = Message {
1620 data: MessageData::Tool {
1621 tool_use_id: tool_id.clone(),
1622 result: steer_tools::ToolResult::FileContent(
1623 steer_tools::result::FileContentResult {
1624 file_path: "/test/file.rs".to_string(),
1625 content: "file content here".to_string(),
1626 line_count: 1,
1627 truncated: false,
1628 },
1629 ),
1630 },
1631 id: "msg_tool".to_string(),
1632 timestamp: 1234567891,
1633 parent_message_id: Some("msg_assistant".to_string()),
1634 };
1635
1636 let messages = vec![assistant_msg, tool_msg];
1637
1638 tui.restore_messages(messages);
1640
1641 let stored_call = tui
1643 .tool_registry
1644 .get_tool_call(&tool_id)
1645 .expect("Tool call should be in registry");
1646 assert_eq!(stored_call.name, "view");
1647 assert_eq!(stored_call.parameters, tool_call.parameters);
1648 }
1649
1650 #[tokio::test]
1651 #[ignore = "Requires TTY - run with `cargo test -- --ignored` in a terminal"]
1652 async fn test_restore_messages_handles_tool_result_before_assistant() {
1653 let _guard = TerminalCleanupGuard;
1654 let path = tempdir().unwrap().path().to_path_buf();
1656 let (client, _server_handle) = local_client_and_server(Some(path)).await;
1657 let model = steer_core::config::model::builtin::claude_3_5_sonnet_20241022();
1658 let session_id = "test_session_id".to_string();
1659 let mut tui = Tui::new(client, model, session_id, None).await.unwrap();
1660
1661 let tool_id = "test_tool_456".to_string();
1662 let real_params = json!({
1663 "file_path": "/another/file.rs"
1664 });
1665
1666 let tool_call = steer_tools::ToolCall {
1667 id: tool_id.clone(),
1668 name: "view".to_string(),
1669 parameters: real_params.clone(),
1670 };
1671
1672 let tool_msg = Message {
1674 data: MessageData::Tool {
1675 tool_use_id: tool_id.clone(),
1676 result: steer_tools::ToolResult::FileContent(
1677 steer_tools::result::FileContentResult {
1678 file_path: "/another/file.rs".to_string(),
1679 content: "file content".to_string(),
1680 line_count: 1,
1681 truncated: false,
1682 },
1683 ),
1684 },
1685 id: "msg_tool".to_string(),
1686 timestamp: 1234567890,
1687 parent_message_id: None,
1688 };
1689
1690 let assistant_msg = Message {
1691 data: MessageData::Assistant {
1692 content: vec![AssistantContent::ToolCall {
1693 tool_call: tool_call.clone(),
1694 }],
1695 },
1696 id: "msg_456".to_string(),
1697 timestamp: 1234567891,
1698 parent_message_id: None,
1699 };
1700
1701 let messages = vec![tool_msg, assistant_msg];
1702
1703 tui.restore_messages(messages);
1704
1705 let stored_call = tui
1707 .tool_registry
1708 .get_tool_call(&tool_id)
1709 .expect("Tool call should be in registry");
1710 assert_eq!(stored_call.parameters, real_params);
1711 assert_eq!(stored_call.name, "view");
1712 }
1713}