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