Skip to main content

stakpak_tui/
app.rs

1mod events;
2mod types;
3
4pub use events::{InputEvent, OutputEvent};
5use stakai::Model;
6pub use types::*;
7
8use crate::services::auto_approve::AutoApproveManager;
9use crate::services::detect_term::ThemeColors;
10use crate::services::file_search::{FileSearch, file_search_worker, find_at_trigger};
11#[cfg(unix)]
12use crate::services::helper_block::push_error_message;
13use crate::services::helper_block::push_styled_message;
14use crate::services::message::Message;
15#[cfg(not(unix))]
16use crate::services::shell_mode::run_background_shell_command;
17#[cfg(unix)]
18use crate::services::shell_mode::run_pty_command;
19use crate::services::shell_mode::{SHELL_PROMPT_PREFIX, ShellEvent};
20use crate::services::textarea::{TextArea, TextAreaState};
21use crate::services::toast::Toast;
22use stakpak_shared::secret_manager::SecretManager;
23use stakpak_shared::task_manager::TaskManagerHandle;
24use std::sync::Arc;
25use tokio::sync::mpsc;
26
27pub struct AppState {
28    pub input_state: InputState,
29    pub messages_scrolling_state: MessagesScrollingState,
30    pub loading_state: LoadingState,
31    pub shell_popup_state: ShellPopupState,
32    pub tool_call_state: ToolCallState,
33    pub dialog_approval_state: DialogApprovalState,
34    pub sessions_state: SessionsState,
35    pub session_tool_calls_state: SessionToolCallsState,
36    pub profile_switcher_state: ProfileSwitcherState,
37    pub rulebook_switcher_state: RulebookSwitcherState,
38    pub model_switcher_state: ModelSwitcherState,
39    pub command_palette_state: CommandPaletteState,
40    pub shortcuts_panel_state: ShortcutsPanelState,
41    pub file_changes_popup_state: FileChangesPopupState,
42    pub usage_tracking_state: UsageTrackingState,
43    pub configuration_state: ConfigurationState,
44    pub quit_intent_state: QuitIntentState,
45    pub terminal_ui_state: TerminalUiState,
46    pub shell_runtime_state: ShellRuntimeState,
47    pub shell_session_state: ShellSessionState,
48    pub banner_state: BannerState,
49    pub toast: Option<Toast>,
50    pub message_interaction_state: MessageInteractionState,
51    pub side_panel_state: SidePanelState,
52    pub user_message_queue_state: UserMessageQueueState,
53    pub message_revert_state: MessageRevertState,
54    pub plan_mode_state: PlanModeState,
55    pub plan_review_state: PlanReviewState,
56    pub ask_user_state: AskUserState,
57    pub tool_approval_popup_state: AutoApprovePopupState,
58    pub approval_settings_persistence_state: ApprovalSettingsPersistenceModal,
59    pub background_tasks_state: BackgroundTasksState,
60}
61
62pub struct AppStateOptions<'a> {
63    pub latest_version: Option<String>,
64    pub redact_secrets: bool,
65    pub privacy_mode: bool,
66    pub is_git_repo: bool,
67    pub auto_approve_tools: Option<&'a Vec<String>>,
68    pub allowed_tools: Option<&'a Vec<String>>,
69    pub input_tx: Option<mpsc::Sender<InputEvent>>,
70    pub model: Model,
71    pub editor_command: Option<String>,
72    /// Auth display info: (config_provider, auth_provider, subscription_name) for local providers
73    pub auth_display_info: (Option<String>, Option<String>, Option<String>),
74    /// Agent board ID for task tracking (from AGENT_BOARD_AGENT_ID env var)
75    pub board_agent_id: Option<String>,
76    /// Content of init prompt
77    pub init_prompt_content: Option<String>,
78    /// Recently used model IDs (most recent first)
79    pub recent_models: Vec<String>,
80    /// Handle to the TaskManager for querying background task status
81    pub task_manager_handle: Option<Arc<TaskManagerHandle>>,
82}
83
84impl AppState {
85    pub fn get_helper_commands() -> Vec<HelperCommand> {
86        // Built-in commands from the unified command system
87        let mut helpers = crate::services::commands::commands_to_helper_commands();
88
89        // Predefined commands shipped with the binary (from libs/api/src/commands/*.md)
90        // Skip any that clash with built-in command names
91        let builtin_names: std::collections::HashSet<String> =
92            helpers.iter().map(|h| h.command.clone()).collect();
93        for (name, description, prompt_content) in stakpak_api::commands::load_predefined_commands()
94        {
95            let command = format!("/{name}");
96            if builtin_names.contains(&command) {
97                continue;
98            }
99            helpers.push(HelperCommand {
100                command,
101                description,
102                source: CommandSource::BuiltInWithPrompt { prompt_content },
103            });
104        }
105
106        // Load custom commands from ~/.stakpak/commands/ and .stakpak/commands/
107        let custom = crate::services::custom_commands::load_custom_commands();
108
109        // Merge: skip custom commands whose names clash with built-in or predefined commands
110        let builtin_names: std::collections::HashSet<String> =
111            helpers.iter().map(|h| h.command.clone()).collect();
112        helpers.extend(
113            custom
114                .into_iter()
115                .filter(|c| !builtin_names.contains(&c.command)),
116        );
117
118        helpers
119    }
120
121    /// Initialize file search channels and spawn worker
122    fn init_file_search_channels(
123        helpers: &[HelperCommand],
124    ) -> (
125        mpsc::Sender<(String, usize)>,
126        mpsc::Receiver<FileSearchResult>,
127    ) {
128        let (file_search_tx, file_search_rx) = mpsc::channel::<(String, usize)>(10);
129        let (result_tx, result_rx) = mpsc::channel::<FileSearchResult>(10);
130        let helpers_clone = helpers.to_vec();
131        let file_search_instance = FileSearch::default();
132        // Spawn file_search worker from file_search.rs
133        tokio::spawn(file_search_worker(
134            file_search_rx,
135            result_tx,
136            helpers_clone,
137            file_search_instance,
138        ));
139        (file_search_tx, result_rx)
140    }
141
142    pub fn new(options: AppStateOptions) -> Self {
143        let AppStateOptions {
144            latest_version,
145            redact_secrets,
146            privacy_mode,
147            is_git_repo,
148            auto_approve_tools,
149            allowed_tools,
150            input_tx,
151            model,
152            editor_command,
153            auth_display_info,
154            board_agent_id,
155            init_prompt_content,
156            recent_models,
157            task_manager_handle,
158        } = options;
159
160        let helpers = Self::get_helper_commands();
161        let (file_search_tx, result_rx) = Self::init_file_search_channels(&helpers);
162
163        AppState {
164            input_state: InputState {
165                text_area: TextArea::new(),
166                text_area_state: TextAreaState::default(),
167                cursor_visible: true,
168                helpers,
169                show_helper_dropdown: false,
170                helper_selected: 0,
171                helper_scroll: 0,
172                filtered_helpers: Vec::new(),
173                filtered_files: Vec::new(),
174                file_search: FileSearch::default(),
175                file_search_tx: Some(file_search_tx),
176                file_search_rx: Some(result_rx),
177                is_pasting: false,
178                pasted_long_text: None,
179                pasted_placeholder: None,
180                pending_pastes: Vec::new(),
181                attached_images: Vec::new(),
182                pending_path_start: None,
183                interactive_commands: crate::constants::INTERACTIVE_COMMANDS
184                    .iter()
185                    .map(|s| s.to_string())
186                    .collect(),
187            },
188            loading_state: LoadingState::default(),
189            messages_scrolling_state: MessagesScrollingState::default(),
190            dialog_approval_state: DialogApprovalState::default(),
191            sessions_state: SessionsState::default(),
192            tool_call_state: ToolCallState {
193                max_retry_attempts: 3,
194                ..Default::default()
195            },
196            session_tool_calls_state: SessionToolCallsState::default(),
197            shell_popup_state: ShellPopupState {
198                cursor_visible: true,
199                ..Default::default()
200            },
201            quit_intent_state: QuitIntentState::default(),
202            terminal_ui_state: TerminalUiState::default(),
203            shell_runtime_state: ShellRuntimeState::default(),
204            shell_session_state: ShellSessionState::default(),
205
206            toast: None,
207            banner_state: BannerState::default(),
208
209            // Message interaction initialization
210            message_interaction_state: MessageInteractionState::default(),
211
212            // Profile switcher initialization
213            profile_switcher_state: ProfileSwitcherState {
214                current_profile_name: "default".to_string(),
215                ..Default::default()
216            },
217
218            // Shortcuts popup initialization
219            shortcuts_panel_state: ShortcutsPanelState::default(),
220            // Rulebook switcher initialization
221            rulebook_switcher_state: RulebookSwitcherState::default(),
222
223            // Model switcher initialization
224            model_switcher_state: ModelSwitcherState {
225                recent_models,
226                ..Default::default()
227            },
228            // Command palette initialization
229            command_palette_state: CommandPaletteState::default(),
230
231            // File changes popup initialization
232            file_changes_popup_state: FileChangesPopupState::default(),
233
234            // tool approval popup initialization
235            tool_approval_popup_state: AutoApprovePopupState::default(),
236
237            // Policy persistence modal initialization
238            approval_settings_persistence_state: ApprovalSettingsPersistenceModal::default(),
239
240            // Usage tracking
241            usage_tracking_state: UsageTrackingState::default(),
242
243            // Configuration state
244            configuration_state: ConfigurationState {
245                secret_manager: SecretManager::new(redact_secrets, privacy_mode),
246                latest_version: latest_version.clone(),
247                is_git_repo,
248                auto_approve_manager: AutoApproveManager::new(auto_approve_tools, input_tx),
249                allowed_tools: allowed_tools.cloned(),
250                model,
251                auth_display_info,
252                init_prompt_content,
253            },
254
255            // Side panel initialization
256            side_panel_state: SidePanelState {
257                board_agent_id,
258                editor_command: crate::services::editor::detect_editor(editor_command)
259                    .unwrap_or_else(|| "nano".to_string()),
260                ..Default::default()
261            },
262            user_message_queue_state: UserMessageQueueState::default(),
263            message_revert_state: MessageRevertState::default(),
264
265            background_tasks_state: BackgroundTasksState {
266                running_background_tasks: 0,
267                task_manager_handle,
268            },
269
270            // Plan mode/review initialization
271            plan_mode_state: PlanModeState::default(),
272            plan_review_state: PlanReviewState::default(),
273            // Ask User inline block initialization
274            ask_user_state: AskUserState {
275                is_focused: true,
276                ..Default::default()
277            },
278        }
279    }
280
281    pub fn update_session_empty_status(&mut self) {
282        let session_empty = !self.messages_scrolling_state.has_user_messages
283            && self.input_state.text_area.text().is_empty();
284        self.input_state.text_area.set_session_empty(session_empty);
285    }
286
287    /// Poll `.stakpak/session/plan.md` for changes and update cached metadata.
288    ///
289    /// Called on each spinner tick (~100 ms) while plan mode is active.
290    /// Uses SHA-256 content hashing to avoid unnecessary re-parsing.
291    /// Returns `Some((old_status, new_status))` when a status transition is detected.
292    pub fn poll_plan_file(
293        &mut self,
294    ) -> Option<(
295        Option<crate::services::plan::PlanStatus>,
296        crate::services::plan::PlanStatus,
297    )> {
298        use crate::services::plan;
299
300        // Only poll when plan mode is active
301        if !self.plan_mode_state.is_active {
302            return None;
303        }
304
305        let session_dir = std::path::Path::new(".stakpak/session");
306        let path = plan::plan_file_path(session_dir);
307
308        let Ok(content) = std::fs::read_to_string(&path) else {
309            // File doesn't exist (yet) — clear stale cache
310            if self.plan_mode_state.metadata.is_some() {
311                self.plan_mode_state.metadata = None;
312                self.plan_mode_state.content_hash = None;
313            }
314            return None;
315        };
316
317        let new_hash = plan::compute_plan_hash(&content);
318
319        // Skip re-parse if content unchanged
320        if self.plan_mode_state.content_hash.as_deref() == Some(&new_hash) {
321            return None;
322        }
323
324        self.plan_mode_state.content_hash = Some(new_hash);
325        let new_meta = plan::parse_plan_front_matter(&content);
326        self.plan_mode_state.metadata = new_meta.clone();
327
328        // Detect status transitions
329        if let Some(ref meta) = new_meta {
330            let new_status = meta.status;
331            let old_status = self.plan_mode_state.previous_status;
332
333            if old_status != Some(new_status) {
334                self.plan_mode_state.previous_status = Some(new_status);
335                return Some((old_status, new_status));
336            }
337        }
338
339        None
340    }
341
342    // Convenience methods for accessing input and cursor (using input_state)
343    pub fn input(&self) -> &str {
344        self.input_state.text_area.text()
345    }
346
347    pub fn cursor_position(&self) -> usize {
348        self.input_state.text_area.cursor()
349    }
350
351    pub fn set_input(&mut self, input: &str) {
352        self.input_state.text_area.set_text(input);
353    }
354
355    pub fn set_cursor_position(&mut self, pos: usize) {
356        self.input_state.text_area.set_cursor(pos);
357    }
358
359    pub fn insert_char(&mut self, c: char) {
360        self.input_state.text_area.insert_str(&c.to_string());
361    }
362
363    pub fn insert_str(&mut self, s: &str) {
364        self.input_state.text_area.insert_str(s);
365    }
366
367    pub fn clear_input(&mut self) {
368        self.input_state.text_area.set_text("");
369    }
370
371    /// Check if user input should be blocked (during profile switch)
372    pub fn is_input_blocked(&self) -> bool {
373        self.profile_switcher_state.switching_in_progress
374    }
375
376    pub fn run_shell_command(&mut self, command: String, input_tx: &mpsc::Sender<InputEvent>) {
377        let (shell_tx, mut shell_rx) = mpsc::channel::<ShellEvent>(100);
378        self.messages_scrolling_state
379            .messages
380            .push(Message::plain_text("SPACING_MARKER"));
381        push_styled_message(
382            self,
383            &command,
384            ThemeColors::text(),
385            SHELL_PROMPT_PREFIX,
386            ThemeColors::magenta(),
387        );
388        self.messages_scrolling_state
389            .messages
390            .push(Message::plain_text("SPACING_MARKER"));
391        let rows = if self.terminal_ui_state.terminal_size.height > 0 {
392            self.terminal_ui_state.terminal_size.height
393        } else {
394            24
395        };
396        let cols = if self.terminal_ui_state.terminal_size.width > 0 {
397            self.terminal_ui_state.terminal_size.width
398        } else {
399            80
400        };
401
402        #[cfg(unix)]
403        let shell_cmd = match run_pty_command(command.clone(), None, shell_tx, rows, cols) {
404            Ok(cmd) => cmd,
405            Err(e) => {
406                push_error_message(self, &format!("Failed to run command: {}", e), None);
407                return;
408            }
409        };
410
411        #[cfg(not(unix))]
412        let shell_cmd = run_background_shell_command(command.clone(), shell_tx);
413
414        self.shell_popup_state.active_shell_command = Some(shell_cmd.clone());
415        self.shell_popup_state.active_shell_command_output = Some(String::new());
416        self.shell_runtime_state.screen = vt100::Parser::new(rows, cols, 0);
417        let input_tx = input_tx.clone();
418        tokio::spawn(async move {
419            while let Some(event) = shell_rx.recv().await {
420                match event {
421                    ShellEvent::Output(line) => {
422                        let _ = input_tx.send(InputEvent::ShellOutput(line)).await;
423                    }
424                    ShellEvent::Error(line) => {
425                        let _ = input_tx.send(InputEvent::ShellError(line)).await;
426                    }
427
428                    ShellEvent::Completed(code) => {
429                        let _ = input_tx.send(InputEvent::ShellCompleted(code)).await;
430                        break;
431                    }
432                    ShellEvent::Clear => {
433                        let _ = input_tx.send(InputEvent::ShellClear).await;
434                    }
435                }
436            }
437        });
438    }
439
440    // --- Poll file_search results and update state (for @ file completion only) ---
441    pub fn poll_file_search_results(&mut self) {
442        if let Some(rx) = &mut self.input_state.file_search_rx {
443            while let Ok(result) = rx.try_recv() {
444                // Get input text before any mutable operations
445                let input_text = self.input_state.text_area.text().to_string();
446
447                let filtered_files = result.filtered_files.clone();
448                self.input_state.filtered_files = filtered_files;
449                self.input_state.file_search.filtered_files =
450                    self.input_state.filtered_files.clone();
451                self.input_state.file_search.is_file_mode =
452                    !self.input_state.filtered_files.is_empty();
453                self.input_state.file_search.trigger_char =
454                    if !self.input_state.filtered_files.is_empty() {
455                        Some('@')
456                    } else {
457                        None
458                    };
459
460                // NOTE: Slash command filtering (filtered_helpers) is now done synchronously
461                // in handle_input_changed / handle_input_backspace to avoid race conditions
462                // that caused buggy behavior in external terminals (iTerm2, Warp, etc.).
463                // The async worker still computes filtered_helpers but we ignore it here.
464
465                // Show dropdown for @ file triggers (slash command dropdown is managed synchronously)
466                let has_at_trigger =
467                    find_at_trigger(&result.input, result.cursor_position).is_some();
468                if has_at_trigger && !self.shell_popup_state.waiting_for_shell_input {
469                    self.input_state.show_helper_dropdown = true;
470                }
471                // If we have file results, reset selection if out of bounds
472                if !self.input_state.filtered_files.is_empty()
473                    && self.input_state.helper_selected >= self.input_state.filtered_files.len()
474                {
475                    self.input_state.helper_selected = 0;
476                }
477
478                // Don't overwrite show_helper_dropdown for slash commands —
479                // that state is already set synchronously by the input handlers.
480                // Only hide if input is completely empty (safety net).
481                if input_text.is_empty() {
482                    self.input_state.show_helper_dropdown = false;
483                }
484            }
485        }
486    }
487    pub fn auto_show_side_panel(&mut self) {
488        if !self.side_panel_state.auto_shown && !self.side_panel_state.is_shown {
489            self.side_panel_state.is_shown = true;
490            self.side_panel_state.auto_shown = true;
491        }
492    }
493}