1use std::collections::{HashSet, VecDeque};
6use std::io::{self, Stdout};
7use std::path::PathBuf;
8use std::time::Duration;
9
10use base64::Engine as _;
11
12const IMAGE_TOKEN_LABEL_PREFIX: &str = "[Image ";
13const IMAGE_TOKEN_LABEL_SUFFIX: &str = "]";
14const FIRST_ATTACHMENT_TOKEN: u32 = 0xE000;
15
16#[derive(Debug, Clone)]
17struct PendingAttachment {
18 image: ImageContent,
19 token: char,
20}
21
22use crate::tui::update::UpdateStatus;
23
24use crate::error::{Error, Result};
25use crate::notifications::{NotificationManager, NotificationManagerHandle};
26use crate::tui::commands::registry::CommandRegistry;
27use crate::tui::model::{ChatItem, NoticeLevel, TuiCommandResponse};
28use crate::tui::theme::Theme;
29use futures::{FutureExt, StreamExt};
30use image::ImageFormat;
31use ratatui::backend::CrosstermBackend;
32use ratatui::crossterm::event::{self, Event, EventStream, KeyCode, KeyEventKind, MouseEvent};
33use ratatui::{Frame, Terminal};
34use steer_grpc::AgentClient;
35use steer_grpc::client_api::{
36 AssistantContent, ClientEvent, EditingMode, ImageContent, ImageSource, LlmStatus, Message,
37 MessageData, ModelId, OpId, Preferences, ProviderId, UserContent, WorkspaceStatus, builtin,
38 default_primary_agent_id,
39};
40
41use crate::tui::events::processor::PendingToolApproval;
42use tokio::sync::mpsc;
43use tracing::{debug, error, info, warn};
44
45fn auth_status_from_source(
46 source: Option<&steer_grpc::client_api::AuthSource>,
47) -> crate::tui::state::AuthStatus {
48 match source {
49 Some(steer_grpc::client_api::AuthSource::ApiKey { .. }) => {
50 crate::tui::state::AuthStatus::ApiKeySet
51 }
52 Some(steer_grpc::client_api::AuthSource::Plugin { .. }) => {
53 crate::tui::state::AuthStatus::OAuthConfigured
54 }
55 _ => crate::tui::state::AuthStatus::NotConfigured,
56 }
57}
58
59fn has_any_auth_source(source: Option<&steer_grpc::client_api::AuthSource>) -> bool {
60 matches!(
61 source,
62 Some(
63 steer_grpc::client_api::AuthSource::ApiKey { .. }
64 | steer_grpc::client_api::AuthSource::Plugin { .. }
65 )
66 )
67}
68
69fn format_inline_image_token(n: usize) -> String {
70 format!("{IMAGE_TOKEN_LABEL_PREFIX}{n}{IMAGE_TOKEN_LABEL_SUFFIX}")
71}
72
73fn image_label_len_at(s: &str) -> Option<usize> {
76 if !s.starts_with(IMAGE_TOKEN_LABEL_PREFIX) {
77 return None;
78 }
79 let end = s.find(IMAGE_TOKEN_LABEL_SUFFIX)?;
80 Some(end + IMAGE_TOKEN_LABEL_SUFFIX.len())
81}
82
83fn attachment_spans(content: &str, attachments: &[PendingAttachment]) -> Vec<(char, usize, usize)> {
84 let mut spans = Vec::new();
85
86 for (start, ch) in content.char_indices() {
87 if !attachments.iter().any(|a| a.token == ch) {
88 continue;
89 }
90
91 let mut end = start + ch.len_utf8();
92 if let Some(label_len) = image_label_len_at(&content[end..]) {
93 end += label_len;
94 }
95
96 spans.push((ch, start, end));
97 }
98
99 spans
100}
101
102fn parse_inline_message_content(content: &str, images: &[PendingAttachment]) -> Vec<UserContent> {
103 if images.is_empty() {
104 let trimmed = content.trim().to_string();
105 if trimmed.is_empty() {
106 return Vec::new();
107 }
108 return vec![UserContent::Text { text: trimmed }];
109 }
110
111 let mut result = Vec::new();
112 let mut text_buf = String::new();
113 let mut cursor = 0;
114
115 while cursor < content.len() {
116 let ch = match content[cursor..].chars().next() {
117 Some(ch) => ch,
118 None => break,
119 };
120
121 if let Some(attachment) = images.iter().find(|attachment| attachment.token == ch) {
122 let trimmed = text_buf.trim().to_string();
123 if !trimmed.is_empty() {
124 result.push(UserContent::Text { text: trimmed });
125 }
126 text_buf.clear();
127 result.push(UserContent::Image {
128 image: attachment.image.clone(),
129 });
130
131 cursor += ch.len_utf8();
132 if let Some(label_len) = image_label_len_at(&content[cursor..]) {
133 cursor += label_len;
134 }
135 continue;
136 }
137
138 text_buf.push(ch);
139 cursor += ch.len_utf8();
140 }
141
142 let trimmed = text_buf.trim().to_string();
143 if !trimmed.is_empty() {
144 result.push(UserContent::Text { text: trimmed });
145 }
146
147 result
148}
149
150fn strip_image_token_labels(content: &str) -> String {
151 let mut output = String::new();
152 let chars: Vec<char> = content.chars().collect();
153 let mut i = 0;
154
155 while i < chars.len() {
156 let ch = chars[i];
157 let ch_u32 = ch as u32;
158 if !(FIRST_ATTACHMENT_TOKEN..=0xF8FF).contains(&ch_u32) {
159 output.push(ch);
160 i += 1;
161 continue;
162 }
163
164 i += 1;
165 let label_start = i;
166 while i < chars.len() && chars[i].is_whitespace() {
167 i += 1;
168 }
169
170 let mut j = i;
171 while j < chars.len() && chars[j] != ']' {
172 j += 1;
173 }
174
175 if j < chars.len() {
176 let candidate: String = chars[i..=j].iter().collect();
177 if candidate.starts_with(IMAGE_TOKEN_LABEL_PREFIX)
178 && candidate.ends_with(IMAGE_TOKEN_LABEL_SUFFIX)
179 {
180 i = j + 1;
181 continue;
182 }
183 }
184
185 output.push(ch);
186 i = label_start;
187 }
188
189 output
190}
191
192fn decode_pasted_image(data: &str) -> Option<ImageContent> {
193 let bytes = base64::engine::general_purpose::STANDARD
194 .decode(data)
195 .ok()?;
196 let format = image::guess_format(&bytes).ok()?;
197
198 let mime_type = match format {
199 ImageFormat::Png => "image/png",
200 ImageFormat::Jpeg => "image/jpeg",
201 ImageFormat::Gif => "image/gif",
202 ImageFormat::WebP => "image/webp",
203 ImageFormat::Bmp => "image/bmp",
204 ImageFormat::Tiff => "image/tiff",
205 _ => return None,
206 }
207 .to_string();
208
209 Some(ImageContent {
210 source: ImageSource::DataUrl {
211 data_url: format!("data:{};base64,{}", mime_type, data),
212 },
213 mime_type,
214 width: None,
215 height: None,
216 bytes: Some(bytes.len() as u64),
217 sha256: None,
218 })
219}
220
221fn encode_clipboard_rgba_image(
222 width: usize,
223 height: usize,
224 rgba_bytes: &[u8],
225) -> Option<ImageContent> {
226 let width = u32::try_from(width).ok()?;
227 let height = u32::try_from(height).ok()?;
228 let rgba = image::RgbaImage::from_raw(width, height, rgba_bytes.to_vec())?;
229
230 let mut png_cursor = io::Cursor::new(Vec::new());
231 image::DynamicImage::ImageRgba8(rgba)
232 .write_to(&mut png_cursor, ImageFormat::Png)
233 .ok()?;
234
235 let png_bytes = png_cursor.into_inner();
236 let encoded = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
237
238 Some(ImageContent {
239 source: ImageSource::DataUrl {
240 data_url: format!("data:image/png;base64,{encoded}"),
241 },
242 mime_type: "image/png".to_string(),
243 width: Some(width),
244 height: Some(height),
245 bytes: u64::try_from(png_bytes.len()).ok(),
246 sha256: None,
247 })
248}
249
250pub(crate) fn format_agent_label(primary_agent_id: &str) -> String {
251 let agent_id = if primary_agent_id.is_empty() {
252 default_primary_agent_id()
253 } else {
254 primary_agent_id
255 };
256 agent_id.to_string()
257}
258
259use crate::tui::events::pipeline::EventPipeline;
260use crate::tui::events::processors::message::MessageEventProcessor;
261use crate::tui::events::processors::processing_state::ProcessingStateProcessor;
262use crate::tui::events::processors::system::SystemEventProcessor;
263use crate::tui::events::processors::tool::ToolEventProcessor;
264use crate::tui::state::RemoteProviderRegistry;
265use crate::tui::state::SetupState;
266use crate::tui::state::{ChatStore, ToolCallRegistry};
267
268use crate::tui::chat_viewport::ChatViewport;
269use crate::tui::terminal::{SetupGuard, cleanup};
270use crate::tui::ui_layout::UiLayout;
271use crate::tui::widgets::EditSelectionOverlayState;
272use crate::tui::widgets::InputPanel;
273use crate::tui::widgets::input_panel::InputPanelParams;
274use tracing::error as tracing_error;
275use tracing::info as tracing_info;
276
277pub mod commands;
278pub mod custom_commands;
279pub mod model;
280pub mod state;
281pub mod terminal;
282pub mod theme;
283pub mod widgets;
284
285mod chat_viewport;
286pub mod core_commands;
287mod events;
288mod handlers;
289mod ui_layout;
290mod update;
291
292#[cfg(test)]
293mod test_utils;
294
295const SPINNER_UPDATE_INTERVAL: Duration = Duration::from_millis(100);
297const SCROLL_FLUSH_INTERVAL: Duration = Duration::from_millis(16);
298const MOUSE_SCROLL_STEP: usize = 1;
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302pub enum InputMode {
303 Simple,
305 VimNormal,
307 VimInsert,
309 BashCommand,
311 AwaitingApproval,
313 ConfirmExit,
315 EditMessageSelection,
317 FuzzyFinder,
319 Setup,
321}
322
323#[derive(Debug, Clone, Copy, PartialEq)]
325enum VimOperator {
326 Delete,
327 Change,
328 Yank,
329}
330
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332enum ScrollDirection {
333 Up,
334 Down,
335}
336
337impl ScrollDirection {
338 fn from_mouse_event(event: &MouseEvent) -> Option<Self> {
339 match event.kind {
340 event::MouseEventKind::ScrollUp => Some(Self::Up),
341 event::MouseEventKind::ScrollDown => Some(Self::Down),
342 _ => None,
343 }
344 }
345}
346
347#[derive(Debug, Clone, Copy)]
348struct PendingScroll {
349 direction: ScrollDirection,
350 steps: usize,
351}
352
353#[derive(Debug, Default)]
355struct VimState {
356 pending_operator: Option<VimOperator>,
358 pending_g: bool,
360 replace_mode: bool,
362 visual_mode: bool,
364}
365
366pub struct Tui {
368 terminal: Terminal<CrosstermBackend<Stdout>>,
370 terminal_size: (u16, u16),
371 input_mode: InputMode,
373 input_panel_state: crate::tui::widgets::input_panel::InputPanelState,
375 editing_message_id: Option<String>,
377 pending_attachments: Vec<PendingAttachment>,
379 next_attachment_token: u32,
380 client: AgentClient,
382 is_processing: bool,
384 progress_message: Option<String>,
386 spinner_state: usize,
388 current_tool_approval: Option<PendingToolApproval>,
389 current_model: ModelId,
391 current_agent_label: Option<String>,
393 event_pipeline: EventPipeline,
395 chat_store: ChatStore,
397 tool_registry: ToolCallRegistry,
399 chat_viewport: ChatViewport,
401 session_id: String,
403 theme: Theme,
405 setup_state: Option<SetupState>,
407 in_flight_operations: HashSet<OpId>,
409 queued_head: Option<steer_grpc::client_api::QueuedWorkItem>,
411 queued_count: usize,
413 command_registry: CommandRegistry,
415 preferences: Preferences,
417 notification_manager: NotificationManagerHandle,
419 double_tap_tracker: crate::tui::state::DoubleTapTracker,
421 vim_state: VimState,
423 mode_stack: VecDeque<InputMode>,
425 last_revision: u64,
427 update_status: UpdateStatus,
429 edit_selection_state: EditSelectionOverlayState,
430}
431
432const MAX_MODE_DEPTH: usize = 8;
433
434impl Tui {
435 fn push_mode(&mut self) {
437 if self.mode_stack.len() == MAX_MODE_DEPTH {
438 self.mode_stack.pop_front(); }
440 self.mode_stack.push_back(self.input_mode);
441 }
442
443 fn pop_mode(&mut self) -> Option<InputMode> {
445 self.mode_stack.pop_back()
446 }
447
448 pub fn switch_mode(&mut self, new_mode: InputMode) {
450 if self.input_mode != new_mode {
451 debug!(
452 "Switching mode from {:?} to {:?}",
453 self.input_mode, new_mode
454 );
455 self.push_mode();
456 self.input_mode = new_mode;
457 }
458 }
459
460 pub fn set_mode(&mut self, new_mode: InputMode) {
462 debug!("Setting mode from {:?} to {:?}", self.input_mode, new_mode);
463 self.input_mode = new_mode;
464 }
465
466 pub fn restore_previous_mode(&mut self) {
468 self.input_mode = self.pop_mode().unwrap_or_else(|| self.default_input_mode());
469 }
470
471 fn default_input_mode(&self) -> InputMode {
473 match self.preferences.ui.editing_mode {
474 EditingMode::Simple => InputMode::Simple,
475 EditingMode::Vim => InputMode::VimNormal,
476 }
477 }
478
479 fn is_text_input_mode(&self) -> bool {
481 matches!(
482 self.input_mode,
483 InputMode::Simple
484 | InputMode::VimInsert
485 | InputMode::BashCommand
486 | InputMode::Setup
487 | InputMode::FuzzyFinder
488 )
489 }
490
491 fn has_pending_send_content(&self) -> bool {
492 self.input_panel_state.has_content() || !self.pending_attachments.is_empty()
493 }
494
495 fn next_attachment_token(&mut self) -> Option<char> {
496 let max = 0x0010_FFFF;
497 while self.next_attachment_token <= max {
498 let candidate = self.next_attachment_token;
499 self.next_attachment_token += 1;
500
501 let Some(token) = char::from_u32(candidate) else {
502 continue;
503 };
504 if token.is_control() || token == '\n' || token == '\r' {
505 continue;
506 }
507 if !((FIRST_ATTACHMENT_TOKEN..=0xF8FF).contains(&(token as u32))) {
508 continue;
509 }
510 if self
511 .pending_attachments
512 .iter()
513 .any(|attachment| attachment.token == token)
514 {
515 continue;
516 }
517 return Some(token);
518 }
519
520 None
521 }
522
523 fn remove_attachment_for_token(&mut self, token: char) {
524 if let Some(index) = self
525 .pending_attachments
526 .iter()
527 .position(|attachment| attachment.token == token)
528 {
529 self.pending_attachments.remove(index);
530 }
531 }
532
533 fn add_pending_attachment(&mut self, image: ImageContent) {
534 let Some(token) = self.next_attachment_token() else {
535 warn!(target: "tui.input", "Ran out of attachment token characters");
536 self.push_notice(
537 NoticeLevel::Warn,
538 "Unable to attach more images in this input.".to_string(),
539 );
540 return;
541 };
542
543 self.pending_attachments
544 .push(PendingAttachment { image, token });
545 let image_number = self.pending_attachments.len();
546 self.input_panel_state.textarea.insert_char(token);
547 self.input_panel_state
548 .textarea
549 .insert_str(format_inline_image_token(image_number));
550 }
551
552 fn cursor_position_from_byte_offset(content: &str, byte_offset: usize) -> (u16, u16) {
553 let offset = byte_offset.min(content.len());
554 let mut row = 0usize;
555 let mut col = 0usize;
556
557 for ch in content[..offset].chars() {
558 if ch == '\n' {
559 row += 1;
560 col = 0;
561 } else {
562 col += 1;
563 }
564 }
565
566 (
567 u16::try_from(row).unwrap_or(u16::MAX),
568 u16::try_from(col).unwrap_or(u16::MAX),
569 )
570 }
571
572 fn replace_input_content_with_cursor_offset(&mut self, content: &str, cursor_offset: usize) {
573 let cursor = Self::cursor_position_from_byte_offset(content, cursor_offset);
574 self.input_panel_state
575 .replace_content(content, Some(cursor));
576 }
577
578 fn replace_input_content_preserving_cursor(&mut self, content: &str) {
579 let current_content = self.input_panel_state.content();
580 let cursor_offset = self
581 .input_panel_state
582 .get_cursor_byte_offset()
583 .min(current_content.len())
584 .min(content.len());
585 self.replace_input_content_with_cursor_offset(content, cursor_offset);
586 }
587
588 fn sync_attachments_from_input_tokens(&mut self) {
589 let content = self.input_panel_state.content();
590
591 if self.pending_attachments.is_empty() {
592 let stripped = strip_image_token_labels(&content);
593 if stripped != content {
594 self.replace_input_content_preserving_cursor(&stripped);
595 }
596 return;
597 }
598
599 let mut normalized = String::new();
600 let mut cursor = 0usize;
601 let mut retained_tokens: HashSet<char> = HashSet::new();
602 let mut image_number = 0usize;
603
604 while cursor < content.len() {
605 let Some(ch) = content[cursor..].chars().next() else {
606 break;
607 };
608
609 if self
610 .pending_attachments
611 .iter()
612 .any(|attachment| attachment.token == ch)
613 {
614 image_number += 1;
615 let label = format_inline_image_token(image_number);
616 retained_tokens.insert(ch);
617 normalized.push(ch);
618 normalized.push_str(&label);
619
620 cursor += ch.len_utf8();
621 if let Some(label_len) = image_label_len_at(&content[cursor..]) {
622 cursor += label_len;
623 }
624 continue;
625 }
626
627 normalized.push(ch);
628 cursor += ch.len_utf8();
629 }
630
631 self.pending_attachments
632 .retain(|attachment| retained_tokens.contains(&attachment.token));
633
634 let normalized = if self.pending_attachments.is_empty() {
635 strip_image_token_labels(&normalized)
636 } else {
637 normalized
638 };
639
640 if normalized != content {
641 self.replace_input_content_preserving_cursor(&normalized);
642 }
643 }
644
645 fn handle_atomic_backspace_delete(&mut self, delete_forward: bool) -> bool {
646 if self.pending_attachments.is_empty() {
647 return false;
648 }
649
650 let content = self.input_panel_state.content();
651 let cursor_offset = self
652 .input_panel_state
653 .get_cursor_byte_offset()
654 .min(content.len());
655
656 let target_offset = if delete_forward {
657 if cursor_offset >= content.len() {
658 return false;
659 }
660 cursor_offset
661 } else {
662 if cursor_offset == 0 {
663 return false;
664 }
665
666 match content[..cursor_offset].char_indices().next_back() {
667 Some((idx, _)) => idx,
668 None => return false,
669 }
670 };
671
672 let Some((token, start, end)) = attachment_spans(&content, &self.pending_attachments)
673 .into_iter()
674 .find(|(_, start, end)| (*start..*end).contains(&target_offset))
675 else {
676 return false;
677 };
678
679 let mut next_content = String::new();
680 next_content.push_str(&content[..start]);
681 next_content.push_str(&content[end..]);
682
683 self.remove_attachment_for_token(token);
684 self.replace_input_content_with_cursor_offset(&next_content, start);
685 true
686 }
687
688 fn try_attach_image_from_clipboard(&mut self) -> bool {
689 let mut clipboard = match arboard::Clipboard::new() {
690 Ok(clipboard) => clipboard,
691 Err(err) => {
692 debug!(target: "tui.input", "Clipboard unavailable for Ctrl+V: {err}");
693 return false;
694 }
695 };
696
697 let image = match clipboard.get_image() {
698 Ok(image) => image,
699 Err(err) => {
700 debug!(target: "tui.input", "No clipboard image found for Ctrl+V: {err}");
701 return false;
702 }
703 };
704
705 if let Some(image_content) =
706 encode_clipboard_rgba_image(image.width, image.height, image.bytes.as_ref())
707 {
708 self.add_pending_attachment(image_content);
709 true
710 } else {
711 warn!(
712 target: "tui.input",
713 "Clipboard image had invalid dimensions: {}x{} ({} bytes)",
714 image.width,
715 image.height,
716 image.bytes.len()
717 );
718 self.push_notice(
719 NoticeLevel::Warn,
720 "Clipboard image format is unsupported.".to_string(),
721 );
722 true
723 }
724 }
725
726 pub async fn new(
728 client: AgentClient,
729 current_model: ModelId,
730
731 session_id: String,
732 theme: Option<Theme>,
733 ) -> Result<Self> {
734 let mut guard = SetupGuard::new();
736
737 let mut stdout = io::stdout();
738 terminal::setup(&mut stdout)?;
739
740 let backend = CrosstermBackend::new(stdout);
741 let terminal = Terminal::new(backend)?;
742 let terminal_size = terminal
743 .size()
744 .map(|s| (s.width, s.height))
745 .unwrap_or((80, 24));
746
747 let preferences = Preferences::load()
749 .map_err(|e| crate::error::Error::Config(e.to_string()))
750 .unwrap_or_default();
751
752 let input_mode = match preferences.ui.editing_mode {
754 EditingMode::Simple => InputMode::Simple,
755 EditingMode::Vim => InputMode::VimNormal,
756 };
757
758 let notification_manager = std::sync::Arc::new(NotificationManager::new(&preferences));
759
760 let mut tui = Self {
761 terminal,
762 terminal_size,
763 input_mode,
764 input_panel_state: crate::tui::widgets::input_panel::InputPanelState::new(
765 session_id.clone(),
766 ),
767 editing_message_id: None,
768 pending_attachments: Vec::new(),
769 next_attachment_token: FIRST_ATTACHMENT_TOKEN,
770 client,
771 is_processing: false,
772 progress_message: None,
773 spinner_state: 0,
774 current_tool_approval: None,
775 current_model,
776 current_agent_label: None,
777 event_pipeline: Self::create_event_pipeline(notification_manager.clone()),
778 chat_store: ChatStore::new(),
779 tool_registry: ToolCallRegistry::new(),
780 chat_viewport: ChatViewport::new(),
781 session_id,
782 theme: theme.unwrap_or_default(),
783 setup_state: None,
784 in_flight_operations: HashSet::new(),
785 queued_head: None,
786 queued_count: 0,
787 command_registry: CommandRegistry::new(),
788 preferences,
789 notification_manager,
790 double_tap_tracker: crate::tui::state::DoubleTapTracker::new(),
791 vim_state: VimState::default(),
792 mode_stack: VecDeque::new(),
793 last_revision: 0,
794 update_status: UpdateStatus::Checking,
795 edit_selection_state: EditSelectionOverlayState::default(),
796 };
797
798 tui.refresh_agent_label().await;
799 tui.notification_manager.set_focus_events_enabled(true);
800
801 guard.disarm();
803
804 Ok(tui)
805 }
806
807 fn restore_messages(&mut self, messages: Vec<Message>) {
809 let message_count = messages.len();
810 info!("Starting to restore {} messages to TUI", message_count);
811
812 for message in &messages {
814 if let MessageData::Tool { tool_use_id, .. } = &message.data {
815 debug!(
816 target: "tui.restore",
817 "Found Tool message with tool_use_id={}",
818 tool_use_id
819 );
820 }
821 }
822
823 self.chat_store.ingest_messages(&messages);
824 if let Some(message) = messages.last() {
825 self.chat_store
826 .set_active_message_id(Some(message.id().to_string()));
827 }
828
829 for message in &messages {
832 if let MessageData::Assistant { content, .. } = &message.data {
833 debug!(
834 target: "tui.restore",
835 "Processing Assistant message id={}",
836 message.id()
837 );
838 for block in content {
839 if let AssistantContent::ToolCall { tool_call, .. } = block {
840 debug!(
841 target: "tui.restore",
842 "Found ToolCall in Assistant message: id={}, name={}, params={}",
843 tool_call.id, tool_call.name, tool_call.parameters
844 );
845
846 self.tool_registry.register_call(tool_call.clone());
848 }
849 }
850 }
851 }
852
853 for message in &messages {
855 if let MessageData::Tool { tool_use_id, .. } = &message.data {
856 debug!(
857 target: "tui.restore",
858 "Updating registry with Tool result for id={}",
859 tool_use_id
860 );
861 }
863 }
864
865 debug!(
866 target: "tui.restore",
867 "Tool registry state after restoration: {} calls registered",
868 self.tool_registry.metrics().completed_count
869 );
870 info!("Successfully restored {} messages to TUI", message_count);
871 }
872
873 fn push_notice(&mut self, level: crate::tui::model::NoticeLevel, text: String) {
875 use crate::tui::model::{ChatItem, ChatItemData, generate_row_id};
876 self.chat_store.push(ChatItem {
877 parent_chat_item_id: None,
878 data: ChatItemData::SystemNotice {
879 id: generate_row_id(),
880 level,
881 text,
882 ts: time::OffsetDateTime::now_utc(),
883 },
884 });
885 }
886
887 fn push_tui_response(&mut self, command: String, response: TuiCommandResponse) {
888 use crate::tui::model::{ChatItem, ChatItemData, generate_row_id};
889 self.chat_store.push(ChatItem {
890 parent_chat_item_id: None,
891 data: ChatItemData::TuiCommandResponse {
892 id: generate_row_id(),
893 command,
894 response,
895 ts: time::OffsetDateTime::now_utc(),
896 },
897 });
898 }
899
900 fn format_grpc_error(error: &steer_grpc::GrpcError) -> String {
901 match error {
902 steer_grpc::GrpcError::CallFailed(status) => status.message().to_string(),
903 _ => error.to_string(),
904 }
905 }
906
907 fn format_workspace_status(status: &WorkspaceStatus) -> String {
908 let mut output = String::new();
909 output.push_str(&format!("Workspace: {}\n", status.workspace_id.as_uuid()));
910 output.push_str(&format!(
911 "Environment: {}\n",
912 status.environment_id.as_uuid()
913 ));
914 output.push_str(&format!("Repo: {}\n", status.repo_id.as_uuid()));
915 output.push_str(&format!("Path: {}\n", status.path.display()));
916
917 match &status.vcs {
918 Some(vcs) => {
919 output.push_str(&format!(
920 "VCS: {} ({})\n\n",
921 vcs.kind.as_str(),
922 vcs.root.display()
923 ));
924 output.push_str(&vcs.status.as_llm_string());
925 }
926 None => {
927 output.push_str("VCS: <none>\n");
928 }
929 }
930
931 output
932 }
933
934 async fn refresh_agent_label(&mut self) {
935 match self.client.get_session(&self.session_id).await {
936 Ok(Some(session)) => {
937 if let Some(config) = session.config.as_ref() {
938 let agent_id = config
939 .primary_agent_id
940 .clone()
941 .unwrap_or_else(|| default_primary_agent_id().to_string());
942 self.current_agent_label = Some(format_agent_label(&agent_id));
943 }
944 }
945 Ok(None) => {
946 warn!(
947 target: "tui.session",
948 "No session data available to populate agent label"
949 );
950 }
951 Err(e) => {
952 warn!(
953 target: "tui.session",
954 "Failed to load session config for agent label: {}",
955 e
956 );
957 }
958 }
959 }
960
961 async fn start_new_session(&mut self) -> Result<()> {
962 use std::collections::HashMap;
963 use steer_grpc::client_api::{
964 CreateSessionParams, SessionPolicyOverrides, SessionToolConfig, WorkspaceConfig,
965 };
966
967 let session_params = CreateSessionParams {
968 workspace: WorkspaceConfig::default(),
969 tool_config: SessionToolConfig::default(),
970 primary_agent_id: None,
971 policy_overrides: SessionPolicyOverrides::empty(),
972 metadata: HashMap::new(),
973 default_model: self.current_model.clone(),
974 };
975
976 let new_session_id = self
977 .client
978 .create_session(session_params)
979 .await
980 .map_err(|e| Error::Generic(format!("Failed to create new session: {e}")))?;
981
982 self.session_id.clone_from(&new_session_id);
983 self.client.subscribe_session_events().await?;
984 self.chat_store = ChatStore::new();
985 self.tool_registry = ToolCallRegistry::new();
986 self.chat_viewport = ChatViewport::new();
987 self.in_flight_operations.clear();
988 self.input_panel_state =
989 crate::tui::widgets::input_panel::InputPanelState::new(new_session_id.clone());
990 self.is_processing = false;
991 self.progress_message = None;
992 self.current_tool_approval = None;
993 self.editing_message_id = None;
994 self.current_agent_label = None;
995 self.refresh_agent_label().await;
996
997 self.load_file_cache().await;
998
999 Ok(())
1000 }
1001
1002 async fn load_file_cache(&mut self) {
1003 info!(target: "tui.file_cache", "Requesting workspace files for session {}", self.session_id);
1004 match self.client.list_workspace_files().await {
1005 Ok(files) => {
1006 self.input_panel_state.file_cache.update(files).await;
1007 }
1008 Err(e) => {
1009 warn!(target: "tui.file_cache", "Failed to request workspace files: {}", e);
1010 }
1011 }
1012 }
1013
1014 pub async fn run(&mut self, event_rx: mpsc::Receiver<ClientEvent>) -> Result<()> {
1015 info!(
1017 "Starting TUI run with {} messages in view model",
1018 self.chat_store.len()
1019 );
1020
1021 self.load_file_cache().await;
1023
1024 let (update_tx, update_rx) = mpsc::channel::<UpdateStatus>(1);
1026 let current_version = env!("CARGO_PKG_VERSION").to_string();
1027 tokio::spawn(async move {
1028 let status = update::check_latest("BrendanGraham14", "steer", ¤t_version).await;
1029 let _ = update_tx.send(status).await;
1030 });
1031
1032 let mut term_event_stream = EventStream::new();
1033
1034 self.run_event_loop(event_rx, &mut term_event_stream, update_rx)
1036 .await
1037 }
1038
1039 async fn run_event_loop(
1040 &mut self,
1041 mut event_rx: mpsc::Receiver<ClientEvent>,
1042 term_event_stream: &mut EventStream,
1043 mut update_rx: mpsc::Receiver<UpdateStatus>,
1044 ) -> Result<()> {
1045 let mut should_exit = false;
1046 let mut needs_redraw = true; let mut last_spinner_char = String::new();
1048 let mut update_rx_closed = false;
1049 let mut pending_scroll: Option<PendingScroll> = None;
1050
1051 let mut tick = tokio::time::interval(SPINNER_UPDATE_INTERVAL);
1053 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1054 let mut scroll_flush = tokio::time::interval(SCROLL_FLUSH_INTERVAL);
1055 scroll_flush.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1056
1057 while !should_exit {
1058 if needs_redraw {
1060 self.draw()?;
1061 needs_redraw = false;
1062 }
1063
1064 tokio::select! {
1065 status = update_rx.recv(), if !update_rx_closed => {
1066 match status {
1067 Some(status) => {
1068 self.update_status = status;
1069 needs_redraw = true;
1070 }
1071 None => {
1072 update_rx_closed = true;
1074 }
1075 }
1076 }
1077 event_res = term_event_stream.next() => {
1078 match event_res {
1079 Some(Ok(evt)) => {
1080 let (event_needs_redraw, event_should_exit) = self
1081 .handle_terminal_event(
1082 evt,
1083 term_event_stream,
1084 &mut pending_scroll,
1085 &mut scroll_flush,
1086 )
1087 .await?;
1088 if event_needs_redraw {
1089 needs_redraw = true;
1090 }
1091 if event_should_exit {
1092 should_exit = true;
1093 }
1094 }
1095 Some(Err(e)) => {
1096 if e.kind() == io::ErrorKind::Interrupted {
1097 debug!(target: "tui.input", "Ignoring interrupted syscall");
1098 } else {
1099 error!(target: "tui.run", "Fatal input error: {}. Exiting.", e);
1100 should_exit = true;
1101 }
1102 }
1103 None => {
1104 should_exit = true;
1106 }
1107 }
1108 }
1109 client_event_opt = event_rx.recv() => {
1110 match client_event_opt {
1111 Some(client_event) => {
1112 self.handle_client_event(client_event).await;
1113 needs_redraw = true;
1114 }
1115 None => {
1116 should_exit = true;
1117 }
1118 }
1119 }
1120 _ = tick.tick() => {
1121 let has_pending_tools = !self.tool_registry.pending_calls().is_empty()
1123 || !self.tool_registry.active_calls().is_empty()
1124 || self.chat_store.has_pending_tools();
1125 let has_in_flight_operations = !self.in_flight_operations.is_empty();
1126
1127 if self.is_processing || has_pending_tools || has_in_flight_operations {
1128 self.spinner_state = self.spinner_state.wrapping_add(1);
1129 let ch = get_spinner_char(self.spinner_state);
1130 if ch != last_spinner_char {
1131 last_spinner_char = ch.to_string();
1132 needs_redraw = true;
1133 }
1134 }
1135
1136 if self.input_mode == InputMode::Setup
1137 && crate::tui::handlers::setup::SetupHandler::poll_oauth_callback(self)
1138 .await?
1139 {
1140 needs_redraw = true;
1141 }
1142 }
1143 _ = scroll_flush.tick(), if pending_scroll.is_some() => {
1144 if let Some(pending) = pending_scroll.take()
1145 && self.apply_scroll_steps(pending.direction, pending.steps) {
1146 needs_redraw = true;
1147 }
1148 }
1149 }
1150 }
1151
1152 Ok(())
1153 }
1154
1155 async fn handle_terminal_event(
1156 &mut self,
1157 event: Event,
1158 term_event_stream: &mut EventStream,
1159 pending_scroll: &mut Option<PendingScroll>,
1160 scroll_flush: &mut tokio::time::Interval,
1161 ) -> Result<(bool, bool)> {
1162 let mut needs_redraw = false;
1163 let mut should_exit = false;
1164 let mut pending_events = VecDeque::new();
1165 pending_events.push_back(event);
1166
1167 while let Some(event) = pending_events.pop_front() {
1168 match event {
1169 Event::FocusGained => {
1170 self.notification_manager.set_terminal_focused(true);
1171 }
1172 Event::FocusLost => {
1173 self.notification_manager.set_terminal_focused(false);
1174 }
1175 Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
1176 match self.handle_key_event(key_event).await {
1177 Ok(exit) => {
1178 if exit {
1179 should_exit = true;
1180 }
1181 }
1182 Err(e) => {
1183 use crate::tui::model::{
1185 ChatItem, ChatItemData, NoticeLevel, generate_row_id,
1186 };
1187 self.chat_store.push(ChatItem {
1188 parent_chat_item_id: None,
1189 data: ChatItemData::SystemNotice {
1190 id: generate_row_id(),
1191 level: NoticeLevel::Error,
1192 text: e.to_string(),
1193 ts: time::OffsetDateTime::now_utc(),
1194 },
1195 });
1196 }
1197 }
1198 needs_redraw = true;
1199 }
1200 Event::Mouse(mouse_event) => {
1201 let (scroll_pending, mouse_needs_redraw, mouse_exit, deferred_event) =
1202 self.handle_mouse_event_coalesced(mouse_event, term_event_stream)?;
1203 if let Some(scroll) = scroll_pending {
1204 let pending_was_empty = pending_scroll.is_none();
1205 match pending_scroll {
1206 Some(pending) if pending.direction == scroll.direction => {
1207 pending.steps = pending.steps.saturating_add(scroll.steps);
1208 }
1209 _ => {
1210 *pending_scroll = Some(scroll);
1211 }
1212 }
1213 if pending_was_empty {
1214 scroll_flush.reset_after(SCROLL_FLUSH_INTERVAL);
1215 }
1216 }
1217 needs_redraw |= mouse_needs_redraw;
1218 should_exit |= mouse_exit;
1219 if let Some(deferred_event) = deferred_event {
1220 pending_events.push_front(deferred_event);
1221 }
1222 }
1223 Event::Resize(width, height) => {
1224 self.terminal_size = (width, height);
1225 needs_redraw = true;
1227 }
1228 Event::Paste(data) => {
1229 if !self.is_text_input_mode() {
1230 continue;
1231 }
1232
1233 if self.input_mode == InputMode::Setup {
1234 if let Some(setup_state) = &mut self.setup_state
1235 && matches!(
1236 &setup_state.current_step,
1237 crate::tui::state::SetupStep::Authentication(_)
1238 )
1239 {
1240 setup_state.auth_input.push_str(&data);
1241 debug!(
1242 target:"tui.run",
1243 "Pasted {} chars in Setup mode",
1244 data.len()
1245 );
1246 needs_redraw = true;
1247 }
1248 continue;
1249 }
1250
1251 let maybe_image = decode_pasted_image(&data);
1252 let had_image = maybe_image.is_some();
1253 let normalized_data =
1254 strip_image_token_labels(&data.replace("\r\n", "\n").replace('\r', "\n"));
1255 let mut text_inserted = false;
1256 if !normalized_data.is_empty() {
1257 self.input_panel_state.insert_str(&normalized_data);
1258 text_inserted = true;
1259 debug!(
1260 target:"tui.run",
1261 "Pasted {} chars in {:?} mode",
1262 normalized_data.len(),
1263 self.input_mode
1264 );
1265 }
1266
1267 if let Some(image) = maybe_image {
1268 self.add_pending_attachment(image);
1269 }
1270
1271 if text_inserted || had_image {
1272 needs_redraw = true;
1273 }
1274 }
1275 Event::Key(_) => {}
1276 }
1277
1278 if should_exit {
1279 break;
1280 }
1281 }
1282
1283 Ok((needs_redraw, should_exit))
1284 }
1285
1286 fn handle_mouse_event_coalesced(
1287 &mut self,
1288 mouse_event: MouseEvent,
1289 term_event_stream: &mut EventStream,
1290 ) -> Result<(Option<PendingScroll>, bool, bool, Option<Event>)> {
1291 let Some(mut last_direction) = ScrollDirection::from_mouse_event(&mouse_event) else {
1292 let needs_redraw = self.handle_mouse_event(mouse_event)?;
1293 return Ok((None, needs_redraw, false, None));
1294 };
1295
1296 let mut steps = 1usize;
1297 let mut deferred_event = None;
1298 let mut should_exit = false;
1299
1300 loop {
1301 let next_event = term_event_stream.next().now_or_never();
1302 let Some(next_event) = next_event else {
1303 break;
1304 };
1305
1306 match next_event {
1307 Some(Ok(Event::Mouse(next_mouse))) => {
1308 if let Some(next_direction) = ScrollDirection::from_mouse_event(&next_mouse) {
1309 if next_direction == last_direction {
1310 steps = steps.saturating_add(1);
1311 } else {
1312 last_direction = next_direction;
1313 steps = 1;
1314 }
1315 continue;
1316 }
1317 deferred_event = Some(Event::Mouse(next_mouse));
1318 break;
1319 }
1320 Some(Ok(other_event)) => {
1321 deferred_event = Some(other_event);
1322 break;
1323 }
1324 Some(Err(e)) => {
1325 if e.kind() == io::ErrorKind::Interrupted {
1326 debug!(target: "tui.input", "Ignoring interrupted syscall");
1327 } else {
1328 error!(target: "tui.run", "Fatal input error: {}. Exiting.", e);
1329 should_exit = true;
1330 }
1331 break;
1332 }
1333 None => {
1334 should_exit = true;
1335 break;
1336 }
1337 }
1338 }
1339
1340 Ok((
1341 Some(PendingScroll {
1342 direction: last_direction,
1343 steps,
1344 }),
1345 false,
1346 should_exit,
1347 deferred_event,
1348 ))
1349 }
1350
1351 fn handle_mouse_event(&mut self, event: MouseEvent) -> Result<bool> {
1353 let needs_redraw = match ScrollDirection::from_mouse_event(&event) {
1354 Some(direction) => self.apply_scroll_steps(direction, 1),
1355 None => false,
1356 };
1357
1358 Ok(needs_redraw)
1359 }
1360
1361 fn apply_scroll_steps(&mut self, direction: ScrollDirection, steps: usize) -> bool {
1362 if !self.is_text_input_mode()
1364 || (self.input_mode == InputMode::Simple && self.input_panel_state.content().is_empty())
1365 {
1366 let amount = steps.saturating_mul(MOUSE_SCROLL_STEP);
1367 match direction {
1368 ScrollDirection::Up => self.chat_viewport.state_mut().scroll_up(amount),
1369 ScrollDirection::Down => self.chat_viewport.state_mut().scroll_down(amount),
1370 }
1371 } else {
1372 false
1373 }
1374 }
1375
1376 fn draw(&mut self) -> Result<()> {
1378 let editing_message_id = self.editing_message_id.clone();
1379 let is_editing = editing_message_id.is_some();
1380 let editing_preview = if is_editing {
1381 self.editing_preview()
1382 } else {
1383 None
1384 };
1385
1386 self.terminal.draw(|f| {
1387 if let Some(setup_state) = &self.setup_state {
1389 use crate::tui::widgets::setup::{
1390 authentication::AuthenticationWidget, completion::CompletionWidget,
1391 provider_selection::ProviderSelectionWidget, welcome::WelcomeWidget,
1392 };
1393
1394 match &setup_state.current_step {
1395 crate::tui::state::SetupStep::Welcome => {
1396 WelcomeWidget::render(f.area(), f.buffer_mut(), &self.theme);
1397 }
1398 crate::tui::state::SetupStep::ProviderSelection => {
1399 ProviderSelectionWidget::render(
1400 f.area(),
1401 f.buffer_mut(),
1402 setup_state,
1403 &self.theme,
1404 );
1405 }
1406 crate::tui::state::SetupStep::Authentication(provider_id) => {
1407 AuthenticationWidget::render(
1408 f.area(),
1409 f.buffer_mut(),
1410 setup_state,
1411 provider_id.clone(),
1412 &self.theme,
1413 );
1414 }
1415 crate::tui::state::SetupStep::Completion => {
1416 CompletionWidget::render(
1417 f.area(),
1418 f.buffer_mut(),
1419 setup_state,
1420 &self.theme,
1421 );
1422 }
1423 }
1424 return;
1425 }
1426
1427 let input_mode = self.input_mode;
1428 let is_processing = self.is_processing;
1429 let spinner_state = self.spinner_state;
1430 let current_tool_call = self.current_tool_approval.as_ref().map(|(_, tc)| tc);
1431 let current_model_owned = self.current_model.clone();
1432
1433 let current_revision = self.chat_store.revision();
1435 if current_revision != self.last_revision {
1436 self.chat_viewport.mark_dirty();
1437 self.last_revision = current_revision;
1438 }
1439
1440 let chat_items: Vec<&ChatItem> = self.chat_store.as_items();
1442
1443 let terminal_size = f.area();
1444
1445 let queue_preview = self.queued_head.as_ref().map(|item| item.content.as_str());
1446 let input_area_height = self.input_panel_state.required_height(
1447 current_tool_call,
1448 terminal_size.width,
1449 terminal_size.height,
1450 queue_preview,
1451 );
1452
1453 let layout = UiLayout::compute(terminal_size, input_area_height, &self.theme);
1454 layout.prepare_background(f, &self.theme);
1455
1456 self.chat_viewport.rebuild(
1457 &chat_items,
1458 layout.chat.width,
1459 self.chat_viewport.state().view_mode,
1460 &self.theme,
1461 &self.chat_store,
1462 editing_message_id.as_deref(),
1463 );
1464
1465 self.chat_viewport
1466 .render(f, layout.chat, spinner_state, None, &self.theme);
1467
1468 let input_panel = InputPanel::new(InputPanelParams {
1469 input_mode,
1470 current_approval: current_tool_call,
1471 is_processing,
1472 spinner_state,
1473 is_editing,
1474 editing_preview: editing_preview.as_deref(),
1475 queued_count: self.queued_count,
1476 queued_preview: queue_preview,
1477 queued_attachment_count: self
1478 .queued_head
1479 .as_ref()
1480 .map_or(0, |item| item.attachment_count),
1481 attachment_count: self.pending_attachments.len(),
1482 theme: &self.theme,
1483 });
1484 f.render_stateful_widget(input_panel, layout.input, &mut self.input_panel_state);
1485
1486 let update_badge = match &self.update_status {
1487 UpdateStatus::Available(info) => {
1488 crate::tui::widgets::status_bar::UpdateBadge::Available {
1489 latest: &info.latest,
1490 }
1491 }
1492 _ => crate::tui::widgets::status_bar::UpdateBadge::None,
1493 };
1494 layout.render_status_bar(
1495 f,
1496 ¤t_model_owned,
1497 self.current_agent_label.as_deref(),
1498 &self.theme,
1499 update_badge,
1500 );
1501
1502 let fuzzy_finder_data = if input_mode == InputMode::FuzzyFinder {
1504 let results = self.input_panel_state.fuzzy_finder.results().to_vec();
1505 let selected = self.input_panel_state.fuzzy_finder.selected_index();
1506 let input_height = self.input_panel_state.required_height(
1507 current_tool_call,
1508 terminal_size.width,
1509 10,
1510 queue_preview,
1511 );
1512 let mode = self.input_panel_state.fuzzy_finder.mode();
1513 Some((results, selected, input_height, mode))
1514 } else {
1515 None
1516 };
1517
1518 if let Some((results, selected_index, input_height, mode)) = fuzzy_finder_data {
1520 Self::render_fuzzy_finder_overlay_static(
1521 f,
1522 &results,
1523 selected_index,
1524 input_height,
1525 mode,
1526 &self.theme,
1527 &self.command_registry,
1528 );
1529 }
1530
1531 if input_mode == InputMode::EditMessageSelection {
1532 use crate::tui::widgets::EditSelectionOverlay;
1533 let overlay = EditSelectionOverlay::new(&self.theme);
1534 f.render_stateful_widget(overlay, terminal_size, &mut self.edit_selection_state);
1535 }
1536 })?;
1537 Ok(())
1538 }
1539
1540 fn render_fuzzy_finder_overlay_static(
1542 f: &mut Frame,
1543 results: &[crate::tui::widgets::fuzzy_finder::PickerItem],
1544 selected_index: usize,
1545 input_panel_height: u16,
1546 mode: crate::tui::widgets::fuzzy_finder::FuzzyFinderMode,
1547 theme: &Theme,
1548 command_registry: &CommandRegistry,
1549 ) {
1550 use ratatui::layout::Rect;
1551 use ratatui::style::Style;
1552 use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState};
1553
1554 if results.is_empty() {
1557 return; }
1559
1560 let total_area = f.area();
1562
1563 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);
1571 let overlay_area = Rect {
1572 x: total_area.x,
1573 y: overlay_y,
1574 width: total_area.width,
1575 height: overlay_height,
1576 };
1577
1578 f.render_widget(Clear, overlay_area);
1580
1581 let items: Vec<ListItem> = match mode {
1584 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Files => results
1585 .iter()
1586 .enumerate()
1587 .rev()
1588 .map(|(i, item)| {
1589 let is_selected = selected_index == i;
1590 let style = if is_selected {
1591 theme.style(theme::Component::PopupSelection)
1592 } else {
1593 Style::default()
1594 };
1595 ListItem::new(item.label.as_str()).style(style)
1596 })
1597 .collect(),
1598 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Commands => {
1599 results
1600 .iter()
1601 .enumerate()
1602 .rev()
1603 .map(|(i, item)| {
1604 let is_selected = selected_index == i;
1605 let style = if is_selected {
1606 theme.style(theme::Component::PopupSelection)
1607 } else {
1608 Style::default()
1609 };
1610
1611 let label = &item.label;
1613 if let Some(cmd_info) = command_registry.get(label.as_str()) {
1614 let line = format!("/{:<12} {}", cmd_info.name, cmd_info.description);
1615 ListItem::new(line).style(style)
1616 } else {
1617 ListItem::new(format!("/{label}")).style(style)
1618 }
1619 })
1620 .collect()
1621 }
1622 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Models
1623 | crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Themes => results
1624 .iter()
1625 .enumerate()
1626 .rev()
1627 .map(|(i, item)| {
1628 let is_selected = selected_index == i;
1629 let style = if is_selected {
1630 theme.style(theme::Component::PopupSelection)
1631 } else {
1632 Style::default()
1633 };
1634 ListItem::new(item.label.as_str()).style(style)
1635 })
1636 .collect(),
1637 };
1638
1639 let list_block = Block::default()
1641 .borders(Borders::ALL)
1642 .border_style(theme.style(theme::Component::PopupBorder))
1643 .title(match mode {
1644 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Files => " Files ",
1645 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Commands => " Commands ",
1646 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Models => " Select Model ",
1647 crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Themes => " Select Theme ",
1648 });
1649
1650 let list = List::new(items)
1651 .block(list_block)
1652 .highlight_style(theme.style(theme::Component::PopupSelection));
1653
1654 let mut list_state = ListState::default();
1656 let reversed_selection = results
1657 .len()
1658 .saturating_sub(1)
1659 .saturating_sub(selected_index);
1660 list_state.select(Some(reversed_selection));
1661
1662 f.render_stateful_widget(list, overlay_area, &mut list_state);
1663 }
1664
1665 fn create_event_pipeline(notification_manager: NotificationManagerHandle) -> EventPipeline {
1667 EventPipeline::new()
1668 .add_processor(Box::new(ProcessingStateProcessor::new(
1669 notification_manager.clone(),
1670 )))
1671 .add_processor(Box::new(MessageEventProcessor::new()))
1672 .add_processor(Box::new(
1673 crate::tui::events::processors::queue::QueueEventProcessor::new(),
1674 ))
1675 .add_processor(Box::new(ToolEventProcessor::new(
1676 notification_manager.clone(),
1677 )))
1678 .add_processor(Box::new(SystemEventProcessor::new(notification_manager)))
1679 }
1680
1681 fn preprocess_client_event_double_tap(
1682 event: &ClientEvent,
1683 double_tap_tracker: &mut crate::tui::state::DoubleTapTracker,
1684 ) {
1685 if matches!(
1686 event,
1687 ClientEvent::OperationCancelled {
1688 popped_queued_item: Some(_),
1689 ..
1690 }
1691 ) {
1692 double_tap_tracker.clear_key(&KeyCode::Esc);
1695 }
1696 }
1697
1698 async fn handle_client_event(&mut self, event: ClientEvent) {
1699 Self::preprocess_client_event_double_tap(&event, &mut self.double_tap_tracker);
1700 let mut messages_updated = false;
1701
1702 match &event {
1703 ClientEvent::WorkspaceChanged => {
1704 self.load_file_cache().await;
1705 }
1706 ClientEvent::WorkspaceFiles { files } => {
1707 info!(target: "tui.handle_client_event", "Received workspace files event with {} files", files.len());
1708 self.input_panel_state
1709 .file_cache
1710 .update(files.clone())
1711 .await;
1712 }
1713 _ => {}
1714 }
1715
1716 let mut ctx = crate::tui::events::processor::ProcessingContext {
1717 chat_store: &mut self.chat_store,
1718 chat_list_state: self.chat_viewport.state_mut(),
1719 tool_registry: &mut self.tool_registry,
1720 client: &self.client,
1721 notification_manager: &self.notification_manager,
1722 input_panel_state: &mut self.input_panel_state,
1723 is_processing: &mut self.is_processing,
1724 progress_message: &mut self.progress_message,
1725 spinner_state: &mut self.spinner_state,
1726 current_tool_approval: &mut self.current_tool_approval,
1727 current_model: &mut self.current_model,
1728 current_agent_label: &mut self.current_agent_label,
1729 messages_updated: &mut messages_updated,
1730 in_flight_operations: &mut self.in_flight_operations,
1731 queued_head: &mut self.queued_head,
1732 queued_count: &mut self.queued_count,
1733 };
1734
1735 if let Err(e) = self.event_pipeline.process_event(event, &mut ctx).await {
1736 tracing::error!(target: "tui.handle_client_event", "Event processing failed: {}", e);
1737 }
1738
1739 if self.current_tool_approval.is_some() && self.input_mode != InputMode::AwaitingApproval {
1740 self.switch_mode(InputMode::AwaitingApproval);
1741 } else if self.current_tool_approval.is_none()
1742 && self.input_mode == InputMode::AwaitingApproval
1743 {
1744 self.restore_previous_mode();
1745 }
1746
1747 if messages_updated {
1748 self.chat_viewport.mark_dirty();
1749 if self.chat_viewport.state_mut().is_at_bottom() {
1750 self.chat_viewport.state_mut().scroll_to_bottom();
1751 }
1752 }
1753 }
1754
1755 async fn send_message(&mut self, content: String) -> Result<()> {
1756 if content.starts_with('/') {
1757 return self.handle_slash_command(content).await;
1758 }
1759
1760 if let Some(message_id_to_edit) = self.editing_message_id.take() {
1761 self.chat_viewport.mark_dirty();
1762 if content.starts_with('!') && content.len() > 1 {
1763 let command = content[1..].trim().to_string();
1764 if let Err(e) = self.client.execute_bash_command(command).await {
1765 self.push_notice(NoticeLevel::Error, Self::format_grpc_error(&e));
1766 }
1767 } else {
1768 let content_blocks =
1769 parse_inline_message_content(&content, &self.pending_attachments);
1770 if let Err(e) = self
1771 .client
1772 .edit_message(
1773 message_id_to_edit,
1774 content_blocks,
1775 self.current_model.clone(),
1776 )
1777 .await
1778 {
1779 self.push_notice(NoticeLevel::Error, Self::format_grpc_error(&e));
1780 }
1781 }
1782 self.pending_attachments.clear();
1783 return Ok(());
1784 }
1785
1786 let content_blocks = parse_inline_message_content(&content, &self.pending_attachments);
1787
1788 if let Err(e) = self
1789 .client
1790 .send_content_message(content_blocks, self.current_model.clone())
1791 .await
1792 {
1793 self.push_notice(NoticeLevel::Error, Self::format_grpc_error(&e));
1794 }
1795 Ok(())
1796 }
1797
1798 async fn handle_slash_command(&mut self, command_input: String) -> Result<()> {
1799 use crate::tui::commands::{AppCommand as TuiAppCommand, TuiCommand, TuiCommandType};
1800 use crate::tui::model::NoticeLevel;
1801
1802 let cmd_name = command_input
1804 .trim()
1805 .strip_prefix('/')
1806 .unwrap_or(command_input.trim());
1807
1808 if let Some(cmd_info) = self.command_registry.get(cmd_name)
1809 && let crate::tui::commands::registry::CommandScope::Custom(custom_cmd) =
1810 &cmd_info.scope
1811 {
1812 let app_cmd = TuiAppCommand::Tui(TuiCommand::Custom(custom_cmd.clone()));
1814 match app_cmd {
1816 TuiAppCommand::Tui(TuiCommand::Custom(custom_cmd)) => {
1817 match custom_cmd {
1819 crate::tui::custom_commands::CustomCommand::Prompt { prompt, .. } => {
1820 self.client
1821 .send_message(prompt, self.current_model.clone())
1822 .await?;
1823 }
1824 }
1825 }
1826 _ => unreachable!(),
1827 }
1828 return Ok(());
1829 }
1830
1831 let app_cmd = match TuiAppCommand::parse(&command_input) {
1833 Ok(cmd) => cmd,
1834 Err(e) => {
1835 self.push_notice(NoticeLevel::Error, e.to_string());
1837 return Ok(());
1838 }
1839 };
1840
1841 match app_cmd {
1843 TuiAppCommand::Tui(tui_cmd) => {
1844 match tui_cmd {
1846 TuiCommand::ReloadFiles => {
1847 self.input_panel_state.file_cache.clear().await;
1848 info!(target: "tui.slash_command", "Cleared file cache, will reload on next access");
1849 self.load_file_cache().await;
1850 self.push_tui_response(
1851 TuiCommandType::ReloadFiles.command_name(),
1852 TuiCommandResponse::Text(
1853 "File cache cleared. Files will be reloaded on next access."
1854 .to_string(),
1855 ),
1856 );
1857 }
1858 TuiCommand::Theme(theme_name) => {
1859 if let Some(name) = theme_name {
1860 let loader = theme::ThemeLoader::new();
1862 match loader.load_theme(&name) {
1863 Ok(new_theme) => {
1864 self.theme = new_theme;
1865 self.push_tui_response(
1866 TuiCommandType::Theme.command_name(),
1867 TuiCommandResponse::Theme { name: name.clone() },
1868 );
1869 }
1870 Err(e) => {
1871 self.push_notice(
1872 NoticeLevel::Error,
1873 format!("Failed to load theme '{name}': {e}"),
1874 );
1875 }
1876 }
1877 } else {
1878 let loader = theme::ThemeLoader::new();
1880 let themes = loader.list_themes();
1881 self.push_tui_response(
1882 TuiCommandType::Theme.command_name(),
1883 TuiCommandResponse::ListThemes(themes),
1884 );
1885 }
1886 }
1887 TuiCommand::Help(command_name) => {
1888 let help_text = if let Some(cmd_name) = command_name {
1890 if let Some(cmd_info) = self.command_registry.get(&cmd_name) {
1892 format!(
1893 "Command: {}\n\nDescription: {}\n\nUsage: {}",
1894 cmd_info.name, cmd_info.description, cmd_info.usage
1895 )
1896 } else {
1897 format!("Unknown command: {cmd_name}")
1898 }
1899 } else {
1900 let mut help_lines = vec!["Available commands:".to_string()];
1902 for cmd_info in self.command_registry.all_commands() {
1903 help_lines.push(format!(
1904 " {:<20} - {}",
1905 cmd_info.usage, cmd_info.description
1906 ));
1907 }
1908 help_lines.join("\n")
1909 };
1910
1911 self.push_tui_response(
1912 TuiCommandType::Help.command_name(),
1913 TuiCommandResponse::Text(help_text),
1914 );
1915 }
1916 TuiCommand::Auth => {
1917 let providers = self.client.list_providers().await.map_err(|e| {
1921 crate::error::Error::Generic(format!(
1922 "Failed to list providers from server: {e}"
1923 ))
1924 })?;
1925 let statuses =
1926 self.client
1927 .get_provider_auth_status(None)
1928 .await
1929 .map_err(|e| {
1930 crate::error::Error::Generic(format!(
1931 "Failed to get provider auth status: {e}"
1932 ))
1933 })?;
1934
1935 let mut provider_status = std::collections::HashMap::new();
1937
1938 let mut status_map = std::collections::HashMap::new();
1939 for s in statuses {
1940 status_map.insert(s.provider_id.clone(), s.auth_source);
1941 }
1942
1943 let registry =
1945 std::sync::Arc::new(RemoteProviderRegistry::from_proto(providers));
1946
1947 for p in registry.all() {
1948 let status = auth_status_from_source(
1949 status_map.get(&p.id).and_then(|s| s.as_ref()),
1950 );
1951 provider_status.insert(ProviderId(p.id.clone()), status);
1952 }
1953
1954 self.setup_state =
1956 Some(crate::tui::state::SetupState::new_for_auth_command(
1957 registry,
1958 provider_status,
1959 ));
1960 self.set_mode(InputMode::Setup);
1963 self.mode_stack.clear();
1965
1966 self.push_tui_response(
1967 TuiCommandType::Auth.to_string(),
1968 TuiCommandResponse::Text(
1969 "Entering authentication setup mode...".to_string(),
1970 ),
1971 );
1972 }
1973 TuiCommand::EditingMode(ref mode_name) => {
1974 let response = match mode_name.as_deref() {
1975 None => {
1976 let mode_str = self.preferences.ui.editing_mode.to_string();
1978 format!("Current editing mode: {mode_str}")
1979 }
1980 Some("simple") => {
1981 self.preferences.ui.editing_mode = EditingMode::Simple;
1982 self.set_mode(InputMode::Simple);
1983 self.preferences
1984 .save()
1985 .map_err(|e| crate::error::Error::Config(e.to_string()))?;
1986 "Switched to Simple mode".to_string()
1987 }
1988 Some("vim") => {
1989 self.preferences.ui.editing_mode = EditingMode::Vim;
1990 self.set_mode(InputMode::VimNormal);
1991 self.preferences
1992 .save()
1993 .map_err(|e| crate::error::Error::Config(e.to_string()))?;
1994 "Switched to Vim mode (Normal)".to_string()
1995 }
1996 Some(mode) => {
1997 format!("Unknown mode: '{mode}'. Use 'simple' or 'vim'")
1998 }
1999 };
2000
2001 self.push_tui_response(
2002 tui_cmd.as_command_str(),
2003 TuiCommandResponse::Text(response),
2004 );
2005 }
2006 TuiCommand::Mcp => {
2007 let servers = self.client.get_mcp_servers().await?;
2008 self.push_tui_response(
2009 tui_cmd.as_command_str(),
2010 TuiCommandResponse::ListMcpServers(servers),
2011 );
2012 }
2013 TuiCommand::Workspace(ref workspace_id) => {
2014 let target_id = if let Some(workspace_id) = workspace_id.clone() {
2015 Some(workspace_id)
2016 } else {
2017 let session = if let Some(session) =
2018 self.client.get_session(&self.session_id).await?
2019 {
2020 session
2021 } else {
2022 self.push_notice(
2023 NoticeLevel::Error,
2024 "Session not found for workspace status".to_string(),
2025 );
2026 return Ok(());
2027 };
2028 let config = if let Some(config) = session.config {
2029 config
2030 } else {
2031 self.push_notice(
2032 NoticeLevel::Error,
2033 "Session config missing for workspace status".to_string(),
2034 );
2035 return Ok(());
2036 };
2037 config.workspace_id.or_else(|| {
2038 config.workspace_ref.map(|reference| reference.workspace_id)
2039 })
2040 };
2041
2042 let target_id = match target_id {
2043 Some(id) if !id.is_empty() => id,
2044 _ => {
2045 self.push_notice(
2046 NoticeLevel::Error,
2047 "Workspace id not available for current session".to_string(),
2048 );
2049 return Ok(());
2050 }
2051 };
2052
2053 match self.client.get_workspace_status(&target_id).await {
2054 Ok(status) => {
2055 let response = Self::format_workspace_status(&status);
2056 self.push_tui_response(
2057 tui_cmd.as_command_str(),
2058 TuiCommandResponse::Text(response),
2059 );
2060 }
2061 Err(e) => {
2062 self.push_notice(NoticeLevel::Error, Self::format_grpc_error(&e));
2063 }
2064 }
2065 }
2066 TuiCommand::Custom(custom_cmd) => match custom_cmd {
2067 crate::tui::custom_commands::CustomCommand::Prompt { prompt, .. } => {
2068 self.client
2069 .send_message(prompt, self.current_model.clone())
2070 .await?;
2071 }
2072 },
2073 TuiCommand::New => {
2074 self.start_new_session().await?;
2075 }
2076 }
2077 }
2078 TuiAppCommand::Core(core_cmd) => match core_cmd {
2079 crate::tui::core_commands::CoreCommandType::Compact => {
2080 if let Err(e) = self
2081 .client
2082 .compact_session(self.current_model.clone())
2083 .await
2084 {
2085 self.push_notice(NoticeLevel::Error, Self::format_grpc_error(&e));
2086 }
2087 }
2088 crate::tui::core_commands::CoreCommandType::Agent { target } => {
2089 if let Some(agent_id) = target {
2090 if let Err(e) = self.client.switch_primary_agent(agent_id.clone()).await {
2091 self.push_notice(NoticeLevel::Error, Self::format_grpc_error(&e));
2092 }
2093 } else {
2094 self.push_notice(NoticeLevel::Error, "Usage: /agent <mode>".to_string());
2095 }
2096 }
2097 crate::tui::core_commands::CoreCommandType::Model { target } => {
2098 if let Some(model_name) = target {
2099 match self.client.resolve_model(&model_name).await {
2100 Ok(model_id) => {
2101 self.current_model = model_id;
2102 }
2103 Err(e) => {
2104 self.push_notice(NoticeLevel::Error, Self::format_grpc_error(&e));
2105 }
2106 }
2107 }
2108 }
2109 },
2110 }
2111
2112 Ok(())
2113 }
2114
2115 fn enter_edit_mode(&mut self, message_id: &str) {
2117 if let Some(item) = self.chat_store.get_by_id(&message_id.to_string())
2119 && let crate::tui::model::ChatItemData::Message(message) = &item.data
2120 && let MessageData::User { content, .. } = &message.data
2121 {
2122 self.pending_attachments.clear();
2124 self.next_attachment_token = FIRST_ATTACHMENT_TOKEN;
2125
2126 let mut text_parts = Vec::new();
2128 for block in content {
2129 match block {
2130 UserContent::Text { text } => {
2131 text_parts.push(text.clone());
2132 }
2133 UserContent::Image { image } => {
2134 let token =
2135 char::from_u32(self.next_attachment_token).unwrap_or('\u{E000}');
2136 self.next_attachment_token += 1;
2137 self.pending_attachments.push(PendingAttachment {
2138 image: image.clone(),
2139 token,
2140 });
2141 let image_number = self.pending_attachments.len();
2142 let label = format_inline_image_token(image_number);
2143 text_parts.push(format!("{token}{label}"));
2144 }
2145 UserContent::CommandExecution { .. } => {}
2146 }
2147 }
2148 let text = text_parts.join("");
2149
2150 self.input_panel_state
2152 .set_content_from_lines(text.lines().collect::<Vec<_>>());
2153 self.input_mode = match self.preferences.ui.editing_mode {
2155 EditingMode::Simple => InputMode::Simple,
2156 EditingMode::Vim => InputMode::VimInsert,
2157 };
2158
2159 self.editing_message_id = Some(message_id.to_string());
2161 self.chat_viewport.mark_dirty();
2162 }
2163 }
2164
2165 fn cancel_edit_mode(&mut self) {
2166 if self.editing_message_id.is_some() {
2167 self.editing_message_id = None;
2168 self.chat_viewport.mark_dirty();
2169 }
2170 }
2171
2172 fn editing_preview(&self) -> Option<String> {
2173 const EDIT_PREVIEW_MAX_LEN: usize = 40;
2174
2175 let message_id = self.editing_message_id.as_ref()?;
2176 let item = self.chat_store.get_by_id(message_id)?;
2177 let crate::tui::model::ChatItemData::Message(message) = &item.data else {
2178 return None;
2179 };
2180
2181 let content = message.content_string();
2182 let preview_line = content
2183 .lines()
2184 .find(|line| !line.trim().is_empty())
2185 .unwrap_or("")
2186 .trim();
2187 if preview_line.is_empty() {
2188 return None;
2189 }
2190
2191 let mut chars = preview_line.chars();
2192 let mut preview: String = chars.by_ref().take(EDIT_PREVIEW_MAX_LEN).collect();
2193 if chars.next().is_some() {
2194 preview.push('…');
2195 }
2196
2197 Some(preview)
2198 }
2199
2200 fn enter_edit_selection_mode(&mut self) {
2201 self.switch_mode(InputMode::EditMessageSelection);
2202 let messages = self.chat_store.user_messages_in_lineage();
2203 self.edit_selection_state.populate(messages);
2204 }
2205}
2206
2207fn get_spinner_char(state: usize) -> &'static str {
2209 const SPINNER_CHARS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2210 SPINNER_CHARS[state % SPINNER_CHARS.len()]
2211}
2212
2213impl Drop for Tui {
2214 fn drop(&mut self) {
2215 crate::tui::terminal::cleanup_with_writer(self.terminal.backend_mut());
2217 }
2218}
2219
2220pub fn setup_panic_hook() {
2222 std::panic::set_hook(Box::new(|panic_info| {
2223 cleanup();
2224 tracing_error!("Application panicked:");
2226 tracing_error!("{panic_info}");
2227 }));
2228}
2229
2230pub async fn run_tui(
2232 client: steer_grpc::AgentClient,
2233 session_id: Option<String>,
2234 model: ModelId,
2235 directory: Option<std::path::PathBuf>,
2236 theme_name: Option<String>,
2237 force_setup: bool,
2238) -> Result<()> {
2239 use std::collections::HashMap;
2240 use steer_grpc::client_api::{
2241 CreateSessionParams, SessionPolicyOverrides, SessionToolConfig, WorkspaceConfig,
2242 };
2243
2244 let loader = theme::ThemeLoader::new();
2246 let theme = if let Some(theme_name) = theme_name {
2247 let path = std::path::Path::new(&theme_name);
2249 let theme_result = if path.is_absolute() || path.exists() {
2250 loader.load_theme_from_path(path)
2252 } else {
2253 loader.load_theme(&theme_name)
2255 };
2256
2257 match theme_result {
2258 Ok(theme) => {
2259 info!("Loaded theme: {}", theme_name);
2260 Some(theme)
2261 }
2262 Err(e) => {
2263 warn!(
2264 "Failed to load theme '{}': {}. Using default theme.",
2265 theme_name, e
2266 );
2267 loader.load_theme("catppuccin-mocha").ok()
2269 }
2270 }
2271 } else {
2272 match loader.load_theme("catppuccin-mocha") {
2274 Ok(theme) => {
2275 info!("Loaded default theme: catppuccin-mocha");
2276 Some(theme)
2277 }
2278 Err(e) => {
2279 warn!(
2280 "Failed to load default theme 'catppuccin-mocha': {}. Using hardcoded default.",
2281 e
2282 );
2283 None
2284 }
2285 }
2286 };
2287
2288 let (session_id, messages) = if let Some(session_id) = session_id {
2289 let (messages, _approved_tools) =
2290 client.resume_session(&session_id).await.map_err(Box::new)?;
2291 info!(
2292 "Resumed session: {} with {} messages",
2293 session_id,
2294 messages.len()
2295 );
2296 tracing_info!("Session ID: {session_id}");
2297 (session_id, messages)
2298 } else {
2299 let workspace = if let Some(ref dir) = directory {
2301 WorkspaceConfig::Local { path: dir.clone() }
2302 } else {
2303 WorkspaceConfig::default()
2304 };
2305 let session_params = CreateSessionParams {
2306 workspace,
2307 tool_config: SessionToolConfig::default(),
2308 primary_agent_id: None,
2309 policy_overrides: SessionPolicyOverrides::empty(),
2310 metadata: HashMap::new(),
2311 default_model: model.clone(),
2312 };
2313
2314 let session_id = client
2315 .create_session(session_params)
2316 .await
2317 .map_err(Box::new)?;
2318 (session_id, vec![])
2319 };
2320
2321 client.subscribe_session_events().await.map_err(Box::new)?;
2322 let event_rx = client.subscribe_client_events().await.map_err(Box::new)?;
2323 let mut tui = Tui::new(client, model.clone(), session_id.clone(), theme.clone()).await?;
2324
2325 struct TuiCleanupGuard;
2327 impl Drop for TuiCleanupGuard {
2328 fn drop(&mut self) {
2329 cleanup();
2330 }
2331 }
2332 let _cleanup_guard = TuiCleanupGuard;
2333
2334 if !messages.is_empty() {
2335 tui.restore_messages(messages.clone());
2336 tui.chat_viewport.state_mut().scroll_to_bottom();
2337 }
2338
2339 let statuses = tui
2341 .client
2342 .get_provider_auth_status(None)
2343 .await
2344 .map_err(|e| Error::Generic(format!("Failed to get provider auth status: {e}")))?;
2345
2346 let has_any_auth = statuses
2347 .iter()
2348 .any(|s| has_any_auth_source(s.auth_source.as_ref()));
2349
2350 let should_run_setup = force_setup
2351 || (!Preferences::config_path()
2352 .map(|p| p.exists())
2353 .unwrap_or(false)
2354 && !has_any_auth);
2355
2356 if should_run_setup {
2358 let providers =
2360 tui.client.list_providers().await.map_err(|e| {
2361 Error::Generic(format!("Failed to list providers from server: {e}"))
2362 })?;
2363 let registry = std::sync::Arc::new(RemoteProviderRegistry::from_proto(providers));
2364
2365 let mut status_map = std::collections::HashMap::new();
2367 for s in statuses {
2368 status_map.insert(s.provider_id.clone(), s.auth_source);
2369 }
2370
2371 let mut provider_status = std::collections::HashMap::new();
2372 for p in registry.all() {
2373 let status = auth_status_from_source(status_map.get(&p.id).and_then(|s| s.as_ref()));
2374 provider_status.insert(ProviderId(p.id.clone()), status);
2375 }
2376
2377 tui.setup_state = Some(crate::tui::state::SetupState::new(
2378 registry,
2379 provider_status,
2380 ));
2381 tui.input_mode = InputMode::Setup;
2382 }
2383
2384 tui.run(event_rx).await
2386}
2387
2388pub async fn run_tui_auth_setup(
2391 client: steer_grpc::AgentClient,
2392 session_id: Option<String>,
2393 model: Option<ModelId>,
2394 session_db: Option<PathBuf>,
2395 theme_name: Option<String>,
2396) -> Result<()> {
2397 run_tui(
2400 client,
2401 session_id,
2402 model.unwrap_or(builtin::claude_sonnet_4_5()),
2403 session_db,
2404 theme_name,
2405 true, )
2407 .await
2408}
2409
2410#[cfg(test)]
2411mod tests {
2412 use crate::tui::test_utils::local_client_and_server;
2413
2414 use super::*;
2415
2416 use serde_json::json;
2417
2418 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2419 use steer_grpc::client_api::{AssistantContent, Message, MessageData, OpId, QueuedWorkItem};
2420
2421 const IMAGE_TOKEN_CHAR: char = '\u{E000}';
2422 use tempfile::tempdir;
2423
2424 struct TerminalCleanupGuard;
2426
2427 impl Drop for TerminalCleanupGuard {
2428 fn drop(&mut self) {
2429 cleanup();
2430 }
2431 }
2432
2433 #[test]
2434 fn operation_cancelled_with_popped_queue_item_clears_esc_double_tap_tracker() {
2435 let mut tracker = crate::tui::state::DoubleTapTracker::new();
2436 tracker.record_key(KeyCode::Esc);
2437
2438 let popped = QueuedWorkItem {
2439 kind: steer_grpc::client_api::QueuedWorkKind::UserMessage,
2440 content: "queued draft".to_string(),
2441 model: None,
2442 queued_at: 123,
2443 op_id: OpId::new(),
2444 message_id: steer_grpc::client_api::MessageId::from_string("msg_queued"),
2445 attachment_count: 0,
2446 };
2447
2448 Tui::preprocess_client_event_double_tap(
2449 &ClientEvent::OperationCancelled {
2450 op_id: OpId::new(),
2451 pending_tool_calls: 0,
2452 popped_queued_item: Some(popped),
2453 },
2454 &mut tracker,
2455 );
2456
2457 assert!(
2458 !tracker.is_double_tap(KeyCode::Esc, Duration::from_millis(300)),
2459 "Esc tracker should be cleared when cancellation restores a queued item"
2460 );
2461 }
2462
2463 #[test]
2464 fn operation_cancelled_without_popped_queue_item_keeps_esc_double_tap_tracker() {
2465 let mut tracker = crate::tui::state::DoubleTapTracker::new();
2466 tracker.record_key(KeyCode::Esc);
2467
2468 Tui::preprocess_client_event_double_tap(
2469 &ClientEvent::OperationCancelled {
2470 op_id: OpId::new(),
2471 pending_tool_calls: 0,
2472 popped_queued_item: None,
2473 },
2474 &mut tracker,
2475 );
2476
2477 assert!(
2478 tracker.is_double_tap(KeyCode::Esc, Duration::from_millis(300)),
2479 "Esc tracker should remain armed when cancellation does not restore queued input"
2480 );
2481 }
2482
2483 #[tokio::test]
2484 #[ignore = "Requires TTY - run with `cargo test -- --ignored` in a terminal"]
2485 async fn test_ctrl_r_scrolls_to_bottom_in_simple_mode() {
2486 let _guard = TerminalCleanupGuard;
2487 let workspace_root = tempdir().expect("tempdir");
2488 let (client, _server_handle) =
2489 local_client_and_server(None, Some(workspace_root.path().to_path_buf())).await;
2490 let model = builtin::claude_sonnet_4_5();
2491 let session_id = "test_session_id".to_string();
2492 let mut tui = Tui::new(client, model, session_id, None)
2493 .await
2494 .expect("create tui");
2495
2496 tui.preferences.ui.editing_mode = EditingMode::Simple;
2497 tui.input_mode = InputMode::Simple;
2498
2499 let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
2500 tui.handle_simple_mode(key).await.expect("handle ctrl+r");
2501
2502 assert_eq!(
2503 tui.chat_viewport.state().view_mode,
2504 crate::tui::widgets::ViewMode::Detailed
2505 );
2506 assert_eq!(
2507 tui.chat_viewport.state_mut().take_scroll_target(),
2508 Some(crate::tui::widgets::ScrollTarget::Bottom)
2509 );
2510 }
2511
2512 #[tokio::test]
2513 #[ignore = "Requires TTY - run with `cargo test -- --ignored` in a terminal"]
2514 async fn test_restore_messages_preserves_tool_call_params() {
2515 let _guard = TerminalCleanupGuard;
2516 let workspace_root = tempdir().expect("tempdir");
2518 let (client, _server_handle) =
2519 local_client_and_server(None, Some(workspace_root.path().to_path_buf())).await;
2520 let model = builtin::claude_sonnet_4_5();
2521 let session_id = "test_session_id".to_string();
2522 let mut tui = Tui::new(client, model, session_id, None)
2523 .await
2524 .expect("create tui");
2525
2526 let tool_id = "test_tool_123".to_string();
2528 let tool_call = steer_tools::ToolCall {
2529 id: tool_id.clone(),
2530 name: "view".to_string(),
2531 parameters: json!({
2532 "file_path": "/test/file.rs",
2533 "offset": 10,
2534 "limit": 100
2535 }),
2536 };
2537
2538 let assistant_msg = Message {
2539 data: MessageData::Assistant {
2540 content: vec![AssistantContent::ToolCall {
2541 tool_call: tool_call.clone(),
2542 thought_signature: None,
2543 }],
2544 },
2545 id: "msg_assistant".to_string(),
2546 timestamp: 1_234_567_890,
2547 parent_message_id: None,
2548 };
2549
2550 let tool_msg = Message {
2551 data: MessageData::Tool {
2552 tool_use_id: tool_id.clone(),
2553 result: steer_tools::ToolResult::FileContent(
2554 steer_tools::result::FileContentResult {
2555 file_path: "/test/file.rs".to_string(),
2556 content: "file content here".to_string(),
2557 line_count: 1,
2558 truncated: false,
2559 },
2560 ),
2561 },
2562 id: "msg_tool".to_string(),
2563 timestamp: 1_234_567_891,
2564 parent_message_id: Some("msg_assistant".to_string()),
2565 };
2566
2567 let messages = vec![assistant_msg, tool_msg];
2568
2569 tui.restore_messages(messages);
2571
2572 if let Some(stored_call) = tui.tool_registry.get_tool_call(&tool_id) {
2574 assert_eq!(stored_call.name, "view");
2575 assert_eq!(stored_call.parameters, tool_call.parameters);
2576 } else {
2577 panic!("Tool call should be in registry");
2578 }
2579 }
2580
2581 #[tokio::test]
2582 #[ignore = "Requires TTY - run with `cargo test -- --ignored` in a terminal"]
2583 async fn test_restore_messages_handles_tool_result_before_assistant() {
2584 let _guard = TerminalCleanupGuard;
2585 let workspace_root = tempdir().expect("tempdir");
2587 let (client, _server_handle) =
2588 local_client_and_server(None, Some(workspace_root.path().to_path_buf())).await;
2589 let model = builtin::claude_sonnet_4_5();
2590 let session_id = "test_session_id".to_string();
2591 let mut tui = Tui::new(client, model, session_id, None)
2592 .await
2593 .expect("create tui");
2594
2595 let tool_id = "test_tool_456".to_string();
2596 let real_params = json!({
2597 "file_path": "/another/file.rs"
2598 });
2599
2600 let tool_call = steer_tools::ToolCall {
2601 id: tool_id.clone(),
2602 name: "view".to_string(),
2603 parameters: real_params.clone(),
2604 };
2605
2606 let tool_msg = Message {
2608 data: MessageData::Tool {
2609 tool_use_id: tool_id.clone(),
2610 result: steer_tools::ToolResult::FileContent(
2611 steer_tools::result::FileContentResult {
2612 file_path: "/another/file.rs".to_string(),
2613 content: "file content".to_string(),
2614 line_count: 1,
2615 truncated: false,
2616 },
2617 ),
2618 },
2619 id: "msg_tool".to_string(),
2620 timestamp: 1_234_567_890,
2621 parent_message_id: None,
2622 };
2623
2624 let assistant_msg = Message {
2625 data: MessageData::Assistant {
2626 content: vec![AssistantContent::ToolCall {
2627 tool_call: tool_call.clone(),
2628 thought_signature: None,
2629 }],
2630 },
2631 id: "msg_456".to_string(),
2632 timestamp: 1_234_567_891,
2633 parent_message_id: None,
2634 };
2635
2636 let messages = vec![tool_msg, assistant_msg];
2637
2638 tui.restore_messages(messages);
2639
2640 if let Some(stored_call) = tui.tool_registry.get_tool_call(&tool_id) {
2642 assert_eq!(stored_call.parameters, real_params);
2643 assert_eq!(stored_call.name, "view");
2644 } else {
2645 panic!("Tool call should be in registry");
2646 }
2647 }
2648
2649 #[test]
2650 fn strip_image_token_labels_removes_rendered_image_marker_text() {
2651 let content = format!("hello{IMAGE_TOKEN_CHAR}[Image 1] world");
2652 assert_eq!(strip_image_token_labels(&content), "hello world");
2653 }
2654
2655 #[test]
2656 fn parse_inline_message_content_preserves_text_image_order() {
2657 let first = PendingAttachment {
2658 image: ImageContent {
2659 source: ImageSource::DataUrl {
2660 data_url: "data:image/png;base64,AAAA".to_string(),
2661 },
2662 mime_type: "image/png".to_string(),
2663 width: Some(1),
2664 height: Some(1),
2665 bytes: Some(4),
2666 sha256: None,
2667 },
2668 token: 'A',
2669 };
2670 let second = PendingAttachment {
2671 image: ImageContent {
2672 source: ImageSource::DataUrl {
2673 data_url: "data:image/jpeg;base64,BBBB".to_string(),
2674 },
2675 mime_type: "image/jpeg".to_string(),
2676 width: Some(1),
2677 height: Some(1),
2678 bytes: Some(4),
2679 sha256: None,
2680 },
2681 token: 'B',
2682 };
2683
2684 let content = format!("before {} middle {} after", first.token, second.token);
2685 let parsed = parse_inline_message_content(&content, &[first.clone(), second.clone()]);
2686
2687 assert_eq!(parsed.len(), 5);
2688 assert!(matches!(
2689 &parsed[0],
2690 UserContent::Text { text } if text == "before"
2691 ));
2692 assert!(matches!(
2693 &parsed[1],
2694 UserContent::Image { image } if image.mime_type == first.image.mime_type
2695 ));
2696 assert!(matches!(
2697 &parsed[2],
2698 UserContent::Text { text } if text == "middle"
2699 ));
2700 assert!(matches!(
2701 &parsed[3],
2702 UserContent::Image { image } if image.mime_type == second.image.mime_type
2703 ));
2704 assert!(matches!(
2705 &parsed[4],
2706 UserContent::Text { text } if text == "after"
2707 ));
2708 }
2709
2710 #[test]
2711 fn parse_inline_message_content_skips_marker_labels_after_tokens() {
2712 let attachment = PendingAttachment {
2713 image: ImageContent {
2714 source: ImageSource::DataUrl {
2715 data_url: "data:image/png;base64,AAAA".to_string(),
2716 },
2717 mime_type: "image/png".to_string(),
2718 width: Some(1),
2719 height: Some(1),
2720 bytes: Some(4),
2721 sha256: None,
2722 },
2723 token: 'A',
2724 };
2725
2726 let content = format!(
2727 "look {}{} done",
2728 attachment.token,
2729 format_inline_image_token(1)
2730 );
2731 let parsed = parse_inline_message_content(&content, &[attachment.clone()]);
2732
2733 assert_eq!(parsed.len(), 3);
2734 assert!(matches!(&parsed[1], UserContent::Image { .. }));
2735 assert!(matches!(
2736 &parsed[2],
2737 UserContent::Text { text } if text == "done"
2738 ));
2739 }
2740
2741 #[test]
2742 fn decode_pasted_image_recognizes_png_base64_and_sets_metadata() {
2743 let png_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2N8AAAAASUVORK5CYII=";
2744
2745 let image = decode_pasted_image(png_base64).expect("png should decode");
2746
2747 assert_eq!(image.mime_type, "image/png");
2748 assert!(matches!(image.source, ImageSource::DataUrl { .. }));
2749 assert_eq!(image.width, None);
2750 assert_eq!(image.height, None);
2751 assert!(image.bytes.is_some());
2752 }
2753
2754 #[test]
2755 fn decode_pasted_image_rejects_non_image_payload() {
2756 let not_image = base64::engine::general_purpose::STANDARD.encode("plain text");
2757 let decoded = decode_pasted_image(¬_image);
2758 assert!(decoded.is_none());
2759 }
2760
2761 #[test]
2762 fn encode_clipboard_rgba_image_converts_to_png_data_url() {
2763 let rgba = [255_u8, 0, 0, 255];
2764 let image = encode_clipboard_rgba_image(1, 1, &rgba);
2765
2766 assert!(image.is_some(), "expected clipboard image to encode");
2767 let image = match image {
2768 Some(image) => image,
2769 None => unreachable!("asserted Some above"),
2770 };
2771
2772 assert_eq!(image.mime_type, "image/png");
2773 assert_eq!(image.width, Some(1));
2774 assert_eq!(image.height, Some(1));
2775 assert!(matches!(image.bytes, Some(bytes) if bytes > 0));
2776 assert!(matches!(image.source, ImageSource::DataUrl { .. }));
2777 }
2778
2779 #[test]
2780 fn encode_clipboard_rgba_image_rejects_invalid_pixel_data() {
2781 let invalid_rgba = [255_u8, 0, 0];
2782 let image = encode_clipboard_rgba_image(1, 1, &invalid_rgba);
2783 assert!(image.is_none());
2784 }
2785}