1use std::collections::VecDeque;
2
3pub(super) use ratatui::crossterm::event::{
4 Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, MouseEvent, MouseEventKind,
5};
6pub(super) use ratatui::prelude::*;
7pub(super) use ratatui::widgets::Clear;
8pub(super) use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
9
10use crate::core_tui::app::types::{
11 DiffOverlayRequest, DiffPreviewState, InlineCommand, InlineEvent, SlashCommandItem,
12 TaskPanelTransientRequest, TransientRequest,
13};
14use crate::core_tui::runner::TuiSessionDriver;
15use crate::core_tui::session::Session as CoreSessionState;
16
17pub mod diff_preview;
18mod events;
19pub mod file_palette;
20pub mod history_picker;
21mod impl_events;
22mod impl_render;
23mod layout;
24mod palette;
25pub mod render;
26pub mod slash;
27pub mod slash_palette;
28mod transient;
29pub mod trust;
30
31use self::file_palette::FilePalette;
32use self::history_picker::HistoryPickerState;
33use self::slash_palette::SlashPalette;
34use self::transient::{
35 TransientFocusPolicy, TransientHost, TransientSurface, TransientVisibilityChange,
36};
37
38pub struct AppSession {
40 pub(crate) core: CoreSessionState,
41 pub(crate) file_palette: Option<FilePalette>,
42 pub(crate) file_palette_active: bool,
43 pub(crate) inline_lists_visible: bool,
44 pub(crate) slash_palette: SlashPalette,
45 pub(crate) history_picker_state: HistoryPickerState,
46 pub(crate) show_task_panel: bool,
47 pub(crate) task_panel_lines: Vec<String>,
48 pub(crate) diff_preview_state: Option<DiffPreviewState>,
49 pub(crate) diff_overlay_queue: VecDeque<DiffOverlayRequest>,
50 pub(crate) transient_host: TransientHost,
51}
52
53pub(super) type Session = AppSession;
54
55impl AppSession {
56 pub fn new_with_logs(
57 theme: crate::core_tui::types::InlineTheme,
58 placeholder: Option<String>,
59 view_rows: u16,
60 show_logs: bool,
61 appearance: Option<crate::core_tui::session::config::AppearanceConfig>,
62 slash_commands: Vec<SlashCommandItem>,
63 app_name: String,
64 ) -> Self {
65 let core = CoreSessionState::new_with_logs(
66 theme,
67 placeholder,
68 view_rows,
69 show_logs,
70 appearance,
71 app_name,
72 );
73
74 Self {
75 core,
76 file_palette: None,
77 file_palette_active: false,
78 inline_lists_visible: true,
79 slash_palette: SlashPalette::with_commands(slash_commands),
80 history_picker_state: HistoryPickerState::new(),
81 show_task_panel: false,
82 task_panel_lines: Vec::new(),
83 diff_preview_state: None,
84 diff_overlay_queue: VecDeque::new(),
85 transient_host: TransientHost::default(),
86 }
87 }
88
89 pub fn new(
90 theme: crate::core_tui::types::InlineTheme,
91 placeholder: Option<String>,
92 view_rows: u16,
93 ) -> Self {
94 Self::new_with_logs(
95 theme,
96 placeholder,
97 view_rows,
98 true,
99 None,
100 Vec::new(),
101 "Agent TUI".to_string(),
102 )
103 }
104
105 pub fn core(&self) -> &CoreSessionState {
106 &self.core
107 }
108
109 pub fn core_mut(&mut self) -> &mut CoreSessionState {
110 &mut self.core
111 }
112
113 pub(crate) fn inline_lists_visible(&self) -> bool {
114 self.inline_lists_visible
115 }
116
117 pub(crate) fn toggle_inline_lists_visibility(&mut self) {
118 self.inline_lists_visible = !self.inline_lists_visible;
119 self.core.mark_dirty();
120 }
121
122 pub(crate) fn ensure_inline_lists_visible_for_trigger(&mut self) {
123 if !self.inline_lists_visible {
124 self.inline_lists_visible = true;
125 self.core.mark_dirty();
126 }
127 }
128
129 pub(crate) fn update_input_triggers(&mut self) {
130 if !self.core.input_enabled() {
131 return;
132 }
133
134 self.check_file_reference_trigger();
135 slash::update_slash_suggestions(self);
136 }
137
138 pub(super) fn show_transient_surface(&mut self, surface: TransientSurface) -> bool {
139 let change = self.transient_host.show(surface);
140 if !change.changed() {
141 return false;
142 }
143
144 self.apply_transient_visibility_change(change);
145 true
146 }
147
148 pub(super) fn close_transient_surface(&mut self, surface: TransientSurface) -> bool {
149 let change = self.transient_host.hide(surface);
150 if !change.changed() {
151 return false;
152 }
153
154 self.apply_transient_visibility_change(change);
155 true
156 }
157
158 pub(super) fn finish_history_picker_interaction(&mut self, was_active: bool) {
159 if was_active && !self.history_picker_state.active {
160 self.close_transient_surface(TransientSurface::HistoryPicker);
161 self.update_input_triggers();
162 }
163 }
164
165 pub(crate) fn set_task_panel_visible(&mut self, visible: bool) {
166 if self.show_task_panel != visible {
167 self.show_task_panel = visible;
168 if visible {
169 self.show_transient_surface(TransientSurface::TaskPanel);
170 } else {
171 self.close_transient_surface(TransientSurface::TaskPanel);
172 }
173 self.core.mark_dirty();
174 }
175 }
176
177 pub(crate) fn visible_transient_surface(&self) -> Option<TransientSurface> {
178 self.transient_host.top()
179 }
180
181 pub(crate) fn visible_bottom_docked_surface(&self) -> Option<TransientSurface> {
182 self.transient_host.visible_bottom_docked()
183 }
184
185 pub(crate) fn history_picker_visible(&self) -> bool {
186 self.history_picker_state.active
187 && self
188 .transient_host
189 .is_visible(TransientSurface::HistoryPicker)
190 }
191
192 pub(crate) fn file_palette_visible(&self) -> bool {
193 self.file_palette_active
194 && self
195 .transient_host
196 .is_visible(TransientSurface::FilePalette)
197 }
198
199 pub(crate) fn slash_palette_visible(&self) -> bool {
200 !self.slash_palette.is_empty()
201 && self
202 .transient_host
203 .is_visible(TransientSurface::SlashPalette)
204 }
205
206 pub(crate) fn has_active_overlay(&self) -> bool {
207 self.core.has_active_overlay()
208 && self
209 .transient_host
210 .is_visible(TransientSurface::FloatingOverlay)
211 }
212
213 pub(crate) fn modal_state(&self) -> Option<&crate::core_tui::session::modal::ModalState> {
214 self.has_active_overlay()
215 .then(|| self.core.modal_state())
216 .flatten()
217 }
218
219 pub(crate) fn modal_state_mut(
220 &mut self,
221 ) -> Option<&mut crate::core_tui::session::modal::ModalState> {
222 if !self.has_active_overlay() {
223 return None;
224 }
225 self.core.modal_state_mut()
226 }
227
228 pub(crate) fn wizard_overlay(
229 &self,
230 ) -> Option<&crate::core_tui::session::modal::WizardModalState> {
231 self.has_active_overlay()
232 .then(|| self.core.wizard_overlay())
233 .flatten()
234 }
235
236 pub(crate) fn wizard_overlay_mut(
237 &mut self,
238 ) -> Option<&mut crate::core_tui::session::modal::WizardModalState> {
239 if !self.has_active_overlay() {
240 return None;
241 }
242 self.core.wizard_overlay_mut()
243 }
244
245 pub(crate) fn close_overlay(&mut self) {
246 if !self.has_active_overlay() {
247 return;
248 }
249
250 self.core.close_overlay();
251 if !self.core.has_active_overlay() {
252 self.close_transient_surface(TransientSurface::FloatingOverlay);
253 }
254 }
255
256 pub(crate) fn diff_preview_state(&self) -> Option<&DiffPreviewState> {
257 self.transient_host
258 .is_visible(TransientSurface::DiffPreview)
259 .then_some(())
260 .and(self.diff_preview_state.as_ref())
261 }
262
263 pub(crate) fn diff_preview_state_mut(&mut self) -> Option<&mut DiffPreviewState> {
264 if !self
265 .transient_host
266 .is_visible(TransientSurface::DiffPreview)
267 {
268 return None;
269 }
270 self.diff_preview_state.as_mut()
271 }
272
273 pub(crate) fn show_diff_overlay(&mut self, request: DiffOverlayRequest) {
274 if self.diff_preview_state.is_some() {
275 self.diff_overlay_queue.push_back(request);
276 return;
277 }
278
279 let mut state = DiffPreviewState::new_with_mode(
280 request.file_path,
281 request.before,
282 request.after,
283 request.hunks,
284 request.mode,
285 );
286 state.current_hunk = request.current_hunk;
287 self.diff_preview_state = Some(state);
288 self.show_transient_surface(TransientSurface::DiffPreview);
289 self.core.mark_dirty();
290 }
291
292 pub(crate) fn close_diff_overlay(&mut self) {
293 if self.diff_preview_state.is_none() {
294 return;
295 }
296 self.diff_preview_state = None;
297 if let Some(next) = self.diff_overlay_queue.pop_front() {
298 self.show_diff_overlay(next);
299 return;
300 }
301 self.close_transient_surface(TransientSurface::DiffPreview);
302 self.core.mark_dirty();
303 }
304
305 pub(crate) fn close_history_picker(&mut self) {
306 if !self.history_picker_state.active {
307 return;
308 }
309 self.history_picker_state
310 .cancel(&mut self.core.input_manager);
311 self.close_transient_surface(TransientSurface::HistoryPicker);
312 self.update_input_triggers();
313 self.mark_dirty();
314 }
315
316 pub(crate) fn show_transient(&mut self, request: TransientRequest) {
317 self.core.clear_inline_prompt_suggestion();
318 match request {
319 TransientRequest::Modal(request) => {
320 self.core
321 .show_overlay(crate::core_tui::types::OverlayRequest::Modal(
322 request.into(),
323 ));
324 self.show_transient_surface(TransientSurface::FloatingOverlay);
325 }
326 TransientRequest::List(request) => {
327 self.core
328 .show_overlay(crate::core_tui::types::OverlayRequest::List(request.into()));
329 self.show_transient_surface(TransientSurface::FloatingOverlay);
330 }
331 TransientRequest::Wizard(request) => {
332 self.core
333 .show_overlay(crate::core_tui::types::OverlayRequest::Wizard(
334 request.into(),
335 ));
336 self.show_transient_surface(TransientSurface::FloatingOverlay);
337 }
338 TransientRequest::Diff(request) => {
339 self.show_diff_overlay(request);
340 }
341 TransientRequest::FilePalette(request) => {
342 self.load_file_palette(request.files, request.workspace);
343 match request.visible {
344 Some(true) => {
345 self.ensure_inline_lists_visible_for_trigger();
346 self.file_palette_active = true;
347 self.show_transient_surface(TransientSurface::FilePalette);
348 }
349 Some(false) => {
350 self.close_file_palette();
351 }
352 None => {}
353 }
354 }
355 TransientRequest::HistoryPicker => {
356 events::open_history_picker(self);
357 }
358 TransientRequest::SlashPalette => {
359 self.ensure_inline_lists_visible_for_trigger();
360 self.show_transient_surface(TransientSurface::SlashPalette);
361 }
362 TransientRequest::TaskPanel(TaskPanelTransientRequest { lines, visible }) => {
363 if !lines.is_empty() {
364 self.task_panel_lines = lines;
365 }
366 if let Some(visible) = visible {
367 self.set_task_panel_visible(visible);
368 } else {
369 self.core.mark_dirty();
370 }
371 }
372 }
373 self.core.mark_dirty();
374 }
375
376 pub(crate) fn close_transient(&mut self) {
377 match self.visible_transient_surface() {
378 Some(TransientSurface::FloatingOverlay) => self.close_overlay(),
379 Some(TransientSurface::DiffPreview) => self.close_diff_overlay(),
380 Some(TransientSurface::HistoryPicker) => self.close_history_picker(),
381 Some(TransientSurface::FilePalette) => self.close_file_palette(),
382 Some(TransientSurface::SlashPalette) => slash::clear_slash_suggestions(self),
383 Some(TransientSurface::TaskPanel) => self.set_task_panel_visible(false),
384 None => {}
385 }
386 }
387
388 pub(crate) fn sync_transient_focus(&mut self) {
389 let Some(surface) = self.visible_transient_surface() else {
390 self.core.set_input_enabled(true);
391 self.core.set_cursor_visible(true);
392 return;
393 };
394
395 match surface.focus_policy() {
396 TransientFocusPolicy::Modal | TransientFocusPolicy::CapturedInput => {
397 self.core.set_input_enabled(false);
398 self.core.set_cursor_visible(false);
399 }
400 TransientFocusPolicy::SharedInput | TransientFocusPolicy::Passive => {
401 self.core.set_input_enabled(true);
402 self.core.set_cursor_visible(true);
403 }
404 }
405 }
406
407 fn apply_transient_visibility_change(&mut self, change: TransientVisibilityChange) {
408 if change.previous_visible == Some(TransientSurface::FilePalette)
409 || change.current_visible == Some(TransientSurface::FilePalette)
410 {
411 self.core.needs_full_clear = true;
412 }
413 self.sync_transient_focus();
414 }
415
416 pub fn handle_command(&mut self, command: InlineCommand) {
417 match command {
418 InlineCommand::SetInput(value) => {
419 self.core
420 .handle_command(crate::core_tui::types::InlineCommand::SetInput(value));
421 self.update_input_triggers();
422 }
423 InlineCommand::ApplySuggestedPrompt(value) => {
424 self.core.handle_command(
425 crate::core_tui::types::InlineCommand::ApplySuggestedPrompt(value),
426 );
427 self.update_input_triggers();
428 }
429 InlineCommand::SetInlinePromptSuggestion {
430 suggestion,
431 llm_generated,
432 } => {
433 self.core.handle_command(
434 crate::core_tui::types::InlineCommand::SetInlinePromptSuggestion {
435 suggestion,
436 llm_generated,
437 },
438 );
439 self.update_input_triggers();
440 }
441 InlineCommand::ClearInlinePromptSuggestion => {
442 self.core.handle_command(
443 crate::core_tui::types::InlineCommand::ClearInlinePromptSuggestion,
444 );
445 self.update_input_triggers();
446 }
447 InlineCommand::ClearInput => {
448 self.core
449 .handle_command(crate::core_tui::types::InlineCommand::ClearInput);
450 self.update_input_triggers();
451 }
452 InlineCommand::CloseTransient => self.close_transient(),
453 InlineCommand::ShowTransient { request } => self.show_transient(*request),
454 _ => {
455 if let Some(core_cmd) = to_core_command(&command) {
456 self.core.handle_command(core_cmd);
457 }
458 }
459 }
460 }
461}
462
463impl std::ops::Deref for AppSession {
464 type Target = CoreSessionState;
465
466 fn deref(&self) -> &Self::Target {
467 &self.core
468 }
469}
470
471impl std::ops::DerefMut for AppSession {
472 fn deref_mut(&mut self) -> &mut Self::Target {
473 &mut self.core
474 }
475}
476
477fn to_core_command(command: &InlineCommand) -> Option<crate::core_tui::types::InlineCommand> {
478 use crate::core_tui::types::InlineCommand as CoreCommand;
479
480 Some(match command {
481 InlineCommand::AppendLine { kind, segments } => CoreCommand::AppendLine {
482 kind: *kind,
483 segments: segments.clone(),
484 },
485 InlineCommand::AppendPastedMessage {
486 kind,
487 text,
488 line_count,
489 } => CoreCommand::AppendPastedMessage {
490 kind: *kind,
491 text: text.clone(),
492 line_count: *line_count,
493 },
494 InlineCommand::Inline { kind, segment } => CoreCommand::Inline {
495 kind: *kind,
496 segment: segment.clone(),
497 },
498 InlineCommand::ReplaceLast {
499 count,
500 kind,
501 lines,
502 link_ranges,
503 } => CoreCommand::ReplaceLast {
504 count: *count,
505 kind: *kind,
506 lines: lines.clone(),
507 link_ranges: link_ranges.clone(),
508 },
509 InlineCommand::SetPrompt { prefix, style } => CoreCommand::SetPrompt {
510 prefix: prefix.clone(),
511 style: style.clone(),
512 },
513 InlineCommand::SetPlaceholder { hint, style } => CoreCommand::SetPlaceholder {
514 hint: hint.clone(),
515 style: style.clone(),
516 },
517 InlineCommand::SetMessageLabels { agent, user } => CoreCommand::SetMessageLabels {
518 agent: agent.clone(),
519 user: user.clone(),
520 },
521 InlineCommand::SetHeaderContext { context } => CoreCommand::SetHeaderContext {
522 context: context.clone(),
523 },
524 InlineCommand::SetInputStatus { left, right } => CoreCommand::SetInputStatus {
525 left: left.clone(),
526 right: right.clone(),
527 },
528 InlineCommand::SetTheme { theme } => CoreCommand::SetTheme {
529 theme: theme.clone(),
530 },
531 InlineCommand::SetAppearance { appearance } => CoreCommand::SetAppearance {
532 appearance: appearance.clone(),
533 },
534 InlineCommand::SetVimModeEnabled(enabled) => CoreCommand::SetVimModeEnabled(*enabled),
535 InlineCommand::SetQueuedInputs { entries } => CoreCommand::SetQueuedInputs {
536 entries: entries.clone(),
537 },
538 InlineCommand::SetCursorVisible(value) => CoreCommand::SetCursorVisible(*value),
539 InlineCommand::SetInputEnabled(value) => CoreCommand::SetInputEnabled(*value),
540 InlineCommand::SetInput(value) => CoreCommand::SetInput(value.clone()),
541 InlineCommand::ApplySuggestedPrompt(value) => {
542 CoreCommand::ApplySuggestedPrompt(value.clone())
543 }
544 InlineCommand::SetInlinePromptSuggestion {
545 suggestion,
546 llm_generated,
547 } => CoreCommand::SetInlinePromptSuggestion {
548 suggestion: suggestion.clone(),
549 llm_generated: *llm_generated,
550 },
551 InlineCommand::ClearInlinePromptSuggestion => CoreCommand::ClearInlinePromptSuggestion,
552 InlineCommand::ClearInput => CoreCommand::ClearInput,
553 InlineCommand::ForceRedraw => CoreCommand::ForceRedraw,
554 InlineCommand::ClearScreen => CoreCommand::ClearScreen,
555 InlineCommand::SuspendEventLoop => CoreCommand::SuspendEventLoop,
556 InlineCommand::ResumeEventLoop => CoreCommand::ResumeEventLoop,
557 InlineCommand::ClearInputQueue => CoreCommand::ClearInputQueue,
558 InlineCommand::SetEditingMode(mode) => CoreCommand::SetEditingMode(*mode),
559 InlineCommand::SetAutonomousMode(enabled) => CoreCommand::SetAutonomousMode(*enabled),
560 InlineCommand::SetSkipConfirmations(skip) => CoreCommand::SetSkipConfirmations(*skip),
561 InlineCommand::Shutdown => CoreCommand::Shutdown,
562 InlineCommand::SetReasoningStage(stage) => CoreCommand::SetReasoningStage(stage.clone()),
563 InlineCommand::ShowTransient { .. } | InlineCommand::CloseTransient => return None,
564 })
565}
566
567impl TuiSessionDriver for AppSession {
568 type Command = InlineCommand;
569 type Event = InlineEvent;
570
571 fn handle_command(&mut self, command: Self::Command) {
572 AppSession::handle_command(self, command);
573 }
574
575 fn handle_event(
576 &mut self,
577 event: CrosstermEvent,
578 events: &UnboundedSender<Self::Event>,
579 callback: Option<&(dyn Fn(&Self::Event) + Send + Sync + 'static)>,
580 ) {
581 AppSession::handle_event(self, event, events, callback);
582 }
583
584 fn handle_tick(&mut self) {
585 self.core.handle_tick();
586 }
587
588 fn render(&mut self, frame: &mut Frame<'_>) {
589 AppSession::render(self, frame);
590 }
591
592 fn take_redraw(&mut self) -> bool {
593 self.core.take_redraw()
594 }
595
596 fn use_steady_cursor(&self) -> bool {
597 self.core.use_steady_cursor()
598 }
599
600 fn should_exit(&self) -> bool {
601 self.core.should_exit()
602 }
603
604 fn request_exit(&mut self) {
605 self.core.request_exit();
606 }
607
608 fn mark_dirty(&mut self) {
609 self.core.mark_dirty();
610 }
611
612 fn update_terminal_title(&mut self) {
613 self.core.update_terminal_title();
614 }
615
616 fn clear_terminal_title(&mut self) {
617 self.core.clear_terminal_title();
618 }
619
620 fn is_running_activity(&self) -> bool {
621 self.core.is_running_activity()
622 }
623
624 fn has_status_spinner(&self) -> bool {
625 self.core.has_status_spinner()
626 }
627
628 fn thinking_spinner_active(&self) -> bool {
629 self.core.thinking_spinner.is_active
630 }
631
632 fn has_active_navigation_ui(&self) -> bool {
633 self.transient_host.has_active_navigation_surface()
634 }
635
636 fn apply_coalesced_scroll(&mut self, line_delta: i32, page_delta: i32) {
637 self.core.apply_coalesced_scroll(line_delta, page_delta);
638 }
639
640 fn set_show_logs(&mut self, show: bool) {
641 self.core.show_logs = show;
642 }
643
644 fn set_active_pty_sessions(
645 &mut self,
646 sessions: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
647 ) {
648 self.core.active_pty_sessions = sessions;
649 }
650
651 fn set_workspace_root(&mut self, root: Option<std::path::PathBuf>) {
652 self.core.set_workspace_root(root);
653 }
654
655 fn set_log_receiver(&mut self, receiver: UnboundedReceiver<crate::core_tui::log::LogEntry>) {
656 self.core.set_log_receiver(receiver);
657 }
658}