Skip to main content

vtcode_tui/core_tui/session/
state.rs

1/// State management and lifecycle operations for Session
2///
3/// This module handles session state including:
4/// - Session initialization and configuration
5/// - Exit management
6/// - Redraw and dirty state tracking
7/// - Screen clearing
8/// - Modal management
9/// - Timeline pane toggling
10/// - Scroll management operations
11use 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    /// Get the next revision counter for message tracking
77    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    /// Check if the session should exit
83    pub fn should_exit(&self) -> bool {
84        self.should_exit
85    }
86
87    /// Request session exit
88    pub fn request_exit(&mut self) {
89        self.should_exit = true;
90    }
91
92    /// Take the redraw flag and reset it
93    ///
94    /// Returns true if a redraw was needed
95    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    /// Mark the session as needing a redraw
105    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    /// Invalidate only the header cache (e.g. when provider/model changes)
114    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    /// Invalidate only the sidebar cache (e.g. when queue changes)
121    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    /// Advance animation state on tick and request redraw when a frame changes.
237    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    /// Mark a specific line as dirty to optimize reflow scans
368    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    /// Ensure the prompt style has a color set
377    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    /// Clear the screen and reset scroll
384    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    /// Toggle logs panel visibility
396    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    /// Show a simple modal dialog
403    #[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    /// Show a help modal using the ratatui-cheese Help widget
418    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    /// Scroll operations
602    ///
603    /// Note: The scroll offset model is inverted for chat-style display:
604    /// offset=0 shows the bottom (newest content), offset=max shows the top.
605    /// Therefore "scroll up" (show older content) increases the offset, and
606    /// "scroll down" (show newer content) decreases it.
607    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            // Content moves down on screen; shift selection to match.
616            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            // Content moves up on screen; shift selection to match.
629            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    /// Apply coalesced scroll from accumulated scroll events
667    /// This is more efficient than calling scroll_line_up/down multiple times
668    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        // Apply page scroll first (larger movements)
673        // Inverted offset model: positive delta = scroll down visually = decrease offset
674        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        // Then apply line scroll
686        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        // Invalidate visible lines cache if offset actually changed
697        if self.scroll_manager.offset() != previous_offset {
698            self.invalidate_transcript_viewport();
699            // Compute actual row delta for selection adjustment.
700            // Inverted model: increasing offset → content moves down → positive row delta.
701            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    /// Invalidate scroll metrics to force recalculation
707    pub(crate) fn invalidate_scroll_metrics(&mut self) {
708        self.scroll_manager.invalidate_metrics();
709        self.invalidate_transcript_viewport();
710    }
711
712    /// Invalidate the transcript cache
713    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    /// Get the current maximum scroll offset
729    pub(crate) fn current_max_scroll_offset(&mut self) -> usize {
730        self.ensure_scroll_metrics();
731        self.scroll_manager.max_offset()
732    }
733
734    /// Enforce scroll bounds after viewport changes
735    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    /// Ensure scroll metrics are up to date
743    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    /// Prepare transcript scroll parameters
765    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    /// Adjust scroll position after content changes to keep the current view stable.
785    /// When the viewport is at the bottom (offset 0), new content naturally stays
786    /// in view without adjustment. Only when the user has scrolled up (offset > 0)
787    /// do we adjust the offset to prevent the view from drifting.
788    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            // Keep content position stable when the user has scrolled away from bottom
793            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    /// Emit an inline event through the channel and callback
803    #[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    /// Handle scroll down event
817    #[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    /// Handle scroll up event
830    #[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}