Skip to main content

vtcode_ui/tui/core_tui/app/session/
mod.rs

1use std::collections::VecDeque;
2
3pub(super) use ratatui::crossterm::event::{
4    Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, MouseEvent, MouseEventKind,
5};
6pub(super) use ratatui::prelude::*;
7pub(super) use ratatui::widgets::Clear;
8pub(super) use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
9
10use crate::tui::config::constants::ui;
11use crate::tui::core_tui::app::types::{
12    CompactActivityMetadata, DiffOverlayRequest, DiffPreviewMode, DiffPreviewState, InlineCommand, InlineEvent,
13    InlineMessageKind, InlineSegment, LocalAgentsTransientRequest, SlashCommandItem, TaskPanelMetadata,
14    TaskPanelTransientRequest, ToolOutputId, TransientActivitySignal, TransientRequest,
15};
16use crate::tui::core_tui::runner::TuiSessionDriver;
17use crate::tui::core_tui::session::Session as CoreSessionState;
18use crate::tui::core_tui::session::action::BindingStore;
19use vtcode_commons::ui_protocol::TaskItemStatus;
20
21mod agent_palette;
22/// Diff preview overlay for file changes.
23pub(crate) mod diff_preview;
24mod events;
25/// File palette for workspace file selection.
26pub mod file_palette;
27/// Command history picker state and rendering.
28pub mod history_picker;
29mod impl_events;
30mod impl_render;
31mod layout;
32mod local_agents;
33mod palette;
34/// Session rendering logic and layout composition.
35pub(crate) mod render;
36/// Slash command detection and suggestion UI.
37pub(crate) mod slash;
38/// Slash command palette widget.
39pub mod slash_palette;
40mod task_panel;
41#[path = "transcript_review/mod.rs"]
42mod tool_output_viewer;
43mod transient;
44/// Workspace trust state management.
45pub mod trust;
46
47use self::file_palette::FilePalette;
48use self::history_picker::HistoryPickerState;
49use self::local_agents::LocalAgentsState;
50use self::slash_palette::SlashPalette;
51use self::tool_output_viewer::ToolOutputViewerState;
52use self::transient::{TransientFocusPolicy, TransientHost, TransientSurface, TransientVisibilityChange};
53use crate::tui::core_tui::style::theme_from_styles;
54use crate::tui::options::FullscreenInteractionSettings;
55use crate::tui::ui::theme;
56use agent_palette::AgentPalette;
57use std::sync::{
58    Arc,
59    atomic::{AtomicUsize, Ordering},
60};
61
62#[derive(Clone, Debug, Default)]
63pub(crate) struct ToolOutputBlock {
64    /// Session-local identifier used to keep a completed capture tied to its
65    /// corresponding transcript entry while the overlay is refreshed.
66    pub(crate) id: u64,
67    /// Index of the summary line when the capture was recorded. The review
68    /// layer uses this identity anchor without reverse-matching command text,
69    /// which is ambiguous when the same command runs more than once.
70    pub(crate) anchor_line: Option<usize>,
71    /// Number of core transcript lines present when this capture was queued.
72    /// PTY previews may use this boundary to find their already-rendered
73    /// header without searching through earlier calls.
74    pub(crate) anchor_search_start: usize,
75    /// Original transcript position for a capture that has no identity anchor.
76    /// `None` is retained for blocks constructed by older callers/tests, which
77    /// are appended as genuine orphan content.
78    pub(crate) recorded_at_line: Option<usize>,
79    pub(crate) lines: Vec<String>,
80}
81
82#[derive(Clone, Debug)]
83pub(crate) struct CompactActivityEntry {
84    pub(crate) line_index: usize,
85    pub(crate) metadata: CompactActivityMetadata,
86}
87
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89pub(crate) struct CompactActivityHitRegion {
90    pub(crate) area: Rect,
91    pub(crate) review_anchor: ToolOutputId,
92}
93
94/// App-level session that layers VT Code features on top of the core session.
95pub struct AppSession {
96    pub(crate) core: CoreSessionState,
97    agent_palette: Option<AgentPalette>,
98    agent_palette_active: bool,
99    file_palette: Option<FilePalette>,
100    pub(crate) file_palette_active: bool,
101    inline_lists_visible: bool,
102    pub(crate) slash_palette: SlashPalette,
103    pub(crate) history_picker_state: HistoryPickerState,
104    local_agents_state: LocalAgentsState,
105    local_agents_auto_opened: bool,
106    pub(crate) show_task_panel: bool,
107    pub(crate) task_panel_lines: Vec<String>,
108    pub(crate) task_panel_statuses: Vec<TaskItemStatus>,
109    pub(crate) task_panel_current: Option<usize>,
110    pub(crate) task_panel_metadata: Option<TaskPanelMetadata>,
111    diff_preview_state: Option<DiffPreviewState>,
112    tool_output_viewer_state: Option<ToolOutputViewerState>,
113    pub(crate) tool_output_blocks: Vec<ToolOutputBlock>,
114    pub(crate) tool_output_revision: u64,
115    pub(crate) compact_activity_entries: Vec<CompactActivityEntry>,
116    pub(crate) compact_activity_hit_regions: Vec<CompactActivityHitRegion>,
117    pub(crate) diff_review_anchors: Vec<vtcode_commons::ui_protocol::DiffReviewAnchor>,
118    diff_overlay_queue: VecDeque<DiffOverlayRequest>,
119    transient_host: TransientHost,
120    transient_active_signal: Option<Arc<TransientActivitySignal>>,
121    preview_callback: Option<crate::tui::core_tui::types::PreviewCallback>,
122}
123
124pub(super) type Session = AppSession;
125
126impl AppSession {
127    /// Create a new session with explicit log visibility, theme, and slash commands.
128    pub(crate) fn new_with_logs(
129        theme: crate::tui::core_tui::types::InlineTheme,
130        placeholder: Option<String>,
131        view_rows: u16,
132        show_logs: bool,
133        appearance: Option<crate::tui::core_tui::session::config::AppearanceConfig>,
134        slash_commands: Vec<SlashCommandItem>,
135        app_name: String,
136    ) -> Self {
137        let core = CoreSessionState::new_with_logs(theme, placeholder, view_rows, show_logs, appearance, app_name);
138
139        Self {
140            core,
141            agent_palette: None,
142            agent_palette_active: false,
143            file_palette: None,
144            file_palette_active: false,
145            inline_lists_visible: true,
146            slash_palette: SlashPalette::with_commands(slash_commands),
147            history_picker_state: HistoryPickerState::new(),
148            local_agents_state: LocalAgentsState::default(),
149            local_agents_auto_opened: false,
150            show_task_panel: false,
151            task_panel_lines: Vec::new(),
152            task_panel_statuses: Vec::new(),
153            task_panel_current: None,
154            task_panel_metadata: None,
155            diff_preview_state: None,
156            tool_output_viewer_state: None,
157            tool_output_blocks: Vec::new(),
158            tool_output_revision: 0,
159            compact_activity_entries: Vec::new(),
160            compact_activity_hit_regions: Vec::new(),
161            diff_review_anchors: Vec::new(),
162            diff_overlay_queue: VecDeque::new(),
163            transient_host: TransientHost::default(),
164            transient_active_signal: None,
165            preview_callback: None,
166        }
167    }
168
169    /// Create a new session with explicit log visibility, theme, slash commands, and key bindings.
170    pub(crate) fn new_with_logs_and_bindings(
171        theme: crate::tui::core_tui::types::InlineTheme,
172        placeholder: Option<String>,
173        view_rows: u16,
174        show_logs: bool,
175        appearance: Option<crate::tui::core_tui::session::config::AppearanceConfig>,
176        slash_commands: Vec<SlashCommandItem>,
177        app_name: String,
178        bindings: BindingStore,
179    ) -> Self {
180        let core = CoreSessionState::new_with_bindings(
181            theme,
182            placeholder,
183            view_rows,
184            show_logs,
185            appearance,
186            app_name,
187            bindings,
188        );
189
190        Self {
191            core,
192            agent_palette: None,
193            agent_palette_active: false,
194            file_palette: None,
195            file_palette_active: false,
196            inline_lists_visible: true,
197            slash_palette: SlashPalette::with_commands(slash_commands),
198            history_picker_state: HistoryPickerState::new(),
199            local_agents_state: LocalAgentsState::default(),
200            local_agents_auto_opened: false,
201            show_task_panel: false,
202            task_panel_lines: Vec::new(),
203            task_panel_statuses: Vec::new(),
204            task_panel_current: None,
205            task_panel_metadata: None,
206            diff_preview_state: None,
207            tool_output_viewer_state: None,
208            tool_output_blocks: Vec::new(),
209            tool_output_revision: 0,
210            compact_activity_entries: Vec::new(),
211            compact_activity_hit_regions: Vec::new(),
212            diff_review_anchors: Vec::new(),
213            diff_overlay_queue: VecDeque::new(),
214            transient_host: TransientHost::default(),
215            transient_active_signal: None,
216            preview_callback: None,
217        }
218    }
219
220    pub fn new(theme: crate::tui::core_tui::types::InlineTheme, placeholder: Option<String>, view_rows: u16) -> Self {
221        Self::new_with_logs(theme, placeholder, view_rows, true, None, Vec::new(), "Agent TUI".to_string())
222    }
223
224    pub fn core(&self) -> &CoreSessionState {
225        &self.core
226    }
227
228    pub fn core_mut(&mut self) -> &mut CoreSessionState {
229        &mut self.core
230    }
231
232    pub(crate) fn set_transient_activity_signal(&mut self, signal: Arc<TransientActivitySignal>) {
233        self.transient_active_signal = Some(signal);
234        self.sync_transient_activity_signal();
235    }
236
237    pub(crate) fn inline_lists_visible(&self) -> bool {
238        self.inline_lists_visible
239    }
240
241    fn toggle_inline_lists_visibility(&mut self) {
242        self.inline_lists_visible = !self.inline_lists_visible;
243        self.core.mark_dirty();
244    }
245
246    fn ensure_inline_lists_visible_for_trigger(&mut self) {
247        if !self.inline_lists_visible {
248            self.inline_lists_visible = true;
249            self.core.mark_dirty();
250        }
251    }
252
253    #[cfg_attr(feature = "profiling", hotpath::measure)]
254    fn update_input_triggers(&mut self) {
255        if !self.core.input_enabled() {
256            return;
257        }
258
259        self.check_agent_reference_trigger();
260        self.check_file_reference_trigger();
261        slash::update_slash_suggestions(self);
262    }
263
264    fn show_transient_surface(&mut self, surface: TransientSurface) -> bool {
265        let change = self.transient_host.show(surface);
266        if !change.changed() {
267            return false;
268        }
269
270        self.apply_transient_visibility_change(change);
271        true
272    }
273
274    fn close_transient_surface(&mut self, surface: TransientSurface) -> bool {
275        let change = self.transient_host.hide(surface);
276        if !change.changed() {
277            return false;
278        }
279
280        self.apply_transient_visibility_change(change);
281        true
282    }
283
284    fn finish_history_picker_interaction(&mut self, was_active: bool) {
285        if was_active && !self.history_picker_state.active {
286            self.close_transient_surface(TransientSurface::HistoryPicker);
287            self.update_input_triggers();
288        }
289    }
290
291    pub(crate) fn set_task_panel_visible(&mut self, visible: bool) {
292        if self.show_task_panel != visible {
293            self.show_task_panel = visible;
294            if visible {
295                self.show_transient_surface(TransientSurface::TaskPanel);
296            } else {
297                self.close_transient_surface(TransientSurface::TaskPanel);
298            }
299            self.core.mark_dirty();
300        }
301    }
302
303    pub(crate) fn toggle_task_panel(&mut self) {
304        let target = !self.show_task_panel;
305        self.set_task_panel_visible(target);
306    }
307
308    fn visible_transient_surface(&self) -> Option<TransientSurface> {
309        self.transient_host.top()
310    }
311
312    fn visible_bottom_docked_surface(&self) -> Option<TransientSurface> {
313        self.transient_host.visible_bottom_docked()
314    }
315
316    fn history_picker_visible(&self) -> bool {
317        self.history_picker_state.active && self.transient_host.is_visible(TransientSurface::HistoryPicker)
318    }
319
320    pub(crate) fn local_agents_visible(&self) -> bool {
321        self.transient_host.is_visible(TransientSurface::LocalAgents)
322    }
323
324    /// Whether any background task (managed subagent, background subprocess,
325    /// or retained exec session) is still running, independent of drawer
326    /// visibility. Background work is asynchronous, so it feeds the loading
327    /// shimmer and animation tick rate but never the turn-busy guards.
328    fn background_activity_active(&self) -> bool {
329        self.local_agents_state.loading_count() > 0
330    }
331
332    pub(crate) fn file_palette_visible(&self) -> bool {
333        self.file_palette_active && self.transient_host.is_visible(TransientSurface::FilePalette)
334    }
335
336    fn agent_palette_visible(&self) -> bool {
337        self.agent_palette_active && self.transient_host.is_visible(TransientSurface::AgentPalette)
338    }
339
340    pub(crate) fn slash_palette_visible(&self) -> bool {
341        !self.slash_palette.is_empty() && self.transient_host.is_visible(TransientSurface::SlashPalette)
342    }
343
344    pub(crate) fn has_active_overlay(&self) -> bool {
345        self.core.has_active_overlay() && self.transient_host.is_visible(TransientSurface::FloatingOverlay)
346    }
347
348    pub(crate) fn modal_state(&self) -> Option<&crate::tui::core_tui::session::modal::ModalState> {
349        self.has_active_overlay().then(|| self.core.modal_state()).flatten()
350    }
351
352    fn modal_state_mut(&mut self) -> Option<&mut crate::tui::core_tui::session::modal::ModalState> {
353        if !self.has_active_overlay() {
354            return None;
355        }
356        self.core.modal_state_mut()
357    }
358
359    fn wizard_overlay(&self) -> Option<&crate::tui::core_tui::session::modal::WizardModalState> {
360        self.has_active_overlay().then(|| self.core.wizard_overlay()).flatten()
361    }
362
363    fn wizard_overlay_mut(&mut self) -> Option<&mut crate::tui::core_tui::session::modal::WizardModalState> {
364        if !self.has_active_overlay() {
365            return None;
366        }
367        self.core.wizard_overlay_mut()
368    }
369
370    fn close_overlay(&mut self) {
371        if !self.has_active_overlay() {
372            return;
373        }
374
375        self.core.close_overlay();
376        if !self.core.has_active_overlay() {
377            self.close_transient_surface(TransientSurface::FloatingOverlay);
378        }
379    }
380
381    /// Apply the process-global theme styles to this session after a palette
382    /// preview changes or is dismissed.
383    fn sync_theme_from_runtime(&mut self) {
384        let inline_theme = theme_from_styles(&theme::active_styles());
385        self.core.theme = inline_theme;
386        self.core.styles.set_theme(self.core.theme.clone());
387    }
388
389    /// End a live theme preview when a list modal is cancelled.
390    ///
391    /// The callback owns application-specific preview state, while the
392    /// runtime fallback guarantees that a stale global preview cannot survive
393    /// a dismissed palette if that callback is absent or fails.
394    fn cancel_theme_preview(&mut self) {
395        if !theme::has_preview_theme() {
396            return;
397        }
398
399        if let Some(callback) = self.preview_callback.as_ref() {
400            let _ = callback(None);
401        }
402        theme::clear_preview_theme();
403        self.sync_theme_from_runtime();
404    }
405
406    pub(crate) fn diff_preview_state(&self) -> Option<&DiffPreviewState> {
407        self.transient_host
408            .is_visible(TransientSurface::DiffPreview)
409            .then_some(())
410            .and(self.diff_preview_state.as_ref())
411    }
412
413    fn diff_preview_state_mut(&mut self) -> Option<&mut DiffPreviewState> {
414        if !self.transient_host.is_visible(TransientSurface::DiffPreview) {
415            return None;
416        }
417        self.diff_preview_state.as_mut()
418    }
419
420    fn tool_output_viewer_state(&self) -> Option<&ToolOutputViewerState> {
421        self.transient_host
422            .is_visible(TransientSurface::ToolOutputViewer)
423            .then_some(())
424            .and(self.tool_output_viewer_state.as_ref())
425    }
426
427    fn tool_output_viewer_state_mut(&mut self) -> Option<&mut ToolOutputViewerState> {
428        if !self.transient_host.is_visible(TransientSurface::ToolOutputViewer) {
429            return None;
430        }
431        self.tool_output_viewer_state.as_mut()
432    }
433
434    pub(crate) fn show_diff_overlay(&mut self, request: DiffOverlayRequest) {
435        if self.diff_preview_state.is_some() {
436            self.diff_overlay_queue.push_back(request);
437            return;
438        }
439
440        let mut state = match request.unified.as_deref() {
441            Some(unified) => DiffPreviewState::from_unified(request.file_path, unified, request.mode),
442            None => DiffPreviewState::new_with_mode(
443                request.file_path,
444                request.before,
445                request.after,
446                request.hunks,
447                request.mode,
448            ),
449        };
450        state.focus_hunk(request.current_hunk);
451        self.diff_preview_state = Some(state);
452        self.show_transient_surface(TransientSurface::DiffPreview);
453        self.core.mark_dirty();
454    }
455
456    pub(crate) fn close_diff_overlay(&mut self) {
457        if self.diff_preview_state.is_none() {
458            return;
459        }
460        self.diff_preview_state = None;
461        if let Some(next) = self.diff_overlay_queue.pop_front() {
462            self.show_diff_overlay(next);
463            return;
464        }
465        self.close_transient_surface(TransientSurface::DiffPreview);
466        self.core.mark_dirty();
467    }
468
469    pub(crate) fn record_diff_review(&mut self, anchor: vtcode_commons::ui_protocol::DiffReviewAnchor) {
470        self.diff_review_anchors.push(anchor);
471        // Bound session-local anchors so a long edit-heavy session cannot grow
472        // without limit; the newest notices stay available for explicit expand.
473        const MAX_DIFF_REVIEW_ANCHORS: usize = 32;
474        if self.diff_review_anchors.len() > MAX_DIFF_REVIEW_ANCHORS {
475            let excess = self.diff_review_anchors.len() - MAX_DIFF_REVIEW_ANCHORS;
476            self.diff_review_anchors.drain(..excess);
477        }
478        // Anchors are activation-only; recording them does not change visible
479        // transcript content, so skip mark_dirty to avoid cache invalidation
480        // on every clipped preview.
481    }
482
483    /// Open full-viewport ReadonlyReview for a completed-edit expand notice.
484    ///
485    /// Matching order:
486    /// 1. stored `notice` contained in the clicked transcript text
487    /// 2. longest specific workspace `file_path` contained in that text
488    /// 3. the sole stored anchor, when only one exists
489    ///
490    /// Refuse rather than open an arbitrary last anchor when multiple payloads
491    /// are present and the notice carries no distinctive path. Takes the
492    /// matching anchor out of the session list so unified content is moved,
493    /// not cloned.
494    pub(crate) fn open_diff_review_for_notice(&mut self, notice_text: &str) -> bool {
495        if !notice_text.contains("review full diff") {
496            return false;
497        }
498
499        fn is_generic_label(path: &str) -> bool {
500            vtcode_commons::ui_protocol::is_generic_diff_review_path(path)
501        }
502
503        let by_notice = self
504            .diff_review_anchors
505            .iter()
506            .enumerate()
507            .rev()
508            .find(|(_, anchor)| !anchor.notice.is_empty() && notice_text.contains(anchor.notice.as_str()))
509            .map(|(index, _)| index);
510
511        let mut by_path: Option<usize> = None;
512        for (index, anchor) in self.diff_review_anchors.iter().enumerate() {
513            if is_generic_label(anchor.file_path.as_str()) {
514                continue;
515            }
516            if !notice_text.contains(anchor.file_path.as_str()) {
517                continue;
518            }
519            let better = match by_path {
520                None => true,
521                Some(prev_index) => anchor.file_path.len() > self.diff_review_anchors[prev_index].file_path.len(),
522            };
523            if better {
524                by_path = Some(index);
525            }
526        }
527
528        let index = by_notice
529            .or(by_path)
530            .or_else(|| (self.diff_review_anchors.len() == 1).then_some(0));
531        let Some(index) = index else {
532            return false;
533        };
534        let anchor = self.diff_review_anchors.swap_remove(index);
535        self.open_diff_review_anchor(anchor)
536    }
537
538    fn open_diff_review_anchor(&mut self, anchor: vtcode_commons::ui_protocol::DiffReviewAnchor) -> bool {
539        self.show_diff_overlay(DiffOverlayRequest {
540            file_path: anchor.file_path,
541            before: String::new(),
542            after: String::new(),
543            hunks: Vec::new(),
544            current_hunk: 0,
545            mode: DiffPreviewMode::ReadonlyReview,
546            unified: Some(anchor.unified),
547        });
548        true
549    }
550
551    fn close_history_picker(&mut self) {
552        if !self.history_picker_state.active {
553            return;
554        }
555        self.history_picker_state.cancel(&mut self.core.input_manager);
556        self.close_transient_surface(TransientSurface::HistoryPicker);
557        self.update_input_triggers();
558        self.mark_dirty();
559    }
560
561    fn open_tool_output_viewer(&mut self, width: u16, height: u16, review_anchor: Option<ToolOutputId>) {
562        self.tool_output_viewer_state = Some(ToolOutputViewerState::open_focused(self, width, height, review_anchor));
563        self.show_transient_surface(TransientSurface::ToolOutputViewer);
564        self.core.mark_dirty();
565    }
566
567    pub(crate) fn record_tool_output_block(&mut self, id: ToolOutputId, mut lines: Vec<String>) {
568        if lines.is_empty() {
569            return;
570        }
571        // Bound retained capture size so long-running floods cannot grow the
572        // TUI heap without limit. Keep the tail — that is what review shows.
573        let max_lines = ui::TUI_TOOL_OUTPUT_CAPTURE_MAX_LINES;
574        if lines.len() > max_lines {
575            let drop = lines.len() - max_lines;
576            lines.drain(..drop);
577        }
578
579        // A non-PTY capture is recorded after the live PTY stream has rendered
580        // its command header. Restrict the fallback search to the current
581        // trailing PTY block so repeated commands cannot borrow an older
582        // header from the conversation.
583        let recorded_at_line = self.core.lines.len();
584        let mut anchor_search_start = recorded_at_line;
585        while anchor_search_start > 0
586            && self
587                .core
588                .lines
589                .get(anchor_search_start - 1)
590                .is_some_and(|line| line.kind == InlineMessageKind::Pty)
591        {
592            anchor_search_start -= 1;
593        }
594        let anchor_line = self.find_live_pty_anchor(lines.first(), anchor_search_start);
595
596        self.tool_output_blocks.push(ToolOutputBlock {
597            id,
598            anchor_line,
599            anchor_search_start,
600            recorded_at_line: Some(recorded_at_line),
601            lines,
602        });
603        self.trim_tool_output_blocks();
604        self.tool_output_revision = self.tool_output_revision.wrapping_add(1);
605        self.core.mark_dirty();
606    }
607
608    /// FIFO-bound capture blocks. Blocks shown in an open tool-output viewer
609    /// are pinned so review does not lose content mid-read; the newest block
610    /// is never dropped. Call again after the viewer closes to re-trim.
611    fn trim_tool_output_blocks(&mut self) {
612        let max_blocks = ui::TUI_TOOL_OUTPUT_BLOCKS_MAX;
613        if self.tool_output_blocks.len() <= max_blocks {
614            return;
615        }
616        let pinned = self
617            .tool_output_viewer_state
618            .as_ref()
619            .map(|viewer| viewer.retained_tool_ids())
620            .unwrap_or_default();
621        while self.tool_output_blocks.len() > max_blocks {
622            let newest = self.tool_output_blocks.len() - 1;
623            let Some(pos) = self
624                .tool_output_blocks
625                .iter()
626                .take(newest)
627                .position(|block| !pinned.contains(&block.id))
628            else {
629                break;
630            };
631            self.tool_output_blocks.remove(pos);
632        }
633    }
634
635    fn find_live_pty_anchor(&self, header: Option<&String>, search_start: usize) -> Option<usize> {
636        let header = header.map(|header| normalize_tool_output_header(header))?;
637        let mut index = self.core.lines.len();
638        while index > search_start {
639            let line_index = index - 1;
640            let line = self.core.lines.get(line_index)?;
641            if line.kind != InlineMessageKind::Pty {
642                break;
643            }
644            if folded_pty_header(&self.core, line_index).trim_end() == header.trim_end() {
645                return Some(line_index);
646            }
647            index = line_index;
648        }
649        None
650    }
651
652    fn handle_core_command(&mut self, command: crate::tui::core_tui::types::InlineCommand) {
653        let evicted_before = self.core.evicted_message_count;
654        self.core.handle_command(command);
655        let evicted = self.core.evicted_message_count.saturating_sub(evicted_before);
656        if evicted == 0 {
657            return;
658        }
659
660        self.compact_activity_entries.retain_mut(|entry| {
661            if entry.line_index < evicted {
662                return false;
663            }
664            entry.line_index -= evicted;
665            true
666        });
667        for block in &mut self.tool_output_blocks {
668            block.anchor_line = block.anchor_line.filter(|&line| line >= evicted).map(|line| line - evicted);
669            block.anchor_search_start = block.anchor_search_start.saturating_sub(evicted);
670            block.recorded_at_line = block.recorded_at_line.map(|line| line.saturating_sub(evicted));
671        }
672        self.compact_activity_hit_regions.clear();
673        self.tool_output_revision = self.tool_output_revision.wrapping_add(1);
674    }
675
676    fn append_tool_output_line(&mut self, id: ToolOutputId, kind: InlineMessageKind, segments: Vec<InlineSegment>) {
677        self.handle_core_command(crate::tui::core_tui::types::InlineCommand::AppendLine { kind, segments });
678        let line_index = self.core.lines.len().saturating_sub(1);
679        // An expand notice must win over an earlier live-PTY header anchor:
680        // the notice is the row the user clicks to open this capture.
681        let is_expand_notice = self
682            .core
683            .lines
684            .get(line_index)
685            .is_some_and(|line| line.segments.iter().any(|segment| segment.text.contains("click to expand")));
686        if let Some(block) = self.tool_output_blocks.iter_mut().find(|block| block.id == id)
687            && (block.anchor_line.is_none() || is_expand_notice)
688        {
689            block.anchor_line = Some(line_index);
690            self.tool_output_revision = self.tool_output_revision.wrapping_add(1);
691            self.core.mark_dirty();
692        }
693    }
694
695    fn update_tool_output_anchors(&mut self, metadata: &CompactActivityMetadata, line_index: usize) {
696        let mut ids = metadata.review_anchors.clone();
697        if ids.is_empty()
698            && let Some(review_anchor) = metadata.review_anchor
699        {
700            ids.push(review_anchor);
701        }
702        for id in ids {
703            if let Some(block) = self.tool_output_blocks.iter_mut().find(|block| block.id == id) {
704                block.anchor_line = Some(line_index);
705            }
706        }
707    }
708
709    fn refresh_compact_activity_presentations(&mut self) {
710        let entries = self.compact_activity_entries.clone();
711        for entry in entries {
712            let is_compact_line = self
713                .core
714                .lines
715                .get(entry.line_index)
716                .is_some_and(|line| line.kind == InlineMessageKind::Info);
717            if !is_compact_line {
718                continue;
719            }
720
721            let segments = tool_output_viewer::compact_activity_segments(self, &entry.metadata);
722            let revision = self.core.next_revision();
723            if let Some(line) = self.core.lines.get_mut(entry.line_index) {
724                line.segments = segments;
725                line.link_ranges.clear();
726                line.revision = revision;
727                self.core.mark_line_dirty(entry.line_index);
728            }
729        }
730        self.core.invalidate_scroll_metrics();
731    }
732
733    /// Visual-only redraw (cursor, scroll, hover). Does not drop header/sidebar caches.
734    pub(crate) fn mark_visual_dirty(&mut self) {
735        self.core.mark_visual_dirty();
736    }
737
738    fn append_compact_activity(&mut self, metadata: CompactActivityMetadata) {
739        let segments = tool_output_viewer::compact_activity_segments(self, &metadata);
740        self.handle_core_command(crate::tui::core_tui::types::InlineCommand::AppendLine {
741            kind: InlineMessageKind::Info,
742            segments,
743        });
744        let line_index = self.core.lines.len().saturating_sub(1);
745        self.compact_activity_entries
746            .push(CompactActivityEntry { line_index, metadata: metadata.clone() });
747        let max_entries = ui::TUI_COMPACT_ACTIVITY_MAX_ENTRIES;
748        if self.compact_activity_entries.len() > max_entries {
749            let drop = self.compact_activity_entries.len() - max_entries;
750            self.compact_activity_entries.drain(..drop);
751        }
752        self.update_tool_output_anchors(&metadata, line_index);
753    }
754
755    fn replace_compact_activity(&mut self, metadata: CompactActivityMetadata) {
756        let can_replace = self
757            .compact_activity_entries
758            .last()
759            .is_some_and(|entry| entry.line_index + 1 == self.core.lines.len());
760        if !can_replace {
761            self.append_compact_activity(metadata);
762            return;
763        }
764
765        let segments = tool_output_viewer::compact_activity_segments(self, &metadata);
766        self.handle_core_command(crate::tui::core_tui::types::InlineCommand::ReplaceLast {
767            count: 1,
768            kind: InlineMessageKind::Info,
769            lines: vec![segments],
770            link_ranges: None,
771        });
772        if let Some(entry) = self.compact_activity_entries.last_mut() {
773            entry.metadata = metadata.clone();
774            entry.line_index = self.core.lines.len().saturating_sub(1);
775        }
776        let line_index = self.core.lines.len().saturating_sub(1);
777        self.update_tool_output_anchors(&metadata, line_index);
778    }
779
780    fn collapse_pty_block(&mut self, metadata: CompactActivityMetadata) {
781        let pty_count = self
782            .core
783            .lines
784            .iter()
785            .rev()
786            .take_while(|line| line.kind == InlineMessageKind::Pty)
787            .count();
788        if pty_count == 0 {
789            self.replace_compact_activity(metadata);
790            return;
791        }
792
793        let old_first_removed = self.core.lines.len().saturating_sub(pty_count);
794        let preceding_activity = self.compact_activity_entries.last().is_some_and(|entry| {
795            entry.line_index + 1 == old_first_removed && entry.metadata.group_id == metadata.group_id
796        });
797        let remove_count = pty_count + usize::from(preceding_activity);
798        let first_removed = self.core.lines.len().saturating_sub(remove_count);
799        let segments = tool_output_viewer::compact_activity_segments(self, &metadata);
800        self.handle_core_command(crate::tui::core_tui::types::InlineCommand::ReplaceLast {
801            count: remove_count,
802            kind: InlineMessageKind::Info,
803            lines: vec![segments],
804            link_ranges: None,
805        });
806        self.compact_activity_entries.retain(|entry| entry.line_index < first_removed);
807        let line_index = self.core.lines.len().saturating_sub(1);
808        self.compact_activity_entries
809            .push(CompactActivityEntry { line_index, metadata: metadata.clone() });
810        self.update_tool_output_anchors(&metadata, line_index);
811    }
812
813    pub(crate) fn compact_activity_for_line(&self, line_index: usize) -> Option<&CompactActivityMetadata> {
814        self.compact_activity_entries
815            .iter()
816            .find(|entry| entry.line_index == line_index)
817            .map(|entry| &entry.metadata)
818    }
819
820    pub(crate) fn compact_activity_review_anchor_at(&self, column: u16, row: u16) -> Option<ToolOutputId> {
821        self.compact_activity_hit_regions
822            .iter()
823            .find(|region| region.area.contains(Position { x: column, y: row }))
824            .map(|region| region.review_anchor)
825    }
826
827    fn close_tool_output_viewer(&mut self) {
828        if self.tool_output_viewer_state.is_none() {
829            return;
830        }
831        self.tool_output_viewer_state = None;
832        self.close_transient_surface(TransientSurface::ToolOutputViewer);
833        // Unpin and drop any over-cap blocks that were held for the viewer.
834        self.trim_tool_output_blocks();
835        self.core.mark_dirty();
836    }
837
838    pub(crate) fn show_transient(&mut self, request: TransientRequest) {
839        self.core.clear_inline_prompt_suggestion();
840        match request {
841            TransientRequest::Modal(request) => {
842                self.core
843                    .show_overlay(crate::tui::core_tui::types::OverlayRequest::Modal(request.into()));
844                self.show_transient_surface(TransientSurface::FloatingOverlay);
845            }
846            TransientRequest::List(request) => {
847                self.core
848                    .show_overlay(crate::tui::core_tui::types::OverlayRequest::List(request.into()));
849                self.show_transient_surface(TransientSurface::FloatingOverlay);
850            }
851            TransientRequest::Wizard(request) => {
852                self.core
853                    .show_overlay(crate::tui::core_tui::types::OverlayRequest::Wizard(request.into()));
854                self.show_transient_surface(TransientSurface::FloatingOverlay);
855            }
856            TransientRequest::Diff(request) => {
857                self.show_diff_overlay(request);
858            }
859            TransientRequest::FilePalette(request) => {
860                self.load_file_palette(request.dir_lister, request.workspace);
861                match request.visible {
862                    Some(true) => {
863                        self.ensure_inline_lists_visible_for_trigger();
864                        self.file_palette_active = true;
865                        self.show_transient_surface(TransientSurface::FilePalette);
866                    }
867                    Some(false) => {
868                        self.close_file_palette();
869                    }
870                    None => {}
871                }
872            }
873            TransientRequest::AgentPalette(request) => {
874                self.load_agent_palette(request.agents);
875                match request.visible {
876                    Some(true) => {
877                        self.ensure_inline_lists_visible_for_trigger();
878                        self.agent_palette_active = true;
879                        self.show_transient_surface(TransientSurface::AgentPalette);
880                    }
881                    Some(false) => {
882                        self.close_agent_palette();
883                    }
884                    None => {}
885                }
886            }
887            TransientRequest::HistoryPicker => {
888                events::open_history_picker(self);
889            }
890            TransientRequest::SlashPalette => {
891                self.ensure_inline_lists_visible_for_trigger();
892                self.show_transient_surface(TransientSurface::SlashPalette);
893            }
894            TransientRequest::TaskPanel(TaskPanelTransientRequest { lines, statuses, current, visible, metadata }) => {
895                // Visibility-only requests (show/hide) must not mutate panel body,
896                // metadata, or terminal-title progress. Content updates own those
897                // fields; typed metadata is the authoritative progress source.
898                if visible.is_none() {
899                    // Parallel statuses must align 1:1 with lines; mismatched
900                    // legacy payloads fall back to the uniform base style.
901                    let aligned = statuses.len() == lines.len();
902                    self.task_panel_statuses = if aligned { statuses } else { Vec::new() };
903                    self.task_panel_current =
904                        (aligned.then_some(current).flatten()).filter(|index| *index < lines.len());
905                    self.task_panel_lines = lines;
906                    self.core.mark_task_panel_content_dirty();
907                    match metadata {
908                        Some(metadata) => {
909                            self.core.set_task_panel_progress_label(Some(format!(
910                                "{}/{}",
911                                metadata.completed, metadata.total
912                            )));
913                            let percent = if metadata.total == 0 {
914                                None
915                            } else {
916                                let completed = metadata.completed.min(metadata.total) as u64;
917                                let total = metadata.total as u64;
918                                let percent = completed.saturating_mul(100) / total.max(1);
919                                Some(u8::try_from(percent.min(100)).unwrap_or(100))
920                            };
921                            self.core
922                                .program_status
923                                .apply(vtcode_commons::program_status::ProgramStatusUpdate::Progress { percent });
924                            self.task_panel_metadata = Some(metadata);
925                        }
926                        None => {
927                            // Content updates without typed metadata (session
928                            // clear) must not leave stale title/progress on the
929                            // panel header or terminal title.
930                            self.task_panel_metadata = None;
931                            self.core.set_task_panel_progress_label(None);
932                            self.core
933                                .program_status
934                                .apply(vtcode_commons::program_status::ProgramStatusUpdate::Progress { percent: None });
935                        }
936                    }
937                }
938                if let Some(visible) = visible {
939                    let suppressed = visible && !self.core.appearance.should_show_task_panel();
940                    if !suppressed {
941                        self.set_task_panel_visible(visible);
942                    }
943                    // When suppressed, the lines are retained so the manual
944                    // `toggle_task_panel` keybinding can reveal them later.
945                } else {
946                    self.core.mark_dirty();
947                }
948            }
949            TransientRequest::LocalAgents(LocalAgentsTransientRequest { visible }) => {
950                if let Some(visible) = visible {
951                    if visible {
952                        self.ensure_inline_lists_visible_for_trigger();
953                        self.open_local_agents_drawer(false);
954                    } else {
955                        self.close_local_agents_drawer(true);
956                    }
957                } else {
958                    self.core.mark_dirty();
959                }
960            }
961        }
962        self.core.mark_dirty();
963    }
964
965    /// Show a help modal using the ratatui-cheese Help widget.
966    ///
967    /// The help flag travels on the request so a queued help modal still
968    /// renders as help when it activates instead of clobbering the overlay
969    /// that is currently visible.
970    fn show_help_modal(&mut self) {
971        self.show_transient(TransientRequest::Modal(crate::tui::core_tui::app::types::ModalOverlayRequest {
972            title: "Keyboard Shortcuts".to_string(),
973            lines: Vec::new(),
974            secure_prompt: None,
975            is_help_modal: true,
976        }));
977    }
978
979    fn should_auto_open_local_agents(&self) -> bool {
980        if self.has_active_overlay() {
981            return false;
982        }
983
984        matches!(
985            self.visible_transient_surface(),
986            None | Some(TransientSurface::TaskPanel | TransientSurface::LocalAgents)
987        )
988    }
989
990    pub(crate) fn close_transient(&mut self) {
991        match self.visible_transient_surface() {
992            Some(TransientSurface::FloatingOverlay) => self.close_overlay(),
993            Some(TransientSurface::DiffPreview) => self.close_diff_overlay(),
994            Some(TransientSurface::ToolOutputViewer) => self.close_tool_output_viewer(),
995            Some(TransientSurface::HistoryPicker) => self.close_history_picker(),
996            Some(TransientSurface::AgentPalette) => self.close_agent_palette(),
997            Some(TransientSurface::FilePalette) => self.close_file_palette(),
998            Some(TransientSurface::SlashPalette) => slash::clear_slash_suggestions(self),
999            Some(TransientSurface::TaskPanel) => self.set_task_panel_visible(false),
1000            Some(TransientSurface::LocalAgents) => {
1001                self.close_local_agents_drawer(true);
1002            }
1003            None => {}
1004        }
1005    }
1006
1007    fn open_local_agents_drawer(&mut self, auto_opened: bool) {
1008        self.local_agents_auto_opened = auto_opened;
1009        self.show_transient_surface(TransientSurface::LocalAgents);
1010    }
1011
1012    fn close_local_agents_drawer(&mut self, clear_auto_opened: bool) {
1013        if clear_auto_opened {
1014            self.local_agents_auto_opened = false;
1015        }
1016        self.close_transient_surface(TransientSurface::LocalAgents);
1017    }
1018
1019    fn sync_transient_focus(&mut self) {
1020        if self.core.activity_state.is_busy() {
1021            self.core.set_input_enabled(false);
1022            self.core.set_cursor_visible(false);
1023            return;
1024        }
1025        let Some(surface) = self.visible_transient_surface() else {
1026            self.core.set_input_enabled(true);
1027            self.core.set_cursor_visible(true);
1028            return;
1029        };
1030
1031        match surface.focus_policy() {
1032            TransientFocusPolicy::Modal | TransientFocusPolicy::CapturedInput => {
1033                self.core.set_input_enabled(false);
1034                self.core.set_cursor_visible(false);
1035            }
1036            TransientFocusPolicy::SharedInput | TransientFocusPolicy::Passive => {
1037                self.core.set_input_enabled(true);
1038                self.core.set_cursor_visible(true);
1039            }
1040        }
1041    }
1042
1043    fn sync_transient_activity_signal(&self) {
1044        let Some(signal) = self.transient_active_signal.as_ref() else {
1045            return;
1046        };
1047        let active = self
1048            .visible_transient_surface()
1049            .is_some_and(|surface| !matches!(surface, TransientSurface::TaskPanel));
1050        signal.set_active(active);
1051    }
1052
1053    fn apply_transient_visibility_change(&mut self, change: TransientVisibilityChange) {
1054        if matches!(change.previous_visible, Some(TransientSurface::FilePalette | TransientSurface::AgentPalette))
1055            || matches!(change.current_visible, Some(TransientSurface::FilePalette | TransientSurface::AgentPalette))
1056        {
1057            self.core.request_full_clear();
1058        }
1059        self.core
1060            .set_local_agents_drawer_visible(change.current_visible == Some(TransientSurface::LocalAgents));
1061        self.sync_transient_focus();
1062        self.sync_transient_activity_signal();
1063    }
1064
1065    pub fn handle_command(&mut self, command: InlineCommand) {
1066        match command {
1067            InlineCommand::SetLocalAgents { entries } => {
1068                let has_delegated_entries = entries
1069                    .iter()
1070                    .any(|entry| entry.kind == crate::tui::core_tui::types::LocalAgentKind::Delegated);
1071                // Auto-open is for live delegated work attention. Finished rows
1072                // stay listed for history, so close the auto-opened window when
1073                // nothing delegated is still loading (not when the list empties).
1074                let has_live_delegated = entries.iter().any(|entry| {
1075                    entry.kind == crate::tui::core_tui::types::LocalAgentKind::Delegated && entry.is_loading()
1076                });
1077                let update = self.local_agents_state.set_entries(entries.clone());
1078                let background_count = self.local_agents_state.loading_count();
1079                let finished_count = self.local_agents_state.finished_count();
1080                self.core.set_local_agents(entries);
1081                self.core.set_background_activity_count(background_count);
1082                self.core.set_background_finished_count(finished_count);
1083                if update.has_new_delegated_entries && self.should_auto_open_local_agents() {
1084                    self.ensure_inline_lists_visible_for_trigger();
1085                    self.open_local_agents_drawer(true);
1086                } else if self.local_agents_auto_opened && !has_live_delegated {
1087                    self.close_local_agents_drawer(true);
1088                } else if !self.local_agents_visible() && !has_delegated_entries {
1089                    self.local_agents_auto_opened = false;
1090                }
1091            }
1092            InlineCommand::SetArchivedHistory { entries } => {
1093                let mut input_entries = Vec::new();
1094                let archived = entries
1095                    .into_iter()
1096                    .map(|e| {
1097                        input_entries.push(
1098                            crate::tui::core_tui::session::input_manager::InputHistoryEntry::from_content_and_timestamp(
1099                                e.content.clone(),
1100                                e.created_at,
1101                            ),
1102                        );
1103                        history_picker::ArchivedPrompt {
1104                            content: e.content,
1105                            created_at: e.created_at,
1106                            session_label: e.session_label,
1107                        }
1108                    })
1109                    .collect();
1110                self.history_picker_state.set_archived_prompts(archived);
1111                self.core.input_manager.prepend_archived_history(input_entries);
1112                self.core.mark_dirty();
1113            }
1114            InlineCommand::SetInput(value) => {
1115                self.handle_core_command(crate::tui::core_tui::types::InlineCommand::SetInput(value));
1116                self.update_input_triggers();
1117            }
1118            InlineCommand::RestoreInputDraft(input) => {
1119                self.handle_core_command(crate::tui::core_tui::types::InlineCommand::RestoreInputDraft(input));
1120                self.update_input_triggers();
1121            }
1122            InlineCommand::ApplySuggestedPrompt(value) => {
1123                self.handle_core_command(crate::tui::core_tui::types::InlineCommand::ApplySuggestedPrompt(value));
1124                self.update_input_triggers();
1125            }
1126            InlineCommand::SetInlinePromptSuggestion { suggestion, llm_generated } => {
1127                self.handle_core_command(crate::tui::core_tui::types::InlineCommand::SetInlinePromptSuggestion {
1128                    suggestion,
1129                    llm_generated,
1130                });
1131                self.update_input_triggers();
1132            }
1133            InlineCommand::ClearInlinePromptSuggestion => {
1134                self.handle_core_command(crate::tui::core_tui::types::InlineCommand::ClearInlinePromptSuggestion);
1135                self.update_input_triggers();
1136            }
1137            InlineCommand::ClearInput => {
1138                self.handle_core_command(crate::tui::core_tui::types::InlineCommand::ClearInput);
1139                self.update_input_triggers();
1140            }
1141            InlineCommand::RecordToolOutput { id, lines } => {
1142                self.record_tool_output_block(id, lines);
1143            }
1144            InlineCommand::AppendToolOutputLine { id, kind, segments } => {
1145                self.append_tool_output_line(id, kind, segments);
1146            }
1147            InlineCommand::AppendCompactActivity(metadata) => {
1148                self.append_compact_activity(metadata);
1149            }
1150            InlineCommand::ReplaceCompactActivity(metadata) => {
1151                self.replace_compact_activity(metadata);
1152            }
1153            InlineCommand::CollapsePtyBlock(metadata) => {
1154                self.collapse_pty_block(metadata);
1155            }
1156            InlineCommand::SetKeyBindings { bindings } => {
1157                self.core.set_bindings(BindingStore::new(bindings));
1158                self.refresh_compact_activity_presentations();
1159            }
1160            InlineCommand::SetAppearance { appearance } => {
1161                let enabling_screen_reader = appearance.screen_reader_mode && !self.core.appearance.screen_reader_mode;
1162                if enabling_screen_reader && let Some(viewer) = self.tool_output_viewer_state.as_mut() {
1163                    viewer.set_render_mode(tool_output_viewer::TranscriptRenderMode::Raw);
1164                }
1165                self.handle_core_command(crate::tui::core_tui::types::InlineCommand::SetAppearance { appearance });
1166                self.refresh_compact_activity_presentations();
1167            }
1168            InlineCommand::SetFullscreenInteraction { interaction } => {
1169                self.handle_core_command(crate::tui::core_tui::types::InlineCommand::SetFullscreenInteraction {
1170                    interaction,
1171                });
1172            }
1173            InlineCommand::ReplaceLast { count, kind, lines, link_ranges } => {
1174                let remove_count = count.min(self.core.lines.len());
1175                let first_removed = self.core.lines.len().saturating_sub(remove_count);
1176                self.compact_activity_entries.retain(|entry| entry.line_index < first_removed);
1177                self.handle_core_command(crate::tui::core_tui::types::InlineCommand::ReplaceLast {
1178                    count,
1179                    kind,
1180                    lines,
1181                    link_ranges,
1182                });
1183            }
1184            InlineCommand::ClearScreen => {
1185                self.tool_output_blocks.clear();
1186                self.compact_activity_entries.clear();
1187                self.compact_activity_hit_regions.clear();
1188                self.diff_review_anchors.clear();
1189                self.tool_output_revision = self.tool_output_revision.wrapping_add(1);
1190                self.handle_core_command(crate::tui::core_tui::types::InlineCommand::ClearScreen);
1191            }
1192            InlineCommand::CloseTransient => self.close_transient(),
1193            InlineCommand::ShowTransient { request } => self.show_transient(*request),
1194            InlineCommand::RecordDiffReview(anchor) => self.record_diff_review(anchor),
1195            InlineCommand::FocusTranscriptReview { id } => self.open_tool_output_viewer(
1196                self.core.transcript_width.max(1),
1197                self.core.transcript_rows.max(1),
1198                Some(id),
1199            ),
1200            InlineCommand::UpdateFilePaletteSearch { files } => {
1201                if let Some(palette) = &mut self.file_palette {
1202                    palette.set_search_index(files);
1203                }
1204            }
1205            InlineCommand::SetSlashCommands { commands } => {
1206                self.slash_palette.set_commands(commands);
1207            }
1208            _ => {
1209                if let Some(core_cmd) = to_core_command(&command) {
1210                    self.handle_core_command(core_cmd);
1211                }
1212            }
1213        }
1214    }
1215}
1216
1217fn normalize_tool_output_header(header: &str) -> String {
1218    crate::tui::core_tui::session::text_utils::strip_ansi_codes(header)
1219        .trim_end()
1220        .to_owned()
1221}
1222
1223fn rendered_core_line_text(core: &CoreSessionState, line_index: usize) -> String {
1224    let Some(line) = core.lines.get(line_index) else {
1225        return String::new();
1226    };
1227    core.render_message_spans_for_line(line)
1228        .into_iter()
1229        .map(|span| crate::tui::core_tui::session::text_utils::strip_ansi_codes(span.content.as_ref()).into_owned())
1230        .collect()
1231}
1232
1233fn folded_pty_header(core: &CoreSessionState, line_index: usize) -> String {
1234    let first = rendered_core_line_text(core, line_index);
1235    if !first.trim_start().starts_with('•') {
1236        return first;
1237    }
1238
1239    let mut folded = first.trim_end().to_owned();
1240    let mut continuation_index = line_index + 1;
1241    while let Some(line) = core.lines.get(continuation_index) {
1242        if line.kind != InlineMessageKind::Pty {
1243            break;
1244        }
1245        let continuation = rendered_core_line_text(core, continuation_index);
1246        let Some(fragment) = continuation.trim_start().strip_prefix('│') else {
1247            break;
1248        };
1249        folded.push(' ');
1250        folded.push_str(fragment.trim());
1251        continuation_index += 1;
1252    }
1253    folded
1254}
1255
1256impl std::ops::Deref for AppSession {
1257    type Target = CoreSessionState;
1258
1259    fn deref(&self) -> &Self::Target {
1260        &self.core
1261    }
1262}
1263
1264impl std::ops::DerefMut for AppSession {
1265    fn deref_mut(&mut self) -> &mut Self::Target {
1266        &mut self.core
1267    }
1268}
1269
1270fn to_core_command(command: &InlineCommand) -> Option<crate::tui::core_tui::types::InlineCommand> {
1271    use crate::tui::core_tui::types::InlineCommand as CoreCommand;
1272
1273    Some(match command {
1274        InlineCommand::AppendLine { kind, segments } => {
1275            CoreCommand::AppendLine { kind: *kind, segments: segments.clone() }
1276        }
1277        InlineCommand::AppendPastedMessage { kind, text, line_count } => CoreCommand::AppendPastedMessage {
1278            kind: *kind,
1279            text: text.clone(),
1280            line_count: *line_count,
1281        },
1282        InlineCommand::Inline { kind, segment } => CoreCommand::Inline { kind: *kind, segment: segment.clone() },
1283        InlineCommand::ReplaceLast { count, kind, lines, link_ranges } => CoreCommand::ReplaceLast {
1284            count: *count,
1285            kind: *kind,
1286            lines: lines.clone(),
1287            link_ranges: link_ranges.clone(),
1288        },
1289        InlineCommand::RecordToolOutput { .. }
1290        | InlineCommand::FocusTranscriptReview { .. }
1291        | InlineCommand::AppendToolOutputLine { .. }
1292        | InlineCommand::AppendCompactActivity(_)
1293        | InlineCommand::ReplaceCompactActivity(_)
1294        | InlineCommand::CollapsePtyBlock(_)
1295        | InlineCommand::SetKeyBindings { .. } => return None,
1296        InlineCommand::SetPrompt { prefix, style } => {
1297            CoreCommand::SetPrompt { prefix: prefix.clone(), style: style.clone() }
1298        }
1299        InlineCommand::SetPlaceholder { hint, style } => {
1300            CoreCommand::SetPlaceholder { hint: hint.clone(), style: style.clone() }
1301        }
1302        InlineCommand::SetMessageLabels { agent, user } => {
1303            CoreCommand::SetMessageLabels { agent: agent.clone(), user: user.clone() }
1304        }
1305        InlineCommand::SetHeaderContext { context } => CoreCommand::SetHeaderContext { context: context.clone() },
1306        InlineCommand::SetInputStatus { left, right } => {
1307            CoreCommand::SetInputStatus { left: left.clone(), right: right.clone() }
1308        }
1309        InlineCommand::SetConfiguredInputStatus { left, right } => {
1310            CoreCommand::SetConfiguredInputStatus { left: left.clone(), right: right.clone() }
1311        }
1312        InlineCommand::ProgramStatus(update) => CoreCommand::ProgramStatus(*update),
1313        InlineCommand::SetActivityState(state) => CoreCommand::SetActivityState(*state),
1314        InlineCommand::UpdateProgress(update) => CoreCommand::UpdateProgress(*update),
1315        InlineCommand::SetTerminalTitleItems { items } => CoreCommand::SetTerminalTitleItems { items: items.clone() },
1316        InlineCommand::SetTerminalTitleThreadLabel { label } => {
1317            CoreCommand::SetTerminalTitleThreadLabel { label: label.clone() }
1318        }
1319        InlineCommand::SetTerminalTitleGitBranch { branch } => {
1320            CoreCommand::SetTerminalTitleGitBranch { branch: branch.clone() }
1321        }
1322        InlineCommand::SetTheme { theme } => CoreCommand::SetTheme { theme: theme.clone() },
1323        InlineCommand::SetColorSchemeAuto { enabled } => CoreCommand::SetColorSchemeAuto { enabled: *enabled },
1324        InlineCommand::SetAppearance { appearance } => CoreCommand::SetAppearance { appearance: appearance.clone() },
1325        InlineCommand::SetFullscreenInteraction { interaction } => {
1326            CoreCommand::SetFullscreenInteraction { interaction: *interaction }
1327        }
1328        InlineCommand::SetVimModeEnabled(enabled) => CoreCommand::SetVimModeEnabled(*enabled),
1329        InlineCommand::SetQueuedInputs { entries } => CoreCommand::SetQueuedInputs { entries: entries.clone() },
1330        InlineCommand::SetSubprocessEntries { entries } => {
1331            CoreCommand::SetSubprocessEntries { entries: entries.clone() }
1332        }
1333        InlineCommand::SetSubagentPreview { text } => CoreCommand::SetSubagentPreview { text: text.clone() },
1334        InlineCommand::SetLocalAgents { .. } => return None,
1335        InlineCommand::SetArchivedHistory { .. } => return None,
1336        InlineCommand::UpdateFilePaletteSearch { .. } => return None,
1337        InlineCommand::SetSlashCommands { .. } => return None,
1338        InlineCommand::SetPrimaryAgent { name, color } => {
1339            CoreCommand::SetPrimaryAgent { name: name.clone(), color: color.clone() }
1340        }
1341        InlineCommand::SetCursorVisible(value) => CoreCommand::SetCursorVisible(*value),
1342        InlineCommand::SetInputEnabled(value) => CoreCommand::SetInputEnabled(*value),
1343        InlineCommand::SetImageInputEnabled(value) => CoreCommand::SetImageInputEnabled(*value),
1344        InlineCommand::SetInput(value) => CoreCommand::SetInput(value.clone()),
1345        InlineCommand::RestoreInputDraft(input) => CoreCommand::RestoreInputDraft(input.clone()),
1346        InlineCommand::ApplySuggestedPrompt(value) => CoreCommand::ApplySuggestedPrompt(value.clone()),
1347        InlineCommand::SetInlinePromptSuggestion { suggestion, llm_generated } => {
1348            CoreCommand::SetInlinePromptSuggestion {
1349                suggestion: suggestion.clone(),
1350                llm_generated: *llm_generated,
1351            }
1352        }
1353        InlineCommand::ClearInlinePromptSuggestion => CoreCommand::ClearInlinePromptSuggestion,
1354        InlineCommand::ClearInput => CoreCommand::ClearInput,
1355        InlineCommand::ForceRedraw => CoreCommand::ForceRedraw,
1356        InlineCommand::ClearScreen => CoreCommand::ClearScreen,
1357        InlineCommand::SuspendEventLoop => CoreCommand::SuspendEventLoop,
1358        InlineCommand::ResumeEventLoop => CoreCommand::ResumeEventLoop,
1359        InlineCommand::ClearInputQueue => CoreCommand::ClearInputQueue,
1360        InlineCommand::StopEventStream => CoreCommand::StopEventStream,
1361        InlineCommand::StartEventStream => CoreCommand::StartEventStream,
1362        InlineCommand::SetSkipConfirmations(skip) => CoreCommand::SetSkipConfirmations(*skip),
1363        InlineCommand::Shutdown => CoreCommand::Shutdown,
1364        InlineCommand::SetReasoningStage(stage) => CoreCommand::SetReasoningStage(stage.clone()),
1365        InlineCommand::ShowTransient { .. } | InlineCommand::CloseTransient | InlineCommand::RecordDiffReview(_) => {
1366            return None;
1367        }
1368    })
1369}
1370
1371impl TuiSessionDriver for AppSession {
1372    type Command = InlineCommand;
1373    type Event = InlineEvent;
1374
1375    fn handle_command(&mut self, command: Self::Command) {
1376        AppSession::handle_command(self, command);
1377    }
1378
1379    fn handle_event(
1380        &mut self,
1381        event: CrosstermEvent,
1382        events: &UnboundedSender<Self::Event>,
1383        callback: Option<&(dyn Fn(&Self::Event) + Send + Sync + 'static)>,
1384    ) {
1385        AppSession::handle_event(self, event, events, callback);
1386    }
1387
1388    fn handle_tick(&mut self) {
1389        // Background work (managed subagents, background subprocesses, retained
1390        // exec sessions) animates the same shimmer phase: `core.handle_tick`
1391        // already ORs `background_status_shimmer_active` into its single update
1392        // per tick, so no separate drawer-visible pass is needed here.
1393        self.core.handle_tick();
1394    }
1395
1396    fn needs_animation_tick(&self) -> bool {
1397        // Core covers drag, spinner, shimmer, core background, and expiries;
1398        // AppSession adds its own drawer-local background loading count, which
1399        // feeds `has_status_spinner` but lives outside `core`.
1400        self.core.needs_animation_tick()
1401            || (self.core.appearance.should_animate_progress_status() && self.background_activity_active())
1402    }
1403
1404    fn render(&mut self, frame: &mut Frame<'_>) {
1405        AppSession::render(self, frame);
1406    }
1407
1408    fn take_redraw(&mut self) -> bool {
1409        self.core.take_redraw()
1410    }
1411
1412    fn use_steady_cursor(&self) -> bool {
1413        self.core.use_steady_cursor()
1414    }
1415
1416    fn is_hovering_link(&self) -> bool {
1417        self.core.is_hovering_link()
1418    }
1419
1420    fn is_selecting_text(&self) -> bool {
1421        self.core.is_selecting_text()
1422    }
1423
1424    fn should_exit(&self) -> bool {
1425        self.core.should_exit()
1426    }
1427
1428    fn request_exit(&mut self) {
1429        self.core.request_exit();
1430    }
1431
1432    fn mark_dirty(&mut self) {
1433        self.core.mark_dirty();
1434    }
1435
1436    fn update_terminal_title(&mut self) {
1437        self.core.update_terminal_title();
1438    }
1439
1440    fn attach_program_status_terminal(&mut self) {
1441        self.core.program_status.attach_terminal();
1442    }
1443
1444    fn flush_program_status(&mut self) {
1445        self.core.program_status.flush();
1446    }
1447
1448    fn shutdown_program_status(&mut self) {
1449        self.core.program_status.shutdown();
1450    }
1451
1452    fn clear_terminal_title(&mut self) {
1453        self.core.clear_terminal_title();
1454    }
1455
1456    fn is_running_activity(&self) -> bool {
1457        // Turn-scoped only. Background tasks and drawer previews are
1458        // asynchronous, so they must not lock mode switches, block slash
1459        // commands, or convert plain submissions into queued input.
1460        self.core.is_running_activity()
1461    }
1462
1463    fn has_status_spinner(&self) -> bool {
1464        // Drives the animation tick rate and the drawer row shimmer, so it
1465        // includes background activity even when the drawer is closed.
1466        self.core.has_status_spinner() || self.background_activity_active()
1467    }
1468
1469    fn thinking_spinner_active(&self) -> bool {
1470        self.core.thinking_spinner.is_active
1471    }
1472
1473    fn has_active_navigation_ui(&self) -> bool {
1474        self.transient_host.has_active_navigation_surface()
1475    }
1476
1477    fn apply_coalesced_scroll(&mut self, line_delta: i32, page_delta: i32) {
1478        self.core.apply_coalesced_scroll(line_delta, page_delta);
1479    }
1480
1481    fn set_show_logs(&mut self, show: bool) {
1482        self.core.show_logs = show;
1483    }
1484
1485    fn set_active_pty_sessions(&mut self, sessions: Option<Arc<AtomicUsize>>) {
1486        self.core.active_pty_sessions = sessions;
1487    }
1488
1489    fn set_workspace_root(&mut self, root: Option<std::path::PathBuf>) {
1490        self.core.set_workspace_root(root);
1491    }
1492
1493    fn set_log_receiver(&mut self, receiver: UnboundedReceiver<crate::tui::core_tui::log::LogEntry>) {
1494        self.core.set_log_receiver(receiver);
1495    }
1496
1497    fn set_fullscreen_active(&mut self, active: bool) {
1498        self.core.set_fullscreen_active(active);
1499    }
1500
1501    fn set_fullscreen_interaction(&mut self, config: FullscreenInteractionSettings) {
1502        self.core.set_fullscreen_interaction(config);
1503    }
1504
1505    fn set_preview_callback(&mut self, callback: Option<crate::tui::core_tui::types::PreviewCallback>) {
1506        self.preview_callback = callback;
1507    }
1508}