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