vtcode_tui/core_tui/session/
state.rs1use 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, InlineListSelection, ListOverlayRequest, ModalOverlayRequest, OverlayRequest,
19 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 clear_suggested_prompt_state(&mut self) {
30 if !self.suggested_prompt_state.active {
31 return;
32 }
33 self.suggested_prompt_state = SuggestedPromptState::default();
34 self.mark_dirty();
35 }
36
37 pub(crate) fn next_revision(&mut self) -> u64 {
39 self.line_revision_counter = self.line_revision_counter.wrapping_add(1);
40 self.line_revision_counter
41 }
42
43 pub fn should_exit(&self) -> bool {
45 self.should_exit
46 }
47
48 pub fn request_exit(&mut self) {
50 self.should_exit = true;
51 }
52
53 pub fn take_redraw(&mut self) -> bool {
57 if self.needs_redraw {
58 self.needs_redraw = false;
59 true
60 } else {
61 false
62 }
63 }
64
65 pub fn mark_dirty(&mut self) {
67 self.needs_redraw = true;
68 self.header_lines_cache = None;
69 self.queued_inputs_preview_cache = None;
70 }
71
72 pub fn invalidate_header_cache(&mut self) {
74 self.header_lines_cache = None;
75 self.header_height_cache.clear();
76 self.mark_dirty();
77 }
78
79 pub fn invalidate_sidebar_cache(&mut self) {
81 self.queued_inputs_preview_cache = None;
82 self.mark_dirty();
83 }
84
85 pub(crate) fn set_transcript_area(&mut self, area: Option<Rect>) {
86 self.transcript_area = area;
87 }
88
89 pub(crate) fn transcript_area(&self) -> Option<Rect> {
90 self.transcript_area
91 }
92
93 pub(crate) fn set_input_area(&mut self, area: Option<Rect>) {
94 self.input_area = area;
95 }
96
97 pub(crate) fn input_area(&self) -> Option<Rect> {
98 self.input_area
99 }
100
101 pub(crate) fn set_bottom_panel_area(&mut self, area: Option<Rect>) {
102 self.bottom_panel_area = area;
103 }
104
105 pub(crate) fn bottom_panel_area(&self) -> Option<Rect> {
106 self.bottom_panel_area
107 }
108
109 pub(crate) fn set_modal_list_area(&mut self, area: Option<Rect>) {
110 self.modal_list_area = area;
111 }
112
113 pub(crate) fn modal_list_area(&self) -> Option<Rect> {
114 self.modal_list_area
115 }
116
117 pub(crate) fn input_enabled(&self) -> bool {
118 self.input_enabled
119 }
120
121 pub(crate) fn set_input_enabled(&mut self, enabled: bool) {
122 self.input_enabled = enabled;
123 }
124
125 pub(crate) fn input_compact_mode(&self) -> bool {
126 self.input_compact_mode
127 }
128
129 pub(crate) fn set_input_compact_mode(&mut self, enabled: bool) {
130 self.input_compact_mode = enabled;
131 }
132
133 pub(crate) fn set_cursor_visible(&mut self, visible: bool) {
134 self.cursor_visible = visible;
135 }
136
137 pub fn handle_tick(&mut self) {
139 let motion_reduced = self.appearance.motion_reduced();
140 let mut animation_updated = false;
141 if !motion_reduced && self.thinking_spinner.is_active && self.thinking_spinner.update() {
142 animation_updated = true;
143 }
144 let shimmer_active = if self.appearance.should_animate_progress_status() {
145 self.is_shimmer_active()
146 } else {
147 false
148 };
149 if shimmer_active && self.shimmer_state.update() {
150 animation_updated = true;
151 }
152 if let Some(until) = self.scroll_cursor_steady_until
153 && Instant::now() >= until
154 {
155 self.scroll_cursor_steady_until = None;
156 self.needs_redraw = true;
157 }
158 if self.last_shimmer_active && !shimmer_active {
159 self.needs_redraw = true;
160 }
161 self.last_shimmer_active = shimmer_active;
162 if animation_updated {
163 self.needs_redraw = true;
164 }
165 }
166
167 pub(crate) fn is_running_activity(&self) -> bool {
168 let left = self.input_status_left.as_deref().unwrap_or("");
169 let running_status = self.appearance.should_animate_progress_status()
170 && (left.contains("Running command:")
171 || left.contains("Running tool:")
172 || left.contains("Running:")
173 || status_requires_shimmer(left));
174 let active_pty = self.active_pty_session_count() > 0;
175 running_status || active_pty
176 }
177
178 pub(crate) fn active_pty_session_count(&self) -> usize {
179 self.active_pty_sessions
180 .as_ref()
181 .map(|counter| counter.load(Ordering::Relaxed))
182 .unwrap_or(0)
183 }
184
185 pub(crate) fn has_status_spinner(&self) -> bool {
186 if !self.appearance.should_animate_progress_status() {
187 return false;
188 }
189 let Some(left) = self.input_status_left.as_deref() else {
190 return false;
191 };
192 status_requires_shimmer(left)
193 }
194
195 pub(crate) fn is_shimmer_active(&self) -> bool {
196 self.has_status_spinner()
197 }
198
199 pub(crate) fn use_steady_cursor(&self) -> bool {
200 if !self.appearance.should_animate_progress_status() {
201 self.scroll_cursor_steady_until.is_some()
202 } else {
203 self.is_shimmer_active() || self.scroll_cursor_steady_until.is_some()
204 }
205 }
206
207 pub(crate) fn mark_scrolling(&mut self) {
208 let steady_duration = Duration::from_millis(ui::TUI_SCROLL_CURSOR_STEADY_MS);
209 if steady_duration.is_zero() {
210 self.scroll_cursor_steady_until = None;
211 } else {
212 self.scroll_cursor_steady_until = Some(Instant::now() + steady_duration);
213 }
214 }
215
216 pub(crate) fn mark_line_dirty(&mut self, index: usize) {
218 self.first_dirty_line = match self.first_dirty_line {
219 Some(current) => Some(current.min(index)),
220 None => Some(index),
221 };
222 self.mark_dirty();
223 }
224
225 pub(crate) fn ensure_prompt_style_color(&mut self) {
227 if self.prompt_style.color.is_none() {
228 self.prompt_style.color = self.theme.primary.or(self.theme.foreground);
229 }
230 }
231
232 pub(crate) fn clear_screen(&mut self) {
234 self.lines.clear();
235 self.collapsed_pastes.clear();
236 self.user_scrolled = false;
237 self.scroll_manager.set_offset(0);
238 self.invalidate_transcript_cache();
239 self.invalidate_scroll_metrics();
240 self.needs_full_clear = true;
241 self.mark_dirty();
242 }
243
244 pub(crate) fn toggle_logs(&mut self) {
246 self.show_logs = !self.show_logs;
247 self.invalidate_scroll_metrics();
248 self.mark_dirty();
249 }
250
251 pub(crate) fn show_modal(
253 &mut self,
254 title: String,
255 lines: Vec<String>,
256 secure_prompt: Option<super::super::types::SecurePromptConfig>,
257 ) {
258 self.show_overlay(OverlayRequest::Modal(ModalOverlayRequest {
259 title,
260 lines,
261 secure_prompt,
262 }));
263 }
264
265 pub(crate) fn show_overlay(&mut self, request: OverlayRequest) {
266 if self.has_active_overlay() {
267 self.overlay_queue.push_back(request);
268 self.mark_dirty();
269 return;
270 }
271 self.activate_overlay(request);
272 }
273
274 pub(crate) fn has_active_overlay(&self) -> bool {
275 self.active_overlay.is_some()
276 }
277
278 pub(crate) fn modal_state(&self) -> Option<&ModalState> {
279 self.active_overlay
280 .as_ref()
281 .and_then(ActiveOverlay::as_modal)
282 }
283
284 pub(crate) fn modal_state_mut(&mut self) -> Option<&mut ModalState> {
285 self.active_overlay
286 .as_mut()
287 .and_then(ActiveOverlay::as_modal_mut)
288 }
289
290 pub(crate) fn wizard_overlay(&self) -> Option<&WizardModalState> {
291 self.active_overlay
292 .as_ref()
293 .and_then(ActiveOverlay::as_wizard)
294 }
295
296 pub(crate) fn wizard_overlay_mut(&mut self) -> Option<&mut WizardModalState> {
297 self.active_overlay
298 .as_mut()
299 .and_then(ActiveOverlay::as_wizard_mut)
300 }
301
302 pub(crate) fn take_modal_state(&mut self) -> Option<ModalState> {
303 if !self
304 .active_overlay
305 .as_ref()
306 .is_some_and(|overlay| matches!(overlay, ActiveOverlay::Modal(_)))
307 {
308 return None;
309 }
310
311 match self.active_overlay.take() {
312 Some(ActiveOverlay::Modal(state)) => Some(*state),
313 Some(ActiveOverlay::Wizard(_)) | None => None,
314 }
315 }
316
317 fn activate_overlay(&mut self, request: OverlayRequest) {
318 match request {
319 OverlayRequest::Modal(request) => {
320 self.clear_last_overlay_list_cache();
321 self.activate_modal_overlay(request);
322 }
323 OverlayRequest::List(request) => self.activate_list_overlay(request),
324 OverlayRequest::Wizard(request) => {
325 self.clear_last_overlay_list_cache();
326 self.activate_wizard_overlay(request);
327 }
328 }
329 }
330
331 pub(crate) fn close_overlay(&mut self) {
332 let Some(state) = self.active_overlay.take() else {
333 return;
334 };
335
336 self.cache_last_overlay_list_state(&state);
337 self.input_enabled = state.restore_input();
338 self.cursor_visible = state.restore_cursor();
339
340 if let Some(next_request) = self.overlay_queue.pop_front() {
341 self.activate_overlay(next_request);
342 return;
343 }
344
345 self.mark_dirty();
346 }
347
348 fn activate_modal_overlay(&mut self, request: ModalOverlayRequest) {
349 let state = ModalState {
350 title: request.title,
351 lines: request.lines,
352 footer_hint: None,
353 hotkeys: Vec::new(),
354 list: None,
355 search: None,
356 secure_prompt: request.secure_prompt,
357 restore_input: true,
358 restore_cursor: true,
359 };
360 if state.secure_prompt.is_none() {
361 self.input_enabled = false;
362 }
363 self.cursor_visible = false;
364 self.active_overlay = Some(ActiveOverlay::Modal(Box::new(state)));
365 self.mark_dirty();
366 }
367
368 fn activate_list_overlay(&mut self, request: ListOverlayRequest) {
369 let anchor_to_bottom = self.should_anchor_list_to_bottom(request.selected.as_ref());
370 let mut list_state = ModalListState::new(request.items, request.selected.clone());
371 let search_state = request.search.map(ModalSearchState::from);
372 if let Some(search) = &search_state {
373 list_state.apply_search_with_preference(&search.query, request.selected);
374 }
375 if anchor_to_bottom {
376 list_state.select_last();
377 }
378 self.clear_last_overlay_list_cache();
379 let state = ModalState {
380 title: request.title,
381 lines: request.lines,
382 footer_hint: request.footer_hint,
383 hotkeys: request.hotkeys,
384 list: Some(list_state),
385 search: search_state,
386 secure_prompt: None,
387 restore_input: true,
388 restore_cursor: true,
389 };
390 self.input_enabled = false;
391 self.cursor_visible = false;
392 self.active_overlay = Some(ActiveOverlay::Modal(Box::new(state)));
393 self.mark_dirty();
394 }
395
396 fn cache_last_overlay_list_state(&mut self, overlay: &ActiveOverlay) {
397 if let ActiveOverlay::Modal(state) = overlay
398 && let Some(list) = state.list.as_ref()
399 {
400 self.last_overlay_list_selection = list.current_selection();
401 self.last_overlay_list_was_last = list.selected_is_last();
402 return;
403 }
404
405 self.last_overlay_list_selection = None;
406 self.last_overlay_list_was_last = false;
407 }
408
409 fn should_anchor_list_to_bottom(&self, preferred: Option<&InlineListSelection>) -> bool {
410 self.last_overlay_list_was_last && self.last_overlay_list_selection.as_ref() == preferred
411 }
412
413 fn clear_last_overlay_list_cache(&mut self) {
414 self.last_overlay_list_selection = None;
415 self.last_overlay_list_was_last = false;
416 }
417
418 fn activate_wizard_overlay(&mut self, request: WizardOverlayRequest) {
419 let wizard = WizardModalState::new(
420 request.title,
421 request.steps,
422 request.current_step,
423 request.search,
424 request.mode,
425 );
426 self.active_overlay = Some(ActiveOverlay::Wizard(Box::new(wizard)));
427 self.input_enabled = false;
428 self.cursor_visible = false;
429 self.mark_dirty();
430 }
431
432 pub fn scroll_line_up(&mut self) {
439 self.mark_scrolling();
440 let previous_offset = self.scroll_manager.offset();
441 self.scroll_manager.scroll_down(1);
442 if self.scroll_manager.offset() != previous_offset {
443 self.user_scrolled = self.scroll_manager.offset() != 0;
444 self.visible_lines_cache = None;
445 }
446 }
447
448 pub fn scroll_line_down(&mut self) {
449 self.mark_scrolling();
450 let previous_offset = self.scroll_manager.offset();
451 self.scroll_manager.scroll_up(1);
452 if self.scroll_manager.offset() != previous_offset {
453 self.user_scrolled = self.scroll_manager.offset() != 0;
454 self.visible_lines_cache = None;
455 }
456 }
457
458 pub(crate) fn scroll_page_up(&mut self) {
459 self.mark_scrolling();
460 let previous_offset = self.scroll_manager.offset();
461 self.scroll_manager
462 .scroll_down(self.viewport_height().max(1));
463 if self.scroll_manager.offset() != previous_offset {
464 self.user_scrolled = self.scroll_manager.offset() != 0;
465 self.visible_lines_cache = None;
466 }
467 }
468
469 pub(crate) fn scroll_page_down(&mut self) {
470 self.mark_scrolling();
471 let page = self.viewport_height().max(1);
472 let previous_offset = self.scroll_manager.offset();
473 self.scroll_manager.scroll_up(page);
474 if self.scroll_manager.offset() != previous_offset {
475 self.user_scrolled = self.scroll_manager.offset() != 0;
476 self.visible_lines_cache = None;
477 }
478 }
479
480 pub(crate) fn viewport_height(&self) -> usize {
481 self.transcript_rows.max(1) as usize
482 }
483
484 pub(crate) fn apply_coalesced_scroll(&mut self, line_delta: i32, page_delta: i32) {
487 self.mark_scrolling();
488 let previous_offset = self.scroll_manager.offset();
489
490 if page_delta != 0 {
493 let page_size = self.viewport_height().max(1);
494 if page_delta > 0 {
495 self.scroll_manager
496 .scroll_up(page_size * page_delta.unsigned_abs() as usize);
497 } else {
498 self.scroll_manager
499 .scroll_down(page_size * page_delta.unsigned_abs() as usize);
500 }
501 }
502
503 if line_delta != 0 {
505 if line_delta > 0 {
506 self.scroll_manager
507 .scroll_up(line_delta.unsigned_abs() as usize);
508 } else {
509 self.scroll_manager
510 .scroll_down(line_delta.unsigned_abs() as usize);
511 }
512 }
513
514 if self.scroll_manager.offset() != previous_offset {
516 self.visible_lines_cache = None;
517 }
518 }
519
520 pub(crate) fn invalidate_scroll_metrics(&mut self) {
522 self.scroll_manager.invalidate_metrics();
523 self.invalidate_transcript_cache();
524 }
525
526 pub(crate) fn invalidate_transcript_cache(&mut self) {
528 if let Some(cache) = self.transcript_cache.as_mut() {
529 cache.invalidate_content();
530 }
531 self.visible_lines_cache = None;
532 self.transcript_content_changed = true;
533
534 if self.first_dirty_line.is_none() {
536 self.first_dirty_line = Some(0);
537 }
538 }
539
540 pub(crate) fn current_max_scroll_offset(&mut self) -> usize {
542 self.ensure_scroll_metrics();
543 self.scroll_manager.max_offset()
544 }
545
546 pub(crate) fn enforce_scroll_bounds(&mut self) {
548 let max_offset = self.current_max_scroll_offset();
549 if self.scroll_manager.offset() > max_offset {
550 self.scroll_manager.set_offset(max_offset);
551 }
552 }
553
554 pub(crate) fn ensure_scroll_metrics(&mut self) {
556 if self.scroll_manager.metrics_valid() {
557 return;
558 }
559
560 let viewport_rows = self.viewport_height();
561 if self.transcript_width == 0 || viewport_rows == 0 {
562 self.scroll_manager.set_total_rows(0);
563 return;
564 }
565
566 let padding = usize::from(ui::INLINE_TRANSCRIPT_BOTTOM_PADDING);
567 let effective_padding = padding.min(viewport_rows.saturating_sub(1));
568 let total_rows = self.total_transcript_rows(self.transcript_width) + effective_padding;
569 self.scroll_manager.set_total_rows(total_rows);
570 }
571
572 pub(crate) fn prepare_transcript_scroll(
574 &mut self,
575 total_rows: usize,
576 viewport_rows: usize,
577 ) -> (usize, usize) {
578 let viewport = viewport_rows.max(1);
579 let clamped_total = total_rows.max(1);
580 self.scroll_manager.set_total_rows(clamped_total);
581 self.scroll_manager.set_viewport_rows(viewport as u16);
582 let max_offset = self.scroll_manager.max_offset();
583
584 if self.scroll_manager.offset() > max_offset {
585 self.scroll_manager.set_offset(max_offset);
586 }
587
588 let top_offset = max_offset.saturating_sub(self.scroll_manager.offset());
589 (top_offset, clamped_total)
590 }
591
592 pub(crate) fn adjust_scroll_after_change(&mut self, previous_max_offset: usize) {
594 use std::cmp::min;
595
596 let new_max_offset = self.current_max_scroll_offset();
597 let current_offset = self.scroll_manager.offset();
598
599 if current_offset >= previous_max_offset && new_max_offset > previous_max_offset {
600 self.scroll_manager.set_offset(new_max_offset);
601 } else if current_offset > 0 && new_max_offset > previous_max_offset {
602 let delta = new_max_offset - previous_max_offset;
603 self.scroll_manager
604 .set_offset(min(current_offset + delta, new_max_offset));
605 }
606 self.enforce_scroll_bounds();
607 }
608
609 #[inline]
611 pub(crate) fn emit_inline_event(
612 &self,
613 event: &InlineEvent,
614 events: &UnboundedSender<InlineEvent>,
615 callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
616 ) {
617 if let Some(cb) = callback {
618 cb(event);
619 }
620 let _ = events.send(event.clone());
621 }
622
623 #[inline]
625 #[allow(dead_code)]
626 pub(crate) fn handle_scroll_down(
627 &mut self,
628 events: &UnboundedSender<InlineEvent>,
629 callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
630 ) {
631 self.scroll_line_down();
632 self.mark_dirty();
633 self.emit_inline_event(&InlineEvent::ScrollLineDown, events, callback);
634 }
635
636 #[inline]
638 #[allow(dead_code)]
639 pub(crate) fn handle_scroll_up(
640 &mut self,
641 events: &UnboundedSender<InlineEvent>,
642 callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
643 ) {
644 self.scroll_line_up();
645 self.mark_dirty();
646 self.emit_inline_event(&InlineEvent::ScrollLineUp, events, callback);
647 }
648}