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