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 next_revision(&mut self) -> u64 {
31 self.line_revision_counter = self.line_revision_counter.wrapping_add(1);
32 self.line_revision_counter
33 }
34
35 pub fn should_exit(&self) -> bool {
37 self.should_exit
38 }
39
40 pub fn request_exit(&mut self) {
42 self.should_exit = true;
43 }
44
45 pub fn take_redraw(&mut self) -> bool {
49 if self.needs_redraw {
50 self.needs_redraw = false;
51 true
52 } else {
53 false
54 }
55 }
56
57 pub fn mark_dirty(&mut self) {
59 self.needs_redraw = true;
60 self.header_lines_cache = None;
61 self.queued_inputs_preview_cache = None;
62 }
63
64 pub fn invalidate_header_cache(&mut self) {
66 self.header_lines_cache = None;
67 self.header_height_cache.clear();
68 self.mark_dirty();
69 }
70
71 pub fn invalidate_sidebar_cache(&mut self) {
73 self.queued_inputs_preview_cache = None;
74 self.mark_dirty();
75 }
76
77 pub(crate) fn set_transcript_area(&mut self, area: Option<Rect>) {
78 self.transcript_area = area;
79 }
80
81 pub(crate) fn set_input_area(&mut self, area: Option<Rect>) {
82 self.input_area = area;
83 }
84
85 pub fn handle_tick(&mut self) {
87 let motion_reduced = self.appearance.motion_reduced();
88 let mut animation_updated = false;
89 if !motion_reduced && self.thinking_spinner.is_active && self.thinking_spinner.update() {
90 animation_updated = true;
91 }
92 let shimmer_active = if self.appearance.should_animate_progress_status() {
93 self.is_shimmer_active()
94 } else {
95 false
96 };
97 if shimmer_active && self.shimmer_state.update() {
98 animation_updated = true;
99 }
100 if let Some(until) = self.scroll_cursor_steady_until
101 && Instant::now() >= until
102 {
103 self.scroll_cursor_steady_until = None;
104 self.needs_redraw = true;
105 }
106 if self.last_shimmer_active && !shimmer_active {
107 self.needs_redraw = true;
108 }
109 self.last_shimmer_active = shimmer_active;
110 if animation_updated {
111 self.needs_redraw = true;
112 }
113 }
114
115 pub(crate) fn is_running_activity(&self) -> bool {
116 let left = self.input_status_left.as_deref().unwrap_or("");
117 let running_status = self.appearance.should_animate_progress_status()
118 && (left.contains("Running command:")
119 || left.contains("Running tool:")
120 || left.contains("Running:")
121 || status_requires_shimmer(left));
122 let active_pty = self
123 .active_pty_sessions
124 .as_ref()
125 .map(|counter| counter.load(Ordering::Relaxed) > 0)
126 .unwrap_or(false);
127 running_status || active_pty
128 }
129
130 pub(crate) fn has_status_spinner(&self) -> bool {
131 if !self.appearance.should_animate_progress_status() {
132 return false;
133 }
134 let Some(left) = self.input_status_left.as_deref() else {
135 return false;
136 };
137 status_requires_shimmer(left)
138 }
139
140 pub(super) fn is_shimmer_active(&self) -> bool {
141 self.has_status_spinner()
142 }
143
144 pub(crate) fn use_steady_cursor(&self) -> bool {
145 if !self.appearance.should_animate_progress_status() {
146 self.scroll_cursor_steady_until.is_some()
147 } else {
148 self.is_shimmer_active() || self.scroll_cursor_steady_until.is_some()
149 }
150 }
151
152 pub(super) fn mark_scrolling(&mut self) {
153 let steady_duration = Duration::from_millis(ui::TUI_SCROLL_CURSOR_STEADY_MS);
154 if steady_duration.is_zero() {
155 self.scroll_cursor_steady_until = None;
156 } else {
157 self.scroll_cursor_steady_until = Some(Instant::now() + steady_duration);
158 }
159 }
160
161 pub(crate) fn mark_line_dirty(&mut self, index: usize) {
163 self.first_dirty_line = match self.first_dirty_line {
164 Some(current) => Some(current.min(index)),
165 None => Some(index),
166 };
167 self.mark_dirty();
168 }
169
170 pub(super) fn ensure_prompt_style_color(&mut self) {
172 if self.prompt_style.color.is_none() {
173 self.prompt_style.color = self.theme.primary.or(self.theme.foreground);
174 }
175 }
176
177 pub(super) fn clear_screen(&mut self) {
179 self.lines.clear();
180 self.collapsed_pastes.clear();
181 self.user_scrolled = false;
182 self.scroll_manager.set_offset(0);
183 self.invalidate_transcript_cache();
184 self.invalidate_scroll_metrics();
185 self.needs_full_clear = true;
186 self.mark_dirty();
187 }
188
189 pub(super) fn toggle_logs(&mut self) {
191 self.show_logs = !self.show_logs;
192 self.invalidate_scroll_metrics();
193 self.mark_dirty();
194 }
195
196 pub(super) fn show_modal(
198 &mut self,
199 title: String,
200 lines: Vec<String>,
201 secure_prompt: Option<SecurePromptConfig>,
202 ) {
203 let state = ModalState {
204 title,
205 lines,
206 footer_hint: None,
207 list: None,
208 search: None,
209 secure_prompt,
210 is_plan_confirmation: false,
211 restore_input: true,
212 restore_cursor: true,
213 };
214 if state.secure_prompt.is_none() {
215 self.input_enabled = false;
216 }
217 self.cursor_visible = false;
218 self.modal = Some(state);
219 self.mark_dirty();
220 }
221
222 pub(super) fn show_list_modal(
224 &mut self,
225 title: String,
226 lines: Vec<String>,
227 items: Vec<InlineListItem>,
228 selected: Option<InlineListSelection>,
229 search: Option<InlineListSearchConfig>,
230 ) {
231 let mut list_state = ModalListState::new(items, selected.clone());
232 let search_state = search.map(ModalSearchState::from);
233 if let Some(search) = &search_state {
234 list_state.apply_search_with_preference(&search.query, selected);
235 }
236 let state = ModalState {
237 title,
238 lines,
239 footer_hint: None,
240 list: Some(list_state),
241 search: search_state,
242 secure_prompt: None,
243 is_plan_confirmation: false,
244 restore_input: true,
245 restore_cursor: true,
246 };
247 self.input_enabled = false;
248 self.cursor_visible = false;
249 self.modal = Some(state);
250 self.mark_dirty();
251 }
252
253 pub(super) fn show_wizard_modal(
255 &mut self,
256 title: String,
257 steps: Vec<WizardStep>,
258 current_step: usize,
259 search: Option<InlineListSearchConfig>,
260 mode: WizardModalMode,
261 ) {
262 let wizard = WizardModalState::new(title, steps, current_step, search, mode);
263 self.wizard_modal = Some(wizard);
264 self.input_enabled = false;
265 self.cursor_visible = false;
266 self.mark_dirty();
267 }
268
269 pub(super) fn close_modal(&mut self) {
271 if let Some(state) = self.modal.take() {
272 self.input_enabled = true;
273 self.cursor_visible = true;
274 if state.secure_prompt.is_some() {
275 }
277 self.mark_dirty();
278 return;
279 }
280
281 if self.wizard_modal.take().is_some() {
282 self.input_enabled = true;
283 self.cursor_visible = true;
284 self.mark_dirty();
285 }
286 }
287
288 pub fn scroll_line_up(&mut self) {
295 self.mark_scrolling();
296 let previous_offset = self.scroll_manager.offset();
297 self.scroll_manager.scroll_down(1);
298 if self.scroll_manager.offset() != previous_offset {
299 self.user_scrolled = self.scroll_manager.offset() != 0;
300 self.visible_lines_cache = None;
301 }
302 }
303
304 pub fn scroll_line_down(&mut self) {
305 self.mark_scrolling();
306 let previous_offset = self.scroll_manager.offset();
307 self.scroll_manager.scroll_up(1);
308 if self.scroll_manager.offset() != previous_offset {
309 self.user_scrolled = self.scroll_manager.offset() != 0;
310 self.visible_lines_cache = None;
311 }
312 }
313
314 pub(super) fn scroll_page_up(&mut self) {
315 self.mark_scrolling();
316 let previous_offset = self.scroll_manager.offset();
317 self.scroll_manager
318 .scroll_down(self.viewport_height().max(1));
319 if self.scroll_manager.offset() != previous_offset {
320 self.user_scrolled = self.scroll_manager.offset() != 0;
321 self.visible_lines_cache = None;
322 }
323 }
324
325 pub(super) fn scroll_page_down(&mut self) {
326 self.mark_scrolling();
327 let page = self.viewport_height().max(1);
328 let previous_offset = self.scroll_manager.offset();
329 self.scroll_manager.scroll_up(page);
330 if self.scroll_manager.offset() != previous_offset {
331 self.user_scrolled = self.scroll_manager.offset() != 0;
332 self.visible_lines_cache = None;
333 }
334 }
335
336 pub(crate) fn viewport_height(&self) -> usize {
337 self.transcript_rows.max(1) as usize
338 }
339
340 pub(crate) fn apply_coalesced_scroll(&mut self, line_delta: i32, page_delta: i32) {
343 self.mark_scrolling();
344 let previous_offset = self.scroll_manager.offset();
345
346 if page_delta != 0 {
349 let page_size = self.viewport_height().max(1);
350 if page_delta > 0 {
351 self.scroll_manager
352 .scroll_up(page_size * page_delta.unsigned_abs() as usize);
353 } else {
354 self.scroll_manager
355 .scroll_down(page_size * page_delta.unsigned_abs() as usize);
356 }
357 }
358
359 if line_delta != 0 {
361 if line_delta > 0 {
362 self.scroll_manager
363 .scroll_up(line_delta.unsigned_abs() as usize);
364 } else {
365 self.scroll_manager
366 .scroll_down(line_delta.unsigned_abs() as usize);
367 }
368 }
369
370 if self.scroll_manager.offset() != previous_offset {
372 self.visible_lines_cache = None;
373 }
374 }
375
376 pub(super) fn invalidate_scroll_metrics(&mut self) {
378 self.scroll_manager.invalidate_metrics();
379 self.invalidate_transcript_cache();
380 }
381
382 pub(super) fn invalidate_transcript_cache(&mut self) {
384 if let Some(cache) = self.transcript_cache.as_mut() {
385 cache.invalidate_content();
386 }
387 self.visible_lines_cache = None;
388 self.transcript_content_changed = true;
389
390 if self.first_dirty_line.is_none() {
392 self.first_dirty_line = Some(0);
393 }
394 }
395
396 pub(super) fn current_max_scroll_offset(&mut self) -> usize {
398 self.ensure_scroll_metrics();
399 self.scroll_manager.max_offset()
400 }
401
402 pub(super) fn enforce_scroll_bounds(&mut self) {
404 let max_offset = self.current_max_scroll_offset();
405 if self.scroll_manager.offset() > max_offset {
406 self.scroll_manager.set_offset(max_offset);
407 }
408 }
409
410 pub(super) fn ensure_scroll_metrics(&mut self) {
412 if self.scroll_manager.metrics_valid() {
413 return;
414 }
415
416 let viewport_rows = self.viewport_height();
417 if self.transcript_width == 0 || viewport_rows == 0 {
418 self.scroll_manager.set_total_rows(0);
419 return;
420 }
421
422 let padding = usize::from(ui::INLINE_TRANSCRIPT_BOTTOM_PADDING);
423 let effective_padding = padding.min(viewport_rows.saturating_sub(1));
424 let total_rows = self.total_transcript_rows(self.transcript_width) + effective_padding;
425 self.scroll_manager.set_total_rows(total_rows);
426 }
427
428 pub(crate) fn prepare_transcript_scroll(
430 &mut self,
431 total_rows: usize,
432 viewport_rows: usize,
433 ) -> (usize, usize) {
434 let viewport = viewport_rows.max(1);
435 let clamped_total = total_rows.max(1);
436 self.scroll_manager.set_total_rows(clamped_total);
437 self.scroll_manager.set_viewport_rows(viewport as u16);
438 let max_offset = self.scroll_manager.max_offset();
439
440 if self.scroll_manager.offset() > max_offset {
441 self.scroll_manager.set_offset(max_offset);
442 }
443
444 let top_offset = max_offset.saturating_sub(self.scroll_manager.offset());
445 (top_offset, clamped_total)
446 }
447
448 pub(super) fn adjust_scroll_after_change(&mut self, previous_max_offset: usize) {
450 use std::cmp::min;
451
452 let new_max_offset = self.current_max_scroll_offset();
453 let current_offset = self.scroll_manager.offset();
454
455 if current_offset >= previous_max_offset && new_max_offset > previous_max_offset {
456 self.scroll_manager.set_offset(new_max_offset);
457 } else if current_offset > 0 && new_max_offset > previous_max_offset {
458 let delta = new_max_offset - previous_max_offset;
459 self.scroll_manager
460 .set_offset(min(current_offset + delta, new_max_offset));
461 }
462 self.enforce_scroll_bounds();
463 }
464
465 #[inline]
467 pub(super) fn emit_inline_event(
468 &self,
469 event: &InlineEvent,
470 events: &UnboundedSender<InlineEvent>,
471 callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
472 ) {
473 if let Some(cb) = callback {
474 cb(event);
475 }
476 let _ = events.send(event.clone());
477 }
478
479 #[inline]
481 #[allow(dead_code)]
482 pub(super) fn handle_scroll_down(
483 &mut self,
484 events: &UnboundedSender<InlineEvent>,
485 callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
486 ) {
487 self.scroll_line_down();
488 self.mark_dirty();
489 self.emit_inline_event(&InlineEvent::ScrollLineDown, events, callback);
490 }
491
492 #[inline]
494 #[allow(dead_code)]
495 pub(super) fn handle_scroll_up(
496 &mut self,
497 events: &UnboundedSender<InlineEvent>,
498 callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
499 ) {
500 self.scroll_line_up();
501 self.mark_dirty();
502 self.emit_inline_event(&InlineEvent::ScrollLineUp, events, callback);
503 }
504}