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