Skip to main content

vtcode_tui/core_tui/app/session/
mod.rs

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
38/// App-level session that layers VT Code features on top of the core session.
39pub 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}