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, InlineListSelection, ListOverlayRequest,
19    ModalOverlayRequest, 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) => {
343                self.clear_last_overlay_list_cache();
344                self.activate_modal_overlay(request);
345            }
346            OverlayRequest::List(request) => self.activate_list_overlay(request),
347            OverlayRequest::Wizard(request) => {
348                self.clear_last_overlay_list_cache();
349                self.activate_wizard_overlay(request);
350            }
351            OverlayRequest::Diff(request) => {
352                self.clear_last_overlay_list_cache();
353                self.activate_diff_overlay(request);
354            }
355        }
356    }
357
358    pub(super) fn close_overlay(&mut self) {
359        let Some(state) = self.active_overlay.take() else {
360            return;
361        };
362
363        self.cache_last_overlay_list_state(&state);
364        self.input_enabled = state.restore_input();
365        self.cursor_visible = state.restore_cursor();
366
367        if let Some(next_request) = self.overlay_queue.pop_front() {
368            self.activate_overlay(next_request);
369            return;
370        }
371
372        self.mark_dirty();
373    }
374
375    fn activate_modal_overlay(&mut self, request: ModalOverlayRequest) {
376        let state = ModalState {
377            title: request.title,
378            lines: request.lines,
379            footer_hint: None,
380            hotkeys: Vec::new(),
381            list: None,
382            search: None,
383            secure_prompt: request.secure_prompt,
384            restore_input: true,
385            restore_cursor: true,
386        };
387        if state.secure_prompt.is_none() {
388            self.input_enabled = false;
389        }
390        self.cursor_visible = false;
391        self.active_overlay = Some(ActiveOverlay::Modal(Box::new(state)));
392        self.mark_dirty();
393    }
394
395    fn activate_list_overlay(&mut self, request: ListOverlayRequest) {
396        self.ensure_inline_lists_visible_for_trigger();
397        let anchor_to_bottom = self.should_anchor_list_to_bottom(request.selected.as_ref());
398        let mut list_state = ModalListState::new(request.items, request.selected.clone());
399        let search_state = request.search.map(ModalSearchState::from);
400        if let Some(search) = &search_state {
401            list_state.apply_search_with_preference(&search.query, request.selected);
402        }
403        if anchor_to_bottom {
404            list_state.select_last();
405        }
406        self.clear_last_overlay_list_cache();
407        let state = ModalState {
408            title: request.title,
409            lines: request.lines,
410            footer_hint: request.footer_hint,
411            hotkeys: request.hotkeys,
412            list: Some(list_state),
413            search: search_state,
414            secure_prompt: None,
415            restore_input: true,
416            restore_cursor: true,
417        };
418        self.input_enabled = false;
419        self.cursor_visible = false;
420        self.active_overlay = Some(ActiveOverlay::Modal(Box::new(state)));
421        self.mark_dirty();
422    }
423
424    fn cache_last_overlay_list_state(&mut self, overlay: &ActiveOverlay) {
425        if let ActiveOverlay::Modal(state) = overlay
426            && let Some(list) = state.list.as_ref()
427        {
428            self.last_overlay_list_selection = list.current_selection();
429            self.last_overlay_list_was_last = list.selected_is_last();
430            return;
431        }
432
433        self.last_overlay_list_selection = None;
434        self.last_overlay_list_was_last = false;
435    }
436
437    fn should_anchor_list_to_bottom(
438        &self,
439        preferred: Option<&InlineListSelection>,
440    ) -> bool {
441        self.last_overlay_list_was_last
442            && self.last_overlay_list_selection.as_ref() == preferred
443    }
444
445    fn clear_last_overlay_list_cache(&mut self) {
446        self.last_overlay_list_selection = None;
447        self.last_overlay_list_was_last = false;
448    }
449
450    fn activate_wizard_overlay(&mut self, request: WizardOverlayRequest) {
451        self.ensure_inline_lists_visible_for_trigger();
452        let wizard = WizardModalState::new(
453            request.title,
454            request.steps,
455            request.current_step,
456            request.search,
457            request.mode,
458        );
459        self.active_overlay = Some(ActiveOverlay::Wizard(Box::new(wizard)));
460        self.input_enabled = false;
461        self.cursor_visible = false;
462        self.mark_dirty();
463    }
464
465    fn activate_diff_overlay(&mut self, request: DiffOverlayRequest) {
466        let mut state = DiffPreviewState::new_with_mode(
467            request.file_path,
468            request.before,
469            request.after,
470            request.hunks,
471            request.mode,
472        );
473        state.current_hunk = request.current_hunk;
474        self.active_overlay = Some(ActiveOverlay::Diff(Box::new(state)));
475        self.input_enabled = false;
476        self.cursor_visible = false;
477        self.mark_dirty();
478    }
479
480    /// Scroll operations
481    ///
482    /// Note: The scroll offset model is inverted for chat-style display:
483    /// offset=0 shows the bottom (newest content), offset=max shows the top.
484    /// Therefore "scroll up" (show older content) increases the offset, and
485    /// "scroll down" (show newer content) decreases it.
486    pub fn scroll_line_up(&mut self) {
487        self.mark_scrolling();
488        let previous_offset = self.scroll_manager.offset();
489        self.scroll_manager.scroll_down(1);
490        if self.scroll_manager.offset() != previous_offset {
491            self.user_scrolled = self.scroll_manager.offset() != 0;
492            self.visible_lines_cache = None;
493        }
494    }
495
496    pub fn scroll_line_down(&mut self) {
497        self.mark_scrolling();
498        let previous_offset = self.scroll_manager.offset();
499        self.scroll_manager.scroll_up(1);
500        if self.scroll_manager.offset() != previous_offset {
501            self.user_scrolled = self.scroll_manager.offset() != 0;
502            self.visible_lines_cache = None;
503        }
504    }
505
506    pub(super) fn scroll_page_up(&mut self) {
507        self.mark_scrolling();
508        let previous_offset = self.scroll_manager.offset();
509        self.scroll_manager
510            .scroll_down(self.viewport_height().max(1));
511        if self.scroll_manager.offset() != previous_offset {
512            self.user_scrolled = self.scroll_manager.offset() != 0;
513            self.visible_lines_cache = None;
514        }
515    }
516
517    pub(super) fn scroll_page_down(&mut self) {
518        self.mark_scrolling();
519        let page = self.viewport_height().max(1);
520        let previous_offset = self.scroll_manager.offset();
521        self.scroll_manager.scroll_up(page);
522        if self.scroll_manager.offset() != previous_offset {
523            self.user_scrolled = self.scroll_manager.offset() != 0;
524            self.visible_lines_cache = None;
525        }
526    }
527
528    pub(crate) fn viewport_height(&self) -> usize {
529        self.transcript_rows.max(1) as usize
530    }
531
532    /// Apply coalesced scroll from accumulated scroll events
533    /// This is more efficient than calling scroll_line_up/down multiple times
534    pub(crate) fn apply_coalesced_scroll(&mut self, line_delta: i32, page_delta: i32) {
535        self.mark_scrolling();
536        let previous_offset = self.scroll_manager.offset();
537
538        // Apply page scroll first (larger movements)
539        // Inverted offset model: positive delta = scroll down visually = decrease offset
540        if page_delta != 0 {
541            let page_size = self.viewport_height().max(1);
542            if page_delta > 0 {
543                self.scroll_manager
544                    .scroll_up(page_size * page_delta.unsigned_abs() as usize);
545            } else {
546                self.scroll_manager
547                    .scroll_down(page_size * page_delta.unsigned_abs() as usize);
548            }
549        }
550
551        // Then apply line scroll
552        if line_delta != 0 {
553            if line_delta > 0 {
554                self.scroll_manager
555                    .scroll_up(line_delta.unsigned_abs() as usize);
556            } else {
557                self.scroll_manager
558                    .scroll_down(line_delta.unsigned_abs() as usize);
559            }
560        }
561
562        // Invalidate visible lines cache if offset actually changed
563        if self.scroll_manager.offset() != previous_offset {
564            self.visible_lines_cache = None;
565        }
566    }
567
568    /// Invalidate scroll metrics to force recalculation
569    pub(super) fn invalidate_scroll_metrics(&mut self) {
570        self.scroll_manager.invalidate_metrics();
571        self.invalidate_transcript_cache();
572    }
573
574    /// Invalidate the transcript cache
575    pub(super) fn invalidate_transcript_cache(&mut self) {
576        if let Some(cache) = self.transcript_cache.as_mut() {
577            cache.invalidate_content();
578        }
579        self.visible_lines_cache = None;
580        self.transcript_content_changed = true;
581
582        // If no specific line was marked dirty, assume everything needs reflow
583        if self.first_dirty_line.is_none() {
584            self.first_dirty_line = Some(0);
585        }
586    }
587
588    /// Get the current maximum scroll offset
589    pub(super) fn current_max_scroll_offset(&mut self) -> usize {
590        self.ensure_scroll_metrics();
591        self.scroll_manager.max_offset()
592    }
593
594    /// Enforce scroll bounds after viewport changes
595    pub(super) fn enforce_scroll_bounds(&mut self) {
596        let max_offset = self.current_max_scroll_offset();
597        if self.scroll_manager.offset() > max_offset {
598            self.scroll_manager.set_offset(max_offset);
599        }
600    }
601
602    /// Ensure scroll metrics are up to date
603    pub(super) fn ensure_scroll_metrics(&mut self) {
604        if self.scroll_manager.metrics_valid() {
605            return;
606        }
607
608        let viewport_rows = self.viewport_height();
609        if self.transcript_width == 0 || viewport_rows == 0 {
610            self.scroll_manager.set_total_rows(0);
611            return;
612        }
613
614        let padding = usize::from(ui::INLINE_TRANSCRIPT_BOTTOM_PADDING);
615        let effective_padding = padding.min(viewport_rows.saturating_sub(1));
616        let total_rows = self.total_transcript_rows(self.transcript_width) + effective_padding;
617        self.scroll_manager.set_total_rows(total_rows);
618    }
619
620    /// Prepare transcript scroll parameters
621    pub(crate) fn prepare_transcript_scroll(
622        &mut self,
623        total_rows: usize,
624        viewport_rows: usize,
625    ) -> (usize, usize) {
626        let viewport = viewport_rows.max(1);
627        let clamped_total = total_rows.max(1);
628        self.scroll_manager.set_total_rows(clamped_total);
629        self.scroll_manager.set_viewport_rows(viewport as u16);
630        let max_offset = self.scroll_manager.max_offset();
631
632        if self.scroll_manager.offset() > max_offset {
633            self.scroll_manager.set_offset(max_offset);
634        }
635
636        let top_offset = max_offset.saturating_sub(self.scroll_manager.offset());
637        (top_offset, clamped_total)
638    }
639
640    /// Adjust scroll position after content changes
641    pub(super) fn adjust_scroll_after_change(&mut self, previous_max_offset: usize) {
642        use std::cmp::min;
643
644        let new_max_offset = self.current_max_scroll_offset();
645        let current_offset = self.scroll_manager.offset();
646
647        if current_offset >= previous_max_offset && new_max_offset > previous_max_offset {
648            self.scroll_manager.set_offset(new_max_offset);
649        } else if current_offset > 0 && new_max_offset > previous_max_offset {
650            let delta = new_max_offset - previous_max_offset;
651            self.scroll_manager
652                .set_offset(min(current_offset + delta, new_max_offset));
653        }
654        self.enforce_scroll_bounds();
655    }
656
657    /// Emit an inline event through the channel and callback
658    #[inline]
659    pub(super) fn emit_inline_event(
660        &self,
661        event: &InlineEvent,
662        events: &UnboundedSender<InlineEvent>,
663        callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
664    ) {
665        if let Some(cb) = callback {
666            cb(event);
667        }
668        let _ = events.send(event.clone());
669    }
670
671    /// Handle scroll down event
672    #[inline]
673    #[allow(dead_code)]
674    pub(super) fn handle_scroll_down(
675        &mut self,
676        events: &UnboundedSender<InlineEvent>,
677        callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
678    ) {
679        self.scroll_line_down();
680        self.mark_dirty();
681        self.emit_inline_event(&InlineEvent::ScrollLineDown, events, callback);
682    }
683
684    /// Handle scroll up event
685    #[inline]
686    #[allow(dead_code)]
687    pub(super) fn handle_scroll_up(
688        &mut self,
689        events: &UnboundedSender<InlineEvent>,
690        callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
691    ) {
692        self.scroll_line_up();
693        self.mark_dirty();
694        self.emit_inline_event(&InlineEvent::ScrollLineUp, events, callback);
695    }
696}