1use crate::app::{AppState, AppStateOptions, InputEvent, OutputEvent};
6use crate::services::detect_term::is_unsupported_terminal;
7use crate::services::handlers::tool::{
8 clear_streaming_tool_results, handle_tool_result, update_session_tool_calls_queue,
9};
10use crate::services::message::Message;
11use crate::view::view;
12use crossterm::event::{
13 DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
14};
15use crossterm::{execute, terminal::EnterAlternateScreen};
16use ratatui::{Terminal, backend::CrosstermBackend};
17use stakpak_shared::models::integrations::openai::{AgentModel, ToolCallResultStatus};
18use std::io;
19use std::sync::Arc;
20use std::sync::atomic::{AtomicBool, Ordering};
21use std::time::Duration;
22use tokio::sync::mpsc::{Receiver, Sender};
23use tokio::time::interval;
24
25use crate::app::ToolCallStatus;
26use crate::terminal::TerminalGuard;
27
28#[derive(Clone, Debug)]
30pub struct RulebookConfig {
31 pub include: Option<Vec<String>>,
32 pub exclude: Option<Vec<String>>,
33 pub include_tags: Option<Vec<String>>,
34 pub exclude_tags: Option<Vec<String>>,
35}
36
37#[allow(clippy::too_many_arguments)]
38pub async fn run_tui(
39 mut input_rx: Receiver<InputEvent>,
40 output_tx: Sender<OutputEvent>,
41 cancel_tx: Option<tokio::sync::broadcast::Sender<()>>,
42 shutdown_tx: tokio::sync::broadcast::Sender<()>,
43 latest_version: Option<String>,
44 redact_secrets: bool,
45 privacy_mode: bool,
46 is_git_repo: bool,
47 auto_approve_tools: Option<&Vec<String>>,
48 allowed_tools: Option<&Vec<String>>,
49 current_profile_name: String,
50 rulebook_config: Option<RulebookConfig>,
51 agent_model: AgentModel,
52 editor_command: Option<String>,
53 auth_display_info: (Option<String>, Option<String>, Option<String>),
54) -> io::Result<()> {
55 let _guard = TerminalGuard;
56
57 crossterm::terminal::enable_raw_mode()?;
58
59 #[cfg(unix)]
61 let terminal_info = crate::services::detect_term::detect_terminal();
62 #[cfg(unix)]
63 let enable_mouse_capture = is_unsupported_terminal(&terminal_info.emulator);
64
65 execute!(
66 std::io::stdout(),
67 EnterAlternateScreen,
68 EnableBracketedPaste
69 )?;
70
71 #[cfg(unix)]
72 if enable_mouse_capture {
73 execute!(std::io::stdout(), EnableMouseCapture)?;
74 } else {
75 execute!(std::io::stdout(), DisableMouseCapture)?;
76 }
77
78 let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stdout()))?;
79
80 let term_size = terminal.size()?;
81
82 let (internal_tx, mut internal_rx) = tokio::sync::mpsc::channel::<InputEvent>(100);
84
85 let board_agent_id = std::env::var("AGENT_BOARD_AGENT_ID").ok();
87
88 let mut state = AppState::new(AppStateOptions {
89 latest_version,
90 redact_secrets,
91 privacy_mode,
92 is_git_repo,
93 auto_approve_tools,
94 allowed_tools,
95 input_tx: Some(internal_tx.clone()),
96 agent_model,
97 editor_command,
98 auth_display_info,
99 board_agent_id,
100 });
101
102 #[cfg(unix)]
104 {
105 state.mouse_capture_enabled = enable_mouse_capture;
106 }
107 #[cfg(not(unix))]
108 {
109 state.mouse_capture_enabled = false;
110 }
111
112 state.current_profile_name = current_profile_name;
114 state.rulebook_config = rulebook_config;
115
116 let welcome_msg =
118 crate::services::helper_block::welcome_messages(state.latest_version.clone(), &state);
119 state.messages.extend(welcome_msg);
120
121 if state.board_agent_id.is_some() {
123 let _ = internal_tx.try_send(InputEvent::RefreshBoardTasks);
124 }
125
126 let internal_tx_thread = internal_tx.clone();
127 let input_paused = Arc::new(AtomicBool::new(false));
129 let input_paused_thread = input_paused.clone();
130
131 std::thread::spawn(move || {
135 loop {
136 if input_paused_thread.load(Ordering::Relaxed) {
138 std::thread::sleep(Duration::from_millis(50));
139 continue;
140 }
141
142 if let Ok(true) = crossterm::event::poll(Duration::from_millis(50))
144 && let Ok(event) = crossterm::event::read()
145 && let Some(event) = crate::event::map_crossterm_event_to_input_event(event)
146 && internal_tx_thread.blocking_send(event).is_err()
147 {
148 break;
149 }
150 }
151 });
152
153 let shell_event_tx = internal_tx.clone();
154
155 let mut spinner_interval = interval(Duration::from_millis(100));
156
157 terminal.draw(|f| view(f, &mut state))?;
159 let mut should_quit = false;
160
161 #[allow(unused_assignments)]
164 let mut pending_scroll_up: i32 = 0;
165 #[allow(unused_assignments)]
166 let mut pending_scroll_down: i32 = 0;
167
168 loop {
169 if state.ctrl_c_pressed_once
171 && let Some(timer) = state.ctrl_c_timer
172 && std::time::Instant::now() > timer
173 {
174 state.ctrl_c_pressed_once = false;
175 state.ctrl_c_timer = None;
176 }
177 tokio::select! {
178 event = input_rx.recv() => {
179 let Some(event) = event else {
180 should_quit = true;
181 continue;
182 };
183 if matches!(event, InputEvent::ShellOutput(_) | InputEvent::ShellError(_) |
184 InputEvent::ShellWaitingForInput | InputEvent::ShellCompleted(_) | InputEvent::ShellClear) {
185 let _ = shell_event_tx.send(event).await;
187 continue;
188 }
189 if let InputEvent::EmergencyClearTerminal = event {
190 emergency_clear_and_redraw(&mut terminal, &mut state)?;
191 continue;
192 }
193 if let InputEvent::RunToolCall(tool_call) = &event {
194 crate::services::update::update(&mut state, InputEvent::ShowConfirmationDialog(tool_call.clone()), 10, 40, &internal_tx, &output_tx, cancel_tx.clone(), &shell_event_tx, term_size);
195 state.poll_file_search_results();
196 terminal.draw(|f| view(f, &mut state))?;
197 continue;
198 }
199 if let InputEvent::ToolResult(ref tool_call_result) = event {
200 clear_streaming_tool_results(&mut state);
201
202 if let Ok(tool_call_uuid) = uuid::Uuid::parse_str(&tool_call_result.call.id) {
206 state.messages.retain(|m| m.id != tool_call_uuid);
207 }
208
209 state.session_tool_calls_queue.insert(tool_call_result.call.id.clone(), ToolCallStatus::Executed);
210 update_session_tool_calls_queue(&mut state, tool_call_result);
211 let tool_name = crate::utils::strip_tool_name(&tool_call_result.call.function.name);
212
213 if tool_call_result.status == ToolCallResultStatus::Cancelled && tool_name == "run_command" {
214 state.latest_tool_call = Some(tool_call_result.call.clone());
215 }
216 let is_cancelled = tool_call_result.status == ToolCallResultStatus::Cancelled;
218 let is_error = tool_call_result.status == ToolCallResultStatus::Error;
219
220 if (is_cancelled || is_error) && tool_name != "run_command" {
221 state.messages.push(Message::render_result_border_block(tool_call_result.clone()));
223 state.messages.push(Message::render_full_content_message(tool_call_result.clone()));
224 } else {
225 match tool_name {
226 "str_replace" | "create" => {
227 state.messages.push(Message::render_result_border_block(tool_call_result.clone()));
229 state.messages.push(Message::render_collapsed_message(tool_call_result.call.clone()));
231 }
232 "run_command_task" => {
233 state.messages.push(Message::render_result_border_block(tool_call_result.clone()));
235 state.messages.push(Message::render_full_content_message(tool_call_result.clone()));
237 }
238 "run_command" => {
239 let command = crate::services::handlers::shell::extract_command_from_tool_call(&tool_call_result.call)
241 .unwrap_or_else(|_| "command".to_string());
242 let run_state = if is_error {
243 crate::services::bash_block::RunCommandState::Error
244 } else if is_cancelled {
245 crate::services::bash_block::RunCommandState::Cancelled
248 } else {
249 crate::services::bash_block::RunCommandState::Completed
250 };
251
252 let run_cmd_msg = Message::render_run_command_block(
253 command,
254 Some(tool_call_result.result.clone()),
255 run_state,
256 None,
257 );
258 let popup_msg = Message::render_full_content_message(tool_call_result.clone());
259
260 if is_cancelled && state.shell_popup_visible {
263 if let Some(shell_msg_id) = state.interactive_shell_message_id {
264 if let Some(pos) = state.messages.iter().position(|m| m.id == shell_msg_id) {
266 state.messages.insert(pos, popup_msg);
268 state.messages.insert(pos, run_cmd_msg);
269 } else {
270 state.messages.push(run_cmd_msg);
272 state.messages.push(popup_msg);
273 }
274 } else {
275 state.messages.push(run_cmd_msg);
277 state.messages.push(popup_msg);
278 }
279 } else {
280 state.messages.push(run_cmd_msg);
282 state.messages.push(popup_msg);
283 }
284 }
285 "read" | "view" | "read_file" => {
286 let (file_path, grep, glob) = crate::services::handlers::tool::extract_view_params_from_tool_call(&tool_call_result.call);
289 let file_path = file_path.unwrap_or_else(|| "file".to_string());
290 let total_lines = tool_call_result.result.lines().count();
291 state.messages.push(Message::render_view_file_block(file_path.clone(), total_lines, grep.clone(), glob.clone()));
292 state.messages.push(Message::render_view_file_block_popup(file_path, total_lines, grep, glob));
294 }
295 _ => {
296 state.messages.push(Message::render_collapsed_command_message(tool_call_result.clone()));
298 state.messages.push(Message::render_full_content_message(tool_call_result.clone()));
300 }
301 }
302
303 if !is_cancelled && !is_error {
305 handle_tool_result(&mut state, tool_call_result.clone());
306 }
307 }
308 crate::services::message::invalidate_message_lines_cache(&mut state);
310 state.stay_at_bottom = true;
311
312 let _ = internal_tx.try_send(InputEvent::RefreshBoardTasks);
315 }
316 if let InputEvent::ToggleMouseCapture = event {
317 #[cfg(unix)]
318 toggle_mouse_capture_with_redraw(&mut terminal, &mut state)?;
319 continue;
320 }
321
322 if let InputEvent::Quit = event {
323 should_quit = true;
324 }
325 else {
326 let main_area_width = if state.show_side_panel {
328 term_size.width.saturating_sub(32 + 1) } else {
330 term_size.width
331 };
332 let term_rect = ratatui::layout::Rect::new(0, 0, main_area_width, term_size.height);
333 let input_height = 3;
334 let margin_height = 2;
335 let dropdown_showing = state.show_helper_dropdown
336 && ((!state.filtered_helpers.is_empty() && state.input().starts_with('/'))
337 || !state.filtered_files.is_empty());
338 let dropdown_height = if dropdown_showing {
339 state.filtered_helpers.len() as u16
340 } else {
341 0
342 };
343 let hint_height = if dropdown_showing { 0 } else { margin_height };
344 let outer_chunks = ratatui::layout::Layout::default()
345 .direction(ratatui::layout::Direction::Vertical)
346 .constraints([
347 ratatui::layout::Constraint::Min(1), ratatui::layout::Constraint::Length(1), ratatui::layout::Constraint::Length(input_height as u16),
350 ratatui::layout::Constraint::Length(dropdown_height),
351 ratatui::layout::Constraint::Length(hint_height),
352 ])
353 .split(term_rect);
354 let message_area_width = outer_chunks[0].width.saturating_sub(2) as usize;
356 let message_area_height = outer_chunks[0].height as usize;
357 crate::services::update::update(&mut state, event, message_area_height, message_area_width, &internal_tx, &output_tx, cancel_tx.clone(), &shell_event_tx, term_size);
358 state.poll_file_search_results();
359 if let Some(file_path) = state.pending_editor_open.take() {
361 let was_mouse_capture_enabled = state.mouse_capture_enabled;
363 if was_mouse_capture_enabled {
364 let _ = execute!(std::io::stdout(), DisableMouseCapture);
365 state.mouse_capture_enabled = false;
366 }
367
368 match crate::services::editor::open_in_editor(
369 &mut terminal,
370 &state.editor_command,
371 &file_path,
372 None,
373 ) {
374 Ok(()) => {
375 }
377 Err(error) => {
378 state.messages.push(Message::info(
380 format!("Failed to open editor: {}", error),
381 Some(ratatui::style::Style::default().fg(ratatui::style::Color::Red)),
382 ));
383 }
384 }
385
386 if was_mouse_capture_enabled {
388 let _ = execute!(std::io::stdout(), EnableMouseCapture);
389 state.mouse_capture_enabled = true;
390 }
391 }
392 }
393 }
394 event = internal_rx.recv() => {
395
396 let Some(event) = event else {
397 should_quit = true;
398 continue;
399 };
400
401 if let InputEvent::ToggleMouseCapture = event {
402 #[cfg(unix)]
403 toggle_mouse_capture_with_redraw(&mut terminal, &mut state)?;
404 continue;
405 }
406 if let InputEvent::Quit = event {
407 should_quit = true;
408 }
409 else {
410 let term_size = terminal.size()?;
411 let main_area_width = if state.show_side_panel {
413 term_size.width.saturating_sub(32 + 1) } else {
415 term_size.width
416 };
417 let term_rect = ratatui::layout::Rect::new(0, 0, main_area_width, term_size.height);
418 let input_height = 3;
419 let margin_height = 2;
420 let dropdown_showing = state.show_helper_dropdown
421 && ((!state.filtered_helpers.is_empty() && state.input().starts_with('/'))
422 || !state.filtered_files.is_empty());
423 let dropdown_height = if dropdown_showing {
424 state.filtered_helpers.len() as u16
425 } else {
426 0
427 };
428 let hint_height = if dropdown_showing { 0 } else { margin_height };
429 let outer_chunks = ratatui::layout::Layout::default()
430 .direction(ratatui::layout::Direction::Vertical)
431 .constraints([
432 ratatui::layout::Constraint::Min(1), ratatui::layout::Constraint::Length(1), ratatui::layout::Constraint::Length(input_height as u16),
435 ratatui::layout::Constraint::Length(dropdown_height),
436 ratatui::layout::Constraint::Length(hint_height),
437 ])
438 .split(term_rect);
439 let message_area_width = outer_chunks[0].width.saturating_sub(2) as usize;
441 let message_area_height = outer_chunks[0].height as usize;
442 if let InputEvent::EmergencyClearTerminal = event {
443 emergency_clear_and_redraw(&mut terminal, &mut state)?;
444 continue;
445 }
446
447 if matches!(event, InputEvent::ScrollUp | InputEvent::ScrollDown) {
450 pending_scroll_up = 0;
451 pending_scroll_down = 0;
452
453 match event {
455 InputEvent::ScrollUp => pending_scroll_up += 1,
456 InputEvent::ScrollDown => pending_scroll_down += 1,
457 _ => {}
458 }
459
460 let mut other_event: Option<InputEvent> = None;
462 while let Ok(next_event) = internal_rx.try_recv() {
463 match next_event {
464 InputEvent::ScrollUp => pending_scroll_up += 1,
465 InputEvent::ScrollDown => pending_scroll_down += 1,
466 other => {
468 other_event = Some(other);
469 break;
470 }
471 }
472 }
473
474 let net_scroll = pending_scroll_down - pending_scroll_up;
476 if net_scroll > 0 {
477 for _ in 0..net_scroll {
479 crate::services::update::update(&mut state, InputEvent::ScrollDown, message_area_height, message_area_width, &internal_tx, &output_tx, cancel_tx.clone(), &shell_event_tx, term_size);
480 }
481 } else if net_scroll < 0 {
482 for _ in 0..(-net_scroll) {
484 crate::services::update::update(&mut state, InputEvent::ScrollUp, message_area_height, message_area_width, &internal_tx, &output_tx, cancel_tx.clone(), &shell_event_tx, term_size);
485 }
486 }
487
488 if let Some(other) = other_event {
490 crate::services::update::update(&mut state, other, message_area_height, message_area_width, &internal_tx, &output_tx, cancel_tx.clone(), &shell_event_tx, term_size);
491 }
492 } else {
493 crate::services::update::update(&mut state, event, message_area_height, message_area_width, &internal_tx, &output_tx, cancel_tx.clone(), &shell_event_tx, term_size);
494 }
495 state.poll_file_search_results();
496
497 if let Some(file_path) = state.pending_editor_open.take() {
499 input_paused.store(true, Ordering::Relaxed);
501 std::thread::sleep(Duration::from_millis(10));
503
504 let was_mouse_capture_enabled = state.mouse_capture_enabled;
506 if was_mouse_capture_enabled {
507 let _ = execute!(std::io::stdout(), DisableMouseCapture);
508 state.mouse_capture_enabled = false;
509 }
510
511 match crate::services::editor::open_in_editor(
512 &mut terminal,
513 &state.editor_command,
514 &file_path,
515 None,
516 ) {
517 Ok(()) => {
518 }
520 Err(error) => {
521 state.messages.push(Message::info(
523 format!("Failed to open editor: {}", error),
524 Some(ratatui::style::Style::default().fg(ratatui::style::Color::Red)),
525 ));
526 }
527 }
528
529 if was_mouse_capture_enabled {
531 let _ = execute!(std::io::stdout(), EnableMouseCapture);
532 state.mouse_capture_enabled = true;
533 }
534
535 input_paused.store(false, Ordering::Relaxed);
537 }
538
539 state.update_session_empty_status();
540 }
541 }
542 _ = spinner_interval.tick() => {
543 if state.ctrl_c_pressed_once
545 && let Some(timer) = state.ctrl_c_timer
546 && std::time::Instant::now() > timer {
547 state.ctrl_c_pressed_once = false;
548 state.ctrl_c_timer = None;
549 }
550 state.spinner_frame = state.spinner_frame.wrapping_add(1);
551 crate::services::shell_popup::update_cursor_blink(&mut state);
553 state.poll_file_search_results();
554 terminal.draw(|f| view(f, &mut state))?;
555 }
556 }
557 if should_quit {
558 break;
559 }
560 state.poll_file_search_results();
561 state.update_session_empty_status();
562 terminal.draw(|f| view(f, &mut state))?;
563 }
564
565 let _ = shutdown_tx.send(());
566 crossterm::terminal::disable_raw_mode()?;
567 execute!(
568 std::io::stdout(),
569 crossterm::terminal::LeaveAlternateScreen,
570 DisableBracketedPaste,
571 DisableMouseCapture
572 )?;
573 Ok(())
574}
575
576pub fn emergency_clear_and_redraw<B: ratatui::backend::Backend>(
577 terminal: &mut Terminal<B>,
578 state: &mut AppState,
579) -> io::Result<()> {
580 use crossterm::{
581 cursor::MoveTo,
582 execute,
583 terminal::{Clear, ClearType},
584 };
585
586 execute!(
588 std::io::stdout(),
589 Clear(ClearType::All),
590 Clear(ClearType::Purge),
591 MoveTo(0, 0)
592 )?;
593
594 terminal.clear()?;
596 terminal.draw(|f| view(f, state))?;
597
598 Ok(())
599}
600
601fn toggle_mouse_capture_with_redraw<B: ratatui::backend::Backend>(
602 terminal: &mut Terminal<B>,
603 state: &mut AppState,
604) -> io::Result<()> {
605 crate::toggle_mouse_capture(state)?;
606 emergency_clear_and_redraw(terminal, state)?;
607 Ok(())
608}