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