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, InlineListSelection, ListOverlayRequest, LocalAgentEntry, LocalAgentKind,
19 ModalOverlayRequest, OverlayRequest, WizardOverlayRequest,
20};
21use super::mouse_selection::MouseSelectionState;
22use super::status_requires_shimmer;
23use super::{
24 ActiveOverlay, InlinePromptSuggestionState, Session, SuggestedPromptState,
25 modal::{ModalListState, ModalSearchState, ModalState, WizardModalState},
26};
27use crate::config::constants::ui;
28use crate::options::FullscreenInteractionSettings;
29
30const COPY_NOTIFICATION_DURATION: Duration = Duration::from_secs(3);
31const COPY_NOTIFICATION_TEXT: &str = "Copied to clipboard";
32const ACTION_REQUIRED_STATUS_TEXT: &str = "Action required";
33const APPROVAL_REQUIRED_STATUS_TEXT: &str = "Approval required";
34const INPUT_REQUIRED_STATUS_TEXT: &str = "Input required";
35
36impl Session {
37 pub(crate) fn set_task_panel_lines(&mut self, lines: Vec<String>) {
38 self.terminal_title_task_progress = extract_task_progress(&lines);
39 self.mark_dirty();
40 }
41
42 pub(crate) fn clear_inline_prompt_suggestion(&mut self) {
43 if self.inline_prompt_suggestion.suggestion.is_none() {
44 return;
45 }
46 self.inline_prompt_suggestion = InlinePromptSuggestionState::default();
47 self.mark_dirty();
48 }
49
50 pub(crate) fn copy_input_selection_to_clipboard(&mut self) -> bool {
51 if self.input_manager.copy_selected_text_to_clipboard() {
52 self.show_copy_notification();
53 true
54 } else {
55 false
56 }
57 }
58
59 pub(crate) fn copy_text_to_clipboard(&mut self, text: &str) {
60 if text.is_empty() {
61 return;
62 }
63
64 MouseSelectionState::copy_to_clipboard(text);
65 self.show_copy_notification();
66 }
67
68 pub(crate) fn clear_suggested_prompt_state(&mut self) {
69 if !self.suggested_prompt_state.active {
70 return;
71 }
72 self.suggested_prompt_state = SuggestedPromptState::default();
73 self.mark_dirty();
74 }
75
76 pub(crate) fn next_revision(&mut self) -> u64 {
78 self.line_revision_counter = self.line_revision_counter.wrapping_add(1);
79 self.line_revision_counter
80 }
81
82 pub fn should_exit(&self) -> bool {
84 self.should_exit
85 }
86
87 pub fn request_exit(&mut self) {
89 self.should_exit = true;
90 }
91
92 pub fn take_redraw(&mut self) -> bool {
96 if self.needs_redraw {
97 self.needs_redraw = false;
98 true
99 } else {
100 false
101 }
102 }
103
104 pub fn mark_dirty(&mut self) {
106 self.needs_redraw = true;
107 self.header_lines_cache = None;
108 self.header_height_cache.clear();
109 self.queued_inputs_preview_cache = None;
110 self.subprocess_entries_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.subprocess_entries_preview_cache = None;
124 self.mark_dirty();
125 }
126
127 pub(crate) fn set_local_agents(&mut self, entries: Vec<LocalAgentEntry>) {
128 if self.local_agents != entries {
129 self.local_agents = entries;
130 self.invalidate_sidebar_cache();
131 }
132 }
133
134 pub(crate) fn has_delegated_local_agents(&self) -> bool {
135 self.local_agents
136 .iter()
137 .any(|entry| entry.kind == LocalAgentKind::Delegated)
138 }
139
140 pub(crate) fn set_local_agents_drawer_visible(&mut self, visible: bool) {
141 if self.local_agents_drawer_visible != visible {
142 self.local_agents_drawer_visible = visible;
143 self.mark_dirty();
144 }
145 }
146
147 pub(crate) fn set_transcript_area(&mut self, area: Option<Rect>) {
148 self.transcript_area = area;
149 }
150
151 pub(crate) fn transcript_area(&self) -> Option<Rect> {
152 self.transcript_area
153 }
154
155 pub(crate) fn set_input_area(&mut self, area: Option<Rect>) {
156 self.input_area = area;
157 }
158
159 pub(crate) fn input_area(&self) -> Option<Rect> {
160 self.input_area
161 }
162
163 pub(crate) fn set_bottom_panel_area(&mut self, area: Option<Rect>) {
164 self.bottom_panel_area = area;
165 }
166
167 pub(crate) fn bottom_panel_area(&self) -> Option<Rect> {
168 self.bottom_panel_area
169 }
170
171 pub(crate) fn set_modal_list_area(&mut self, area: Option<Rect>) {
172 self.modal_list_area = area;
173 }
174
175 pub(crate) fn modal_list_area(&self) -> Option<Rect> {
176 self.modal_list_area
177 }
178
179 pub(crate) fn set_modal_text_areas(&mut self, areas: Vec<Rect>) {
180 self.modal_text_areas = areas;
181 }
182
183 pub(crate) fn modal_text_areas(&self) -> &[Rect] {
184 &self.modal_text_areas
185 }
186
187 pub(crate) fn set_modal_link_targets(&mut self, targets: Vec<super::TranscriptFileLinkTarget>) {
188 self.modal_link_targets = targets;
189 }
190
191 #[cfg(test)]
192 pub(crate) fn modal_link_targets(&self) -> &[super::TranscriptFileLinkTarget] {
193 &self.modal_link_targets
194 }
195
196 pub(crate) fn input_enabled(&self) -> bool {
197 self.input_enabled
198 }
199
200 pub(crate) fn set_input_enabled(&mut self, enabled: bool) {
201 self.input_enabled = enabled;
202 }
203
204 pub(crate) fn input_compact_mode(&self) -> bool {
205 self.input_compact_mode
206 }
207
208 pub(crate) fn set_input_compact_mode(&mut self, enabled: bool) {
209 self.input_compact_mode = enabled;
210 }
211
212 pub(crate) fn set_cursor_visible(&mut self, visible: bool) {
213 self.cursor_visible = visible;
214 }
215
216 pub(crate) fn current_transcript_revision(&self) -> u64 {
217 self.line_revision_counter
218 }
219
220 pub(crate) fn invalidate_transcript_viewport(&mut self) {
221 self.visible_lines_cache = None;
222 }
223
224 pub(crate) fn request_transcript_clear(&mut self) {
225 self.transcript_clear_required = true;
226 }
227
228 pub(crate) fn set_fullscreen_active(&mut self, active: bool) {
229 self.fullscreen.active = active;
230 }
231
232 pub(crate) fn set_fullscreen_interaction(&mut self, config: FullscreenInteractionSettings) {
233 self.fullscreen.interaction = config;
234 }
235
236 pub fn handle_tick(&mut self) {
238 let motion_reduced = self.appearance.motion_reduced();
239 let mut animation_updated = false;
240 if !motion_reduced && self.thinking_spinner.is_active && self.thinking_spinner.update() {
241 animation_updated = true;
242 }
243 let shimmer_active = if self.appearance.should_animate_progress_status() {
244 self.is_shimmer_active()
245 } else {
246 false
247 };
248 if shimmer_active && self.shimmer_state.update() {
249 animation_updated = true;
250 }
251 if let Some(until) = self.scroll_cursor_steady_until
252 && Instant::now() >= until
253 {
254 self.scroll_cursor_steady_until = None;
255 self.needs_redraw = true;
256 }
257 if let Some(until) = self.copy_notification_until
258 && Instant::now() >= until
259 {
260 self.copy_notification_until = None;
261 self.needs_redraw = true;
262 }
263 if self.last_shimmer_active && !shimmer_active {
264 self.needs_redraw = true;
265 }
266 self.last_shimmer_active = shimmer_active;
267 if animation_updated {
268 self.needs_redraw = true;
269 }
270 }
271
272 pub(crate) fn show_copy_notification(&mut self) {
273 self.copy_notification_until = Some(Instant::now() + COPY_NOTIFICATION_DURATION);
274 self.needs_redraw = true;
275 }
276
277 pub(crate) fn copy_notification_text(&self) -> Option<&'static str> {
278 self.copy_notification_until
279 .filter(|until| Instant::now() < *until)
280 .map(|_| COPY_NOTIFICATION_TEXT)
281 }
282
283 pub(crate) fn overlay_attention_status_text(&self) -> Option<&'static str> {
284 if let Some(modal) = self.modal_state() {
285 let normalized_title = modal.title.trim().to_ascii_lowercase();
286
287 if normalized_title.contains("input required") {
288 Some(INPUT_REQUIRED_STATUS_TEXT)
289 } else if normalized_title.contains("approval")
290 || normalized_title.contains("permission")
291 {
292 Some(APPROVAL_REQUIRED_STATUS_TEXT)
293 } else {
294 Some(ACTION_REQUIRED_STATUS_TEXT)
295 }
296 } else if self.wizard_overlay().is_some() || self.has_active_overlay() {
297 Some(ACTION_REQUIRED_STATUS_TEXT)
298 } else {
299 None
300 }
301 }
302
303 pub(crate) fn status_left_text(&self) -> Option<&str> {
304 self.input_status_left
305 .as_deref()
306 .map(str::trim)
307 .filter(|value| !value.is_empty())
308 .or_else(|| self.overlay_attention_status_text())
309 }
310
311 pub(crate) fn status_right_text(&self) -> Option<&str> {
312 self.input_status_right
313 .as_deref()
314 .map(str::trim)
315 .filter(|value| !value.is_empty())
316 }
317
318 pub(crate) fn is_running_activity(&self) -> bool {
319 let left = self.status_left_text().unwrap_or("");
320 let running_status = self.appearance.should_animate_progress_status()
321 && (left.contains("Running command:")
322 || left.contains("Running tool:")
323 || left.contains("Running:")
324 || status_requires_shimmer(left));
325 let active_pty = self.active_pty_session_count() > 0;
326 running_status || active_pty
327 }
328
329 pub(crate) fn active_pty_session_count(&self) -> usize {
330 self.active_pty_sessions
331 .as_ref()
332 .map(|counter| counter.load(Ordering::Relaxed))
333 .unwrap_or(0)
334 }
335
336 pub(crate) fn has_status_spinner(&self) -> bool {
337 if !self.appearance.should_animate_progress_status() {
338 return false;
339 }
340 let Some(left) = self.status_left_text() else {
341 return false;
342 };
343 status_requires_shimmer(left)
344 }
345
346 pub(crate) fn is_shimmer_active(&self) -> bool {
347 self.has_status_spinner() || self.thinking_spinner.is_active
348 }
349
350 pub(crate) fn use_steady_cursor(&self) -> bool {
351 if !self.appearance.should_animate_progress_status() {
352 self.scroll_cursor_steady_until.is_some()
353 } else {
354 self.is_shimmer_active() || self.scroll_cursor_steady_until.is_some()
355 }
356 }
357
358 pub(crate) fn mark_scrolling(&mut self) {
359 let steady_duration = Duration::from_millis(ui::TUI_SCROLL_CURSOR_STEADY_MS);
360 if steady_duration.is_zero() {
361 self.scroll_cursor_steady_until = None;
362 } else {
363 self.scroll_cursor_steady_until = Some(Instant::now() + steady_duration);
364 }
365 }
366
367 pub(crate) fn mark_line_dirty(&mut self, index: usize) {
369 self.first_dirty_line = match self.first_dirty_line {
370 Some(current) => Some(current.min(index)),
371 None => Some(index),
372 };
373 self.mark_dirty();
374 }
375
376 pub(crate) fn ensure_prompt_style_color(&mut self) {
378 if self.prompt_style.color.is_none() {
379 self.prompt_style.color = self.theme.primary.or(self.theme.foreground);
380 }
381 }
382
383 pub(crate) fn clear_screen(&mut self) {
385 self.lines.clear();
386 self.collapsed_pastes.clear();
387 self.user_scrolled = false;
388 self.scroll_manager.set_offset(0);
389 self.invalidate_transcript_cache();
390 self.invalidate_scroll_metrics();
391 self.needs_full_clear = true;
392 self.mark_dirty();
393 }
394
395 pub(crate) fn toggle_logs(&mut self) {
397 self.show_logs = !self.show_logs;
398 self.invalidate_scroll_metrics();
399 self.mark_dirty();
400 }
401
402 #[expect(dead_code)]
404 pub(crate) fn show_modal(
405 &mut self,
406 title: String,
407 lines: Vec<String>,
408 secure_prompt: Option<super::super::types::SecurePromptConfig>,
409 ) {
410 self.show_overlay(OverlayRequest::Modal(ModalOverlayRequest {
411 title,
412 lines,
413 secure_prompt,
414 }));
415 }
416
417 pub(crate) fn show_help_modal(&mut self) {
419 self.show_overlay(OverlayRequest::Modal(ModalOverlayRequest {
420 title: "Keyboard Shortcuts".to_string(),
421 lines: Vec::new(),
422 secure_prompt: None,
423 }));
424 if let Some(ActiveOverlay::Modal(state)) = self.active_overlay.as_mut() {
425 state.is_help_modal = true;
426 }
427 }
428
429 pub(crate) fn show_overlay(&mut self, request: OverlayRequest) {
430 if self.has_active_overlay() {
431 self.overlay_queue.push_back(request);
432 self.mark_dirty();
433 return;
434 }
435 self.activate_overlay(request);
436 }
437
438 pub(crate) fn has_active_overlay(&self) -> bool {
439 self.active_overlay.is_some()
440 }
441
442 pub(crate) fn modal_state(&self) -> Option<&ModalState> {
443 self.active_overlay
444 .as_ref()
445 .and_then(ActiveOverlay::as_modal)
446 }
447
448 pub(crate) fn modal_state_mut(&mut self) -> Option<&mut ModalState> {
449 self.active_overlay
450 .as_mut()
451 .and_then(ActiveOverlay::as_modal_mut)
452 }
453
454 pub(crate) fn wizard_overlay(&self) -> Option<&WizardModalState> {
455 self.active_overlay
456 .as_ref()
457 .and_then(ActiveOverlay::as_wizard)
458 }
459
460 pub(crate) fn wizard_overlay_mut(&mut self) -> Option<&mut WizardModalState> {
461 self.active_overlay
462 .as_mut()
463 .and_then(ActiveOverlay::as_wizard_mut)
464 }
465
466 pub(crate) fn take_modal_state(&mut self) -> Option<ModalState> {
467 if !self
468 .active_overlay
469 .as_ref()
470 .is_some_and(|overlay| matches!(overlay, ActiveOverlay::Modal(_)))
471 {
472 return None;
473 }
474
475 match self.active_overlay.take() {
476 Some(ActiveOverlay::Modal(state)) => Some(*state),
477 Some(ActiveOverlay::Wizard(_)) | None => None,
478 }
479 }
480
481 fn activate_overlay(&mut self, request: OverlayRequest) {
482 match request {
483 OverlayRequest::Modal(request) => {
484 self.clear_last_overlay_list_cache();
485 self.activate_modal_overlay(request);
486 }
487 OverlayRequest::List(request) => self.activate_list_overlay(request),
488 OverlayRequest::Wizard(request) => {
489 self.clear_last_overlay_list_cache();
490 self.activate_wizard_overlay(request);
491 }
492 }
493 }
494
495 pub(crate) fn close_overlay(&mut self) {
496 let Some(state) = self.active_overlay.take() else {
497 return;
498 };
499
500 self.modal_list_area = None;
501 self.modal_text_areas.clear();
502 self.modal_link_targets.clear();
503 self.cache_last_overlay_list_state(&state);
504 self.input_enabled = state.restore_input();
505 self.cursor_visible = state.restore_cursor();
506
507 if let Some(next_request) = self.overlay_queue.pop_front() {
508 self.activate_overlay(next_request);
509 return;
510 }
511
512 self.mark_dirty();
513 }
514
515 fn activate_modal_overlay(&mut self, request: ModalOverlayRequest) {
516 let state = ModalState {
517 title: request.title,
518 lines: request.lines,
519 footer_hint: None,
520 hotkeys: Vec::new(),
521 list: None,
522 search: None,
523 secure_prompt: request.secure_prompt,
524 restore_input: true,
525 restore_cursor: true,
526 is_help_modal: false,
527 };
528 if state.secure_prompt.is_none() {
529 self.input_enabled = false;
530 }
531 self.cursor_visible = false;
532 self.active_overlay = Some(ActiveOverlay::Modal(Box::new(state)));
533 self.mark_dirty();
534 }
535
536 fn activate_list_overlay(&mut self, request: ListOverlayRequest) {
537 let anchor_to_bottom = self.should_anchor_list_to_bottom(request.selected.as_ref());
538 let mut list_state = ModalListState::new(request.items, request.selected.clone());
539 let search_state = request.search.map(ModalSearchState::from);
540 if let Some(search) = &search_state {
541 list_state.apply_search_with_preference(&search.query, request.selected);
542 }
543 if anchor_to_bottom {
544 list_state.select_last();
545 }
546 self.clear_last_overlay_list_cache();
547 let state = ModalState {
548 title: request.title,
549 lines: request.lines,
550 footer_hint: request.footer_hint,
551 hotkeys: request.hotkeys,
552 list: Some(list_state),
553 search: search_state,
554 secure_prompt: None,
555 restore_input: true,
556 restore_cursor: true,
557 is_help_modal: false,
558 };
559 self.input_enabled = false;
560 self.cursor_visible = false;
561 self.active_overlay = Some(ActiveOverlay::Modal(Box::new(state)));
562 self.mark_dirty();
563 }
564
565 fn cache_last_overlay_list_state(&mut self, overlay: &ActiveOverlay) {
566 if let ActiveOverlay::Modal(state) = overlay
567 && let Some(list) = state.list.as_ref()
568 {
569 self.last_overlay_list_selection = list.current_selection();
570 self.last_overlay_list_was_last = list.selected_is_last();
571 return;
572 }
573
574 self.last_overlay_list_selection = None;
575 self.last_overlay_list_was_last = false;
576 }
577
578 fn should_anchor_list_to_bottom(&self, preferred: Option<&InlineListSelection>) -> bool {
579 self.last_overlay_list_was_last && self.last_overlay_list_selection.as_ref() == preferred
580 }
581
582 fn clear_last_overlay_list_cache(&mut self) {
583 self.last_overlay_list_selection = None;
584 self.last_overlay_list_was_last = false;
585 }
586
587 fn activate_wizard_overlay(&mut self, request: WizardOverlayRequest) {
588 let wizard = WizardModalState::new(
589 request.title,
590 request.steps,
591 request.current_step,
592 request.search,
593 request.mode,
594 );
595 self.active_overlay = Some(ActiveOverlay::Wizard(Box::new(wizard)));
596 self.input_enabled = false;
597 self.cursor_visible = false;
598 self.mark_dirty();
599 }
600
601 pub fn scroll_line_up(&mut self) {
608 self.mark_scrolling();
609 self.ensure_scroll_metrics();
610 let previous_offset = self.scroll_manager.offset();
611 self.scroll_manager.scroll_down(1);
612 if self.scroll_manager.offset() != previous_offset {
613 self.user_scrolled = self.scroll_manager.offset() != 0;
614 self.visible_lines_cache = None;
615 self.mouse_selection.adjust_for_scroll(1);
617 }
618 }
619
620 pub fn scroll_line_down(&mut self) {
621 self.mark_scrolling();
622 self.ensure_scroll_metrics();
623 let previous_offset = self.scroll_manager.offset();
624 self.scroll_manager.scroll_up(1);
625 if self.scroll_manager.offset() != previous_offset {
626 self.user_scrolled = self.scroll_manager.offset() != 0;
627 self.visible_lines_cache = None;
628 self.mouse_selection.adjust_for_scroll(-1);
630 }
631 }
632
633 pub(crate) fn scroll_page_up(&mut self) {
634 self.mark_scrolling();
635 self.ensure_scroll_metrics();
636 let previous_offset = self.scroll_manager.offset();
637 let page = self.viewport_height().max(1);
638 self.scroll_manager.scroll_down(page);
639 if self.scroll_manager.offset() != previous_offset {
640 let actual_delta = self.scroll_manager.offset() - previous_offset;
641 self.user_scrolled = self.scroll_manager.offset() != 0;
642 self.visible_lines_cache = None;
643 self.mouse_selection.adjust_for_scroll(actual_delta as i32);
644 }
645 }
646
647 pub(crate) fn scroll_page_down(&mut self) {
648 self.mark_scrolling();
649 self.ensure_scroll_metrics();
650 let page = self.viewport_height().max(1);
651 let previous_offset = self.scroll_manager.offset();
652 self.scroll_manager.scroll_up(page);
653 if self.scroll_manager.offset() != previous_offset {
654 let actual_delta = previous_offset - self.scroll_manager.offset();
655 self.user_scrolled = self.scroll_manager.offset() != 0;
656 self.visible_lines_cache = None;
657 self.mouse_selection
658 .adjust_for_scroll(-(actual_delta as i32));
659 }
660 }
661
662 pub(crate) fn viewport_height(&self) -> usize {
663 self.transcript_rows.max(1) as usize
664 }
665
666 pub(crate) fn apply_coalesced_scroll(&mut self, line_delta: i32, page_delta: i32) {
669 self.mark_scrolling();
670 let previous_offset = self.scroll_manager.offset();
671
672 if page_delta != 0 {
675 let page_size = self.viewport_height().max(1);
676 if page_delta > 0 {
677 self.scroll_manager
678 .scroll_up(page_size * page_delta.unsigned_abs() as usize);
679 } else {
680 self.scroll_manager
681 .scroll_down(page_size * page_delta.unsigned_abs() as usize);
682 }
683 }
684
685 if line_delta != 0 {
687 if line_delta > 0 {
688 self.scroll_manager
689 .scroll_up(line_delta.unsigned_abs() as usize);
690 } else {
691 self.scroll_manager
692 .scroll_down(line_delta.unsigned_abs() as usize);
693 }
694 }
695
696 if self.scroll_manager.offset() != previous_offset {
698 self.invalidate_transcript_viewport();
699 let offset_delta = self.scroll_manager.offset() as i64 - previous_offset as i64;
702 self.mouse_selection.adjust_for_scroll(offset_delta as i32);
703 }
704 }
705
706 pub(crate) fn invalidate_scroll_metrics(&mut self) {
708 self.scroll_manager.invalidate_metrics();
709 self.invalidate_transcript_viewport();
710 }
711
712 pub(crate) fn invalidate_transcript_cache(&mut self) {
714 let had_cache = if let Some(cache) = self.transcript_cache.as_mut() {
715 cache.invalidate_content();
716 true
717 } else {
718 false
719 };
720 self.invalidate_transcript_viewport();
721 self.request_transcript_clear();
722
723 if had_cache || self.first_dirty_line.is_none() {
724 self.first_dirty_line = Some(0);
725 }
726 }
727
728 pub(crate) fn current_max_scroll_offset(&mut self) -> usize {
730 self.ensure_scroll_metrics();
731 self.scroll_manager.max_offset()
732 }
733
734 pub(crate) fn enforce_scroll_bounds(&mut self) {
736 let max_offset = self.current_max_scroll_offset();
737 if self.scroll_manager.offset() > max_offset {
738 self.scroll_manager.set_offset(max_offset);
739 }
740 }
741
742 pub(crate) fn ensure_scroll_metrics(&mut self) {
744 if self.scroll_manager.metrics_valid() {
745 return;
746 }
747
748 let viewport_rows = self.viewport_height();
749 if self.transcript_width == 0 || viewport_rows == 0 {
750 self.scroll_manager
751 .set_viewport_rows(viewport_rows.max(1) as u16);
752 self.scroll_manager.set_total_rows(0);
753 return;
754 }
755
756 let padding = usize::from(ui::INLINE_TRANSCRIPT_BOTTOM_PADDING);
757 let effective_padding = padding.min(viewport_rows.saturating_sub(1));
758 let total_rows = self.total_transcript_rows(self.transcript_width) + effective_padding;
759 self.scroll_manager.set_viewport_rows(viewport_rows as u16);
760 self.scroll_manager.set_total_rows(total_rows);
761 self.scroll_manager.clamp_offset();
762 }
763
764 pub(crate) fn prepare_transcript_scroll(
766 &mut self,
767 total_rows: usize,
768 viewport_rows: usize,
769 ) -> (usize, usize) {
770 let viewport = viewport_rows.max(1);
771 let clamped_total = total_rows.max(1);
772 self.scroll_manager.set_viewport_rows(viewport as u16);
773 self.scroll_manager.set_total_rows(clamped_total);
774 let max_offset = self.scroll_manager.max_offset();
775
776 if self.scroll_manager.offset() > max_offset {
777 self.scroll_manager.set_offset(max_offset);
778 }
779
780 let top_offset = max_offset.saturating_sub(self.scroll_manager.offset());
781 (top_offset, clamped_total)
782 }
783
784 pub(crate) fn adjust_scroll_after_change(&mut self, previous_max_offset: usize) {
789 let new_max_offset = self.current_max_scroll_offset();
790
791 if self.scroll_manager.offset() > 0 && new_max_offset > previous_max_offset {
792 use std::cmp::min;
794 let current_offset = self.scroll_manager.offset();
795 let delta = new_max_offset - previous_max_offset;
796 self.scroll_manager
797 .set_offset(min(current_offset + delta, new_max_offset));
798 }
799 self.enforce_scroll_bounds();
800 }
801
802 #[inline]
804 pub(crate) fn emit_inline_event(
805 &self,
806 event: &InlineEvent,
807 events: &UnboundedSender<InlineEvent>,
808 callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
809 ) {
810 if let Some(cb) = callback {
811 cb(event);
812 }
813 let _ = events.send(event.clone());
814 }
815
816 #[inline]
818 #[expect(dead_code)]
819 pub(crate) fn handle_scroll_down(
820 &mut self,
821 events: &UnboundedSender<InlineEvent>,
822 callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
823 ) {
824 self.scroll_line_down();
825 self.mark_dirty();
826 self.emit_inline_event(&InlineEvent::ScrollLineDown, events, callback);
827 }
828
829 #[inline]
831 #[expect(dead_code)]
832 pub(crate) fn handle_scroll_up(
833 &mut self,
834 events: &UnboundedSender<InlineEvent>,
835 callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
836 ) {
837 self.scroll_line_up();
838 self.mark_dirty();
839 self.emit_inline_event(&InlineEvent::ScrollLineUp, events, callback);
840 }
841}
842
843fn extract_task_progress(lines: &[String]) -> Option<String> {
844 let line = lines
845 .iter()
846 .find_map(|line| line.trim().strip_prefix("Progress: ").map(str::trim))?;
847 let summary = line.split_whitespace().next()?.trim();
848 (!summary.is_empty()).then(|| summary.to_string())
849}