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