Skip to main content

stakpak_tui/app/
types.rs

1//! Type Definitions Module
2//!
3//! This module contains all type definitions used throughout the TUI application.
4//! Types are organized here for better maintainability and code organization.
5
6use 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
30// Type alias to reduce complexity - now stores processed lines for better performance
31pub type MessageLinesCache = (Vec<Message>, usize, Vec<Line<'static>>);
32
33/// Cached rendered lines for a single message.
34/// Uses Arc to avoid expensive cloning when returning cached lines.
35#[derive(Clone, Debug)]
36pub struct RenderedMessageCache {
37    /// Hash of the message content for change detection
38    pub content_hash: u64,
39    /// The rendered lines for this message (shared via Arc to avoid cloning)
40    pub rendered_lines: Arc<Vec<Line<'static>>>,
41    /// Width the message was rendered at
42    pub width: usize,
43}
44
45/// Per-message cache for efficient incremental rendering.
46/// Only re-renders messages that have actually changed.
47pub type PerMessageCache = HashMap<Uuid, RenderedMessageCache>;
48
49/// Cache for the currently visible lines on screen.
50/// This avoids re-slicing and cloning on every frame when only scroll position changes.
51#[derive(Clone, Debug)]
52pub struct VisibleLinesCache {
53    /// The scroll position these lines were computed for
54    pub scroll: usize,
55    /// The width these lines were computed for
56    pub width: usize,
57    /// The height (number of lines) requested
58    pub height: usize,
59    /// The visible lines (Arc to avoid cloning on every frame)
60    pub lines: Arc<Vec<Line<'static>>>,
61    /// Generation counter from assembled cache (to detect when source changed)
62    pub source_generation: u64,
63}
64
65/// Performance metrics for render operations (for benchmarking)
66#[derive(Debug, Default, Clone)]
67pub struct RenderMetrics {
68    /// Total time spent rendering in the last render cycle (microseconds)
69    pub last_render_time_us: u64,
70    /// Number of messages that hit the cache
71    pub cache_hits: usize,
72    /// Number of messages that missed the cache and required re-rendering
73    pub cache_misses: usize,
74    /// Total number of lines rendered
75    pub total_lines: usize,
76    /// Rolling average render time (microseconds)
77    pub avg_render_time_us: u64,
78    /// Number of render cycles tracked for average
79    render_count: u64,
80}
81
82impl RenderMetrics {
83    pub fn new() -> Self {
84        Self::default()
85    }
86
87    /// Record a new render cycle's metrics
88    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        // Update rolling average
101        self.render_count += 1;
102        if self.render_count == 1 {
103            self.avg_render_time_us = render_time_us;
104        } else {
105            // Exponential moving average with alpha = 0.1
106            self.avg_render_time_us = (self.avg_render_time_us * 9 + render_time_us) / 10;
107        }
108    }
109
110    /// Reset metrics (useful for benchmarking specific scenarios)
111    pub fn reset(&mut self) {
112        *self = Self::default();
113    }
114}
115
116/// Async file_search result struct
117pub 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/// Source of a slash command — built-in or loaded from a custom `.md` file.
125#[derive(Debug, Clone, PartialEq)]
126pub enum CommandSource {
127    /// Hard-coded command handled by `execute_command()`.
128    BuiltIn,
129    /// Built-in command whose prompt is embedded at compile time (e.g. `/claw`, `/review`).
130    /// Handled generically by the `_ =>` fallback in `execute_command()` — no bespoke
131    /// match arm required. If the prompt contains `{input}`, it accepts user arguments
132    /// and the placeholder is replaced at runtime; otherwise the prompt fires as-is.
133    BuiltInWithPrompt { prompt_content: String },
134    /// User-defined command loaded from `~/.stakpak/commands/` or `.stakpak/commands/`.
135    /// The `prompt_content` is the raw markdown body of the file.
136    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/// Stashed state for the "existing plan found" modal.
204///
205/// When plan mode is requested and `.stakpak/session/plan.md` already exists,
206/// the inline prompt is stashed here while the user decides whether to resume
207/// or start fresh.
208#[derive(Debug, Clone)]
209pub struct ExistingPlanPrompt {
210    /// The inline prompt from `/plan <prompt>` (or `None` for bare `/plan`).
211    pub inline_prompt: Option<String>,
212    /// Metadata parsed from the existing plan file (for display in the modal).
213    pub metadata: Option<crate::services::plan::PlanMetadata>,
214}
215
216/// Input & TextArea state - handles user input, autocomplete dropdowns, and file search
217#[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    /// Counter to block stay_at_bottom for N frames (used when scroll_to_last_message_start needs to persist)
280    pub block_stay_at_bottom_frames: u8,
281    /// When scroll is locked, this stores how many lines from the end we want to show at top of viewport
282    /// This allows us to maintain relative position even as total_lines changes
283    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    /// Per-message rendered line cache for efficient incremental rendering
293    pub per_message_cache: PerMessageCache,
294    /// Assembled lines cache (the final combined output of all message lines)
295    /// Format: (cache_key_hash, lines, generation_counter)
296    pub assembled_lines_cache: Option<(u64, Vec<Line<'static>>, u64)>,
297    /// Cache for visible lines on screen (avoids cloning on every frame)
298    pub visible_lines_cache: Option<VisibleLinesCache>,
299    /// Generation counter for assembled cache (increments on each rebuild)
300    pub cache_generation: u64,
301    /// Performance metrics for render operations
302    pub render_metrics: RenderMetrics,
303    /// Last width used for rendering (to detect width changes)
304    pub last_render_width: usize,
305    /// Maps line ranges to message info for click detection
306    /// Format: Vec<(start_line, end_line, message_id, is_user_message, message_text, user_message_index)>
307    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/// Shell popup and shell-command execution UI state.
483#[derive(Default)]
484pub struct ShellPopupState {
485    pub is_visible: bool,
486    pub is_expanded: bool,
487    pub scroll: usize,
488    /// Flag to request a terminal clear and redraw (e.g., after shell popup closes)
489    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    /// Tracks if the initial shell prompt has been shown (before command is typed)
504    pub shell_initial_prompt_shown: bool,
505    /// Tracks if the command has been typed into the shell (after initial prompt)
506    pub shell_command_typed: bool,
507}
508
509/// Tool-call streaming, retry, and cancellation lifecycle state.
510#[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    /// When true, cancellation has been requested (ESC pressed) but the final ToolResult
518    /// hasn't arrived yet. Late StreamToolResult/StreamAssistantMessage events should be ignored.
519    pub cancel_requested: bool,
520    pub latest_tool_call: Option<ToolCall>,
521    /// Stable message ID for the tool call streaming preview block
522    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
535/// Dialog visibility, approval-bar interaction, and tool-approval selection state.
536pub 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    /// Whether plan mode is active (set by /plan command, cleared by /new session)
646    pub is_active: bool,
647    /// Cached plan metadata from `.stakpak/session/plan.md` front matter
648    pub metadata: Option<crate::services::plan::PlanMetadata>,
649    /// SHA-256 hash of the last-read plan content (for change detection)
650    pub content_hash: Option<String>,
651    /// Previous plan status (for detecting transitions)
652    pub previous_status: Option<crate::services::plan::PlanStatus>,
653    /// Whether plan review was auto-opened for current reviewing transition
654    pub review_auto_opened: bool,
655    /// When set, the "existing plan found" modal is visible.
656    /// Contains the stashed prompt and plan metadata for the modal to display.
657    pub existing_prompt: Option<ExistingPlanPrompt>,
658}
659
660#[derive(Default)]
661pub struct PlanReviewState {
662    /// Whether the plan review overlay is visible
663    pub is_visible: bool,
664    /// Scroll offset (line index of the top visible line)
665    pub scroll: usize,
666    /// Currently selected line (0-indexed)
667    pub cursor_line: usize,
668    /// Cached plan content (loaded when review opens)
669    pub content: String,
670    /// Cached split lines of plan content
671    pub lines: Vec<String>,
672    /// Cached plan comments (loaded when review opens)
673    pub comments: Option<crate::services::plan_comments::PlanComments>,
674    /// Resolved anchors mapping comment IDs to line numbers
675    pub resolved_anchors: Vec<(String, crate::services::plan_comments::ResolvedAnchor)>,
676    /// Whether the comment input modal is open
677    pub show_comment_modal: bool,
678    /// Text buffer for composing a new comment
679    pub comment_input: String,
680    /// Selected comment ID (for reply targeting)
681    pub selected_comment: Option<String>,
682    /// Kind of comment modal currently open
683    pub modal_kind: Option<crate::services::plan_review::CommentModalKind>,
684    /// Confirmation dialog currently shown (approve, feedback, delete)
685    pub confirm: Option<crate::services::plan_review::ConfirmAction>,
686}
687
688#[derive(Default)]
689pub struct AskUserState {
690    /// Whether the ask user interaction is active
691    pub is_visible: bool,
692    /// Questions to display in the inline block
693    pub questions: Vec<stakpak_shared::models::integrations::openai::AskUserQuestion>,
694    /// User's answers (question label -> answer)
695    pub answers: HashMap<String, stakpak_shared::models::integrations::openai::AskUserAnswer>,
696    /// Currently selected tab index (question index, or questions.len() for Submit)
697    pub current_tab: usize,
698    /// Currently selected option index within the current question
699    pub selected_option: usize,
700    /// Custom input text when "Type something..." is selected
701    pub custom_input: String,
702    /// The tool call that triggered this (for sending result back)
703    pub tool_call: Option<ToolCall>,
704    /// Message ID for the inline ask_user block in the messages list
705    pub message_id: Option<Uuid>,
706    /// Whether the ask_user block has keyboard focus (Tab toggles)
707    pub is_focused: bool,
708    /// Multi-select toggle state: question label -> list of currently selected option values
709    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/// Mode for the unified shortcuts/commands/sessions popup
778#[derive(Debug, Clone, Copy, PartialEq, Default)]
779pub enum ShortcutsPopupMode {
780    #[default]
781    Commands,
782    Shortcuts,
783    Sessions,
784}
785
786/// Mode for the model switcher popup filter tabs
787#[derive(Debug, Clone, Copy, PartialEq, Default)]
788pub enum ModelSwitcherMode {
789    #[default]
790    All, // Show all models grouped by provider
791    Reasoning, // Show only models with reasoning support
792}
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/// What triggered the persistence modal — determines the action after save/discard.
812#[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    /// Currently selected option (0 = Profile, 1 = Project Directory, 2 = Discard).
823    pub selected: usize,
824    /// What action to take after the user makes their choice.
825    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}