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