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