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