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 pub auth_display_info: (Option<String>, Option<String>, Option<String>),
74 pub board_agent_id: Option<String>,
76 pub init_prompt_content: Option<String>,
78 pub recent_models: Vec<String>,
80 pub task_manager_handle: Option<Arc<TaskManagerHandle>>,
82}
83
84impl AppState {
85 pub fn get_helper_commands() -> Vec<HelperCommand> {
86 let mut helpers = crate::services::commands::commands_to_helper_commands();
88
89 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 let custom = crate::services::custom_commands::load_custom_commands();
108
109 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 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 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_state: MessageInteractionState::default(),
211
212 profile_switcher_state: ProfileSwitcherState {
214 current_profile_name: "default".to_string(),
215 ..Default::default()
216 },
217
218 shortcuts_panel_state: ShortcutsPanelState::default(),
220 rulebook_switcher_state: RulebookSwitcherState::default(),
222
223 model_switcher_state: ModelSwitcherState {
225 recent_models,
226 ..Default::default()
227 },
228 command_palette_state: CommandPaletteState::default(),
230
231 file_changes_popup_state: FileChangesPopupState::default(),
233
234 tool_approval_popup_state: AutoApprovePopupState::default(),
236
237 approval_settings_persistence_state: ApprovalSettingsPersistenceModal::default(),
239
240 usage_tracking_state: UsageTrackingState::default(),
242
243 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_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_state: PlanModeState::default(),
272 plan_review_state: PlanReviewState::default(),
273 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 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 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 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 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 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 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 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 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 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 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 !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 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}