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        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}