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;
27use tui_popup::PopupState;
28
29impl Session {
30    /// Get the next revision counter for message tracking
31    pub(crate) fn next_revision(&mut self) -> u64 {
32        self.line_revision_counter = self.line_revision_counter.wrapping_add(1);
33        self.line_revision_counter
34    }
35
36    /// Check if the session should exit
37    pub fn should_exit(&self) -> bool {
38        self.should_exit
39    }
40
41    /// Request session exit
42    pub fn request_exit(&mut self) {
43        self.should_exit = true;
44    }
45
46    /// Take the redraw flag and reset it
47    ///
48    /// Returns true if a redraw was needed
49    pub fn take_redraw(&mut self) -> bool {
50        if self.needs_redraw {
51            self.needs_redraw = false;
52            true
53        } else {
54            false
55        }
56    }
57
58    /// Mark the session as needing a redraw
59    pub fn mark_dirty(&mut self) {
60        self.needs_redraw = true;
61        self.header_lines_cache = None;
62        self.queued_inputs_preview_cache = None;
63    }
64
65    /// Invalidate only the header cache (e.g. when provider/model changes)
66    pub fn invalidate_header_cache(&mut self) {
67        self.header_lines_cache = None;
68        self.header_height_cache.clear();
69        self.mark_dirty();
70    }
71
72    /// Invalidate only the sidebar cache (e.g. when queue changes)
73    pub fn invalidate_sidebar_cache(&mut self) {
74        self.queued_inputs_preview_cache = None;
75        self.mark_dirty();
76    }
77
78    pub(crate) fn set_transcript_area(&mut self, area: Option<Rect>) {
79        self.transcript_area = area;
80    }
81
82    pub(crate) fn set_input_area(&mut self, area: Option<Rect>) {
83        self.input_area = area;
84    }
85
86    /// Advance animation state on tick and request redraw when a frame changes.
87    pub fn handle_tick(&mut self) {
88        let motion_reduced = self.appearance.motion_reduced();
89        let mut animation_updated = false;
90        if !motion_reduced && self.thinking_spinner.is_active && self.thinking_spinner.update() {
91            animation_updated = true;
92        }
93        let shimmer_active = if self.appearance.should_animate_progress_status() {
94            self.is_shimmer_active()
95        } else {
96            false
97        };
98        if shimmer_active && self.shimmer_state.update() {
99            animation_updated = true;
100        }
101        if let Some(until) = self.scroll_cursor_steady_until
102            && Instant::now() >= until
103        {
104            self.scroll_cursor_steady_until = None;
105            self.needs_redraw = true;
106        }
107        if self.last_shimmer_active && !shimmer_active {
108            self.needs_redraw = true;
109        }
110        self.last_shimmer_active = shimmer_active;
111        if animation_updated {
112            self.needs_redraw = true;
113        }
114    }
115
116    pub(crate) fn is_running_activity(&self) -> bool {
117        let left = self.input_status_left.as_deref().unwrap_or("");
118        let running_status = self.appearance.should_animate_progress_status()
119            && (left.contains("Running command:")
120                || left.contains("Running tool:")
121                || left.contains("Running:")
122                || status_requires_shimmer(left));
123        let active_pty = self
124            .active_pty_sessions
125            .as_ref()
126            .map(|counter| counter.load(Ordering::Relaxed) > 0)
127            .unwrap_or(false);
128        running_status || active_pty
129    }
130
131    pub(crate) fn has_status_spinner(&self) -> bool {
132        if !self.appearance.should_animate_progress_status() {
133            return false;
134        }
135        let Some(left) = self.input_status_left.as_deref() else {
136            return false;
137        };
138        status_requires_shimmer(left)
139    }
140
141    pub(super) fn is_shimmer_active(&self) -> bool {
142        self.has_status_spinner()
143    }
144
145    pub(crate) fn use_steady_cursor(&self) -> bool {
146        if !self.appearance.should_animate_progress_status() {
147            self.scroll_cursor_steady_until.is_some()
148        } else {
149            self.is_shimmer_active() || self.scroll_cursor_steady_until.is_some()
150        }
151    }
152
153    pub(super) fn mark_scrolling(&mut self) {
154        let steady_duration = Duration::from_millis(ui::TUI_SCROLL_CURSOR_STEADY_MS);
155        if steady_duration.is_zero() {
156            self.scroll_cursor_steady_until = None;
157        } else {
158            self.scroll_cursor_steady_until = Some(Instant::now() + steady_duration);
159        }
160    }
161
162    /// Mark a specific line as dirty to optimize reflow scans
163    pub(crate) fn mark_line_dirty(&mut self, index: usize) {
164        self.first_dirty_line = match self.first_dirty_line {
165            Some(current) => Some(current.min(index)),
166            None => Some(index),
167        };
168        self.mark_dirty();
169    }
170
171    /// Ensure the prompt style has a color set
172    pub(super) fn ensure_prompt_style_color(&mut self) {
173        if self.prompt_style.color.is_none() {
174            self.prompt_style.color = self.theme.primary.or(self.theme.foreground);
175        }
176    }
177
178    /// Clear the screen and reset scroll
179    pub(super) fn clear_screen(&mut self) {
180        self.lines.clear();
181        self.collapsed_pastes.clear();
182        self.user_scrolled = false;
183        self.scroll_manager.set_offset(0);
184        self.invalidate_transcript_cache();
185        self.invalidate_scroll_metrics();
186        self.needs_full_clear = true;
187        self.mark_dirty();
188    }
189
190    /// Toggle logs panel visibility
191    pub(super) fn toggle_logs(&mut self) {
192        self.show_logs = !self.show_logs;
193        self.invalidate_scroll_metrics();
194        self.mark_dirty();
195    }
196
197    /// Show a simple modal dialog
198    pub(super) fn show_modal(
199        &mut self,
200        title: String,
201        lines: Vec<String>,
202        secure_prompt: Option<SecurePromptConfig>,
203    ) {
204        let state = ModalState {
205            title,
206            lines,
207            footer_hint: None,
208            list: None,
209            search: None,
210            secure_prompt,
211            is_plan_confirmation: false,
212            popup_state: PopupState::default(),
213            restore_input: true,
214            restore_cursor: true,
215        };
216        if state.secure_prompt.is_none() {
217            self.input_enabled = false;
218        }
219        self.cursor_visible = false;
220        self.modal = Some(state);
221        self.mark_dirty();
222    }
223
224    /// Show a modal with a list of selectable items
225    pub(super) fn show_list_modal(
226        &mut self,
227        title: String,
228        lines: Vec<String>,
229        items: Vec<InlineListItem>,
230        selected: Option<InlineListSelection>,
231        search: Option<InlineListSearchConfig>,
232    ) {
233        let mut list_state = ModalListState::new(items, selected.clone());
234        let search_state = search.map(ModalSearchState::from);
235        if let Some(search) = &search_state {
236            list_state.apply_search_with_preference(&search.query, selected);
237        }
238        let state = ModalState {
239            title,
240            lines,
241            footer_hint: None,
242            list: Some(list_state),
243            search: search_state,
244            secure_prompt: None,
245            is_plan_confirmation: false,
246            popup_state: PopupState::default(),
247            restore_input: true,
248            restore_cursor: true,
249        };
250        self.input_enabled = false;
251        self.cursor_visible = false;
252        self.modal = Some(state);
253        self.mark_dirty();
254    }
255
256    /// Show a multi-step wizard modal with tabs for navigation
257    pub(super) fn show_wizard_modal(
258        &mut self,
259        title: String,
260        steps: Vec<WizardStep>,
261        current_step: usize,
262        search: Option<InlineListSearchConfig>,
263        mode: WizardModalMode,
264    ) {
265        let wizard = WizardModalState::new(title, steps, current_step, search, mode);
266        self.wizard_modal = Some(wizard);
267        self.input_enabled = false;
268        self.cursor_visible = false;
269        self.mark_dirty();
270    }
271
272    /// Close the currently open modal
273    pub(super) fn close_modal(&mut self) {
274        if let Some(state) = self.modal.take() {
275            self.input_enabled = true;
276            self.cursor_visible = true;
277            if state.secure_prompt.is_some() {
278                // Secure prompt modal closed, don't restore input
279            }
280            self.mark_dirty();
281            return;
282        }
283
284        if self.wizard_modal.take().is_some() {
285            self.input_enabled = true;
286            self.cursor_visible = true;
287            self.mark_dirty();
288        }
289    }
290
291    /// Scroll operations
292    ///
293    /// Note: The scroll offset model is inverted for chat-style display:
294    /// offset=0 shows the bottom (newest content), offset=max shows the top.
295    /// Therefore "scroll up" (show older content) increases the offset, and
296    /// "scroll down" (show newer content) decreases it.
297    pub fn scroll_line_up(&mut self) {
298        self.mark_scrolling();
299        let previous_offset = self.scroll_manager.offset();
300        self.scroll_manager.scroll_down(1);
301        if self.scroll_manager.offset() != previous_offset {
302            self.user_scrolled = self.scroll_manager.offset() != 0;
303            self.visible_lines_cache = None;
304        }
305    }
306
307    pub fn scroll_line_down(&mut self) {
308        self.mark_scrolling();
309        let previous_offset = self.scroll_manager.offset();
310        self.scroll_manager.scroll_up(1);
311        if self.scroll_manager.offset() != previous_offset {
312            self.user_scrolled = self.scroll_manager.offset() != 0;
313            self.visible_lines_cache = None;
314        }
315    }
316
317    pub(super) fn scroll_page_up(&mut self) {
318        self.mark_scrolling();
319        let previous_offset = self.scroll_manager.offset();
320        self.scroll_manager
321            .scroll_down(self.viewport_height().max(1));
322        if self.scroll_manager.offset() != previous_offset {
323            self.user_scrolled = self.scroll_manager.offset() != 0;
324            self.visible_lines_cache = None;
325        }
326    }
327
328    pub(super) fn scroll_page_down(&mut self) {
329        self.mark_scrolling();
330        let page = self.viewport_height().max(1);
331        let previous_offset = self.scroll_manager.offset();
332        self.scroll_manager.scroll_up(page);
333        if self.scroll_manager.offset() != previous_offset {
334            self.user_scrolled = self.scroll_manager.offset() != 0;
335            self.visible_lines_cache = None;
336        }
337    }
338
339    pub(crate) fn viewport_height(&self) -> usize {
340        self.transcript_rows.max(1) as usize
341    }
342
343    /// Apply coalesced scroll from accumulated scroll events
344    /// This is more efficient than calling scroll_line_up/down multiple times
345    pub(crate) fn apply_coalesced_scroll(&mut self, line_delta: i32, page_delta: i32) {
346        self.mark_scrolling();
347        let previous_offset = self.scroll_manager.offset();
348
349        // Apply page scroll first (larger movements)
350        // Inverted offset model: positive delta = scroll down visually = decrease offset
351        if page_delta != 0 {
352            let page_size = self.viewport_height().max(1);
353            if page_delta > 0 {
354                self.scroll_manager
355                    .scroll_up(page_size * page_delta.unsigned_abs() as usize);
356            } else {
357                self.scroll_manager
358                    .scroll_down(page_size * page_delta.unsigned_abs() as usize);
359            }
360        }
361
362        // Then apply line scroll
363        if line_delta != 0 {
364            if line_delta > 0 {
365                self.scroll_manager
366                    .scroll_up(line_delta.unsigned_abs() as usize);
367            } else {
368                self.scroll_manager
369                    .scroll_down(line_delta.unsigned_abs() as usize);
370            }
371        }
372
373        // Invalidate visible lines cache if offset actually changed
374        if self.scroll_manager.offset() != previous_offset {
375            self.visible_lines_cache = None;
376        }
377    }
378
379    /// Invalidate scroll metrics to force recalculation
380    pub(super) fn invalidate_scroll_metrics(&mut self) {
381        self.scroll_manager.invalidate_metrics();
382        self.invalidate_transcript_cache();
383    }
384
385    /// Invalidate the transcript cache
386    pub(super) fn invalidate_transcript_cache(&mut self) {
387        if let Some(cache) = self.transcript_cache.as_mut() {
388            cache.invalidate_content();
389        }
390        self.visible_lines_cache = None;
391        self.transcript_content_changed = true;
392
393        // If no specific line was marked dirty, assume everything needs reflow
394        if self.first_dirty_line.is_none() {
395            self.first_dirty_line = Some(0);
396        }
397    }
398
399    /// Get the current maximum scroll offset
400    pub(super) fn current_max_scroll_offset(&mut self) -> usize {
401        self.ensure_scroll_metrics();
402        self.scroll_manager.max_offset()
403    }
404
405    /// Enforce scroll bounds after viewport changes
406    pub(super) fn enforce_scroll_bounds(&mut self) {
407        let max_offset = self.current_max_scroll_offset();
408        if self.scroll_manager.offset() > max_offset {
409            self.scroll_manager.set_offset(max_offset);
410        }
411    }
412
413    /// Ensure scroll metrics are up to date
414    pub(super) fn ensure_scroll_metrics(&mut self) {
415        if self.scroll_manager.metrics_valid() {
416            return;
417        }
418
419        let viewport_rows = self.viewport_height();
420        if self.transcript_width == 0 || viewport_rows == 0 {
421            self.scroll_manager.set_total_rows(0);
422            return;
423        }
424
425        let padding = usize::from(ui::INLINE_TRANSCRIPT_BOTTOM_PADDING);
426        let effective_padding = padding.min(viewport_rows.saturating_sub(1));
427        let total_rows = self.total_transcript_rows(self.transcript_width) + effective_padding;
428        self.scroll_manager.set_total_rows(total_rows);
429    }
430
431    /// Prepare transcript scroll parameters
432    pub(crate) fn prepare_transcript_scroll(
433        &mut self,
434        total_rows: usize,
435        viewport_rows: usize,
436    ) -> (usize, usize) {
437        let viewport = viewport_rows.max(1);
438        let clamped_total = total_rows.max(1);
439        self.scroll_manager.set_total_rows(clamped_total);
440        self.scroll_manager.set_viewport_rows(viewport as u16);
441        let max_offset = self.scroll_manager.max_offset();
442
443        if self.scroll_manager.offset() > max_offset {
444            self.scroll_manager.set_offset(max_offset);
445        }
446
447        let top_offset = max_offset.saturating_sub(self.scroll_manager.offset());
448        (top_offset, clamped_total)
449    }
450
451    /// Adjust scroll position after content changes
452    pub(super) fn adjust_scroll_after_change(&mut self, previous_max_offset: usize) {
453        use std::cmp::min;
454
455        let new_max_offset = self.current_max_scroll_offset();
456        let current_offset = self.scroll_manager.offset();
457
458        if current_offset >= previous_max_offset && new_max_offset > previous_max_offset {
459            self.scroll_manager.set_offset(new_max_offset);
460        } else if current_offset > 0 && new_max_offset > previous_max_offset {
461            let delta = new_max_offset - previous_max_offset;
462            self.scroll_manager
463                .set_offset(min(current_offset + delta, new_max_offset));
464        }
465        self.enforce_scroll_bounds();
466    }
467
468    /// Emit an inline event through the channel and callback
469    #[inline]
470    pub(super) fn emit_inline_event(
471        &self,
472        event: &InlineEvent,
473        events: &UnboundedSender<InlineEvent>,
474        callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
475    ) {
476        if let Some(cb) = callback {
477            cb(event);
478        }
479        let _ = events.send(event.clone());
480    }
481
482    /// Handle scroll down event
483    #[inline]
484    #[allow(dead_code)]
485    pub(super) fn handle_scroll_down(
486        &mut self,
487        events: &UnboundedSender<InlineEvent>,
488        callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
489    ) {
490        self.scroll_line_down();
491        self.mark_dirty();
492        self.emit_inline_event(&InlineEvent::ScrollLineDown, events, callback);
493    }
494
495    /// Handle scroll up event
496    #[inline]
497    #[allow(dead_code)]
498    pub(super) fn handle_scroll_up(
499        &mut self,
500        events: &UnboundedSender<InlineEvent>,
501        callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
502    ) {
503        self.scroll_line_up();
504        self.mark_dirty();
505        self.emit_inline_event(&InlineEvent::ScrollLineUp, events, callback);
506    }
507}