1mod events;
2mod types;
3
4pub use events::{InputEvent, OutputEvent};
5use stakpak_shared::models::llm::{LLMModel, LLMTokenUsage};
6pub use types::*;
7
8use crate::services::approval_bar::ApprovalBar;
9use crate::services::auto_approve::AutoApproveManager;
10use crate::services::board_tasks::TaskProgress;
11use crate::services::changeset::{Changeset, SidePanelSection, TodoItem};
12use crate::services::detect_term::AdaptiveColors;
13use crate::services::file_search::{FileSearch, file_search_worker, find_at_trigger};
14use crate::services::helper_block::push_error_message;
15use crate::services::helper_block::push_styled_message;
16use crate::services::message::Message;
17#[cfg(not(unix))]
18use crate::services::shell_mode::run_background_shell_command;
19#[cfg(unix)]
20use crate::services::shell_mode::run_pty_command;
21use crate::services::shell_mode::{SHELL_PROMPT_PREFIX, ShellCommand, ShellEvent};
22use crate::services::textarea::{TextArea, TextAreaState};
23use ratatui::layout::Size;
24use ratatui::text::Line;
25use stakpak_api::models::ListRuleBook;
26use stakpak_shared::models::integrations::openai::{AgentModel, ToolCall, ToolCallResult};
27use stakpak_shared::secret_manager::SecretManager;
28use std::collections::HashMap;
29use tokio::sync::mpsc;
30use uuid::Uuid;
31
32pub struct AppState {
33 pub text_area: TextArea,
35 pub text_area_state: TextAreaState,
36 pub cursor_visible: bool,
37 pub helpers: Vec<HelperCommand>,
38 pub show_helper_dropdown: bool,
39 pub helper_selected: usize,
40 pub helper_scroll: usize,
41 pub filtered_helpers: Vec<HelperCommand>,
42 pub filtered_files: Vec<String>,
43 pub file_search: FileSearch,
44 pub file_search_tx: Option<mpsc::Sender<(String, usize)>>,
45 pub file_search_rx: Option<mpsc::Receiver<FileSearchResult>>,
46 pub is_pasting: bool,
47 pub pasted_long_text: Option<String>,
48 pub pasted_placeholder: Option<String>,
49 pub pending_pastes: Vec<(String, String)>,
50 pub attached_images: Vec<AttachedImage>,
52 pub pending_path_start: Option<usize>,
53 pub interactive_commands: Vec<String>,
54
55 pub messages: Vec<Message>,
57 pub scroll: usize,
58 pub scroll_to_bottom: bool,
59 pub stay_at_bottom: bool,
60 pub content_changed_while_scrolled_up: bool,
61 pub message_lines_cache: Option<MessageLinesCache>,
62 pub collapsed_message_lines_cache: Option<MessageLinesCache>,
63 pub processed_lines_cache: Option<(Vec<Message>, usize, Vec<Line<'static>>)>,
64 pub show_collapsed_messages: bool,
65 pub collapsed_messages_scroll: usize,
66 pub collapsed_messages_selected: usize,
67 pub has_user_messages: bool,
68 pub per_message_cache: PerMessageCache,
70 pub assembled_lines_cache: Option<(usize, Vec<Line<'static>>, u64)>,
73 pub visible_lines_cache: Option<VisibleLinesCache>,
75 pub cache_generation: u64,
77 pub render_metrics: RenderMetrics,
79 pub last_render_width: usize,
81
82 pub loading: bool,
84 pub loading_type: LoadingType,
85 pub spinner_frame: usize,
86 pub loading_manager: LoadingStateManager,
87
88 pub shell_popup_visible: bool,
90 pub shell_popup_expanded: bool,
91 pub shell_popup_scroll: usize,
92 pub shell_cursor_visible: bool,
93 pub shell_cursor_blink_timer: u8,
94 pub active_shell_command: Option<ShellCommand>,
95 pub active_shell_command_output: Option<String>,
96 pub waiting_for_shell_input: bool,
97 pub shell_tool_calls: Option<Vec<ToolCallResult>>,
98 pub shell_loading: bool,
99 pub shell_pending_command_value: Option<String>,
100 pub shell_pending_command_executed: bool,
101 pub shell_pending_command_output: Option<String>,
102 pub show_shell_mode: bool, pub shell_mode_input: String, pub is_tool_call_shell_command: bool,
106 pub ondemand_shell_mode: bool,
107 pub shell_pending_command: Option<String>,
108 pub shell_pending_command_output_count: usize,
109 pub shell_initial_prompt_shown: bool,
111 pub shell_command_typed: bool,
113
114 pub pending_bash_message_id: Option<Uuid>,
116 pub streaming_tool_results: HashMap<Uuid, String>,
117 pub streaming_tool_result_id: Option<Uuid>,
118 pub completed_tool_calls: std::collections::HashSet<Uuid>,
119 pub is_streaming: bool,
120 pub latest_tool_call: Option<ToolCall>,
121 pub retry_attempts: usize,
122 pub max_retry_attempts: usize,
123 pub last_user_message_for_retry: Option<String>,
124 pub is_retrying: bool,
125
126 pub is_dialog_open: bool,
128 pub dialog_command: Option<ToolCall>,
129 pub dialog_selected: usize,
130 pub dialog_message_id: Option<Uuid>,
131 pub dialog_focused: bool,
132 pub approval_bar: ApprovalBar,
133 pub message_tool_calls: Option<Vec<ToolCall>>,
134 pub message_approved_tools: Vec<ToolCall>,
135 pub message_rejected_tools: Vec<ToolCall>,
136 pub toggle_approved_message: bool,
137 pub show_shortcuts: bool,
138
139 pub sessions: Vec<SessionInfo>,
141 pub session_selected: usize,
142 pub account_info: String,
143
144 pub session_tool_calls_queue: std::collections::HashMap<String, ToolCallStatus>,
146 pub tool_call_execution_order: Vec<String>,
147 pub last_message_tool_calls: Vec<ToolCall>,
148
149 pub show_profile_switcher: bool,
151 pub available_profiles: Vec<String>,
152 pub profile_switcher_selected: usize,
153 pub current_profile_name: String,
154 pub profile_switching_in_progress: bool,
155 pub profile_switch_status_message: Option<String>,
156
157 pub show_rulebook_switcher: bool,
159 pub available_rulebooks: Vec<ListRuleBook>,
160 pub selected_rulebooks: std::collections::HashSet<String>,
161 pub rulebook_switcher_selected: usize,
162 pub rulebook_search_input: String,
163 pub filtered_rulebooks: Vec<ListRuleBook>,
164 pub rulebook_config: Option<crate::RulebookConfig>,
165
166 pub show_command_palette: bool,
168 pub command_palette_selected: usize,
169 pub command_palette_scroll: usize,
170 pub command_palette_search: String,
171
172 pub show_shortcuts_popup: bool,
174 pub shortcuts_scroll: usize,
175 pub shortcuts_popup_mode: ShortcutsPopupMode,
176
177 pub show_file_changes_popup: bool,
179 pub file_changes_selected: usize,
180 pub file_changes_scroll: usize,
181 pub file_changes_search: String,
182
183 pub current_message_usage: LLMTokenUsage,
185 pub total_session_usage: LLMTokenUsage,
186 pub context_usage_percent: u64,
187
188 pub secret_manager: SecretManager,
190 pub latest_version: Option<String>,
191 pub is_git_repo: bool,
192 pub auto_approve_manager: AutoApproveManager,
193 pub allowed_tools: Option<Vec<String>>,
194 pub agent_model: AgentModel,
195 pub llm_model: Option<LLMModel>,
196 pub auth_display_info: (Option<String>, Option<String>, Option<String>),
198
199 pub ctrl_c_pressed_once: bool,
201 pub ctrl_c_timer: Option<std::time::Instant>,
202 pub mouse_capture_enabled: bool,
203 pub terminal_size: Size,
204 pub shell_screen: vt100::Parser,
205 pub shell_scroll: u16,
206 pub shell_history_lines: Vec<ratatui::text::Line<'static>>, pub interactive_shell_message_id: Option<Uuid>,
208 pub shell_interaction_occurred: bool,
209
210 pub show_side_panel: bool,
212 pub side_panel_focus: SidePanelSection,
213 pub side_panel_section_collapsed: std::collections::HashMap<SidePanelSection, bool>,
214 pub side_panel_areas: HashMap<SidePanelSection, ratatui::layout::Rect>,
216 pub session_id: String,
218 pub changeset: Changeset,
219
220 pub todos: Vec<TodoItem>,
221 pub task_progress: Option<TaskProgress>,
223 pub session_start_time: std::time::Instant,
224
225 pub side_panel_auto_shown: bool,
227
228 pub board_agent_id: Option<String>,
230
231 pub editor_command: String,
233
234 pub pending_editor_open: Option<String>,
236
237 pub billing_info: Option<stakpak_shared::models::billing::BillingResponse>,
239}
240
241pub struct AppStateOptions<'a> {
242 pub latest_version: Option<String>,
243 pub redact_secrets: bool,
244 pub privacy_mode: bool,
245 pub is_git_repo: bool,
246 pub auto_approve_tools: Option<&'a Vec<String>>,
247 pub allowed_tools: Option<&'a Vec<String>>,
248 pub input_tx: Option<mpsc::Sender<InputEvent>>,
249 pub agent_model: AgentModel,
250 pub editor_command: Option<String>,
251 pub auth_display_info: (Option<String>, Option<String>, Option<String>),
253 pub board_agent_id: Option<String>,
255}
256
257impl AppState {
258 pub fn get_helper_commands() -> Vec<HelperCommand> {
259 crate::services::commands::commands_to_helper_commands()
261 }
262
263 fn init_file_search_channels(
265 helpers: &[HelperCommand],
266 ) -> (
267 mpsc::Sender<(String, usize)>,
268 mpsc::Receiver<FileSearchResult>,
269 ) {
270 let (file_search_tx, file_search_rx) = mpsc::channel::<(String, usize)>(10);
271 let (result_tx, result_rx) = mpsc::channel::<FileSearchResult>(10);
272 let helpers_clone = helpers.to_vec();
273 let file_search_instance = FileSearch::default();
274 tokio::spawn(file_search_worker(
276 file_search_rx,
277 result_tx,
278 helpers_clone,
279 file_search_instance,
280 ));
281 (file_search_tx, result_rx)
282 }
283
284 pub fn new(options: AppStateOptions) -> Self {
285 let AppStateOptions {
286 latest_version,
287 redact_secrets,
288 privacy_mode,
289 is_git_repo,
290 auto_approve_tools,
291 allowed_tools,
292 input_tx,
293 agent_model,
294 editor_command,
295 auth_display_info,
296 board_agent_id,
297 } = options;
298
299 let helpers = Self::get_helper_commands();
300 let (file_search_tx, result_rx) = Self::init_file_search_channels(&helpers);
301
302 AppState {
303 text_area: TextArea::new(),
304 text_area_state: TextAreaState::default(),
305 cursor_visible: true,
306 messages: Vec::new(), scroll: 0,
308 scroll_to_bottom: false,
309 stay_at_bottom: true,
310 content_changed_while_scrolled_up: false,
311 helpers: helpers.clone(),
312 show_helper_dropdown: false,
313 helper_selected: 0,
314 helper_scroll: 0,
315 filtered_helpers: helpers,
316 filtered_files: Vec::new(),
317 show_shortcuts: false,
318 is_dialog_open: false,
319 dialog_command: None,
320 dialog_selected: 0,
321 loading: false,
322 loading_type: LoadingType::Llm,
323 spinner_frame: 0,
324 sessions: Vec::new(),
325 session_selected: 0,
326 account_info: String::new(),
327 pending_bash_message_id: None,
328 streaming_tool_results: HashMap::new(),
329 streaming_tool_result_id: None,
330 completed_tool_calls: std::collections::HashSet::new(),
331 shell_popup_visible: false,
332 shell_popup_expanded: false,
333 shell_popup_scroll: 0,
334 shell_cursor_visible: true,
335 shell_cursor_blink_timer: 0,
336 active_shell_command: None,
337 active_shell_command_output: None,
338 waiting_for_shell_input: false,
339 is_pasting: false,
340 shell_tool_calls: None,
341 shell_loading: false,
342 shell_pending_command_value: None,
343 shell_pending_command_executed: false,
344 shell_pending_command_output: None,
345 show_shell_mode: false,
347 shell_mode_input: String::new(),
348 is_tool_call_shell_command: false,
349 ondemand_shell_mode: false,
350 shell_pending_command: None,
351 shell_pending_command_output_count: 0,
352 shell_initial_prompt_shown: false,
353 shell_command_typed: false,
354 attached_images: Vec::new(),
355 pending_path_start: None,
356 dialog_message_id: None,
357 file_search: FileSearch::default(),
358 secret_manager: SecretManager::new(redact_secrets, privacy_mode),
359 latest_version: latest_version.clone(),
360 ctrl_c_pressed_once: false,
361 ctrl_c_timer: None,
362 pasted_long_text: None,
363 pasted_placeholder: None,
364 file_search_tx: Some(file_search_tx),
365 file_search_rx: Some(result_rx),
366 is_streaming: false,
367 interactive_commands: crate::constants::INTERACTIVE_COMMANDS
368 .iter()
369 .map(|s| s.to_string())
370 .collect(),
371 auto_approve_manager: AutoApproveManager::new(auto_approve_tools, input_tx),
372 allowed_tools: allowed_tools.cloned(),
373 dialog_focused: false, latest_tool_call: None,
375 retry_attempts: 0,
376 max_retry_attempts: 3,
377 last_user_message_for_retry: None,
378 is_retrying: false,
379 show_collapsed_messages: false,
380 collapsed_messages_scroll: 0,
381 collapsed_messages_selected: 0,
382 is_git_repo,
383 message_lines_cache: None,
384 collapsed_message_lines_cache: None,
385 processed_lines_cache: None,
386 per_message_cache: HashMap::new(),
387 assembled_lines_cache: None,
388 visible_lines_cache: None,
389 cache_generation: 0,
390 render_metrics: RenderMetrics::new(),
391 last_render_width: 0,
392 pending_pastes: Vec::new(),
393 mouse_capture_enabled: false, loading_manager: LoadingStateManager::new(),
395 has_user_messages: false,
396 message_tool_calls: None,
397 approval_bar: ApprovalBar::new(),
398 message_approved_tools: Vec::new(),
399 message_rejected_tools: Vec::new(),
400 toggle_approved_message: true,
401 terminal_size: Size {
402 width: 0,
403 height: 0,
404 },
405 session_tool_calls_queue: std::collections::HashMap::new(),
406 tool_call_execution_order: Vec::new(),
407 last_message_tool_calls: Vec::new(),
408 shell_screen: vt100::Parser::new(24, 80, 1000),
409 shell_scroll: 0,
410 shell_history_lines: Vec::new(),
411 interactive_shell_message_id: None,
412 shell_interaction_occurred: false,
413
414 show_profile_switcher: false,
416 available_profiles: Vec::new(),
417 profile_switcher_selected: 0,
418 current_profile_name: "default".to_string(),
419 profile_switching_in_progress: false,
420 profile_switch_status_message: None,
421 rulebook_config: None,
422
423 show_shortcuts_popup: false,
425 shortcuts_scroll: 0,
426 shortcuts_popup_mode: ShortcutsPopupMode::default(),
427 show_rulebook_switcher: false,
429 available_rulebooks: Vec::new(),
430 selected_rulebooks: std::collections::HashSet::new(),
431 rulebook_switcher_selected: 0,
432 rulebook_search_input: String::new(),
433 filtered_rulebooks: Vec::new(),
434 show_command_palette: false,
436 command_palette_selected: 0,
437 command_palette_scroll: 0,
438 command_palette_search: String::new(),
439
440 show_file_changes_popup: false,
442 file_changes_selected: 0,
443 file_changes_scroll: 0,
444 file_changes_search: String::new(),
445
446 current_message_usage: LLMTokenUsage {
448 prompt_tokens: 0,
449 completion_tokens: 0,
450 total_tokens: 0,
451 prompt_tokens_details: None,
452 },
453 total_session_usage: LLMTokenUsage {
454 prompt_tokens: 0,
455 completion_tokens: 0,
456 total_tokens: 0,
457 prompt_tokens_details: None,
458 },
459 context_usage_percent: 0,
460 agent_model,
461 llm_model: None,
462
463 show_side_panel: false,
465 side_panel_focus: SidePanelSection::Context,
466 side_panel_section_collapsed: {
467 let mut collapsed = std::collections::HashMap::new();
468 collapsed.insert(SidePanelSection::Context, false); collapsed.insert(SidePanelSection::Billing, false); collapsed.insert(SidePanelSection::Tasks, false); collapsed.insert(SidePanelSection::Changeset, false); collapsed
473 },
474 side_panel_areas: HashMap::new(),
475 changeset: Changeset::new(),
476 todos: Vec::new(),
477 task_progress: None,
478 session_start_time: std::time::Instant::now(),
479 side_panel_auto_shown: false,
480 session_id: String::new(), board_agent_id,
482 editor_command: crate::services::editor::detect_editor(editor_command)
483 .unwrap_or_else(|| "nano".to_string()),
484 pending_editor_open: None,
485 billing_info: None,
486 auth_display_info,
487 }
488 }
489
490 pub fn update_session_empty_status(&mut self) {
491 let session_empty = !self.has_user_messages && self.text_area.text().is_empty();
493 self.text_area.set_session_empty(session_empty);
494 }
495
496 pub fn input(&self) -> &str {
498 self.text_area.text()
499 }
500
501 pub fn cursor_position(&self) -> usize {
502 self.text_area.cursor()
503 }
504
505 pub fn set_input(&mut self, input: &str) {
506 self.text_area.set_text(input);
507 }
508
509 pub fn set_cursor_position(&mut self, pos: usize) {
510 self.text_area.set_cursor(pos);
511 }
512
513 pub fn insert_char(&mut self, c: char) {
514 self.text_area.insert_str(&c.to_string());
515 }
516
517 pub fn insert_str(&mut self, s: &str) {
518 self.text_area.insert_str(s);
519 }
520
521 pub fn clear_input(&mut self) {
522 self.text_area.set_text("");
523 }
524
525 pub fn is_input_blocked(&self) -> bool {
527 self.profile_switching_in_progress
528 }
529
530 pub fn run_shell_command(&mut self, command: String, input_tx: &mpsc::Sender<InputEvent>) {
531 let (shell_tx, mut shell_rx) = mpsc::channel::<ShellEvent>(100);
532 self.messages.push(Message::plain_text("SPACING_MARKER"));
533 push_styled_message(
534 self,
535 &command,
536 AdaptiveColors::text(),
537 SHELL_PROMPT_PREFIX,
538 AdaptiveColors::dark_magenta(),
539 );
540 self.messages.push(Message::plain_text("SPACING_MARKER"));
541 let rows = if self.terminal_size.height > 0 {
542 self.terminal_size.height
543 } else {
544 24
545 };
546 let cols = if self.terminal_size.width > 0 {
547 self.terminal_size.width
548 } else {
549 80
550 };
551
552 #[cfg(unix)]
553 let shell_cmd = match run_pty_command(command.clone(), None, shell_tx, rows, cols) {
554 Ok(cmd) => cmd,
555 Err(e) => {
556 push_error_message(self, &format!("Failed to run command: {}", e), None);
557 return;
558 }
559 };
560
561 #[cfg(not(unix))]
562 let shell_cmd = run_background_shell_command(command.clone(), shell_tx);
563
564 self.active_shell_command = Some(shell_cmd.clone());
565 self.active_shell_command_output = Some(String::new());
566 self.shell_screen = vt100::Parser::new(rows, cols, 0);
567 let input_tx = input_tx.clone();
568 tokio::spawn(async move {
569 while let Some(event) = shell_rx.recv().await {
570 match event {
571 ShellEvent::Output(line) => {
572 let _ = input_tx.send(InputEvent::ShellOutput(line)).await;
573 }
574 ShellEvent::Error(line) => {
575 let _ = input_tx.send(InputEvent::ShellError(line)).await;
576 }
577
578 ShellEvent::Completed(code) => {
579 let _ = input_tx.send(InputEvent::ShellCompleted(code)).await;
580 break;
581 }
582 ShellEvent::Clear => {
583 let _ = input_tx.send(InputEvent::ShellClear).await;
584 }
585 }
586 }
587 });
588 }
589
590 pub fn poll_file_search_results(&mut self) {
592 if let Some(rx) = &mut self.file_search_rx {
593 while let Ok(result) = rx.try_recv() {
594 let input_text = self.text_area.text().to_string();
596
597 let filtered_files = result.filtered_files.clone();
598 self.filtered_files = filtered_files;
599 self.file_search.filtered_files = self.filtered_files.clone();
600 self.file_search.is_file_mode = !self.filtered_files.is_empty();
601 self.file_search.trigger_char = if !self.filtered_files.is_empty() {
602 Some('@')
603 } else {
604 None
605 };
606
607 self.filtered_helpers = result.filtered_helpers;
609
610 if !self.filtered_helpers.is_empty()
612 && self.helper_selected >= self.filtered_helpers.len()
613 {
614 self.helper_selected = 0;
615 }
616
617 let has_at_trigger =
619 find_at_trigger(&result.input, result.cursor_position).is_some();
620 self.show_helper_dropdown = (input_text.trim().starts_with('/'))
621 || (!self.filtered_helpers.is_empty() && input_text.starts_with('/'))
622 || (has_at_trigger && !self.waiting_for_shell_input);
623 }
624 }
625 }
626 pub fn auto_show_side_panel(&mut self) {
627 if !self.side_panel_auto_shown && !self.show_side_panel {
628 self.show_side_panel = true;
629 self.side_panel_auto_shown = true;
630 }
631 }
632}