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, InlineListItem, InlineListSearchConfig, InlineListSelection, SecurePromptConfig,
19    WizardModalMode, WizardStep,
20};
21use super::status_requires_shimmer;
22use super::{
23    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
148            .active_pty_sessions
149            .as_ref()
150            .map(|counter| counter.load(Ordering::Relaxed) > 0)
151            .unwrap_or(false);
152        running_status || active_pty
153    }
154
155    pub(crate) fn has_status_spinner(&self) -> bool {
156        if !self.appearance.should_animate_progress_status() {
157            return false;
158        }
159        let Some(left) = self.input_status_left.as_deref() else {
160            return false;
161        };
162        status_requires_shimmer(left)
163    }
164
165    pub(super) fn is_shimmer_active(&self) -> bool {
166        self.has_status_spinner()
167    }
168
169    pub(crate) fn use_steady_cursor(&self) -> bool {
170        if !self.appearance.should_animate_progress_status() {
171            self.scroll_cursor_steady_until.is_some()
172        } else {
173            self.is_shimmer_active() || self.scroll_cursor_steady_until.is_some()
174        }
175    }
176
177    pub(super) fn mark_scrolling(&mut self) {
178        let steady_duration = Duration::from_millis(ui::TUI_SCROLL_CURSOR_STEADY_MS);
179        if steady_duration.is_zero() {
180            self.scroll_cursor_steady_until = None;
181        } else {
182            self.scroll_cursor_steady_until = Some(Instant::now() + steady_duration);
183        }
184    }
185
186    /// Mark a specific line as dirty to optimize reflow scans
187    pub(crate) fn mark_line_dirty(&mut self, index: usize) {
188        self.first_dirty_line = match self.first_dirty_line {
189            Some(current) => Some(current.min(index)),
190            None => Some(index),
191        };
192        self.mark_dirty();
193    }
194
195    /// Ensure the prompt style has a color set
196    pub(super) fn ensure_prompt_style_color(&mut self) {
197        if self.prompt_style.color.is_none() {
198            self.prompt_style.color = self.theme.primary.or(self.theme.foreground);
199        }
200    }
201
202    /// Clear the screen and reset scroll
203    pub(super) fn clear_screen(&mut self) {
204        self.lines.clear();
205        self.collapsed_pastes.clear();
206        self.user_scrolled = false;
207        self.scroll_manager.set_offset(0);
208        self.invalidate_transcript_cache();
209        self.invalidate_scroll_metrics();
210        self.needs_full_clear = true;
211        self.mark_dirty();
212    }
213
214    /// Toggle logs panel visibility
215    pub(super) fn toggle_logs(&mut self) {
216        self.show_logs = !self.show_logs;
217        self.invalidate_scroll_metrics();
218        self.mark_dirty();
219    }
220
221    /// Show a simple modal dialog
222    pub(super) fn show_modal(
223        &mut self,
224        title: String,
225        lines: Vec<String>,
226        secure_prompt: Option<SecurePromptConfig>,
227    ) {
228        let state = ModalState {
229            title,
230            lines,
231            footer_hint: None,
232            list: None,
233            search: None,
234            secure_prompt,
235            is_plan_confirmation: false,
236            restore_input: true,
237            restore_cursor: true,
238        };
239        if state.secure_prompt.is_none() {
240            self.input_enabled = false;
241        }
242        self.cursor_visible = false;
243        self.modal = Some(state);
244        self.mark_dirty();
245    }
246
247    /// Show a modal with a list of selectable items
248    pub(super) fn show_list_modal(
249        &mut self,
250        title: String,
251        lines: Vec<String>,
252        items: Vec<InlineListItem>,
253        selected: Option<InlineListSelection>,
254        search: Option<InlineListSearchConfig>,
255    ) {
256        self.ensure_inline_lists_visible_for_trigger();
257        let mut list_state = ModalListState::new(items, selected.clone());
258        let search_state = search.map(ModalSearchState::from);
259        if let Some(search) = &search_state {
260            list_state.apply_search_with_preference(&search.query, selected);
261        }
262        let state = ModalState {
263            title,
264            lines,
265            footer_hint: None,
266            list: Some(list_state),
267            search: search_state,
268            secure_prompt: None,
269            is_plan_confirmation: false,
270            restore_input: true,
271            restore_cursor: true,
272        };
273        self.input_enabled = false;
274        self.cursor_visible = false;
275        self.modal = Some(state);
276        self.mark_dirty();
277    }
278
279    /// Show a multi-step wizard modal with tabs for navigation
280    pub(super) fn show_wizard_modal(
281        &mut self,
282        title: String,
283        steps: Vec<WizardStep>,
284        current_step: usize,
285        search: Option<InlineListSearchConfig>,
286        mode: WizardModalMode,
287    ) {
288        self.ensure_inline_lists_visible_for_trigger();
289        let wizard = WizardModalState::new(title, steps, current_step, search, mode);
290        self.wizard_modal = Some(wizard);
291        self.input_enabled = false;
292        self.cursor_visible = false;
293        self.mark_dirty();
294    }
295
296    /// Close the currently open modal
297    pub(super) fn close_modal(&mut self) {
298        if let Some(state) = self.modal.take() {
299            self.input_enabled = true;
300            self.cursor_visible = true;
301            if state.secure_prompt.is_some() {
302                // Secure prompt modal closed, don't restore input
303            }
304            self.mark_dirty();
305            return;
306        }
307
308        if self.wizard_modal.take().is_some() {
309            self.input_enabled = true;
310            self.cursor_visible = true;
311            self.mark_dirty();
312        }
313    }
314
315    /// Scroll operations
316    ///
317    /// Note: The scroll offset model is inverted for chat-style display:
318    /// offset=0 shows the bottom (newest content), offset=max shows the top.
319    /// Therefore "scroll up" (show older content) increases the offset, and
320    /// "scroll down" (show newer content) decreases it.
321    pub fn scroll_line_up(&mut self) {
322        self.mark_scrolling();
323        let previous_offset = self.scroll_manager.offset();
324        self.scroll_manager.scroll_down(1);
325        if self.scroll_manager.offset() != previous_offset {
326            self.user_scrolled = self.scroll_manager.offset() != 0;
327            self.visible_lines_cache = None;
328        }
329    }
330
331    pub fn scroll_line_down(&mut self) {
332        self.mark_scrolling();
333        let previous_offset = self.scroll_manager.offset();
334        self.scroll_manager.scroll_up(1);
335        if self.scroll_manager.offset() != previous_offset {
336            self.user_scrolled = self.scroll_manager.offset() != 0;
337            self.visible_lines_cache = None;
338        }
339    }
340
341    pub(super) fn scroll_page_up(&mut self) {
342        self.mark_scrolling();
343        let previous_offset = self.scroll_manager.offset();
344        self.scroll_manager
345            .scroll_down(self.viewport_height().max(1));
346        if self.scroll_manager.offset() != previous_offset {
347            self.user_scrolled = self.scroll_manager.offset() != 0;
348            self.visible_lines_cache = None;
349        }
350    }
351
352    pub(super) fn scroll_page_down(&mut self) {
353        self.mark_scrolling();
354        let page = self.viewport_height().max(1);
355        let previous_offset = self.scroll_manager.offset();
356        self.scroll_manager.scroll_up(page);
357        if self.scroll_manager.offset() != previous_offset {
358            self.user_scrolled = self.scroll_manager.offset() != 0;
359            self.visible_lines_cache = None;
360        }
361    }
362
363    pub(crate) fn viewport_height(&self) -> usize {
364        self.transcript_rows.max(1) as usize
365    }
366
367    /// Apply coalesced scroll from accumulated scroll events
368    /// This is more efficient than calling scroll_line_up/down multiple times
369    pub(crate) fn apply_coalesced_scroll(&mut self, line_delta: i32, page_delta: i32) {
370        self.mark_scrolling();
371        let previous_offset = self.scroll_manager.offset();
372
373        // Apply page scroll first (larger movements)
374        // Inverted offset model: positive delta = scroll down visually = decrease offset
375        if page_delta != 0 {
376            let page_size = self.viewport_height().max(1);
377            if page_delta > 0 {
378                self.scroll_manager
379                    .scroll_up(page_size * page_delta.unsigned_abs() as usize);
380            } else {
381                self.scroll_manager
382                    .scroll_down(page_size * page_delta.unsigned_abs() as usize);
383            }
384        }
385
386        // Then apply line scroll
387        if line_delta != 0 {
388            if line_delta > 0 {
389                self.scroll_manager
390                    .scroll_up(line_delta.unsigned_abs() as usize);
391            } else {
392                self.scroll_manager
393                    .scroll_down(line_delta.unsigned_abs() as usize);
394            }
395        }
396
397        // Invalidate visible lines cache if offset actually changed
398        if self.scroll_manager.offset() != previous_offset {
399            self.visible_lines_cache = None;
400        }
401    }
402
403    /// Invalidate scroll metrics to force recalculation
404    pub(super) fn invalidate_scroll_metrics(&mut self) {
405        self.scroll_manager.invalidate_metrics();
406        self.invalidate_transcript_cache();
407    }
408
409    /// Invalidate the transcript cache
410    pub(super) fn invalidate_transcript_cache(&mut self) {
411        if let Some(cache) = self.transcript_cache.as_mut() {
412            cache.invalidate_content();
413        }
414        self.visible_lines_cache = None;
415        self.transcript_content_changed = true;
416
417        // If no specific line was marked dirty, assume everything needs reflow
418        if self.first_dirty_line.is_none() {
419            self.first_dirty_line = Some(0);
420        }
421    }
422
423    /// Get the current maximum scroll offset
424    pub(super) fn current_max_scroll_offset(&mut self) -> usize {
425        self.ensure_scroll_metrics();
426        self.scroll_manager.max_offset()
427    }
428
429    /// Enforce scroll bounds after viewport changes
430    pub(super) fn enforce_scroll_bounds(&mut self) {
431        let max_offset = self.current_max_scroll_offset();
432        if self.scroll_manager.offset() > max_offset {
433            self.scroll_manager.set_offset(max_offset);
434        }
435    }
436
437    /// Ensure scroll metrics are up to date
438    pub(super) fn ensure_scroll_metrics(&mut self) {
439        if self.scroll_manager.metrics_valid() {
440            return;
441        }
442
443        let viewport_rows = self.viewport_height();
444        if self.transcript_width == 0 || viewport_rows == 0 {
445            self.scroll_manager.set_total_rows(0);
446            return;
447        }
448
449        let padding = usize::from(ui::INLINE_TRANSCRIPT_BOTTOM_PADDING);
450        let effective_padding = padding.min(viewport_rows.saturating_sub(1));
451        let total_rows = self.total_transcript_rows(self.transcript_width) + effective_padding;
452        self.scroll_manager.set_total_rows(total_rows);
453    }
454
455    /// Prepare transcript scroll parameters
456    pub(crate) fn prepare_transcript_scroll(
457        &mut self,
458        total_rows: usize,
459        viewport_rows: usize,
460    ) -> (usize, usize) {
461        let viewport = viewport_rows.max(1);
462        let clamped_total = total_rows.max(1);
463        self.scroll_manager.set_total_rows(clamped_total);
464        self.scroll_manager.set_viewport_rows(viewport as u16);
465        let max_offset = self.scroll_manager.max_offset();
466
467        if self.scroll_manager.offset() > max_offset {
468            self.scroll_manager.set_offset(max_offset);
469        }
470
471        let top_offset = max_offset.saturating_sub(self.scroll_manager.offset());
472        (top_offset, clamped_total)
473    }
474
475    /// Adjust scroll position after content changes
476    pub(super) fn adjust_scroll_after_change(&mut self, previous_max_offset: usize) {
477        use std::cmp::min;
478
479        let new_max_offset = self.current_max_scroll_offset();
480        let current_offset = self.scroll_manager.offset();
481
482        if current_offset >= previous_max_offset && new_max_offset > previous_max_offset {
483            self.scroll_manager.set_offset(new_max_offset);
484        } else if current_offset > 0 && new_max_offset > previous_max_offset {
485            let delta = new_max_offset - previous_max_offset;
486            self.scroll_manager
487                .set_offset(min(current_offset + delta, new_max_offset));
488        }
489        self.enforce_scroll_bounds();
490    }
491
492    /// Emit an inline event through the channel and callback
493    #[inline]
494    pub(super) fn emit_inline_event(
495        &self,
496        event: &InlineEvent,
497        events: &UnboundedSender<InlineEvent>,
498        callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
499    ) {
500        if let Some(cb) = callback {
501            cb(event);
502        }
503        let _ = events.send(event.clone());
504    }
505
506    /// Handle scroll down event
507    #[inline]
508    #[allow(dead_code)]
509    pub(super) fn handle_scroll_down(
510        &mut self,
511        events: &UnboundedSender<InlineEvent>,
512        callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
513    ) {
514        self.scroll_line_down();
515        self.mark_dirty();
516        self.emit_inline_event(&InlineEvent::ScrollLineDown, events, callback);
517    }
518
519    /// Handle scroll up event
520    #[inline]
521    #[allow(dead_code)]
522    pub(super) fn handle_scroll_up(
523        &mut self,
524        events: &UnboundedSender<InlineEvent>,
525        callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
526    ) {
527        self.scroll_line_up();
528        self.mark_dirty();
529        self.emit_inline_event(&InlineEvent::ScrollLineUp, events, callback);
530    }
531}