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::notifications::{NotificationManager, NotificationManagerHandle};
14use crate::tui::commands::registry::CommandRegistry;
15use crate::tui::model::{ChatItem, NoticeLevel, TuiCommandResponse};
16use crate::tui::theme::Theme;
17use futures::{FutureExt, StreamExt};
18use ratatui::backend::CrosstermBackend;
19use ratatui::crossterm::event::{
20 self, Event, EventStream, KeyCode, KeyEventKind, MouseEvent,
21};
22use ratatui::{Frame, Terminal};
23use steer_grpc::AgentClient;
24use steer_grpc::client_api::{
25 AssistantContent, ClientEvent, EditingMode, LlmStatus, Message, MessageData, ModelId, OpId,
26 Preferences, ProviderId, UserContent, WorkspaceStatus, builtin, default_primary_agent_id,
27};
28
29use crate::tui::events::processor::PendingToolApproval;
30use tokio::sync::mpsc;
31use tracing::{debug, error, info, warn};
32
33fn auth_status_from_source(
34 source: Option<&steer_grpc::client_api::AuthSource>,
35) -> crate::tui::state::AuthStatus {
36 match source {
37 Some(steer_grpc::client_api::AuthSource::ApiKey { .. }) => {
38 crate::tui::state::AuthStatus::ApiKeySet
39 }
40 Some(steer_grpc::client_api::AuthSource::Plugin { .. }) => {
41 crate::tui::state::AuthStatus::OAuthConfigured
42 }
43 _ => crate::tui::state::AuthStatus::NotConfigured,
44 }
45}
46
47fn has_any_auth_source(source: Option<&steer_grpc::client_api::AuthSource>) -> bool {
48 matches!(
49 source,
50 Some(
51 steer_grpc::client_api::AuthSource::ApiKey { .. }
52 | steer_grpc::client_api::AuthSource::Plugin { .. }
53 )
54 )
55}
56
57pub(crate) fn format_agent_label(primary_agent_id: &str) -> String {
58 let agent_id = if primary_agent_id.is_empty() {
59 default_primary_agent_id()
60 } else {
61 primary_agent_id
62 };
63 agent_id.to_string()
64}
65
66use crate::tui::events::pipeline::EventPipeline;
67use crate::tui::events::processors::message::MessageEventProcessor;
68use crate::tui::events::processors::processing_state::ProcessingStateProcessor;
69use crate::tui::events::processors::system::SystemEventProcessor;
70use crate::tui::events::processors::tool::ToolEventProcessor;
71use crate::tui::state::RemoteProviderRegistry;
72use crate::tui::state::SetupState;
73use crate::tui::state::{ChatStore, ToolCallRegistry};
74
75use crate::tui::chat_viewport::ChatViewport;
76use crate::tui::terminal::{SetupGuard, cleanup};
77use crate::tui::ui_layout::UiLayout;
78use crate::tui::widgets::EditSelectionOverlayState;
79use crate::tui::widgets::InputPanel;
80use crate::tui::widgets::input_panel::InputPanelParams;
81use tracing::error as tracing_error;
82use tracing::info as tracing_info;
83
84pub mod commands;
85pub mod custom_commands;
86pub mod model;
87pub mod state;
88pub mod terminal;
89pub mod theme;
90pub mod widgets;
91
92mod chat_viewport;
93pub mod core_commands;
94mod events;
95mod handlers;
96mod ui_layout;
97mod update;
98
99#[cfg(test)]
100mod test_utils;
101
102const SPINNER_UPDATE_INTERVAL: Duration = Duration::from_millis(100);
104const SCROLL_FLUSH_INTERVAL: Duration = Duration::from_millis(16);
105const MOUSE_SCROLL_STEP: usize = 1;
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum InputMode {
110 Simple,
112 VimNormal,
114 VimInsert,
116 BashCommand,
118 AwaitingApproval,
120 ConfirmExit,
122 EditMessageSelection,
124 FuzzyFinder,
126 Setup,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq)]
132enum VimOperator {
133 Delete,
134 Change,
135 Yank,
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139enum ScrollDirection {
140 Up,
141 Down,
142}
143
144impl ScrollDirection {
145 fn from_mouse_event(event: &MouseEvent) -> Option<Self> {
146 match event.kind {
147 event::MouseEventKind::ScrollUp => Some(Self::Up),
148 event::MouseEventKind::ScrollDown => Some(Self::Down),
149 _ => None,
150 }
151 }
152}
153
154#[derive(Debug, Clone, Copy)]
155struct PendingScroll {
156 direction: ScrollDirection,
157 steps: usize,
158}
159
160#[derive(Debug, Default)]
162struct VimState {
163 pending_operator: Option<VimOperator>,
165 pending_g: bool,
167 replace_mode: bool,
169 visual_mode: bool,
171}
172
173pub struct Tui {
175 terminal: Terminal<CrosstermBackend<Stdout>>,
177 terminal_size: (u16, u16),
178 input_mode: InputMode,
180 input_panel_state: crate::tui::widgets::input_panel::InputPanelState,
182 editing_message_id: Option<String>,
184 client: AgentClient,
186 is_processing: bool,
188 progress_message: Option<String>,
190 spinner_state: usize,
192 current_tool_approval: Option<PendingToolApproval>,
193 current_model: ModelId,
195 current_agent_label: Option<String>,
197 event_pipeline: EventPipeline,
199 chat_store: ChatStore,
201 tool_registry: ToolCallRegistry,
203 chat_viewport: ChatViewport,
205 session_id: String,
207 theme: Theme,
209 setup_state: Option<SetupState>,
211 in_flight_operations: HashSet<OpId>,
213 queued_head: Option<steer_grpc::client_api::QueuedWorkItem>,
215 queued_count: usize,
217 command_registry: CommandRegistry,
219 preferences: Preferences,
221 notification_manager: NotificationManagerHandle,
223 double_tap_tracker: crate::tui::state::DoubleTapTracker,
225 vim_state: VimState,
227 mode_stack: VecDeque<InputMode>,
229 last_revision: u64,
231 update_status: UpdateStatus,
233 edit_selection_state: EditSelectionOverlayState,
234}
235
236const MAX_MODE_DEPTH: usize = 8;
237
238impl Tui {
239 fn push_mode(&mut self) {
241 if self.mode_stack.len() == MAX_MODE_DEPTH {
242 self.mode_stack.pop_front(); }
244 self.mode_stack.push_back(self.input_mode);
245 }
246
247 fn pop_mode(&mut self) -> Option<InputMode> {
249 self.mode_stack.pop_back()
250 }
251
252 pub fn switch_mode(&mut self, new_mode: InputMode) {
254 if self.input_mode != new_mode {
255 debug!(
256 "Switching mode from {:?} to {:?}",
257 self.input_mode, new_mode
258 );
259 self.push_mode();
260 self.input_mode = new_mode;
261 }
262 }
263
264 pub fn set_mode(&mut self, new_mode: InputMode) {
266 debug!("Setting mode from {:?} to {:?}", self.input_mode, new_mode);
267 self.input_mode = new_mode;
268 }
269
270 pub fn restore_previous_mode(&mut self) {
272 self.input_mode = self.pop_mode().unwrap_or_else(|| self.default_input_mode());
273 }
274
275 fn default_input_mode(&self) -> InputMode {
277 match self.preferences.ui.editing_mode {
278 EditingMode::Simple => InputMode::Simple,
279 EditingMode::Vim => InputMode::VimNormal,
280 }
281 }
282
283 fn is_text_input_mode(&self) -> bool {
285 matches!(
286 self.input_mode,
287 InputMode::Simple
288 | InputMode::VimInsert
289 | InputMode::BashCommand
290 | InputMode::Setup
291 | InputMode::FuzzyFinder
292 )
293 }
294 pub async fn new(
296 client: AgentClient,
297 current_model: ModelId,
298
299 session_id: String,
300 theme: Option<Theme>,
301 ) -> Result<Self> {
302 let mut guard = SetupGuard::new();
304
305 let mut stdout = io::stdout();
306 terminal::setup(&mut stdout)?;
307
308 let backend = CrosstermBackend::new(stdout);
309 let terminal = Terminal::new(backend)?;
310 let terminal_size = terminal
311 .size()
312 .map(|s| (s.width, s.height))
313 .unwrap_or((80, 24));
314
315 let preferences = Preferences::load()
317 .map_err(|e| crate::error::Error::Config(e.to_string()))
318 .unwrap_or_default();
319
320 let input_mode = match preferences.ui.editing_mode {
322 EditingMode::Simple => InputMode::Simple,
323 EditingMode::Vim => InputMode::VimNormal,
324 };
325
326 let notification_manager = std::sync::Arc::new(NotificationManager::new(&preferences));
327
328 let mut tui = Self {
329 terminal,
330 terminal_size,
331 input_mode,
332 input_panel_state: crate::tui::widgets::input_panel::InputPanelState::new(
333 session_id.clone(),
334 ),
335 editing_message_id: None,
336 client,
337 is_processing: false,
338 progress_message: None,
339 spinner_state: 0,
340 current_tool_approval: None,
341 current_model,
342 current_agent_label: None,
343 event_pipeline: Self::create_event_pipeline(notification_manager.clone()),
344 chat_store: ChatStore::new(),
345 tool_registry: ToolCallRegistry::new(),
346 chat_viewport: ChatViewport::new(),
347 session_id,
348 theme: theme.unwrap_or_default(),
349 setup_state: None,
350 in_flight_operations: HashSet::new(),
351 queued_head: None,
352 queued_count: 0,
353 command_registry: CommandRegistry::new(),
354 preferences,
355 notification_manager,
356 double_tap_tracker: crate::tui::state::DoubleTapTracker::new(),
357 vim_state: VimState::default(),
358 mode_stack: VecDeque::new(),
359 last_revision: 0,
360 update_status: UpdateStatus::Checking,
361 edit_selection_state: EditSelectionOverlayState::default(),
362 };
363
364 tui.refresh_agent_label().await;
365 tui.notification_manager.set_focus_events_enabled(true);
366
367 guard.disarm();
369
370 Ok(tui)
371 }
372
373 fn restore_messages(&mut self, messages: Vec<Message>) {
375 let message_count = messages.len();
376 info!("Starting to restore {} messages to TUI", message_count);
377
378 for message in &messages {
380 if let MessageData::Tool { tool_use_id, .. } = &message.data {
381 debug!(
382 target: "tui.restore",
383 "Found Tool message with tool_use_id={}",
384 tool_use_id
385 );
386 }
387 }
388
389 self.chat_store.ingest_messages(&messages);
390 if let Some(message) = messages.last() {
391 self.chat_store
392 .set_active_message_id(Some(message.id().to_string()));
393 }
394
395 for message in &messages {
398 if let MessageData::Assistant { content, .. } = &message.data {
399 debug!(
400 target: "tui.restore",
401 "Processing Assistant message id={}",
402 message.id()
403 );
404 for block in content {
405 if let AssistantContent::ToolCall { tool_call, .. } = block {
406 debug!(
407 target: "tui.restore",
408 "Found ToolCall in Assistant message: id={}, name={}, params={}",
409 tool_call.id, tool_call.name, tool_call.parameters
410 );
411
412 self.tool_registry.register_call(tool_call.clone());
414 }
415 }
416 }
417 }
418
419 for message in &messages {
421 if let MessageData::Tool { tool_use_id, .. } = &message.data {
422 debug!(
423 target: "tui.restore",
424 "Updating registry with Tool result for id={}",
425 tool_use_id
426 );
427 }
429 }
430
431 debug!(
432 target: "tui.restore",
433 "Tool registry state after restoration: {} calls registered",
434 self.tool_registry.metrics().completed_count
435 );
436 info!("Successfully restored {} messages to TUI", message_count);
437 }
438
439 fn push_notice(&mut self, level: crate::tui::model::NoticeLevel, text: String) {
441 use crate::tui::model::{ChatItem, ChatItemData, generate_row_id};
442 self.chat_store.push(ChatItem {
443 parent_chat_item_id: None,
444 data: ChatItemData::SystemNotice {
445 id: generate_row_id(),
446 level,
447 text,
448 ts: time::OffsetDateTime::now_utc(),
449 },
450 });
451 }
452
453 fn push_tui_response(&mut self, command: String, response: TuiCommandResponse) {
454 use crate::tui::model::{ChatItem, ChatItemData, generate_row_id};
455 self.chat_store.push(ChatItem {
456 parent_chat_item_id: None,
457 data: ChatItemData::TuiCommandResponse {
458 id: generate_row_id(),
459 command,
460 response,
461 ts: time::OffsetDateTime::now_utc(),
462 },
463 });
464 }
465
466 fn format_grpc_error(error: &steer_grpc::GrpcError) -> String {
467 match error {
468 steer_grpc::GrpcError::CallFailed(status) => status.message().to_string(),
469 _ => error.to_string(),
470 }
471 }
472
473 fn format_workspace_status(status: &WorkspaceStatus) -> String {
474 let mut output = String::new();
475 output.push_str(&format!("Workspace: {}\n", status.workspace_id.as_uuid()));
476 output.push_str(&format!(
477 "Environment: {}\n",
478 status.environment_id.as_uuid()
479 ));
480 output.push_str(&format!("Repo: {}\n", status.repo_id.as_uuid()));
481 output.push_str(&format!("Path: {}\n", status.path.display()));
482
483 match &status.vcs {
484 Some(vcs) => {
485 output.push_str(&format!(
486 "VCS: {} ({})\n\n",
487 vcs.kind.as_str(),
488 vcs.root.display()
489 ));
490 output.push_str(&vcs.status.as_llm_string());
491 }
492 None => {
493 output.push_str("VCS: <none>\n");
494 }
495 }
496
497 output
498 }
499
500 async fn refresh_agent_label(&mut self) {
501 match self.client.get_session(&self.session_id).await {
502 Ok(Some(session)) => {
503 if let Some(config) = session.config.as_ref() {
504 let agent_id = config
505 .primary_agent_id
506 .clone()
507 .unwrap_or_else(|| default_primary_agent_id().to_string());
508 self.current_agent_label = Some(format_agent_label(&agent_id));
509 }
510 }
511 Ok(None) => {
512 warn!(
513 target: "tui.session",
514 "No session data available to populate agent label"
515 );
516 }
517 Err(e) => {
518 warn!(
519 target: "tui.session",
520 "Failed to load session config for agent label: {}",
521 e
522 );
523 }
524 }
525 }
526
527 async fn start_new_session(&mut self) -> Result<()> {
528 use std::collections::HashMap;
529 use steer_grpc::client_api::{
530 CreateSessionParams, SessionPolicyOverrides, SessionToolConfig, WorkspaceConfig,
531 };
532
533 let session_params = CreateSessionParams {
534 workspace: WorkspaceConfig::default(),
535 tool_config: SessionToolConfig::default(),
536 primary_agent_id: None,
537 policy_overrides: SessionPolicyOverrides::empty(),
538 metadata: HashMap::new(),
539 default_model: self.current_model.clone(),
540 };
541
542 let new_session_id = self
543 .client
544 .create_session(session_params)
545 .await
546 .map_err(|e| Error::Generic(format!("Failed to create new session: {e}")))?;
547
548 self.session_id.clone_from(&new_session_id);
549 self.client.subscribe_session_events().await?;
550 self.chat_store = ChatStore::new();
551 self.tool_registry = ToolCallRegistry::new();
552 self.chat_viewport = ChatViewport::new();
553 self.in_flight_operations.clear();
554 self.input_panel_state =
555 crate::tui::widgets::input_panel::InputPanelState::new(new_session_id.clone());
556 self.is_processing = false;
557 self.progress_message = None;
558 self.current_tool_approval = None;
559 self.editing_message_id = None;
560 self.current_agent_label = None;
561 self.refresh_agent_label().await;
562
563 self.load_file_cache().await;
564
565 Ok(())
566 }
567
568 async fn load_file_cache(&mut self) {
569 info!(target: "tui.file_cache", "Requesting workspace files for session {}", self.session_id);
570 match self.client.list_workspace_files().await {
571 Ok(files) => {
572 self.input_panel_state.file_cache.update(files).await;
573 }
574 Err(e) => {
575 warn!(target: "tui.file_cache", "Failed to request workspace files: {}", e);
576 }
577 }
578 }
579
580 pub async fn run(&mut self, event_rx: mpsc::Receiver<ClientEvent>) -> Result<()> {
581 info!(
583 "Starting TUI run with {} messages in view model",
584 self.chat_store.len()
585 );
586
587 self.load_file_cache().await;
589
590 let (update_tx, update_rx) = mpsc::channel::<UpdateStatus>(1);
592 let current_version = env!("CARGO_PKG_VERSION").to_string();
593 tokio::spawn(async move {
594 let status = update::check_latest("BrendanGraham14", "steer", ¤t_version).await;
595 let _ = update_tx.send(status).await;
596 });
597
598 let mut term_event_stream = EventStream::new();
599
600 self.run_event_loop(event_rx, &mut term_event_stream, update_rx)
602 .await
603 }
604
605 async fn run_event_loop(
606 &mut self,
607 mut event_rx: mpsc::Receiver<ClientEvent>,
608 term_event_stream: &mut EventStream,
609 mut update_rx: mpsc::Receiver<UpdateStatus>,
610 ) -> Result<()> {
611 let mut should_exit = false;
612 let mut needs_redraw = true; let mut last_spinner_char = String::new();
614 let mut update_rx_closed = false;
615 let mut pending_scroll: Option<PendingScroll> = None;
616
617 let mut tick = tokio::time::interval(SPINNER_UPDATE_INTERVAL);
619 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
620 let mut scroll_flush = tokio::time::interval(SCROLL_FLUSH_INTERVAL);
621 scroll_flush.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
622
623 while !should_exit {
624 if needs_redraw {
626 self.draw()?;
627 needs_redraw = false;
628 }
629
630 tokio::select! {
631 status = update_rx.recv(), if !update_rx_closed => {
632 match status {
633 Some(status) => {
634 self.update_status = status;
635 needs_redraw = true;
636 }
637 None => {
638 update_rx_closed = true;
640 }
641 }
642 }
643 event_res = term_event_stream.next() => {
644 match event_res {
645 Some(Ok(evt)) => {
646 let (event_needs_redraw, event_should_exit) = self
647 .handle_terminal_event(
648 evt,
649 term_event_stream,
650 &mut pending_scroll,
651 &mut scroll_flush,
652 )
653 .await?;
654 if event_needs_redraw {
655 needs_redraw = true;
656 }
657 if event_should_exit {
658 should_exit = true;
659 }
660 }
661 Some(Err(e)) => {
662 if e.kind() == io::ErrorKind::Interrupted {
663 debug!(target: "tui.input", "Ignoring interrupted syscall");
664 } else {
665 error!(target: "tui.run", "Fatal input error: {}. Exiting.", e);
666 should_exit = true;
667 }
668 }
669 None => {
670 should_exit = true;
672 }
673 }
674 }
675 client_event_opt = event_rx.recv() => {
676 match client_event_opt {
677 Some(client_event) => {
678 self.handle_client_event(client_event).await;
679 needs_redraw = true;
680 }
681 None => {
682 should_exit = true;
683 }
684 }
685 }
686 _ = tick.tick() => {
687 let has_pending_tools = !self.tool_registry.pending_calls().is_empty()
689 || !self.tool_registry.active_calls().is_empty()
690 || self.chat_store.has_pending_tools();
691 let has_in_flight_operations = !self.in_flight_operations.is_empty();
692
693 if self.is_processing || has_pending_tools || has_in_flight_operations {
694 self.spinner_state = self.spinner_state.wrapping_add(1);
695 let ch = get_spinner_char(self.spinner_state);
696 if ch != last_spinner_char {
697 last_spinner_char = ch.to_string();
698 needs_redraw = true;
699 }
700 }
701
702 if self.input_mode == InputMode::Setup
703 && crate::tui::handlers::setup::SetupHandler::poll_oauth_callback(self)
704 .await?
705 {
706 needs_redraw = true;
707 }
708 }
709 _ = scroll_flush.tick(), if pending_scroll.is_some() => {
710 if let Some(pending) = pending_scroll.take()
711 && self.apply_scroll_steps(pending.direction, pending.steps) {
712 needs_redraw = true;
713 }
714 }
715 }
716 }
717
718 Ok(())
719 }
720
721 async fn handle_terminal_event(
722 &mut self,
723 event: Event,
724 term_event_stream: &mut EventStream,
725 pending_scroll: &mut Option<PendingScroll>,
726 scroll_flush: &mut tokio::time::Interval,
727 ) -> Result<(bool, bool)> {
728 let mut needs_redraw = false;
729 let mut should_exit = false;
730 let mut pending_events = VecDeque::new();
731 pending_events.push_back(event);
732
733 while let Some(event) = pending_events.pop_front() {
734 match event {
735 Event::FocusGained => {
736 self.notification_manager.set_terminal_focused(true);
737 }
738 Event::FocusLost => {
739 self.notification_manager.set_terminal_focused(false);
740 }
741 Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
742 match self.handle_key_event(key_event).await {
743 Ok(exit) => {
744 if exit {
745 should_exit = true;
746 }
747 }
748 Err(e) => {
749 use crate::tui::model::{
751 ChatItem, ChatItemData, NoticeLevel, generate_row_id,
752 };
753 self.chat_store.push(ChatItem {
754 parent_chat_item_id: None,
755 data: ChatItemData::SystemNotice {
756 id: generate_row_id(),
757 level: NoticeLevel::Error,
758 text: e.to_string(),
759 ts: time::OffsetDateTime::now_utc(),
760 },
761 });
762 }
763 }
764 needs_redraw = true;
765 }
766 Event::Mouse(mouse_event) => {
767 let (scroll_pending, mouse_needs_redraw, mouse_exit, deferred_event) =
768 self.handle_mouse_event_coalesced(mouse_event, term_event_stream)?;
769 if let Some(scroll) = scroll_pending {
770 let pending_was_empty = pending_scroll.is_none();
771 match pending_scroll {
772 Some(pending) if pending.direction == scroll.direction => {
773 pending.steps = pending.steps.saturating_add(scroll.steps);
774 }
775 _ => {
776 *pending_scroll = Some(scroll);
777 }
778 }
779 if pending_was_empty {
780 scroll_flush.reset_after(SCROLL_FLUSH_INTERVAL);
781 }
782 }
783 needs_redraw |= mouse_needs_redraw;
784 should_exit |= mouse_exit;
785 if let Some(deferred_event) = deferred_event {
786 pending_events.push_front(deferred_event);
787 }
788 }
789 Event::Resize(width, height) => {
790 self.terminal_size = (width, height);
791 needs_redraw = true;
793 }
794 Event::Paste(data) => {
795 if self.is_text_input_mode() {
797 if self.input_mode == InputMode::Setup {
798 if let Some(setup_state) = &mut self.setup_state {
800 if let crate::tui::state::SetupStep::Authentication(_) =
801 &setup_state.current_step
802 {
803 setup_state.auth_input.push_str(&data);
804 debug!(
805 target:"tui.run",
806 "Pasted {} chars in Setup mode",
807 data.len()
808 );
809 needs_redraw = true;
810 } else {
811 }
813 }
814 } else {
815 let normalized_data = data.replace("\r\n", "\n").replace('\r', "\n");
816 self.input_panel_state.insert_str(&normalized_data);
817 debug!(
818 target:"tui.run",
819 "Pasted {} chars in {:?} mode",
820 normalized_data.len(),
821 self.input_mode
822 );
823 needs_redraw = true;
824 }
825 }
826 }
827 Event::Key(_) => {}
828 }
829
830 if should_exit {
831 break;
832 }
833 }
834
835 Ok((needs_redraw, should_exit))
836 }
837
838 fn handle_mouse_event_coalesced(
839 &mut self,
840 mouse_event: MouseEvent,
841 term_event_stream: &mut EventStream,
842 ) -> Result<(Option<PendingScroll>, bool, bool, Option<Event>)> {
843 let Some(mut last_direction) = ScrollDirection::from_mouse_event(&mouse_event) else {
844 let needs_redraw = self.handle_mouse_event(mouse_event)?;
845 return Ok((None, needs_redraw, false, None));
846 };
847
848 let mut steps = 1usize;
849 let mut deferred_event = None;
850 let mut should_exit = false;
851
852 loop {
853 let next_event = term_event_stream.next().now_or_never();
854 let Some(next_event) = next_event else {
855 break;
856 };
857
858 match next_event {
859 Some(Ok(Event::Mouse(next_mouse))) => {
860 if let Some(next_direction) = ScrollDirection::from_mouse_event(&next_mouse) {
861 if next_direction == last_direction {
862 steps = steps.saturating_add(1);
863 } else {
864 last_direction = next_direction;
865 steps = 1;
866 }
867 continue;
868 }
869 deferred_event = Some(Event::Mouse(next_mouse));
870 break;
871 }
872 Some(Ok(other_event)) => {
873 deferred_event = Some(other_event);
874 break;
875 }
876 Some(Err(e)) => {
877 if e.kind() == io::ErrorKind::Interrupted {
878 debug!(target: "tui.input", "Ignoring interrupted syscall");
879 } else {
880 error!(target: "tui.run", "Fatal input error: {}. Exiting.", e);
881 should_exit = true;
882 }
883 break;
884 }
885 None => {
886 should_exit = true;
887 break;
888 }
889 }
890 }
891
892 Ok((
893 Some(PendingScroll {
894 direction: last_direction,
895 steps,
896 }),
897 false,
898 should_exit,
899 deferred_event,
900 ))
901 }
902
903 fn handle_mouse_event(&mut self, event: MouseEvent) -> Result<bool> {
905 let needs_redraw = match ScrollDirection::from_mouse_event(&event) {
906 Some(direction) => self.apply_scroll_steps(direction, 1),
907 None => false,
908 };
909
910 Ok(needs_redraw)
911 }
912
913 fn apply_scroll_steps(&mut self, direction: ScrollDirection, steps: usize) -> bool {
914 if !self.is_text_input_mode()
916 || (self.input_mode == InputMode::Simple && self.input_panel_state.content().is_empty())
917 {
918 let amount = steps.saturating_mul(MOUSE_SCROLL_STEP);
919 match direction {
920 ScrollDirection::Up => self.chat_viewport.state_mut().scroll_up(amount),
921 ScrollDirection::Down => self.chat_viewport.state_mut().scroll_down(amount),
922 }
923 } else {
924 false
925 }
926 }
927
928 fn draw(&mut self) -> Result<()> {
930 let editing_message_id = self.editing_message_id.clone();
931 let is_editing = editing_message_id.is_some();
932 let editing_preview = if is_editing {
933 self.editing_preview()
934 } else {
935 None
936 };
937
938 self.terminal.draw(|f| {
939 if let Some(setup_state) = &self.setup_state {
941 use crate::tui::widgets::setup::{
942 authentication::AuthenticationWidget, completion::CompletionWidget,
943 provider_selection::ProviderSelectionWidget, welcome::WelcomeWidget,
944 };
945
946 match &setup_state.current_step {
947 crate::tui::state::SetupStep::Welcome => {
948 WelcomeWidget::render(f.area(), f.buffer_mut(), &self.theme);
949 }
950 crate::tui::state::SetupStep::ProviderSelection => {
951 ProviderSelectionWidget::render(
952 f.area(),
953 f.buffer_mut(),
954 setup_state,
955 &self.theme,
956 );
957 }
958 crate::tui::state::SetupStep::Authentication(provider_id) => {
959 AuthenticationWidget::render(
960 f.area(),
961 f.buffer_mut(),
962 setup_state,
963 provider_id.clone(),
964 &self.theme,
965 );
966 }
967 crate::tui::state::SetupStep::Completion => {
968 CompletionWidget::render(
969 f.area(),
970 f.buffer_mut(),
971 setup_state,
972 &self.theme,
973 );
974 }
975 }
976 return;
977 }
978
979 let input_mode = self.input_mode;
980 let is_processing = self.is_processing;
981 let spinner_state = self.spinner_state;
982 let current_tool_call = self.current_tool_approval.as_ref().map(|(_, tc)| tc);
983 let current_model_owned = self.current_model.clone();
984
985 let current_revision = self.chat_store.revision();
987 if current_revision != self.last_revision {
988 self.chat_viewport.mark_dirty();
989 self.last_revision = current_revision;
990 }
991
992 let chat_items: Vec<&ChatItem> = self.chat_store.as_items();
994
995 let terminal_size = f.area();
996
997 let queue_preview = self.queued_head.as_ref().map(|item| item.content.as_str());
998 let input_area_height = self.input_panel_state.required_height(
999 current_tool_call,
1000 terminal_size.width,
1001 terminal_size.height,
1002 queue_preview,
1003 );
1004
1005 let layout = UiLayout::compute(terminal_size, input_area_height, &self.theme);
1006 layout.prepare_background(f, &self.theme);
1007
1008 self.chat_viewport.rebuild(
1009 &chat_items,
1010 layout.chat.width,
1011 self.chat_viewport.state().view_mode,
1012 &self.theme,
1013 &self.chat_store,
1014 editing_message_id.as_deref(),
1015 );
1016
1017 self.chat_viewport
1018 .render(f, layout.chat, spinner_state, None, &self.theme);
1019
1020 let input_panel = InputPanel::new(InputPanelParams {
1021 input_mode,
1022 current_approval: current_tool_call,
1023 is_processing,
1024 spinner_state,
1025 is_editing,
1026 editing_preview: editing_preview.as_deref(),
1027 queued_count: self.queued_count,
1028 queued_preview: queue_preview,
1029 theme: &self.theme,
1030 });
1031 f.render_stateful_widget(input_panel, layout.input, &mut self.input_panel_state);
1032
1033 let update_badge = match &self.update_status {
1034 UpdateStatus::Available(info) => {
1035 crate::tui::widgets::status_bar::UpdateBadge::Available {
1036 latest: &info.latest,
1037 }
1038 }
1039 _ => crate::tui::widgets::status_bar::UpdateBadge::None,
1040 };
1041 layout.render_status_bar(
1042 f,
1043 ¤t_model_owned,
1044 self.current_agent_label.as_deref(),
1045 &self.theme,
1046 update_badge,
1047 );
1048
1049 let fuzzy_finder_data = if input_mode == InputMode::FuzzyFinder {
1051 let results = self.input_panel_state.fuzzy_finder.results().to_vec();
1052 let selected = self.input_panel_state.fuzzy_finder.selected_index();
1053 let input_height = self.input_panel_state.required_height(
1054 current_tool_call,
1055 terminal_size.width,
1056 10,
1057 queue_preview,
1058 );
1059 let mode = self.input_panel_state.fuzzy_finder.mode();
1060 Some((results, selected, input_height, mode))
1061 } else {
1062 None
1063 };
1064
1065 if let Some((results, selected_index, input_height, mode)) = fuzzy_finder_data {
1067 Self::render_fuzzy_finder_overlay_static(
1068 f,
1069 &results,
1070 selected_index,
1071 input_height,
1072 mode,
1073 &self.theme,
1074 &self.command_registry,
1075 );
1076 }
1077
1078 if input_mode == InputMode::EditMessageSelection {
1079 use crate::tui::widgets::EditSelectionOverlay;
1080 let overlay = EditSelectionOverlay::new(&self.theme);
1081 f.render_stateful_widget(overlay, terminal_size, &mut self.edit_selection_state);
1082 }
1083 })?;
1084 Ok(())
1085 }
1086
1087 fn render_fuzzy_finder_overlay_static(
1089 f: &mut Frame,
1090 results: &[crate::tui::widgets::fuzzy_finder::PickerItem],
1091 selected_index: usize,
1092 input_panel_height: u16,
1093 mode: crate::tui::widgets::fuzzy_finder::FuzzyFinderMode,
1094 theme: &Theme,
1095 command_registry: &CommandRegistry,
1096 ) {
1097 use ratatui::layout::Rect;
1098 use ratatui::style::Style;
1099 use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState};
1100
1101 if results.is_empty() {
1104 return; }
1106
1107 let total_area = f.area();
1109
1110 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);
1118 let overlay_area = Rect {
1119 x: total_area.x,
1120 y: overlay_y,
1121 width: total_area.width,
1122 height: overlay_height,
1123 };
1124
1125 f.render_widget(Clear, overlay_area);
1127
1128 let items: Vec<ListItem> = match mode {
1131 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Files => results
1132 .iter()
1133 .enumerate()
1134 .rev()
1135 .map(|(i, item)| {
1136 let is_selected = selected_index == i;
1137 let style = if is_selected {
1138 theme.style(theme::Component::PopupSelection)
1139 } else {
1140 Style::default()
1141 };
1142 ListItem::new(item.label.as_str()).style(style)
1143 })
1144 .collect(),
1145 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Commands => {
1146 results
1147 .iter()
1148 .enumerate()
1149 .rev()
1150 .map(|(i, item)| {
1151 let is_selected = selected_index == i;
1152 let style = if is_selected {
1153 theme.style(theme::Component::PopupSelection)
1154 } else {
1155 Style::default()
1156 };
1157
1158 let label = &item.label;
1160 if let Some(cmd_info) = command_registry.get(label.as_str()) {
1161 let line = format!("/{:<12} {}", cmd_info.name, cmd_info.description);
1162 ListItem::new(line).style(style)
1163 } else {
1164 ListItem::new(format!("/{label}")).style(style)
1165 }
1166 })
1167 .collect()
1168 }
1169 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Models
1170 | crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Themes => results
1171 .iter()
1172 .enumerate()
1173 .rev()
1174 .map(|(i, item)| {
1175 let is_selected = selected_index == i;
1176 let style = if is_selected {
1177 theme.style(theme::Component::PopupSelection)
1178 } else {
1179 Style::default()
1180 };
1181 ListItem::new(item.label.as_str()).style(style)
1182 })
1183 .collect(),
1184 };
1185
1186 let list_block = Block::default()
1188 .borders(Borders::ALL)
1189 .border_style(theme.style(theme::Component::PopupBorder))
1190 .title(match mode {
1191 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Files => " Files ",
1192 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Commands => " Commands ",
1193 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Models => " Select Model ",
1194 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Themes => " Select Theme ",
1195 });
1196
1197 let list = List::new(items)
1198 .block(list_block)
1199 .highlight_style(theme.style(theme::Component::PopupSelection));
1200
1201 let mut list_state = ListState::default();
1203 let reversed_selection = results
1204 .len()
1205 .saturating_sub(1)
1206 .saturating_sub(selected_index);
1207 list_state.select(Some(reversed_selection));
1208
1209 f.render_stateful_widget(list, overlay_area, &mut list_state);
1210 }
1211
1212 fn create_event_pipeline(notification_manager: NotificationManagerHandle) -> EventPipeline {
1214 EventPipeline::new()
1215 .add_processor(Box::new(ProcessingStateProcessor::new(
1216 notification_manager.clone(),
1217 )))
1218 .add_processor(Box::new(MessageEventProcessor::new()))
1219 .add_processor(Box::new(
1220 crate::tui::events::processors::queue::QueueEventProcessor::new(),
1221 ))
1222 .add_processor(Box::new(ToolEventProcessor::new(
1223 notification_manager.clone(),
1224 )))
1225 .add_processor(Box::new(SystemEventProcessor::new(notification_manager)))
1226 }
1227
1228 fn preprocess_client_event_double_tap(
1229 event: &ClientEvent,
1230 double_tap_tracker: &mut crate::tui::state::DoubleTapTracker,
1231 ) {
1232 if matches!(
1233 event,
1234 ClientEvent::OperationCancelled {
1235 popped_queued_item: Some(_),
1236 ..
1237 }
1238 ) {
1239 double_tap_tracker.clear_key(&KeyCode::Esc);
1242 }
1243 }
1244
1245 async fn handle_client_event(&mut self, event: ClientEvent) {
1246 Self::preprocess_client_event_double_tap(&event, &mut self.double_tap_tracker);
1247 let mut messages_updated = false;
1248
1249 match &event {
1250 ClientEvent::WorkspaceChanged => {
1251 self.load_file_cache().await;
1252 }
1253 ClientEvent::WorkspaceFiles { files } => {
1254 info!(target: "tui.handle_client_event", "Received workspace files event with {} files", files.len());
1255 self.input_panel_state
1256 .file_cache
1257 .update(files.clone())
1258 .await;
1259 }
1260 _ => {}
1261 }
1262
1263 let mut ctx = crate::tui::events::processor::ProcessingContext {
1264 chat_store: &mut self.chat_store,
1265 chat_list_state: self.chat_viewport.state_mut(),
1266 tool_registry: &mut self.tool_registry,
1267 client: &self.client,
1268 notification_manager: &self.notification_manager,
1269 input_panel_state: &mut self.input_panel_state,
1270 is_processing: &mut self.is_processing,
1271 progress_message: &mut self.progress_message,
1272 spinner_state: &mut self.spinner_state,
1273 current_tool_approval: &mut self.current_tool_approval,
1274 current_model: &mut self.current_model,
1275 current_agent_label: &mut self.current_agent_label,
1276 messages_updated: &mut messages_updated,
1277 in_flight_operations: &mut self.in_flight_operations,
1278 queued_head: &mut self.queued_head,
1279 queued_count: &mut self.queued_count,
1280 };
1281
1282 if let Err(e) = self.event_pipeline.process_event(event, &mut ctx).await {
1283 tracing::error!(target: "tui.handle_client_event", "Event processing failed: {}", e);
1284 }
1285
1286 if self.current_tool_approval.is_some() && self.input_mode != InputMode::AwaitingApproval {
1287 self.switch_mode(InputMode::AwaitingApproval);
1288 } else if self.current_tool_approval.is_none()
1289 && self.input_mode == InputMode::AwaitingApproval
1290 {
1291 self.restore_previous_mode();
1292 }
1293
1294 if messages_updated {
1295 self.chat_viewport.mark_dirty();
1296 if self.chat_viewport.state_mut().is_at_bottom() {
1297 self.chat_viewport.state_mut().scroll_to_bottom();
1298 }
1299 }
1300 }
1301
1302 async fn send_message(&mut self, content: String) -> Result<()> {
1303 if content.starts_with('/') {
1304 return self.handle_slash_command(content).await;
1305 }
1306
1307 if let Some(message_id_to_edit) = self.editing_message_id.take() {
1308 self.chat_viewport.mark_dirty();
1309 if content.starts_with('!') && content.len() > 1 {
1310 let command = content[1..].trim().to_string();
1311 if let Err(e) = self.client.execute_bash_command(command).await {
1312 self.push_notice(NoticeLevel::Error, Self::format_grpc_error(&e));
1313 }
1314 } else if let Err(e) = self
1315 .client
1316 .edit_message(message_id_to_edit, content, self.current_model.clone())
1317 .await
1318 {
1319 self.push_notice(NoticeLevel::Error, Self::format_grpc_error(&e));
1320 }
1321 return Ok(());
1322 }
1323 if let Err(e) = self
1324 .client
1325 .send_message(content, self.current_model.clone())
1326 .await
1327 {
1328 self.push_notice(NoticeLevel::Error, Self::format_grpc_error(&e));
1329 }
1330 Ok(())
1331 }
1332
1333 async fn handle_slash_command(&mut self, command_input: String) -> Result<()> {
1334 use crate::tui::commands::{AppCommand as TuiAppCommand, TuiCommand, TuiCommandType};
1335 use crate::tui::model::NoticeLevel;
1336
1337 let cmd_name = command_input
1339 .trim()
1340 .strip_prefix('/')
1341 .unwrap_or(command_input.trim());
1342
1343 if let Some(cmd_info) = self.command_registry.get(cmd_name)
1344 && let crate::tui::commands::registry::CommandScope::Custom(custom_cmd) =
1345 &cmd_info.scope
1346 {
1347 let app_cmd = TuiAppCommand::Tui(TuiCommand::Custom(custom_cmd.clone()));
1349 match app_cmd {
1351 TuiAppCommand::Tui(TuiCommand::Custom(custom_cmd)) => {
1352 match custom_cmd {
1354 crate::tui::custom_commands::CustomCommand::Prompt { prompt, .. } => {
1355 self.client
1356 .send_message(prompt, self.current_model.clone())
1357 .await?;
1358 }
1359 }
1360 }
1361 _ => unreachable!(),
1362 }
1363 return Ok(());
1364 }
1365
1366 let app_cmd = match TuiAppCommand::parse(&command_input) {
1368 Ok(cmd) => cmd,
1369 Err(e) => {
1370 self.push_notice(NoticeLevel::Error, e.to_string());
1372 return Ok(());
1373 }
1374 };
1375
1376 match app_cmd {
1378 TuiAppCommand::Tui(tui_cmd) => {
1379 match tui_cmd {
1381 TuiCommand::ReloadFiles => {
1382 self.input_panel_state.file_cache.clear().await;
1383 info!(target: "tui.slash_command", "Cleared file cache, will reload on next access");
1384 self.load_file_cache().await;
1385 self.push_tui_response(
1386 TuiCommandType::ReloadFiles.command_name(),
1387 TuiCommandResponse::Text(
1388 "File cache cleared. Files will be reloaded on next access."
1389 .to_string(),
1390 ),
1391 );
1392 }
1393 TuiCommand::Theme(theme_name) => {
1394 if let Some(name) = theme_name {
1395 let loader = theme::ThemeLoader::new();
1397 match loader.load_theme(&name) {
1398 Ok(new_theme) => {
1399 self.theme = new_theme;
1400 self.push_tui_response(
1401 TuiCommandType::Theme.command_name(),
1402 TuiCommandResponse::Theme { name: name.clone() },
1403 );
1404 }
1405 Err(e) => {
1406 self.push_notice(
1407 NoticeLevel::Error,
1408 format!("Failed to load theme '{name}': {e}"),
1409 );
1410 }
1411 }
1412 } else {
1413 let loader = theme::ThemeLoader::new();
1415 let themes = loader.list_themes();
1416 self.push_tui_response(
1417 TuiCommandType::Theme.command_name(),
1418 TuiCommandResponse::ListThemes(themes),
1419 );
1420 }
1421 }
1422 TuiCommand::Help(command_name) => {
1423 let help_text = if let Some(cmd_name) = command_name {
1425 if let Some(cmd_info) = self.command_registry.get(&cmd_name) {
1427 format!(
1428 "Command: {}\n\nDescription: {}\n\nUsage: {}",
1429 cmd_info.name, cmd_info.description, cmd_info.usage
1430 )
1431 } else {
1432 format!("Unknown command: {cmd_name}")
1433 }
1434 } else {
1435 let mut help_lines = vec!["Available commands:".to_string()];
1437 for cmd_info in self.command_registry.all_commands() {
1438 help_lines.push(format!(
1439 " {:<20} - {}",
1440 cmd_info.usage, cmd_info.description
1441 ));
1442 }
1443 help_lines.join("\n")
1444 };
1445
1446 self.push_tui_response(
1447 TuiCommandType::Help.command_name(),
1448 TuiCommandResponse::Text(help_text),
1449 );
1450 }
1451 TuiCommand::Auth => {
1452 let providers = self.client.list_providers().await.map_err(|e| {
1456 crate::error::Error::Generic(format!(
1457 "Failed to list providers from server: {e}"
1458 ))
1459 })?;
1460 let statuses =
1461 self.client
1462 .get_provider_auth_status(None)
1463 .await
1464 .map_err(|e| {
1465 crate::error::Error::Generic(format!(
1466 "Failed to get provider auth status: {e}"
1467 ))
1468 })?;
1469
1470 let mut provider_status = std::collections::HashMap::new();
1472
1473 let mut status_map = std::collections::HashMap::new();
1474 for s in statuses {
1475 status_map.insert(s.provider_id.clone(), s.auth_source);
1476 }
1477
1478 let registry =
1480 std::sync::Arc::new(RemoteProviderRegistry::from_proto(providers));
1481
1482 for p in registry.all() {
1483 let status = auth_status_from_source(
1484 status_map.get(&p.id).and_then(|s| s.as_ref()),
1485 );
1486 provider_status.insert(ProviderId(p.id.clone()), status);
1487 }
1488
1489 self.setup_state =
1491 Some(crate::tui::state::SetupState::new_for_auth_command(
1492 registry,
1493 provider_status,
1494 ));
1495 self.set_mode(InputMode::Setup);
1498 self.mode_stack.clear();
1500
1501 self.push_tui_response(
1502 TuiCommandType::Auth.to_string(),
1503 TuiCommandResponse::Text(
1504 "Entering authentication setup mode...".to_string(),
1505 ),
1506 );
1507 }
1508 TuiCommand::EditingMode(ref mode_name) => {
1509 let response = match mode_name.as_deref() {
1510 None => {
1511 let mode_str = self.preferences.ui.editing_mode.to_string();
1513 format!("Current editing mode: {mode_str}")
1514 }
1515 Some("simple") => {
1516 self.preferences.ui.editing_mode = EditingMode::Simple;
1517 self.set_mode(InputMode::Simple);
1518 self.preferences
1519 .save()
1520 .map_err(|e| crate::error::Error::Config(e.to_string()))?;
1521 "Switched to Simple mode".to_string()
1522 }
1523 Some("vim") => {
1524 self.preferences.ui.editing_mode = EditingMode::Vim;
1525 self.set_mode(InputMode::VimNormal);
1526 self.preferences
1527 .save()
1528 .map_err(|e| crate::error::Error::Config(e.to_string()))?;
1529 "Switched to Vim mode (Normal)".to_string()
1530 }
1531 Some(mode) => {
1532 format!("Unknown mode: '{mode}'. Use 'simple' or 'vim'")
1533 }
1534 };
1535
1536 self.push_tui_response(
1537 tui_cmd.as_command_str(),
1538 TuiCommandResponse::Text(response),
1539 );
1540 }
1541 TuiCommand::Mcp => {
1542 let servers = self.client.get_mcp_servers().await?;
1543 self.push_tui_response(
1544 tui_cmd.as_command_str(),
1545 TuiCommandResponse::ListMcpServers(servers),
1546 );
1547 }
1548 TuiCommand::Workspace(ref workspace_id) => {
1549 let target_id = if let Some(workspace_id) = workspace_id.clone() {
1550 Some(workspace_id)
1551 } else {
1552 let session = if let Some(session) =
1553 self.client.get_session(&self.session_id).await?
1554 {
1555 session
1556 } else {
1557 self.push_notice(
1558 NoticeLevel::Error,
1559 "Session not found for workspace status".to_string(),
1560 );
1561 return Ok(());
1562 };
1563 let config = if let Some(config) = session.config {
1564 config
1565 } else {
1566 self.push_notice(
1567 NoticeLevel::Error,
1568 "Session config missing for workspace status".to_string(),
1569 );
1570 return Ok(());
1571 };
1572 config.workspace_id.or_else(|| {
1573 config.workspace_ref.map(|reference| reference.workspace_id)
1574 })
1575 };
1576
1577 let target_id = match target_id {
1578 Some(id) if !id.is_empty() => id,
1579 _ => {
1580 self.push_notice(
1581 NoticeLevel::Error,
1582 "Workspace id not available for current session".to_string(),
1583 );
1584 return Ok(());
1585 }
1586 };
1587
1588 match self.client.get_workspace_status(&target_id).await {
1589 Ok(status) => {
1590 let response = Self::format_workspace_status(&status);
1591 self.push_tui_response(
1592 tui_cmd.as_command_str(),
1593 TuiCommandResponse::Text(response),
1594 );
1595 }
1596 Err(e) => {
1597 self.push_notice(NoticeLevel::Error, Self::format_grpc_error(&e));
1598 }
1599 }
1600 }
1601 TuiCommand::Custom(custom_cmd) => match custom_cmd {
1602 crate::tui::custom_commands::CustomCommand::Prompt { prompt, .. } => {
1603 self.client
1604 .send_message(prompt, self.current_model.clone())
1605 .await?;
1606 }
1607 },
1608 TuiCommand::New => {
1609 self.start_new_session().await?;
1610 }
1611 }
1612 }
1613 TuiAppCommand::Core(core_cmd) => match core_cmd {
1614 crate::tui::core_commands::CoreCommandType::Compact => {
1615 if let Err(e) = self
1616 .client
1617 .compact_session(self.current_model.clone())
1618 .await
1619 {
1620 self.push_notice(NoticeLevel::Error, Self::format_grpc_error(&e));
1621 }
1622 }
1623 crate::tui::core_commands::CoreCommandType::Agent { target } => {
1624 if let Some(agent_id) = target {
1625 if let Err(e) = self.client.switch_primary_agent(agent_id.clone()).await {
1626 self.push_notice(NoticeLevel::Error, Self::format_grpc_error(&e));
1627 }
1628 } else {
1629 self.push_notice(NoticeLevel::Error, "Usage: /agent <mode>".to_string());
1630 }
1631 }
1632 crate::tui::core_commands::CoreCommandType::Model { target } => {
1633 if let Some(model_name) = target {
1634 match self.client.resolve_model(&model_name).await {
1635 Ok(model_id) => {
1636 self.current_model = model_id;
1637 }
1638 Err(e) => {
1639 self.push_notice(NoticeLevel::Error, Self::format_grpc_error(&e));
1640 }
1641 }
1642 }
1643 }
1644 },
1645 }
1646
1647 Ok(())
1648 }
1649
1650 fn enter_edit_mode(&mut self, message_id: &str) {
1652 if let Some(item) = self.chat_store.get_by_id(&message_id.to_string())
1654 && let crate::tui::model::ChatItemData::Message(message) = &item.data
1655 && let MessageData::User { content, .. } = &message.data
1656 {
1657 let text = content
1659 .iter()
1660 .filter_map(|block| match block {
1661 UserContent::Text { text } => Some(text.as_str()),
1662 UserContent::CommandExecution { .. } => None,
1663 })
1664 .collect::<Vec<_>>()
1665 .join("\n");
1666
1667 self.input_panel_state
1669 .set_content_from_lines(text.lines().collect::<Vec<_>>());
1670 self.input_mode = match self.preferences.ui.editing_mode {
1672 EditingMode::Simple => InputMode::Simple,
1673 EditingMode::Vim => InputMode::VimInsert,
1674 };
1675
1676 self.editing_message_id = Some(message_id.to_string());
1678 self.chat_viewport.mark_dirty();
1679 }
1680 }
1681
1682 fn cancel_edit_mode(&mut self) {
1683 if self.editing_message_id.is_some() {
1684 self.editing_message_id = None;
1685 self.chat_viewport.mark_dirty();
1686 }
1687 }
1688
1689 fn editing_preview(&self) -> Option<String> {
1690 const EDIT_PREVIEW_MAX_LEN: usize = 40;
1691
1692 let message_id = self.editing_message_id.as_ref()?;
1693 let item = self.chat_store.get_by_id(message_id)?;
1694 let crate::tui::model::ChatItemData::Message(message) = &item.data else {
1695 return None;
1696 };
1697
1698 let content = message.content_string();
1699 let preview_line = content
1700 .lines()
1701 .find(|line| !line.trim().is_empty())
1702 .unwrap_or("")
1703 .trim();
1704 if preview_line.is_empty() {
1705 return None;
1706 }
1707
1708 let mut chars = preview_line.chars();
1709 let mut preview: String = chars.by_ref().take(EDIT_PREVIEW_MAX_LEN).collect();
1710 if chars.next().is_some() {
1711 preview.push('…');
1712 }
1713
1714 Some(preview)
1715 }
1716
1717 fn enter_edit_selection_mode(&mut self) {
1718 self.switch_mode(InputMode::EditMessageSelection);
1719 let messages = self.chat_store.user_messages_in_lineage();
1720 self.edit_selection_state.populate(messages);
1721 }
1722}
1723
1724fn get_spinner_char(state: usize) -> &'static str {
1726 const SPINNER_CHARS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1727 SPINNER_CHARS[state % SPINNER_CHARS.len()]
1728}
1729
1730impl Drop for Tui {
1731 fn drop(&mut self) {
1732 crate::tui::terminal::cleanup_with_writer(self.terminal.backend_mut());
1734 }
1735}
1736
1737pub fn setup_panic_hook() {
1739 std::panic::set_hook(Box::new(|panic_info| {
1740 cleanup();
1741 tracing_error!("Application panicked:");
1743 tracing_error!("{panic_info}");
1744 }));
1745}
1746
1747pub async fn run_tui(
1749 client: steer_grpc::AgentClient,
1750 session_id: Option<String>,
1751 model: ModelId,
1752 directory: Option<std::path::PathBuf>,
1753 theme_name: Option<String>,
1754 force_setup: bool,
1755) -> Result<()> {
1756 use std::collections::HashMap;
1757 use steer_grpc::client_api::{
1758 CreateSessionParams, SessionPolicyOverrides, SessionToolConfig, WorkspaceConfig,
1759 };
1760
1761 let loader = theme::ThemeLoader::new();
1763 let theme = if let Some(theme_name) = theme_name {
1764 let path = std::path::Path::new(&theme_name);
1766 let theme_result = if path.is_absolute() || path.exists() {
1767 loader.load_theme_from_path(path)
1769 } else {
1770 loader.load_theme(&theme_name)
1772 };
1773
1774 match theme_result {
1775 Ok(theme) => {
1776 info!("Loaded theme: {}", theme_name);
1777 Some(theme)
1778 }
1779 Err(e) => {
1780 warn!(
1781 "Failed to load theme '{}': {}. Using default theme.",
1782 theme_name, e
1783 );
1784 loader.load_theme("catppuccin-mocha").ok()
1786 }
1787 }
1788 } else {
1789 match loader.load_theme("catppuccin-mocha") {
1791 Ok(theme) => {
1792 info!("Loaded default theme: catppuccin-mocha");
1793 Some(theme)
1794 }
1795 Err(e) => {
1796 warn!(
1797 "Failed to load default theme 'catppuccin-mocha': {}. Using hardcoded default.",
1798 e
1799 );
1800 None
1801 }
1802 }
1803 };
1804
1805 let (session_id, messages) = if let Some(session_id) = session_id {
1806 let (messages, _approved_tools) =
1807 client.resume_session(&session_id).await.map_err(Box::new)?;
1808 info!(
1809 "Resumed session: {} with {} messages",
1810 session_id,
1811 messages.len()
1812 );
1813 tracing_info!("Session ID: {session_id}");
1814 (session_id, messages)
1815 } else {
1816 let workspace = if let Some(ref dir) = directory {
1818 WorkspaceConfig::Local { path: dir.clone() }
1819 } else {
1820 WorkspaceConfig::default()
1821 };
1822 let session_params = CreateSessionParams {
1823 workspace,
1824 tool_config: SessionToolConfig::default(),
1825 primary_agent_id: None,
1826 policy_overrides: SessionPolicyOverrides::empty(),
1827 metadata: HashMap::new(),
1828 default_model: model.clone(),
1829 };
1830
1831 let session_id = client
1832 .create_session(session_params)
1833 .await
1834 .map_err(Box::new)?;
1835 (session_id, vec![])
1836 };
1837
1838 client.subscribe_session_events().await.map_err(Box::new)?;
1839 let event_rx = client.subscribe_client_events().await.map_err(Box::new)?;
1840 let mut tui = Tui::new(client, model.clone(), session_id.clone(), theme.clone()).await?;
1841
1842 struct TuiCleanupGuard;
1844 impl Drop for TuiCleanupGuard {
1845 fn drop(&mut self) {
1846 cleanup();
1847 }
1848 }
1849 let _cleanup_guard = TuiCleanupGuard;
1850
1851 if !messages.is_empty() {
1852 tui.restore_messages(messages.clone());
1853 tui.chat_viewport.state_mut().scroll_to_bottom();
1854 }
1855
1856 let statuses = tui
1858 .client
1859 .get_provider_auth_status(None)
1860 .await
1861 .map_err(|e| Error::Generic(format!("Failed to get provider auth status: {e}")))?;
1862
1863 let has_any_auth = statuses
1864 .iter()
1865 .any(|s| has_any_auth_source(s.auth_source.as_ref()));
1866
1867 let should_run_setup = force_setup
1868 || (!Preferences::config_path()
1869 .map(|p| p.exists())
1870 .unwrap_or(false)
1871 && !has_any_auth);
1872
1873 if should_run_setup {
1875 let providers =
1877 tui.client.list_providers().await.map_err(|e| {
1878 Error::Generic(format!("Failed to list providers from server: {e}"))
1879 })?;
1880 let registry = std::sync::Arc::new(RemoteProviderRegistry::from_proto(providers));
1881
1882 let mut status_map = std::collections::HashMap::new();
1884 for s in statuses {
1885 status_map.insert(s.provider_id.clone(), s.auth_source);
1886 }
1887
1888 let mut provider_status = std::collections::HashMap::new();
1889 for p in registry.all() {
1890 let status = auth_status_from_source(status_map.get(&p.id).and_then(|s| s.as_ref()));
1891 provider_status.insert(ProviderId(p.id.clone()), status);
1892 }
1893
1894 tui.setup_state = Some(crate::tui::state::SetupState::new(
1895 registry,
1896 provider_status,
1897 ));
1898 tui.input_mode = InputMode::Setup;
1899 }
1900
1901 tui.run(event_rx).await
1903}
1904
1905pub async fn run_tui_auth_setup(
1908 client: steer_grpc::AgentClient,
1909 session_id: Option<String>,
1910 model: Option<ModelId>,
1911 session_db: Option<PathBuf>,
1912 theme_name: Option<String>,
1913) -> Result<()> {
1914 run_tui(
1917 client,
1918 session_id,
1919 model.unwrap_or(builtin::claude_sonnet_4_5()),
1920 session_db,
1921 theme_name,
1922 true, )
1924 .await
1925}
1926
1927#[cfg(test)]
1928mod tests {
1929 use crate::tui::test_utils::local_client_and_server;
1930
1931 use super::*;
1932
1933 use serde_json::json;
1934
1935 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1936 use steer_grpc::client_api::{AssistantContent, Message, MessageData, OpId, QueuedWorkItem};
1937 use tempfile::tempdir;
1938
1939 struct TerminalCleanupGuard;
1941
1942 impl Drop for TerminalCleanupGuard {
1943 fn drop(&mut self) {
1944 cleanup();
1945 }
1946 }
1947
1948 #[test]
1949 fn operation_cancelled_with_popped_queue_item_clears_esc_double_tap_tracker() {
1950 let mut tracker = crate::tui::state::DoubleTapTracker::new();
1951 tracker.record_key(KeyCode::Esc);
1952
1953 let popped = QueuedWorkItem {
1954 kind: steer_grpc::client_api::QueuedWorkKind::UserMessage,
1955 content: "queued draft".to_string(),
1956 model: None,
1957 queued_at: 123,
1958 op_id: OpId::new(),
1959 message_id: steer_grpc::client_api::MessageId::from_string("msg_queued"),
1960 };
1961
1962 Tui::preprocess_client_event_double_tap(
1963 &ClientEvent::OperationCancelled {
1964 op_id: OpId::new(),
1965 pending_tool_calls: 0,
1966 popped_queued_item: Some(popped),
1967 },
1968 &mut tracker,
1969 );
1970
1971 assert!(
1972 !tracker.is_double_tap(KeyCode::Esc, Duration::from_millis(300)),
1973 "Esc tracker should be cleared when cancellation restores a queued item"
1974 );
1975 }
1976
1977 #[test]
1978 fn operation_cancelled_without_popped_queue_item_keeps_esc_double_tap_tracker() {
1979 let mut tracker = crate::tui::state::DoubleTapTracker::new();
1980 tracker.record_key(KeyCode::Esc);
1981
1982 Tui::preprocess_client_event_double_tap(
1983 &ClientEvent::OperationCancelled {
1984 op_id: OpId::new(),
1985 pending_tool_calls: 0,
1986 popped_queued_item: None,
1987 },
1988 &mut tracker,
1989 );
1990
1991 assert!(
1992 tracker.is_double_tap(KeyCode::Esc, Duration::from_millis(300)),
1993 "Esc tracker should remain armed when cancellation does not restore queued input"
1994 );
1995 }
1996
1997 #[tokio::test]
1998 #[ignore = "Requires TTY - run with `cargo test -- --ignored` in a terminal"]
1999 async fn test_ctrl_r_scrolls_to_bottom_in_simple_mode() {
2000 let _guard = TerminalCleanupGuard;
2001 let workspace_root = tempdir().expect("tempdir");
2002 let (client, _server_handle) =
2003 local_client_and_server(None, Some(workspace_root.path().to_path_buf())).await;
2004 let model = builtin::claude_sonnet_4_5();
2005 let session_id = "test_session_id".to_string();
2006 let mut tui = Tui::new(client, model, session_id, None)
2007 .await
2008 .expect("create tui");
2009
2010 tui.preferences.ui.editing_mode = EditingMode::Simple;
2011 tui.input_mode = InputMode::Simple;
2012
2013 let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
2014 tui.handle_simple_mode(key).await.expect("handle ctrl+r");
2015
2016 assert_eq!(
2017 tui.chat_viewport.state().view_mode,
2018 crate::tui::widgets::ViewMode::Detailed
2019 );
2020 assert_eq!(
2021 tui.chat_viewport.state_mut().take_scroll_target(),
2022 Some(crate::tui::widgets::ScrollTarget::Bottom)
2023 );
2024 }
2025
2026 #[tokio::test]
2027 #[ignore = "Requires TTY - run with `cargo test -- --ignored` in a terminal"]
2028 async fn test_restore_messages_preserves_tool_call_params() {
2029 let _guard = TerminalCleanupGuard;
2030 let workspace_root = tempdir().expect("tempdir");
2032 let (client, _server_handle) =
2033 local_client_and_server(None, Some(workspace_root.path().to_path_buf())).await;
2034 let model = builtin::claude_sonnet_4_5();
2035 let session_id = "test_session_id".to_string();
2036 let mut tui = Tui::new(client, model, session_id, None)
2037 .await
2038 .expect("create tui");
2039
2040 let tool_id = "test_tool_123".to_string();
2042 let tool_call = steer_tools::ToolCall {
2043 id: tool_id.clone(),
2044 name: "view".to_string(),
2045 parameters: json!({
2046 "file_path": "/test/file.rs",
2047 "offset": 10,
2048 "limit": 100
2049 }),
2050 };
2051
2052 let assistant_msg = Message {
2053 data: MessageData::Assistant {
2054 content: vec![AssistantContent::ToolCall {
2055 tool_call: tool_call.clone(),
2056 thought_signature: None,
2057 }],
2058 },
2059 id: "msg_assistant".to_string(),
2060 timestamp: 1_234_567_890,
2061 parent_message_id: None,
2062 };
2063
2064 let tool_msg = Message {
2065 data: MessageData::Tool {
2066 tool_use_id: tool_id.clone(),
2067 result: steer_tools::ToolResult::FileContent(
2068 steer_tools::result::FileContentResult {
2069 file_path: "/test/file.rs".to_string(),
2070 content: "file content here".to_string(),
2071 line_count: 1,
2072 truncated: false,
2073 },
2074 ),
2075 },
2076 id: "msg_tool".to_string(),
2077 timestamp: 1_234_567_891,
2078 parent_message_id: Some("msg_assistant".to_string()),
2079 };
2080
2081 let messages = vec![assistant_msg, tool_msg];
2082
2083 tui.restore_messages(messages);
2085
2086 if let Some(stored_call) = tui.tool_registry.get_tool_call(&tool_id) {
2088 assert_eq!(stored_call.name, "view");
2089 assert_eq!(stored_call.parameters, tool_call.parameters);
2090 } else {
2091 panic!("Tool call should be in registry");
2092 }
2093 }
2094
2095 #[tokio::test]
2096 #[ignore = "Requires TTY - run with `cargo test -- --ignored` in a terminal"]
2097 async fn test_restore_messages_handles_tool_result_before_assistant() {
2098 let _guard = TerminalCleanupGuard;
2099 let workspace_root = tempdir().expect("tempdir");
2101 let (client, _server_handle) =
2102 local_client_and_server(None, Some(workspace_root.path().to_path_buf())).await;
2103 let model = builtin::claude_sonnet_4_5();
2104 let session_id = "test_session_id".to_string();
2105 let mut tui = Tui::new(client, model, session_id, None)
2106 .await
2107 .expect("create tui");
2108
2109 let tool_id = "test_tool_456".to_string();
2110 let real_params = json!({
2111 "file_path": "/another/file.rs"
2112 });
2113
2114 let tool_call = steer_tools::ToolCall {
2115 id: tool_id.clone(),
2116 name: "view".to_string(),
2117 parameters: real_params.clone(),
2118 };
2119
2120 let tool_msg = Message {
2122 data: MessageData::Tool {
2123 tool_use_id: tool_id.clone(),
2124 result: steer_tools::ToolResult::FileContent(
2125 steer_tools::result::FileContentResult {
2126 file_path: "/another/file.rs".to_string(),
2127 content: "file content".to_string(),
2128 line_count: 1,
2129 truncated: false,
2130 },
2131 ),
2132 },
2133 id: "msg_tool".to_string(),
2134 timestamp: 1_234_567_890,
2135 parent_message_id: None,
2136 };
2137
2138 let assistant_msg = Message {
2139 data: MessageData::Assistant {
2140 content: vec![AssistantContent::ToolCall {
2141 tool_call: tool_call.clone(),
2142 thought_signature: None,
2143 }],
2144 },
2145 id: "msg_456".to_string(),
2146 timestamp: 1_234_567_891,
2147 parent_message_id: None,
2148 };
2149
2150 let messages = vec![tool_msg, assistant_msg];
2151
2152 tui.restore_messages(messages);
2153
2154 if let Some(stored_call) = tui.tool_registry.get_tool_call(&tool_id) {
2156 assert_eq!(stored_call.parameters, real_params);
2157 assert_eq!(stored_call.name, "view");
2158 } else {
2159 panic!("Tool call should be in registry");
2160 }
2161 }
2162}