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, 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 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
148 .active_pty_sessions
149 .as_ref()
150 .map(|counter| counter.load(Ordering::Relaxed) > 0)
151 .unwrap_or(false);
152 running_status || active_pty
153 }
154
155 pub(crate) fn has_status_spinner(&self) -> bool {
156 if !self.appearance.should_animate_progress_status() {
157 return false;
158 }
159 let Some(left) = self.input_status_left.as_deref() else {
160 return false;
161 };
162 status_requires_shimmer(left)
163 }
164
165 pub(super) fn is_shimmer_active(&self) -> bool {
166 self.has_status_spinner()
167 }
168
169 pub(crate) fn use_steady_cursor(&self) -> bool {
170 if !self.appearance.should_animate_progress_status() {
171 self.scroll_cursor_steady_until.is_some()
172 } else {
173 self.is_shimmer_active() || self.scroll_cursor_steady_until.is_some()
174 }
175 }
176
177 pub(super) fn mark_scrolling(&mut self) {
178 let steady_duration = Duration::from_millis(ui::TUI_SCROLL_CURSOR_STEADY_MS);
179 if steady_duration.is_zero() {
180 self.scroll_cursor_steady_until = None;
181 } else {
182 self.scroll_cursor_steady_until = Some(Instant::now() + steady_duration);
183 }
184 }
185
186 pub(crate) fn mark_line_dirty(&mut self, index: usize) {
188 self.first_dirty_line = match self.first_dirty_line {
189 Some(current) => Some(current.min(index)),
190 None => Some(index),
191 };
192 self.mark_dirty();
193 }
194
195 pub(super) fn ensure_prompt_style_color(&mut self) {
197 if self.prompt_style.color.is_none() {
198 self.prompt_style.color = self.theme.primary.or(self.theme.foreground);
199 }
200 }
201
202 pub(super) fn clear_screen(&mut self) {
204 self.lines.clear();
205 self.collapsed_pastes.clear();
206 self.user_scrolled = false;
207 self.scroll_manager.set_offset(0);
208 self.invalidate_transcript_cache();
209 self.invalidate_scroll_metrics();
210 self.needs_full_clear = true;
211 self.mark_dirty();
212 }
213
214 pub(super) fn toggle_logs(&mut self) {
216 self.show_logs = !self.show_logs;
217 self.invalidate_scroll_metrics();
218 self.mark_dirty();
219 }
220
221 pub(super) fn show_modal(
223 &mut self,
224 title: String,
225 lines: Vec<String>,
226 secure_prompt: Option<SecurePromptConfig>,
227 ) {
228 let state = ModalState {
229 title,
230 lines,
231 footer_hint: None,
232 list: None,
233 search: None,
234 secure_prompt,
235 is_plan_confirmation: false,
236 restore_input: true,
237 restore_cursor: true,
238 };
239 if state.secure_prompt.is_none() {
240 self.input_enabled = false;
241 }
242 self.cursor_visible = false;
243 self.modal = Some(state);
244 self.mark_dirty();
245 }
246
247 pub(super) fn show_list_modal(
249 &mut self,
250 title: String,
251 lines: Vec<String>,
252 items: Vec<InlineListItem>,
253 selected: Option<InlineListSelection>,
254 search: Option<InlineListSearchConfig>,
255 ) {
256 self.ensure_inline_lists_visible_for_trigger();
257 let mut list_state = ModalListState::new(items, selected.clone());
258 let search_state = search.map(ModalSearchState::from);
259 if let Some(search) = &search_state {
260 list_state.apply_search_with_preference(&search.query, selected);
261 }
262 let state = ModalState {
263 title,
264 lines,
265 footer_hint: None,
266 list: Some(list_state),
267 search: search_state,
268 secure_prompt: None,
269 is_plan_confirmation: false,
270 restore_input: true,
271 restore_cursor: true,
272 };
273 self.input_enabled = false;
274 self.cursor_visible = false;
275 self.modal = Some(state);
276 self.mark_dirty();
277 }
278
279 pub(super) fn show_wizard_modal(
281 &mut self,
282 title: String,
283 steps: Vec<WizardStep>,
284 current_step: usize,
285 search: Option<InlineListSearchConfig>,
286 mode: WizardModalMode,
287 ) {
288 self.ensure_inline_lists_visible_for_trigger();
289 let wizard = WizardModalState::new(title, steps, current_step, search, mode);
290 self.wizard_modal = Some(wizard);
291 self.input_enabled = false;
292 self.cursor_visible = false;
293 self.mark_dirty();
294 }
295
296 pub(super) fn close_modal(&mut self) {
298 if let Some(state) = self.modal.take() {
299 self.input_enabled = true;
300 self.cursor_visible = true;
301 if state.secure_prompt.is_some() {
302 }
304 self.mark_dirty();
305 return;
306 }
307
308 if self.wizard_modal.take().is_some() {
309 self.input_enabled = true;
310 self.cursor_visible = true;
311 self.mark_dirty();
312 }
313 }
314
315 pub fn scroll_line_up(&mut self) {
322 self.mark_scrolling();
323 let previous_offset = self.scroll_manager.offset();
324 self.scroll_manager.scroll_down(1);
325 if self.scroll_manager.offset() != previous_offset {
326 self.user_scrolled = self.scroll_manager.offset() != 0;
327 self.visible_lines_cache = None;
328 }
329 }
330
331 pub fn scroll_line_down(&mut self) {
332 self.mark_scrolling();
333 let previous_offset = self.scroll_manager.offset();
334 self.scroll_manager.scroll_up(1);
335 if self.scroll_manager.offset() != previous_offset {
336 self.user_scrolled = self.scroll_manager.offset() != 0;
337 self.visible_lines_cache = None;
338 }
339 }
340
341 pub(super) fn scroll_page_up(&mut self) {
342 self.mark_scrolling();
343 let previous_offset = self.scroll_manager.offset();
344 self.scroll_manager
345 .scroll_down(self.viewport_height().max(1));
346 if self.scroll_manager.offset() != previous_offset {
347 self.user_scrolled = self.scroll_manager.offset() != 0;
348 self.visible_lines_cache = None;
349 }
350 }
351
352 pub(super) fn scroll_page_down(&mut self) {
353 self.mark_scrolling();
354 let page = self.viewport_height().max(1);
355 let previous_offset = self.scroll_manager.offset();
356 self.scroll_manager.scroll_up(page);
357 if self.scroll_manager.offset() != previous_offset {
358 self.user_scrolled = self.scroll_manager.offset() != 0;
359 self.visible_lines_cache = None;
360 }
361 }
362
363 pub(crate) fn viewport_height(&self) -> usize {
364 self.transcript_rows.max(1) as usize
365 }
366
367 pub(crate) fn apply_coalesced_scroll(&mut self, line_delta: i32, page_delta: i32) {
370 self.mark_scrolling();
371 let previous_offset = self.scroll_manager.offset();
372
373 if page_delta != 0 {
376 let page_size = self.viewport_height().max(1);
377 if page_delta > 0 {
378 self.scroll_manager
379 .scroll_up(page_size * page_delta.unsigned_abs() as usize);
380 } else {
381 self.scroll_manager
382 .scroll_down(page_size * page_delta.unsigned_abs() as usize);
383 }
384 }
385
386 if line_delta != 0 {
388 if line_delta > 0 {
389 self.scroll_manager
390 .scroll_up(line_delta.unsigned_abs() as usize);
391 } else {
392 self.scroll_manager
393 .scroll_down(line_delta.unsigned_abs() as usize);
394 }
395 }
396
397 if self.scroll_manager.offset() != previous_offset {
399 self.visible_lines_cache = None;
400 }
401 }
402
403 pub(super) fn invalidate_scroll_metrics(&mut self) {
405 self.scroll_manager.invalidate_metrics();
406 self.invalidate_transcript_cache();
407 }
408
409 pub(super) fn invalidate_transcript_cache(&mut self) {
411 if let Some(cache) = self.transcript_cache.as_mut() {
412 cache.invalidate_content();
413 }
414 self.visible_lines_cache = None;
415 self.transcript_content_changed = true;
416
417 if self.first_dirty_line.is_none() {
419 self.first_dirty_line = Some(0);
420 }
421 }
422
423 pub(super) fn current_max_scroll_offset(&mut self) -> usize {
425 self.ensure_scroll_metrics();
426 self.scroll_manager.max_offset()
427 }
428
429 pub(super) fn enforce_scroll_bounds(&mut self) {
431 let max_offset = self.current_max_scroll_offset();
432 if self.scroll_manager.offset() > max_offset {
433 self.scroll_manager.set_offset(max_offset);
434 }
435 }
436
437 pub(super) fn ensure_scroll_metrics(&mut self) {
439 if self.scroll_manager.metrics_valid() {
440 return;
441 }
442
443 let viewport_rows = self.viewport_height();
444 if self.transcript_width == 0 || viewport_rows == 0 {
445 self.scroll_manager.set_total_rows(0);
446 return;
447 }
448
449 let padding = usize::from(ui::INLINE_TRANSCRIPT_BOTTOM_PADDING);
450 let effective_padding = padding.min(viewport_rows.saturating_sub(1));
451 let total_rows = self.total_transcript_rows(self.transcript_width) + effective_padding;
452 self.scroll_manager.set_total_rows(total_rows);
453 }
454
455 pub(crate) fn prepare_transcript_scroll(
457 &mut self,
458 total_rows: usize,
459 viewport_rows: usize,
460 ) -> (usize, usize) {
461 let viewport = viewport_rows.max(1);
462 let clamped_total = total_rows.max(1);
463 self.scroll_manager.set_total_rows(clamped_total);
464 self.scroll_manager.set_viewport_rows(viewport as u16);
465 let max_offset = self.scroll_manager.max_offset();
466
467 if self.scroll_manager.offset() > max_offset {
468 self.scroll_manager.set_offset(max_offset);
469 }
470
471 let top_offset = max_offset.saturating_sub(self.scroll_manager.offset());
472 (top_offset, clamped_total)
473 }
474
475 pub(super) fn adjust_scroll_after_change(&mut self, previous_max_offset: usize) {
477 use std::cmp::min;
478
479 let new_max_offset = self.current_max_scroll_offset();
480 let current_offset = self.scroll_manager.offset();
481
482 if current_offset >= previous_max_offset && new_max_offset > previous_max_offset {
483 self.scroll_manager.set_offset(new_max_offset);
484 } else if current_offset > 0 && new_max_offset > previous_max_offset {
485 let delta = new_max_offset - previous_max_offset;
486 self.scroll_manager
487 .set_offset(min(current_offset + delta, new_max_offset));
488 }
489 self.enforce_scroll_bounds();
490 }
491
492 #[inline]
494 pub(super) fn emit_inline_event(
495 &self,
496 event: &InlineEvent,
497 events: &UnboundedSender<InlineEvent>,
498 callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
499 ) {
500 if let Some(cb) = callback {
501 cb(event);
502 }
503 let _ = events.send(event.clone());
504 }
505
506 #[inline]
508 #[allow(dead_code)]
509 pub(super) fn handle_scroll_down(
510 &mut self,
511 events: &UnboundedSender<InlineEvent>,
512 callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
513 ) {
514 self.scroll_line_down();
515 self.mark_dirty();
516 self.emit_inline_event(&InlineEvent::ScrollLineDown, events, callback);
517 }
518
519 #[inline]
521 #[allow(dead_code)]
522 pub(super) fn handle_scroll_up(
523 &mut self,
524 events: &UnboundedSender<InlineEvent>,
525 callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
526 ) {
527 self.scroll_line_up();
528 self.mark_dirty();
529 self.emit_inline_event(&InlineEvent::ScrollLineUp, events, callback);
530 }
531}