1use crate::services::approval_bar::ApprovalBar;
7use crate::services::auto_approve::AutoApproveManager;
8use crate::services::auto_approve::AutoApprovePolicy;
9use crate::services::banner::BannerMessage;
10use crate::services::file_search::FileSearch;
11use crate::services::message::Message;
12use crate::services::shell_mode::ShellCommand;
13use crate::services::text_selection::SelectionState;
14use crate::services::textarea::{TextArea, TextAreaState};
15use ratatui::text::Line;
16use stakai::Model;
17use stakpak_api::models::ListRuleBook;
18use stakpak_shared::models::integrations::openai::{
19 ContentPart, TaskPauseInfo, ToolCall, ToolCallResult,
20};
21use stakpak_shared::models::llm::LLMTokenUsage;
22use stakpak_shared::secret_manager::SecretManager;
23use stakpak_shared::task_manager::TaskManagerHandle;
24use std::collections::{HashMap, HashSet, VecDeque};
25use std::path::PathBuf;
26use std::sync::Arc;
27use tokio::sync::mpsc;
28use uuid::Uuid;
29
30pub type MessageLinesCache = (Vec<Message>, usize, Vec<Line<'static>>);
32
33#[derive(Clone, Debug)]
36pub struct RenderedMessageCache {
37 pub content_hash: u64,
39 pub rendered_lines: Arc<Vec<Line<'static>>>,
41 pub width: usize,
43}
44
45pub type PerMessageCache = HashMap<Uuid, RenderedMessageCache>;
48
49#[derive(Clone, Debug)]
52pub struct VisibleLinesCache {
53 pub scroll: usize,
55 pub width: usize,
57 pub height: usize,
59 pub lines: Arc<Vec<Line<'static>>>,
61 pub source_generation: u64,
63}
64
65#[derive(Debug, Default, Clone)]
67pub struct RenderMetrics {
68 pub last_render_time_us: u64,
70 pub cache_hits: usize,
72 pub cache_misses: usize,
74 pub total_lines: usize,
76 pub avg_render_time_us: u64,
78 render_count: u64,
80}
81
82impl RenderMetrics {
83 pub fn new() -> Self {
84 Self::default()
85 }
86
87 pub fn record_render(
89 &mut self,
90 render_time_us: u64,
91 cache_hits: usize,
92 cache_misses: usize,
93 total_lines: usize,
94 ) {
95 self.last_render_time_us = render_time_us;
96 self.cache_hits = cache_hits;
97 self.cache_misses = cache_misses;
98 self.total_lines = total_lines;
99
100 self.render_count += 1;
102 if self.render_count == 1 {
103 self.avg_render_time_us = render_time_us;
104 } else {
105 self.avg_render_time_us = (self.avg_render_time_us * 9 + render_time_us) / 10;
107 }
108 }
109
110 pub fn reset(&mut self) {
112 *self = Self::default();
113 }
114}
115
116pub struct FileSearchResult {
118 pub filtered_helpers: Vec<HelperCommand>,
119 pub filtered_files: Vec<String>,
120 pub cursor_position: usize,
121 pub input: String,
122}
123
124#[derive(Debug, Clone, PartialEq)]
126pub enum CommandSource {
127 BuiltIn,
129 BuiltInWithPrompt { prompt_content: String },
134 Custom { prompt_content: String },
137}
138
139#[derive(Debug, Clone)]
140pub struct HelperCommand {
141 pub command: String,
142 pub description: String,
143 pub source: CommandSource,
144}
145
146#[derive(Debug, Clone)]
147pub struct AttachedImage {
148 pub placeholder: String,
149 pub path: PathBuf,
150 pub filename: String,
151 pub dimensions: (u32, u32),
152 pub start_pos: usize,
153 pub end_pos: usize,
154}
155
156#[derive(Debug, Clone, PartialEq)]
157pub struct PendingUserMessage {
158 pub final_input: String,
159 pub shell_tool_calls: Option<Vec<ToolCallResult>>,
160 pub image_parts: Vec<ContentPart>,
161 pub user_message_text: String,
162}
163
164impl PendingUserMessage {
165 pub fn new(
166 final_input: String,
167 shell_tool_calls: Option<Vec<ToolCallResult>>,
168 image_parts: Vec<ContentPart>,
169 user_message_text: String,
170 ) -> Self {
171 Self {
172 final_input,
173 shell_tool_calls,
174 image_parts,
175 user_message_text,
176 }
177 }
178
179 pub fn merge_from(&mut self, other: PendingUserMessage) {
180 fn append_with_separator(target: &mut String, value: &str) {
181 if value.is_empty() {
182 return;
183 }
184 if !target.is_empty() {
185 target.push_str("\n\n");
186 }
187 target.push_str(value);
188 }
189
190 append_with_separator(&mut self.final_input, &other.final_input);
191 append_with_separator(&mut self.user_message_text, &other.user_message_text);
192
193 self.image_parts.extend(other.image_parts);
194
195 match (&mut self.shell_tool_calls, other.shell_tool_calls) {
196 (Some(existing), Some(mut incoming)) => existing.append(&mut incoming),
197 (None, Some(incoming)) => self.shell_tool_calls = Some(incoming),
198 _ => {}
199 }
200 }
201}
202
203#[derive(Debug, Clone)]
209pub struct ExistingPlanPrompt {
210 pub inline_prompt: Option<String>,
212 pub metadata: Option<crate::services::plan::PlanMetadata>,
214}
215
216#[derive(Debug)]
218pub struct InputState {
219 pub text_area: TextArea,
220 pub text_area_state: TextAreaState,
221 pub cursor_visible: bool,
222 pub helpers: Vec<HelperCommand>,
223 pub show_helper_dropdown: bool,
224 pub helper_selected: usize,
225 pub helper_scroll: usize,
226 pub filtered_helpers: Vec<HelperCommand>,
227 pub filtered_files: Vec<String>,
228 pub file_search: FileSearch,
229 pub file_search_tx: Option<mpsc::Sender<(String, usize)>>,
230 pub file_search_rx: Option<mpsc::Receiver<FileSearchResult>>,
231 pub is_pasting: bool,
232 pub pasted_long_text: Option<String>,
233 pub pasted_placeholder: Option<String>,
234 pub pending_pastes: Vec<(String, String)>,
235 pub attached_images: Vec<AttachedImage>,
236 pub pending_path_start: Option<usize>,
237 pub interactive_commands: Vec<String>,
238}
239
240impl Default for InputState {
241 fn default() -> Self {
242 Self {
243 text_area: TextArea::new(),
244 text_area_state: TextAreaState::default(),
245 cursor_visible: true,
246 helpers: Vec::new(),
247 show_helper_dropdown: false,
248 helper_selected: 0,
249 helper_scroll: 0,
250 filtered_helpers: Vec::new(),
251 filtered_files: Vec::new(),
252 file_search: FileSearch::default(),
253 file_search_tx: None,
254 file_search_rx: None,
255 is_pasting: false,
256 pasted_long_text: None,
257 pasted_placeholder: None,
258 pending_pastes: Vec::new(),
259 attached_images: Vec::new(),
260 pending_path_start: None,
261 interactive_commands: Vec::new(),
262 }
263 }
264}
265
266pub struct LoadingState {
267 pub is_loading: bool,
268 pub loading_type: LoadingType,
269 pub spinner_frame: usize,
270 pub loading_manager: LoadingStateManager,
271}
272
273pub struct MessagesScrollingState {
274 pub messages: Vec<Message>,
275 pub scroll: usize,
276 pub scroll_to_bottom: bool,
277 pub scroll_to_last_message_start: bool,
278 pub stay_at_bottom: bool,
279 pub block_stay_at_bottom_frames: u8,
281 pub scroll_lines_from_end: Option<usize>,
284 pub content_changed_while_scrolled_up: bool,
285 pub message_lines_cache: Option<MessageLinesCache>,
286 pub collapsed_message_lines_cache: Option<MessageLinesCache>,
287 pub processed_lines_cache: Option<(Vec<Message>, usize, Vec<Line<'static>>)>,
288 pub show_collapsed_messages: bool,
289 pub collapsed_messages_scroll: usize,
290 pub collapsed_messages_selected: usize,
291 pub has_user_messages: bool,
292 pub per_message_cache: PerMessageCache,
294 pub assembled_lines_cache: Option<(u64, Vec<Line<'static>>, u64)>,
297 pub visible_lines_cache: Option<VisibleLinesCache>,
299 pub cache_generation: u64,
301 pub render_metrics: RenderMetrics,
303 pub last_render_width: usize,
305 pub line_to_message_map: Vec<(usize, usize, Uuid, bool, String, usize)>,
308}
309
310impl Default for MessagesScrollingState {
311 fn default() -> Self {
312 Self {
313 messages: Vec::new(),
314 scroll: 0,
315 scroll_to_bottom: false,
316 scroll_to_last_message_start: false,
317 stay_at_bottom: true,
318 block_stay_at_bottom_frames: 0,
319 scroll_lines_from_end: None,
320 content_changed_while_scrolled_up: false,
321 message_lines_cache: None,
322 collapsed_message_lines_cache: None,
323 processed_lines_cache: None,
324 show_collapsed_messages: false,
325 collapsed_messages_scroll: 0,
326 collapsed_messages_selected: 0,
327 has_user_messages: false,
328 per_message_cache: HashMap::new(),
329 assembled_lines_cache: None,
330 visible_lines_cache: None,
331 cache_generation: 0,
332 render_metrics: RenderMetrics::new(),
333 last_render_width: 0,
334 line_to_message_map: Vec::new(),
335 }
336 }
337}
338
339pub struct SidePanelState {
340 pub is_shown: bool,
341 pub focused_section: crate::services::changeset::SidePanelSection,
342 pub collapsed_sections: HashMap<crate::services::changeset::SidePanelSection, bool>,
343 pub areas: HashMap<crate::services::changeset::SidePanelSection, ratatui::layout::Rect>,
344 pub session_id: String,
345 pub session_id_copied_at: Option<std::time::Instant>,
346 pub changeset: crate::services::changeset::Changeset,
347 pub todos: Vec<crate::services::changeset::TodoItem>,
348 pub task_progress: Option<crate::services::board_tasks::TaskProgress>,
349 pub session_start_time: std::time::Instant,
350 pub auto_shown: bool,
351 pub board_agent_id: Option<String>,
352 pub editor_command: String,
353 pub pending_editor_open: Option<String>,
354 pub billing_info: Option<stakpak_shared::models::billing::BillingResponse>,
355}
356
357impl Default for SidePanelState {
358 fn default() -> Self {
359 let mut collapsed = HashMap::new();
360 collapsed.insert(crate::services::changeset::SidePanelSection::Context, false);
361 collapsed.insert(crate::services::changeset::SidePanelSection::Billing, false);
362 collapsed.insert(crate::services::changeset::SidePanelSection::Tasks, false);
363 collapsed.insert(
364 crate::services::changeset::SidePanelSection::Changeset,
365 false,
366 );
367
368 Self {
369 is_shown: false,
370 focused_section: crate::services::changeset::SidePanelSection::Context,
371 collapsed_sections: collapsed,
372 areas: HashMap::new(),
373 session_id: String::new(),
374 session_id_copied_at: None,
375 changeset: crate::services::changeset::Changeset::new(),
376 todos: Vec::new(),
377 task_progress: None,
378 session_start_time: std::time::Instant::now(),
379 auto_shown: false,
380 board_agent_id: None,
381 editor_command: "nano".to_string(),
382 pending_editor_open: None,
383 billing_info: None,
384 }
385 }
386}
387
388pub struct ConfigurationState {
389 pub secret_manager: SecretManager,
390 pub latest_version: Option<String>,
391 pub is_git_repo: bool,
392 pub auto_approve_manager: AutoApproveManager,
393 pub allowed_tools: Option<Vec<String>>,
394 pub model: Model,
395 pub auth_display_info: (Option<String>, Option<String>, Option<String>),
396 pub init_prompt_content: Option<String>,
397}
398
399#[derive(Default)]
400pub struct QuitIntentState {
401 pub ctrl_c_pressed_once: bool,
402 pub ctrl_c_timer: Option<std::time::Instant>,
403}
404
405pub struct TerminalUiState {
406 pub mouse_capture_enabled: bool,
407 pub terminal_size: ratatui::layout::Size,
408}
409
410impl Default for TerminalUiState {
411 fn default() -> Self {
412 Self {
413 mouse_capture_enabled: false,
414 terminal_size: ratatui::layout::Size {
415 width: 0,
416 height: 0,
417 },
418 }
419 }
420}
421
422pub struct ShellRuntimeState {
423 pub screen: vt100::Parser,
424 pub scroll: u16,
425 pub history_lines: Vec<Line<'static>>,
426}
427
428impl Default for ShellRuntimeState {
429 fn default() -> Self {
430 Self {
431 screen: vt100::Parser::new(24, 80, 1000),
432 scroll: 0,
433 history_lines: Vec::new(),
434 }
435 }
436}
437
438#[derive(Default)]
439pub struct ShellSessionState {
440 pub interactive_shell_message_id: Option<Uuid>,
441 pub shell_interaction_occurred: bool,
442}
443
444#[derive(Default)]
445pub struct BannerState {
446 pub message: Option<BannerMessage>,
447 pub area: Option<ratatui::layout::Rect>,
448 pub click_regions: Vec<(String, ratatui::layout::Rect)>,
449 pub dismiss_region: Option<ratatui::layout::Rect>,
450}
451
452#[derive(Default)]
453pub struct UserMessageQueueState {
454 pub pending_user_messages: VecDeque<PendingUserMessage>,
455}
456
457#[derive(Default)]
458pub struct MessageRevertState {
459 pub user_message_count: usize,
460 pub pending_revert_index: Option<usize>,
461}
462
463#[derive(Default)]
464pub struct MessageInteractionState {
465 pub show_message_action_popup: bool,
466 pub message_action_popup_selected: usize,
467 pub message_action_popup_position: Option<(u16, u16)>,
468 pub message_action_target_message_id: Option<Uuid>,
469 pub message_action_target_text: Option<String>,
470 pub message_area_y: u16,
471 pub message_area_x: u16,
472 pub message_area_height: u16,
473 pub hover_row: Option<u16>,
474 pub collapsed_popup_area_y: u16,
475 pub collapsed_popup_area_x: u16,
476 pub collapsed_popup_area_height: u16,
477 pub selection: SelectionState,
478 pub selection_auto_scroll: i32,
479 pub input_content_area: Option<ratatui::layout::Rect>,
480}
481
482#[derive(Default)]
484pub struct ShellPopupState {
485 pub is_visible: bool,
486 pub is_expanded: bool,
487 pub scroll: usize,
488 pub needs_terminal_clear: bool,
490 pub cursor_visible: bool,
491 pub cursor_blink_timer: u8,
492 pub active_shell_command: Option<ShellCommand>,
493 pub active_shell_command_output: Option<String>,
494 pub waiting_for_shell_input: bool,
495 pub shell_tool_calls: Option<Vec<ToolCallResult>>,
496 pub is_loading: bool,
497 pub pending_command_value: Option<String>,
498 pub pending_command_executed: bool,
499 pub pending_command_output: Option<String>,
500 pub pending_command_output_count: usize,
501 pub is_tool_call_shell_command: bool,
502 pub ondemand_shell_mode: bool,
503 pub shell_initial_prompt_shown: bool,
505 pub shell_command_typed: bool,
507}
508
509#[derive(Default)]
511pub struct ToolCallState {
512 pub pending_bash_message_id: Option<Uuid>,
513 pub streaming_tool_results: HashMap<Uuid, String>,
514 pub streaming_tool_result_id: Option<Uuid>,
515 pub completed_tool_calls: HashSet<Uuid>,
516 pub is_streaming: bool,
517 pub cancel_requested: bool,
520 pub latest_tool_call: Option<ToolCall>,
521 pub tool_call_stream_preview_id: Option<Uuid>,
523 pub retry_attempts: usize,
524 pub max_retry_attempts: usize,
525 pub last_user_message_for_retry: Option<String>,
526 pub is_retrying: bool,
527 pub subagent_pause_info: HashMap<String, TaskPauseInfo>,
528}
529
530pub struct BackgroundTasksState {
531 pub running_background_tasks: usize,
532 pub task_manager_handle: Option<Arc<TaskManagerHandle>>,
533}
534
535pub struct DialogApprovalState {
537 pub is_dialog_open: bool,
538 pub dialog_command: Option<ToolCall>,
539 pub dialog_selected: usize,
540 pub dialog_message_id: Option<Uuid>,
541 pub dialog_focused: bool,
542 pub approval_bar: ApprovalBar,
543 pub message_tool_calls: Option<Vec<ToolCall>>,
544 pub message_approved_tools: Vec<ToolCall>,
545 pub message_rejected_tools: Vec<ToolCall>,
546 pub toggle_approved_message: bool,
547 pub show_shortcuts: bool,
548}
549
550impl Default for DialogApprovalState {
551 fn default() -> Self {
552 Self {
553 is_dialog_open: false,
554 dialog_command: None,
555 dialog_selected: 0,
556 dialog_message_id: None,
557 dialog_focused: false,
558 approval_bar: ApprovalBar::new(),
559 message_tool_calls: None,
560 message_approved_tools: Vec::new(),
561 message_rejected_tools: Vec::new(),
562 toggle_approved_message: true,
563 show_shortcuts: false,
564 }
565 }
566}
567
568#[derive(Default)]
569pub struct SessionsState {
570 pub sessions: Vec<SessionInfo>,
571 pub session_selected: usize,
572 pub account_info: String,
573}
574
575#[derive(Default)]
576pub struct SessionToolCallsState {
577 pub session_tool_calls_queue: HashMap<String, ToolCallStatus>,
578 pub tool_call_execution_order: Vec<String>,
579 pub last_message_tool_calls: Vec<ToolCall>,
580}
581
582#[derive(Default)]
583pub struct ProfileSwitcherState {
584 pub show_profile_switcher: bool,
585 pub available_profiles: Vec<String>,
586 pub selected_index: usize,
587 pub current_profile_name: String,
588 pub switching_in_progress: bool,
589 pub switch_status_message: Option<String>,
590}
591
592#[derive(Default)]
593pub struct RulebookSwitcherState {
594 pub show_rulebook_switcher: bool,
595 pub available_rulebooks: Vec<ListRuleBook>,
596 pub selected_rulebooks: HashSet<String>,
597 pub is_selected: usize,
598 pub rulebook_search_input: String,
599 pub filtered_rulebooks: Vec<ListRuleBook>,
600 pub rulebook_config: Option<crate::RulebookConfig>,
601}
602
603#[derive(Default)]
604pub struct ModelSwitcherState {
605 pub is_visible: bool,
606 pub is_selected: usize,
607 pub mode: ModelSwitcherMode,
608 pub search: String,
609 pub available_models: Vec<Model>,
610 pub current_model: Option<Model>,
611 pub recent_models: Vec<String>,
612}
613
614#[derive(Default)]
615pub struct CommandPaletteState {
616 pub is_visible: bool,
617 pub is_selected: usize,
618 pub scroll: usize,
619 pub search: String,
620}
621
622#[derive(Default)]
623pub struct ShortcutsPanelState {
624 pub is_visible: bool,
625 pub scroll: usize,
626 pub mode: ShortcutsPopupMode,
627}
628
629#[derive(Default)]
630pub struct FileChangesPopupState {
631 pub is_visible: bool,
632 pub is_selected: usize,
633 pub scroll: usize,
634 pub search: String,
635}
636
637pub struct UsageTrackingState {
638 pub current_message_usage: LLMTokenUsage,
639 pub total_session_usage: LLMTokenUsage,
640 pub context_usage_percent: u64,
641}
642
643#[derive(Default)]
644pub struct PlanModeState {
645 pub is_active: bool,
647 pub metadata: Option<crate::services::plan::PlanMetadata>,
649 pub content_hash: Option<String>,
651 pub previous_status: Option<crate::services::plan::PlanStatus>,
653 pub review_auto_opened: bool,
655 pub existing_prompt: Option<ExistingPlanPrompt>,
658}
659
660#[derive(Default)]
661pub struct PlanReviewState {
662 pub is_visible: bool,
664 pub scroll: usize,
666 pub cursor_line: usize,
668 pub content: String,
670 pub lines: Vec<String>,
672 pub comments: Option<crate::services::plan_comments::PlanComments>,
674 pub resolved_anchors: Vec<(String, crate::services::plan_comments::ResolvedAnchor)>,
676 pub show_comment_modal: bool,
678 pub comment_input: String,
680 pub selected_comment: Option<String>,
682 pub modal_kind: Option<crate::services::plan_review::CommentModalKind>,
684 pub confirm: Option<crate::services::plan_review::ConfirmAction>,
686}
687
688#[derive(Default)]
689pub struct AskUserState {
690 pub is_visible: bool,
692 pub questions: Vec<stakpak_shared::models::integrations::openai::AskUserQuestion>,
694 pub answers: HashMap<String, stakpak_shared::models::integrations::openai::AskUserAnswer>,
696 pub current_tab: usize,
698 pub selected_option: usize,
700 pub custom_input: String,
702 pub tool_call: Option<ToolCall>,
704 pub message_id: Option<Uuid>,
706 pub is_focused: bool,
708 pub multi_selections: HashMap<String, Vec<String>>,
710}
711
712impl Default for UsageTrackingState {
713 fn default() -> Self {
714 Self {
715 current_message_usage: LLMTokenUsage {
716 prompt_tokens: 0,
717 completion_tokens: 0,
718 total_tokens: 0,
719 prompt_tokens_details: None,
720 },
721 total_session_usage: LLMTokenUsage {
722 prompt_tokens: 0,
723 completion_tokens: 0,
724 total_tokens: 0,
725 prompt_tokens_details: None,
726 },
727 context_usage_percent: 0,
728 }
729 }
730}
731
732impl Default for LoadingState {
733 fn default() -> Self {
734 Self {
735 is_loading: false,
736 loading_type: LoadingType::Llm,
737 spinner_frame: 0,
738 loading_manager: LoadingStateManager::new(),
739 }
740 }
741}
742
743#[derive(Debug)]
744pub struct SessionInfo {
745 pub title: String,
746 pub id: String,
747 pub updated_at: String,
748 pub checkpoints: Vec<String>,
749}
750
751#[derive(Debug, PartialEq)]
752pub enum LoadingType {
753 Llm,
754 Sessions,
755}
756
757#[derive(Debug, Clone, PartialEq, Eq, Hash)]
758pub enum LoadingOperation {
759 LlmRequest,
760 ToolExecution,
761 SessionsList,
762 StreamProcessing,
763 LocalContext,
764 Rulebooks,
765 CheckpointResume,
766}
767
768#[derive(Debug, Clone, PartialEq)]
769pub enum ToolCallStatus {
770 Approved,
771 Rejected,
772 Executed,
773 Skipped,
774 Pending,
775}
776
777#[derive(Debug, Clone, Copy, PartialEq, Default)]
779pub enum ShortcutsPopupMode {
780 #[default]
781 Commands,
782 Shortcuts,
783 Sessions,
784}
785
786#[derive(Debug, Clone, Copy, PartialEq, Default)]
788pub enum ModelSwitcherMode {
789 #[default]
790 All, Reasoning, }
793
794#[derive(Debug, Clone, PartialEq, Eq)]
795pub struct AutoApprovePopupRow {
796 pub tool_name: String,
797 pub policy: AutoApprovePolicy,
798 pub original_policy: AutoApprovePolicy,
799}
800
801#[derive(Debug, Clone, Default)]
802pub struct AutoApprovePopupState {
803 pub is_visible: bool,
804 pub rows: Vec<AutoApprovePopupRow>,
805 pub row_selected: usize,
806 pub scroll: usize,
807 pub filter_text: String,
808 pub filtered_rows: Vec<usize>,
809}
810
811#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
813pub enum ApprovalSettingsPersistenceTrigger {
814 #[default]
815 Quit,
816 NewSession,
817}
818
819#[derive(Debug, Default)]
820pub struct ApprovalSettingsPersistenceModal {
821 pub is_visible: bool,
822 pub selected: usize,
824 pub trigger: ApprovalSettingsPersistenceTrigger,
826}
827
828impl AutoApprovePopupState {
829 pub fn reset(&mut self) {
830 self.is_visible = false;
831 self.rows.clear();
832 self.row_selected = 0;
833 self.scroll = 0;
834 self.filter_text.clear();
835 self.filtered_rows.clear();
836 }
837
838 pub fn apply_filter(&mut self) {
839 self.filtered_rows = self
840 .rows
841 .iter()
842 .enumerate()
843 .filter(|(_, row)| {
844 row.tool_name
845 .to_lowercase()
846 .contains(&self.filter_text.to_lowercase())
847 })
848 .map(|(i, _)| i)
849 .collect();
850 self.row_selected = 0;
851 self.scroll = 0;
852 }
853
854 pub fn visible_count(&self) -> usize {
855 if self.filter_text.is_empty() {
856 self.rows.len()
857 } else {
858 self.filtered_rows.len()
859 }
860 }
861
862 pub fn get_row_index(&self, visual_index: usize) -> Option<usize> {
863 let visible = self.visible_rows();
864 if visual_index < visible.len() {
865 Some(visible[visual_index])
866 } else {
867 None
868 }
869 }
870
871 fn visible_rows(&self) -> Vec<usize> {
872 if self.filter_text.is_empty() {
873 (0..self.rows.len()).collect()
874 } else {
875 self.filtered_rows.clone()
876 }
877 }
878}
879
880#[derive(Debug)]
881pub struct LoadingStateManager {
882 active_operations: std::collections::HashSet<LoadingOperation>,
883}
884
885impl Default for LoadingStateManager {
886 fn default() -> Self {
887 Self::new()
888 }
889}
890
891impl LoadingStateManager {
892 pub fn new() -> Self {
893 Self {
894 active_operations: std::collections::HashSet::new(),
895 }
896 }
897
898 pub fn start_operation(&mut self, operation: LoadingOperation) {
899 self.active_operations.insert(operation);
900 }
901
902 pub fn end_operation(&mut self, operation: LoadingOperation) {
903 self.active_operations.remove(&operation);
904 }
905
906 pub fn is_loading(&self) -> bool {
907 !self.active_operations.is_empty()
908 }
909
910 pub fn get_loading_type(&self) -> LoadingType {
911 if self
912 .active_operations
913 .contains(&LoadingOperation::SessionsList)
914 {
915 LoadingType::Sessions
916 } else {
917 LoadingType::Llm
918 }
919 }
920
921 pub fn clear_all(&mut self) {
922 self.active_operations.clear();
923 }
924}
925
926#[cfg(test)]
927mod tests {
928 use super::*;
929 use stakpak_shared::models::integrations::openai::{
930 FunctionCall, ToolCall, ToolCallResultStatus,
931 };
932
933 fn tool_result(id: &str) -> ToolCallResult {
934 ToolCallResult {
935 call: ToolCall {
936 id: id.to_string(),
937 r#type: "function".to_string(),
938 function: FunctionCall {
939 name: "run_command".to_string(),
940 arguments: "{}".to_string(),
941 },
942 metadata: None,
943 },
944 result: format!("result-{id}"),
945 status: ToolCallResultStatus::Success,
946 }
947 }
948
949 #[test]
950 fn pending_user_message_merge_combines_all_parts() {
951 let mut first = PendingUserMessage::new(
952 "first".to_string(),
953 Some(vec![tool_result("t1")]),
954 vec![ContentPart {
955 r#type: "text".to_string(),
956 text: Some("img-1".to_string()),
957 image_url: None,
958 }],
959 "first".to_string(),
960 );
961
962 let second = PendingUserMessage::new(
963 "second".to_string(),
964 Some(vec![tool_result("t2")]),
965 vec![ContentPart {
966 r#type: "text".to_string(),
967 text: Some("img-2".to_string()),
968 image_url: None,
969 }],
970 "second".to_string(),
971 );
972
973 first.merge_from(second);
974
975 assert_eq!(first.final_input, "first\n\nsecond");
976 assert_eq!(first.user_message_text, "first\n\nsecond");
977 assert_eq!(first.image_parts.len(), 2);
978 assert_eq!(
979 first
980 .shell_tool_calls
981 .as_ref()
982 .map(std::vec::Vec::len)
983 .unwrap_or_default(),
984 2
985 );
986 }
987
988 #[test]
989 fn pending_user_message_merge_skips_empty_text_with_no_extra_separator() {
990 let mut first = PendingUserMessage::new("".to_string(), None, Vec::new(), "".to_string());
991
992 let second = PendingUserMessage::new(
993 "second".to_string(),
994 None,
995 vec![ContentPart {
996 r#type: "text".to_string(),
997 text: Some("img-2".to_string()),
998 image_url: None,
999 }],
1000 "second".to_string(),
1001 );
1002
1003 first.merge_from(second);
1004
1005 assert_eq!(first.final_input, "second");
1006 assert_eq!(first.user_message_text, "second");
1007 assert_eq!(first.image_parts.len(), 1);
1008 }
1009
1010 #[test]
1011 fn pending_user_message_merge_adopts_incoming_tool_calls_when_initially_none() {
1012 let mut first =
1013 PendingUserMessage::new("first".to_string(), None, Vec::new(), "first".to_string());
1014
1015 let second = PendingUserMessage::new(
1016 "second".to_string(),
1017 Some(vec![tool_result("t2")]),
1018 Vec::new(),
1019 "second".to_string(),
1020 );
1021
1022 first.merge_from(second);
1023
1024 assert_eq!(
1025 first
1026 .shell_tool_calls
1027 .as_ref()
1028 .map(std::vec::Vec::len)
1029 .unwrap_or_default(),
1030 1
1031 );
1032 }
1033}