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