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