Skip to main content

vtcode_tui/core_tui/session/
state.rs

1/// State management and lifecycle operations for Session
2///
3/// This module handles session state including:
4/// - Session initialization and configuration
5/// - Exit management
6/// - Redraw and dirty state tracking
7/// - Screen clearing
8/// - Modal management
9/// - Timeline pane toggling
10/// - Scroll management operations
11use std::sync::atomic::Ordering;
12use std::time::{Duration, Instant};
13
14use ratatui::layout::Rect;
15use tokio::sync::mpsc::UnboundedSender;
16
17use super::super::types::{
18    DiffOverlayRequest, DiffPreviewState, InlineEvent, ListOverlayRequest, ModalOverlayRequest,
19    OverlayRequest, WizardOverlayRequest,
20};
21use super::status_requires_shimmer;
22use super::{
23    ActiveOverlay, Session, SuggestedPromptState,
24    modal::{ModalListState, ModalSearchState, ModalState, WizardModalState},
25};
26use crate::config::constants::ui;
27
28impl Session {
29    pub(crate) fn inline_lists_visible(&self) -> bool {
30        self.inline_lists_visible
31    }
32
33    pub(crate) fn ensure_inline_lists_visible_for_trigger(&mut self) {
34        if self.inline_lists_visible {
35            return;
36        }
37        self.inline_lists_visible = true;
38        self.mark_dirty();
39    }
40
41    pub(crate) fn toggle_inline_lists_visibility(&mut self) {
42        self.inline_lists_visible = !self.inline_lists_visible;
43        if !self.inline_lists_visible {
44            if self.file_palette_active {
45                self.close_file_palette();
46            }
47            if self.history_picker_state.active {
48                self.history_picker_state.cancel(&mut self.input_manager);
49            }
50        }
51        self.mark_dirty();
52    }
53
54    pub(crate) fn set_task_panel_visible(&mut self, visible: bool) {
55        if self.show_task_panel == visible {
56            return;
57        }
58        self.show_task_panel = visible;
59        self.mark_dirty();
60    }
61
62    pub(crate) fn set_task_panel_lines(&mut self, lines: Vec<String>) {
63        if self.task_panel_lines == lines {
64            return;
65        }
66        self.task_panel_lines = lines;
67        self.mark_dirty();
68    }
69
70    pub(crate) fn clear_suggested_prompt_state(&mut self) {
71        if !self.suggested_prompt_state.active {
72            return;
73        }
74        self.suggested_prompt_state = SuggestedPromptState::default();
75        self.mark_dirty();
76    }
77
78    /// Get the next revision counter for message tracking
79    pub(crate) fn next_revision(&mut self) -> u64 {
80        self.line_revision_counter = self.line_revision_counter.wrapping_add(1);
81        self.line_revision_counter
82    }
83
84    /// Check if the session should exit
85    pub fn should_exit(&self) -> bool {
86        self.should_exit
87    }
88
89    /// Request session exit
90    pub fn request_exit(&mut self) {
91        self.should_exit = true;
92    }
93
94    /// Take the redraw flag and reset it
95    ///
96    /// Returns true if a redraw was needed
97    pub fn take_redraw(&mut self) -> bool {
98        if self.needs_redraw {
99            self.needs_redraw = false;
100            true
101        } else {
102            false
103        }
104    }
105
106    /// Mark the session as needing a redraw
107    pub fn mark_dirty(&mut self) {
108        self.needs_redraw = true;
109        self.header_lines_cache = None;
110        self.queued_inputs_preview_cache = None;
111    }
112
113    /// Invalidate only the header cache (e.g. when provider/model changes)
114    pub fn invalidate_header_cache(&mut self) {
115        self.header_lines_cache = None;
116        self.header_height_cache.clear();
117        self.mark_dirty();
118    }
119
120    /// Invalidate only the sidebar cache (e.g. when queue changes)
121    pub fn invalidate_sidebar_cache(&mut self) {
122        self.queued_inputs_preview_cache = None;
123        self.mark_dirty();
124    }
125
126    pub(crate) fn set_transcript_area(&mut self, area: Option<Rect>) {
127        self.transcript_area = area;
128    }
129
130    pub(crate) fn set_input_area(&mut self, area: Option<Rect>) {
131        self.input_area = area;
132    }
133
134    pub(crate) fn set_bottom_panel_area(&mut self, area: Option<Rect>) {
135        self.bottom_panel_area = area;
136    }
137
138    pub(crate) fn set_modal_list_area(&mut self, area: Option<Rect>) {
139        self.modal_list_area = area;
140    }
141
142    /// Advance animation state on tick and request redraw when a frame changes.
143    pub fn handle_tick(&mut self) {
144        let motion_reduced = self.appearance.motion_reduced();
145        let mut animation_updated = false;
146        if !motion_reduced && self.thinking_spinner.is_active && self.thinking_spinner.update() {
147            animation_updated = true;
148        }
149        let shimmer_active = if self.appearance.should_animate_progress_status() {
150            self.is_shimmer_active()
151        } else {
152            false
153        };
154        if shimmer_active && self.shimmer_state.update() {
155            animation_updated = true;
156        }
157        if let Some(until) = self.scroll_cursor_steady_until
158            && Instant::now() >= until
159        {
160            self.scroll_cursor_steady_until = None;
161            self.needs_redraw = true;
162        }
163        if self.last_shimmer_active && !shimmer_active {
164            self.needs_redraw = true;
165        }
166        self.last_shimmer_active = shimmer_active;
167        if animation_updated {
168            self.needs_redraw = true;
169        }
170    }
171
172    pub(crate) fn is_running_activity(&self) -> bool {
173        let left = self.input_status_left.as_deref().unwrap_or("");
174        let running_status = self.appearance.should_animate_progress_status()
175            && (left.contains("Running command:")
176                || left.contains("Running tool:")
177                || left.contains("Running:")
178                || status_requires_shimmer(left));
179        let active_pty = self.active_pty_session_count() > 0;
180        running_status || active_pty
181    }
182
183    pub(crate) fn active_pty_session_count(&self) -> usize {
184        self.active_pty_sessions
185            .as_ref()
186            .map(|counter| counter.load(Ordering::Relaxed))
187            .unwrap_or(0)
188    }
189
190    pub(crate) fn has_status_spinner(&self) -> bool {
191        if !self.appearance.should_animate_progress_status() {
192            return false;
193        }
194        let Some(left) = self.input_status_left.as_deref() else {
195            return false;
196        };
197        status_requires_shimmer(left)
198    }
199
200    pub(super) fn is_shimmer_active(&self) -> bool {
201        self.has_status_spinner()
202    }
203
204    pub(crate) fn use_steady_cursor(&self) -> bool {
205        if !self.appearance.should_animate_progress_status() {
206            self.scroll_cursor_steady_until.is_some()
207        } else {
208            self.is_shimmer_active() || self.scroll_cursor_steady_until.is_some()
209        }
210    }
211
212    pub(super) fn mark_scrolling(&mut self) {
213        let steady_duration = Duration::from_millis(ui::TUI_SCROLL_CURSOR_STEADY_MS);
214        if steady_duration.is_zero() {
215            self.scroll_cursor_steady_until = None;
216        } else {
217            self.scroll_cursor_steady_until = Some(Instant::now() + steady_duration);
218        }
219    }
220
221    /// Mark a specific line as dirty to optimize reflow scans
222    pub(crate) fn mark_line_dirty(&mut self, index: usize) {
223        self.first_dirty_line = match self.first_dirty_line {
224            Some(current) => Some(current.min(index)),
225            None => Some(index),
226        };
227        self.mark_dirty();
228    }
229
230    /// Ensure the prompt style has a color set
231    pub(super) fn ensure_prompt_style_color(&mut self) {
232        if self.prompt_style.color.is_none() {
233            self.prompt_style.color = self.theme.primary.or(self.theme.foreground);
234        }
235    }
236
237    /// Clear the screen and reset scroll
238    pub(super) fn clear_screen(&mut self) {
239        self.lines.clear();
240        self.collapsed_pastes.clear();
241        self.user_scrolled = false;
242        self.scroll_manager.set_offset(0);
243        self.invalidate_transcript_cache();
244        self.invalidate_scroll_metrics();
245        self.needs_full_clear = true;
246        self.mark_dirty();
247    }
248
249    /// Toggle logs panel visibility
250    pub(super) fn toggle_logs(&mut self) {
251        self.show_logs = !self.show_logs;
252        self.invalidate_scroll_metrics();
253        self.mark_dirty();
254    }
255
256    /// Show a simple modal dialog
257    pub(super) fn show_modal(
258        &mut self,
259        title: String,
260        lines: Vec<String>,
261        secure_prompt: Option<super::super::types::SecurePromptConfig>,
262    ) {
263        self.show_overlay(OverlayRequest::Modal(ModalOverlayRequest {
264            title,
265            lines,
266            secure_prompt,
267        }));
268    }
269
270    pub(super) fn show_overlay(&mut self, request: OverlayRequest) {
271        if self.has_active_overlay() {
272            self.overlay_queue.push_back(request);
273            self.mark_dirty();
274            return;
275        }
276        self.activate_overlay(request);
277    }
278
279    pub(crate) fn has_active_overlay(&self) -> bool {
280        self.active_overlay.is_some()
281    }
282
283    pub(crate) fn modal_state(&self) -> Option<&ModalState> {
284        self.active_overlay
285            .as_ref()
286            .and_then(ActiveOverlay::as_modal)
287    }
288
289    pub(crate) fn modal_state_mut(&mut self) -> Option<&mut ModalState> {
290        self.active_overlay
291            .as_mut()
292            .and_then(ActiveOverlay::as_modal_mut)
293    }
294
295    pub(crate) fn wizard_overlay(&self) -> Option<&WizardModalState> {
296        self.active_overlay
297            .as_ref()
298            .and_then(ActiveOverlay::as_wizard)
299    }
300
301    pub(crate) fn wizard_overlay_mut(&mut self) -> Option<&mut WizardModalState> {
302        self.active_overlay
303            .as_mut()
304            .and_then(ActiveOverlay::as_wizard_mut)
305    }
306
307    pub(crate) fn diff_preview_state(&self) -> Option<&DiffPreviewState> {
308        self.active_overlay
309            .as_ref()
310            .and_then(ActiveOverlay::as_diff)
311    }
312
313    pub(crate) fn diff_preview_state_mut(&mut self) -> Option<&mut DiffPreviewState> {
314        self.active_overlay
315            .as_mut()
316            .and_then(ActiveOverlay::as_diff_mut)
317    }
318
319    pub(crate) fn modal_overlay_active(&self) -> bool {
320        self.active_overlay
321            .as_ref()
322            .is_some_and(ActiveOverlay::is_modal_like)
323    }
324
325    pub(crate) fn take_modal_state(&mut self) -> Option<ModalState> {
326        if !self
327            .active_overlay
328            .as_ref()
329            .is_some_and(|overlay| matches!(overlay, ActiveOverlay::Modal(_)))
330        {
331            return None;
332        }
333
334        match self.active_overlay.take() {
335            Some(ActiveOverlay::Modal(state)) => Some(*state),
336            Some(ActiveOverlay::Wizard(_) | ActiveOverlay::Diff(_)) | None => None,
337        }
338    }
339
340    fn activate_overlay(&mut self, request: OverlayRequest) {
341        match request {
342            OverlayRequest::Modal(request) => self.activate_modal_overlay(request),
343            OverlayRequest::List(request) => self.activate_list_overlay(request),
344            OverlayRequest::Wizard(request) => self.activate_wizard_overlay(request),
345            OverlayRequest::Diff(request) => self.activate_diff_overlay(request),
346        }
347    }
348
349    pub(super) fn close_overlay(&mut self) {
350        let Some(state) = self.active_overlay.take() else {
351            return;
352        };
353
354        self.input_enabled = state.restore_input();
355        self.cursor_visible = state.restore_cursor();
356
357        if let Some(next_request) = self.overlay_queue.pop_front() {
358            self.activate_overlay(next_request);
359            return;
360        }
361
362        self.mark_dirty();
363    }
364
365    fn activate_modal_overlay(&mut self, request: ModalOverlayRequest) {
366        let state = ModalState {
367            title: request.title,
368            lines: request.lines,
369            footer_hint: None,
370            hotkeys: Vec::new(),
371            list: None,
372            search: None,
373            secure_prompt: request.secure_prompt,
374            restore_input: true,
375            restore_cursor: true,
376        };
377        if state.secure_prompt.is_none() {
378            self.input_enabled = false;
379        }
380        self.cursor_visible = false;
381        self.active_overlay = Some(ActiveOverlay::Modal(Box::new(state)));
382        self.mark_dirty();
383    }
384
385    fn activate_list_overlay(&mut self, request: ListOverlayRequest) {
386        self.ensure_inline_lists_visible_for_trigger();
387        let mut list_state = ModalListState::new(request.items, request.selected.clone());
388        let search_state = request.search.map(ModalSearchState::from);
389        if let Some(search) = &search_state {
390            list_state.apply_search_with_preference(&search.query, request.selected);
391        }
392        let state = ModalState {
393            title: request.title,
394            lines: request.lines,
395            footer_hint: request.footer_hint,
396            hotkeys: request.hotkeys,
397            list: Some(list_state),
398            search: search_state,
399            secure_prompt: None,
400            restore_input: true,
401            restore_cursor: true,
402        };
403        self.input_enabled = false;
404        self.cursor_visible = false;
405        self.active_overlay = Some(ActiveOverlay::Modal(Box::new(state)));
406        self.mark_dirty();
407    }
408
409    fn activate_wizard_overlay(&mut self, request: WizardOverlayRequest) {
410        self.ensure_inline_lists_visible_for_trigger();
411        let wizard = WizardModalState::new(
412            request.title,
413            request.steps,
414            request.current_step,
415            request.search,
416            request.mode,
417        );
418        self.active_overlay = Some(ActiveOverlay::Wizard(Box::new(wizard)));
419        self.input_enabled = false;
420        self.cursor_visible = false;
421        self.mark_dirty();
422    }
423
424    fn activate_diff_overlay(&mut self, request: DiffOverlayRequest) {
425        let mut state = DiffPreviewState::new_with_mode(
426            request.file_path,
427            request.before,
428            request.after,
429            request.hunks,
430            request.mode,
431        );
432        state.current_hunk = request.current_hunk;
433        self.active_overlay = Some(ActiveOverlay::Diff(Box::new(state)));
434        self.input_enabled = false;
435        self.cursor_visible = false;
436        self.mark_dirty();
437    }
438
439    /// Scroll operations
440    ///
441    /// Note: The scroll offset model is inverted for chat-style display:
442    /// offset=0 shows the bottom (newest content), offset=max shows the top.
443    /// Therefore "scroll up" (show older content) increases the offset, and
444    /// "scroll down" (show newer content) decreases it.
445    pub fn scroll_line_up(&mut self) {
446        self.mark_scrolling();
447        let previous_offset = self.scroll_manager.offset();
448        self.scroll_manager.scroll_down(1);
449        if self.scroll_manager.offset() != previous_offset {
450            self.user_scrolled = self.scroll_manager.offset() != 0;
451            self.visible_lines_cache = None;
452        }
453    }
454
455    pub fn scroll_line_down(&mut self) {
456        self.mark_scrolling();
457        let previous_offset = self.scroll_manager.offset();
458        self.scroll_manager.scroll_up(1);
459        if self.scroll_manager.offset() != previous_offset {
460            self.user_scrolled = self.scroll_manager.offset() != 0;
461            self.visible_lines_cache = None;
462        }
463    }
464
465    pub(super) fn scroll_page_up(&mut self) {
466        self.mark_scrolling();
467        let previous_offset = self.scroll_manager.offset();
468        self.scroll_manager
469            .scroll_down(self.viewport_height().max(1));
470        if self.scroll_manager.offset() != previous_offset {
471            self.user_scrolled = self.scroll_manager.offset() != 0;
472            self.visible_lines_cache = None;
473        }
474    }
475
476    pub(super) fn scroll_page_down(&mut self) {
477        self.mark_scrolling();
478        let page = self.viewport_height().max(1);
479        let previous_offset = self.scroll_manager.offset();
480        self.scroll_manager.scroll_up(page);
481        if self.scroll_manager.offset() != previous_offset {
482            self.user_scrolled = self.scroll_manager.offset() != 0;
483            self.visible_lines_cache = None;
484        }
485    }
486
487    pub(crate) fn viewport_height(&self) -> usize {
488        self.transcript_rows.max(1) as usize
489    }
490
491    /// Apply coalesced scroll from accumulated scroll events
492    /// This is more efficient than calling scroll_line_up/down multiple times
493    pub(crate) fn apply_coalesced_scroll(&mut self, line_delta: i32, page_delta: i32) {
494        self.mark_scrolling();
495        let previous_offset = self.scroll_manager.offset();
496
497        // Apply page scroll first (larger movements)
498        // Inverted offset model: positive delta = scroll down visually = decrease offset
499        if page_delta != 0 {
500            let page_size = self.viewport_height().max(1);
501            if page_delta > 0 {
502                self.scroll_manager
503                    .scroll_up(page_size * page_delta.unsigned_abs() as usize);
504            } else {
505                self.scroll_manager
506                    .scroll_down(page_size * page_delta.unsigned_abs() as usize);
507            }
508        }
509
510        // Then apply line scroll
511        if line_delta != 0 {
512            if line_delta > 0 {
513                self.scroll_manager
514                    .scroll_up(line_delta.unsigned_abs() as usize);
515            } else {
516                self.scroll_manager
517                    .scroll_down(line_delta.unsigned_abs() as usize);
518            }
519        }
520
521        // Invalidate visible lines cache if offset actually changed
522        if self.scroll_manager.offset() != previous_offset {
523            self.visible_lines_cache = None;
524        }
525    }
526
527    /// Invalidate scroll metrics to force recalculation
528    pub(super) fn invalidate_scroll_metrics(&mut self) {
529        self.scroll_manager.invalidate_metrics();
530        self.invalidate_transcript_cache();
531    }
532
533    /// Invalidate the transcript cache
534    pub(super) fn invalidate_transcript_cache(&mut self) {
535        if let Some(cache) = self.transcript_cache.as_mut() {
536            cache.invalidate_content();
537        }
538        self.visible_lines_cache = None;
539        self.transcript_content_changed = true;
540
541        // If no specific line was marked dirty, assume everything needs reflow
542        if self.first_dirty_line.is_none() {
543            self.first_dirty_line = Some(0);
544        }
545    }
546
547    /// Get the current maximum scroll offset
548    pub(super) fn current_max_scroll_offset(&mut self) -> usize {
549        self.ensure_scroll_metrics();
550        self.scroll_manager.max_offset()
551    }
552
553    /// Enforce scroll bounds after viewport changes
554    pub(super) fn enforce_scroll_bounds(&mut self) {
555        let max_offset = self.current_max_scroll_offset();
556        if self.scroll_manager.offset() > max_offset {
557            self.scroll_manager.set_offset(max_offset);
558        }
559    }
560
561    /// Ensure scroll metrics are up to date
562    pub(super) fn ensure_scroll_metrics(&mut self) {
563        if self.scroll_manager.metrics_valid() {
564            return;
565        }
566
567        let viewport_rows = self.viewport_height();
568        if self.transcript_width == 0 || viewport_rows == 0 {
569            self.scroll_manager.set_total_rows(0);
570            return;
571        }
572
573        let padding = usize::from(ui::INLINE_TRANSCRIPT_BOTTOM_PADDING);
574        let effective_padding = padding.min(viewport_rows.saturating_sub(1));
575        let total_rows = self.total_transcript_rows(self.transcript_width) + effective_padding;
576        self.scroll_manager.set_total_rows(total_rows);
577    }
578
579    /// Prepare transcript scroll parameters
580    pub(crate) fn prepare_transcript_scroll(
581        &mut self,
582        total_rows: usize,
583        viewport_rows: usize,
584    ) -> (usize, usize) {
585        let viewport = viewport_rows.max(1);
586        let clamped_total = total_rows.max(1);
587        self.scroll_manager.set_total_rows(clamped_total);
588        self.scroll_manager.set_viewport_rows(viewport as u16);
589        let max_offset = self.scroll_manager.max_offset();
590
591        if self.scroll_manager.offset() > max_offset {
592            self.scroll_manager.set_offset(max_offset);
593        }
594
595        let top_offset = max_offset.saturating_sub(self.scroll_manager.offset());
596        (top_offset, clamped_total)
597    }
598
599    /// Adjust scroll position after content changes
600    pub(super) fn adjust_scroll_after_change(&mut self, previous_max_offset: usize) {
601        use std::cmp::min;
602
603        let new_max_offset = self.current_max_scroll_offset();
604        let current_offset = self.scroll_manager.offset();
605
606        if current_offset >= previous_max_offset && new_max_offset > previous_max_offset {
607            self.scroll_manager.set_offset(new_max_offset);
608        } else if current_offset > 0 && new_max_offset > previous_max_offset {
609            let delta = new_max_offset - previous_max_offset;
610            self.scroll_manager
611                .set_offset(min(current_offset + delta, new_max_offset));
612        }
613        self.enforce_scroll_bounds();
614    }
615
616    /// Emit an inline event through the channel and callback
617    #[inline]
618    pub(super) fn emit_inline_event(
619        &self,
620        event: &InlineEvent,
621        events: &UnboundedSender<InlineEvent>,
622        callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
623    ) {
624        if let Some(cb) = callback {
625            cb(event);
626        }
627        let _ = events.send(event.clone());
628    }
629
630    /// Handle scroll down event
631    #[inline]
632    #[allow(dead_code)]
633    pub(super) fn handle_scroll_down(
634        &mut self,
635        events: &UnboundedSender<InlineEvent>,
636        callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
637    ) {
638        self.scroll_line_down();
639        self.mark_dirty();
640        self.emit_inline_event(&InlineEvent::ScrollLineDown, events, callback);
641    }
642
643    /// Handle scroll up event
644    #[inline]
645    #[allow(dead_code)]
646    pub(super) fn handle_scroll_up(
647        &mut self,
648        events: &UnboundedSender<InlineEvent>,
649        callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
650    ) {
651        self.scroll_line_up();
652        self.mark_dirty();
653        self.emit_inline_event(&InlineEvent::ScrollLineUp, events, callback);
654    }
655}