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 match request {
318 TransientRequest::Modal(request) => {
319 self.core
320 .show_overlay(crate::core_tui::types::OverlayRequest::Modal(
321 request.into(),
322 ));
323 self.show_transient_surface(TransientSurface::FloatingOverlay);
324 }
325 TransientRequest::List(request) => {
326 self.core
327 .show_overlay(crate::core_tui::types::OverlayRequest::List(request.into()));
328 self.show_transient_surface(TransientSurface::FloatingOverlay);
329 }
330 TransientRequest::Wizard(request) => {
331 self.core
332 .show_overlay(crate::core_tui::types::OverlayRequest::Wizard(
333 request.into(),
334 ));
335 self.show_transient_surface(TransientSurface::FloatingOverlay);
336 }
337 TransientRequest::Diff(request) => {
338 self.show_diff_overlay(request);
339 }
340 TransientRequest::FilePalette(request) => {
341 self.load_file_palette(request.files, request.workspace);
342 match request.visible {
343 Some(true) => {
344 self.ensure_inline_lists_visible_for_trigger();
345 self.file_palette_active = true;
346 self.show_transient_surface(TransientSurface::FilePalette);
347 }
348 Some(false) => {
349 self.close_file_palette();
350 }
351 None => {}
352 }
353 }
354 TransientRequest::HistoryPicker => {
355 events::open_history_picker(self);
356 }
357 TransientRequest::SlashPalette => {
358 self.ensure_inline_lists_visible_for_trigger();
359 self.show_transient_surface(TransientSurface::SlashPalette);
360 }
361 TransientRequest::TaskPanel(TaskPanelTransientRequest { lines, visible }) => {
362 if !lines.is_empty() {
363 self.task_panel_lines = lines;
364 }
365 if let Some(visible) = visible {
366 self.set_task_panel_visible(visible);
367 } else {
368 self.core.mark_dirty();
369 }
370 }
371 }
372 self.core.mark_dirty();
373 }
374
375 pub(crate) fn close_transient(&mut self) {
376 match self.visible_transient_surface() {
377 Some(TransientSurface::FloatingOverlay) => self.close_overlay(),
378 Some(TransientSurface::DiffPreview) => self.close_diff_overlay(),
379 Some(TransientSurface::HistoryPicker) => self.close_history_picker(),
380 Some(TransientSurface::FilePalette) => self.close_file_palette(),
381 Some(TransientSurface::SlashPalette) => slash::clear_slash_suggestions(self),
382 Some(TransientSurface::TaskPanel) => self.set_task_panel_visible(false),
383 None => {}
384 }
385 }
386
387 pub(crate) fn sync_transient_focus(&mut self) {
388 let Some(surface) = self.visible_transient_surface() else {
389 self.core.set_input_enabled(true);
390 self.core.set_cursor_visible(true);
391 return;
392 };
393
394 match surface.focus_policy() {
395 TransientFocusPolicy::Modal | TransientFocusPolicy::CapturedInput => {
396 self.core.set_input_enabled(false);
397 self.core.set_cursor_visible(false);
398 }
399 TransientFocusPolicy::SharedInput | TransientFocusPolicy::Passive => {
400 self.core.set_input_enabled(true);
401 self.core.set_cursor_visible(true);
402 }
403 }
404 }
405
406 fn apply_transient_visibility_change(&mut self, change: TransientVisibilityChange) {
407 if change.previous_visible == Some(TransientSurface::FilePalette)
408 || change.current_visible == Some(TransientSurface::FilePalette)
409 {
410 self.core.needs_full_clear = true;
411 }
412 self.sync_transient_focus();
413 }
414
415 pub fn handle_command(&mut self, command: InlineCommand) {
416 match command {
417 InlineCommand::SetInput(value) => {
418 self.core
419 .handle_command(crate::core_tui::types::InlineCommand::SetInput(value));
420 self.update_input_triggers();
421 }
422 InlineCommand::ApplySuggestedPrompt(value) => {
423 self.core.handle_command(
424 crate::core_tui::types::InlineCommand::ApplySuggestedPrompt(value),
425 );
426 self.update_input_triggers();
427 }
428 InlineCommand::ClearInput => {
429 self.core
430 .handle_command(crate::core_tui::types::InlineCommand::ClearInput);
431 self.update_input_triggers();
432 }
433 InlineCommand::CloseTransient => self.close_transient(),
434 InlineCommand::ShowTransient { request } => self.show_transient(*request),
435 _ => {
436 if let Some(core_cmd) = to_core_command(&command) {
437 self.core.handle_command(core_cmd);
438 }
439 }
440 }
441 }
442}
443
444impl std::ops::Deref for AppSession {
445 type Target = CoreSessionState;
446
447 fn deref(&self) -> &Self::Target {
448 &self.core
449 }
450}
451
452impl std::ops::DerefMut for AppSession {
453 fn deref_mut(&mut self) -> &mut Self::Target {
454 &mut self.core
455 }
456}
457
458fn to_core_command(command: &InlineCommand) -> Option<crate::core_tui::types::InlineCommand> {
459 use crate::core_tui::types::InlineCommand as CoreCommand;
460
461 Some(match command {
462 InlineCommand::AppendLine { kind, segments } => CoreCommand::AppendLine {
463 kind: *kind,
464 segments: segments.clone(),
465 },
466 InlineCommand::AppendPastedMessage {
467 kind,
468 text,
469 line_count,
470 } => CoreCommand::AppendPastedMessage {
471 kind: *kind,
472 text: text.clone(),
473 line_count: *line_count,
474 },
475 InlineCommand::Inline { kind, segment } => CoreCommand::Inline {
476 kind: *kind,
477 segment: segment.clone(),
478 },
479 InlineCommand::ReplaceLast {
480 count,
481 kind,
482 lines,
483 link_ranges,
484 } => CoreCommand::ReplaceLast {
485 count: *count,
486 kind: *kind,
487 lines: lines.clone(),
488 link_ranges: link_ranges.clone(),
489 },
490 InlineCommand::SetPrompt { prefix, style } => CoreCommand::SetPrompt {
491 prefix: prefix.clone(),
492 style: style.clone(),
493 },
494 InlineCommand::SetPlaceholder { hint, style } => CoreCommand::SetPlaceholder {
495 hint: hint.clone(),
496 style: style.clone(),
497 },
498 InlineCommand::SetMessageLabels { agent, user } => CoreCommand::SetMessageLabels {
499 agent: agent.clone(),
500 user: user.clone(),
501 },
502 InlineCommand::SetHeaderContext { context } => CoreCommand::SetHeaderContext {
503 context: context.clone(),
504 },
505 InlineCommand::SetInputStatus { left, right } => CoreCommand::SetInputStatus {
506 left: left.clone(),
507 right: right.clone(),
508 },
509 InlineCommand::SetTheme { theme } => CoreCommand::SetTheme {
510 theme: theme.clone(),
511 },
512 InlineCommand::SetAppearance { appearance } => CoreCommand::SetAppearance {
513 appearance: appearance.clone(),
514 },
515 InlineCommand::SetVimModeEnabled(enabled) => CoreCommand::SetVimModeEnabled(*enabled),
516 InlineCommand::SetQueuedInputs { entries } => CoreCommand::SetQueuedInputs {
517 entries: entries.clone(),
518 },
519 InlineCommand::SetCursorVisible(value) => CoreCommand::SetCursorVisible(*value),
520 InlineCommand::SetInputEnabled(value) => CoreCommand::SetInputEnabled(*value),
521 InlineCommand::SetInput(value) => CoreCommand::SetInput(value.clone()),
522 InlineCommand::ApplySuggestedPrompt(value) => {
523 CoreCommand::ApplySuggestedPrompt(value.clone())
524 }
525 InlineCommand::ClearInput => CoreCommand::ClearInput,
526 InlineCommand::ForceRedraw => CoreCommand::ForceRedraw,
527 InlineCommand::ClearScreen => CoreCommand::ClearScreen,
528 InlineCommand::SuspendEventLoop => CoreCommand::SuspendEventLoop,
529 InlineCommand::ResumeEventLoop => CoreCommand::ResumeEventLoop,
530 InlineCommand::ClearInputQueue => CoreCommand::ClearInputQueue,
531 InlineCommand::SetEditingMode(mode) => CoreCommand::SetEditingMode(*mode),
532 InlineCommand::SetAutonomousMode(enabled) => CoreCommand::SetAutonomousMode(*enabled),
533 InlineCommand::SetSkipConfirmations(skip) => CoreCommand::SetSkipConfirmations(*skip),
534 InlineCommand::Shutdown => CoreCommand::Shutdown,
535 InlineCommand::SetReasoningStage(stage) => CoreCommand::SetReasoningStage(stage.clone()),
536 InlineCommand::ShowTransient { .. } | InlineCommand::CloseTransient => return None,
537 })
538}
539
540impl TuiSessionDriver for AppSession {
541 type Command = InlineCommand;
542 type Event = InlineEvent;
543
544 fn handle_command(&mut self, command: Self::Command) {
545 AppSession::handle_command(self, command);
546 }
547
548 fn handle_event(
549 &mut self,
550 event: CrosstermEvent,
551 events: &UnboundedSender<Self::Event>,
552 callback: Option<&(dyn Fn(&Self::Event) + Send + Sync + 'static)>,
553 ) {
554 AppSession::handle_event(self, event, events, callback);
555 }
556
557 fn handle_tick(&mut self) {
558 self.core.handle_tick();
559 }
560
561 fn render(&mut self, frame: &mut Frame<'_>) {
562 AppSession::render(self, frame);
563 }
564
565 fn take_redraw(&mut self) -> bool {
566 self.core.take_redraw()
567 }
568
569 fn use_steady_cursor(&self) -> bool {
570 self.core.use_steady_cursor()
571 }
572
573 fn should_exit(&self) -> bool {
574 self.core.should_exit()
575 }
576
577 fn request_exit(&mut self) {
578 self.core.request_exit();
579 }
580
581 fn mark_dirty(&mut self) {
582 self.core.mark_dirty();
583 }
584
585 fn update_terminal_title(&mut self) {
586 self.core.update_terminal_title();
587 }
588
589 fn clear_terminal_title(&mut self) {
590 self.core.clear_terminal_title();
591 }
592
593 fn is_running_activity(&self) -> bool {
594 self.core.is_running_activity()
595 }
596
597 fn has_status_spinner(&self) -> bool {
598 self.core.has_status_spinner()
599 }
600
601 fn thinking_spinner_active(&self) -> bool {
602 self.core.thinking_spinner.is_active
603 }
604
605 fn has_active_navigation_ui(&self) -> bool {
606 self.transient_host.has_active_navigation_surface()
607 }
608
609 fn apply_coalesced_scroll(&mut self, line_delta: i32, page_delta: i32) {
610 self.core.apply_coalesced_scroll(line_delta, page_delta);
611 }
612
613 fn set_show_logs(&mut self, show: bool) {
614 self.core.show_logs = show;
615 }
616
617 fn set_active_pty_sessions(
618 &mut self,
619 sessions: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
620 ) {
621 self.core.active_pty_sessions = sessions;
622 }
623
624 fn set_workspace_root(&mut self, root: Option<std::path::PathBuf>) {
625 self.core.set_workspace_root(root);
626 }
627
628 fn set_log_receiver(&mut self, receiver: UnboundedReceiver<crate::core_tui::log::LogEntry>) {
629 self.core.set_log_receiver(receiver);
630 }
631}