Skip to main content

vtcode_ui/tui/core_tui/
session.rs

1use std::{collections::VecDeque, sync::Arc, time::Instant};
2
3#[cfg(test)]
4use anstyle::Color as AnsiColorEnum;
5use anstyle::RgbColor;
6use ratatui::crossterm::event::{
7    Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind,
8};
9
10use ratatui::{
11    Frame,
12    layout::{Constraint, Layout, Rect},
13    text::{Line, Span, Text},
14    widgets::{Clear, ListState, Widget},
15};
16use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
17
18use super::{
19    style::{measure_text_width, ratatui_color_from_ansi, ratatui_style_from_inline},
20    types::{
21        ActivityState, InlineCommand, InlineEvent, InlineHeaderContext, InlineListSelection, InlineMessageKind,
22        InlineTextStyle, InlineTheme, OverlayRequest,
23    },
24};
25use crate::tui::config::constants::ui;
26use crate::tui::core_tui::types::LocalAgentEntry;
27use crate::tui::options::FullscreenInteractionSettings;
28use crate::tui::ui::tui::widgets::SessionWidget;
29
30mod frame_layout;
31mod header;
32mod impl_events;
33mod impl_init;
34mod impl_input;
35mod impl_layout;
36mod impl_logs;
37mod impl_render;
38mod impl_scroll;
39mod impl_style;
40pub(crate) mod inline_list;
41mod input;
42pub(crate) mod input_manager;
43mod layout_state;
44pub(crate) mod list_navigator;
45pub(crate) mod list_panel;
46mod message;
47pub mod modal;
48pub mod mouse_selection;
49mod navigation;
50mod progress;
51mod queue;
52pub(crate) mod render;
53mod render_state;
54mod scroll;
55pub mod styling;
56pub(crate) mod text_utils;
57mod textarea_bridge;
58mod transcript;
59pub(crate) mod utils;
60pub(crate) mod wrapping;
61
62// New modular components (refactored from main session.rs)
63mod command;
64mod editing;
65
66pub mod action;
67pub(crate) mod clipboard_image;
68pub mod config;
69mod driver;
70mod events;
71pub(crate) mod message_renderer;
72mod messages;
73pub(crate) mod mode_switch_guard;
74mod reflow;
75pub(crate) mod reverse_search;
76mod spinner;
77mod state;
78mod sticky_prompt;
79pub mod terminal_capabilities;
80pub(crate) mod terminal_title;
81#[cfg(test)]
82mod tests;
83mod tool_renderer;
84mod transcript_links;
85mod vim;
86
87use self::input_manager::InputManager;
88use self::layout_state::SessionAreas;
89pub(crate) use self::message::TranscriptLine;
90use self::message::{MessageLabels, MessageLine};
91use self::modal::{ModalState, WizardModalState};
92
93pub use self::action::{Action, BindingStore, parse_key_binding};
94use self::config::AppearanceConfig;
95pub(crate) use self::input::status_requires_shimmer;
96use self::mouse_selection::{DragAutoScroll, DragAutoScrollDirection, MouseSelectionState};
97use self::queue::QueueOverlay;
98use self::render_state::RenderState;
99use self::scroll::ScrollManager;
100pub(crate) use self::spinner::pulse_spinner_frame_for_phase;
101use self::spinner::{ShimmerState, ThinkingSpinner};
102use self::styling::SessionStyles;
103use self::transcript::TranscriptReflowCache;
104use self::transcript_links::TranscriptFileLinkTarget;
105pub(crate) use self::transcript_links::TranscriptLinkClickAction;
106use self::vim::VimState;
107#[cfg(test)]
108use super::types::InlineHeaderHighlight;
109// TaskPlan integration intentionally omitted in this UI crate.
110use crate::tui::ui::tui::log::{LogEntry, highlight_log_entry};
111
112const USER_PREFIX: &str = "";
113const PLACEHOLDER_COLOR: RgbColor = RgbColor(ui::PLACEHOLDER_R, ui::PLACEHOLDER_G, ui::PLACEHOLDER_B);
114const MAX_LOG_LINES: usize = 256;
115const MAX_LOG_DRAIN_PER_TICK: usize = 256;
116
117#[derive(Clone, Debug)]
118struct CollapsedPaste {
119    line_index: usize,
120    full_text: String,
121}
122
123#[derive(Clone, Debug, Default)]
124pub(crate) struct SuggestedPromptState {
125    active: bool,
126}
127
128#[derive(Clone, Copy, Debug, PartialEq, Eq)]
129pub(crate) enum InlinePromptSuggestionSource {
130    Llm,
131    Local,
132}
133
134#[derive(Clone, Debug, Default, PartialEq, Eq)]
135pub(crate) struct InlinePromptSuggestionState {
136    suggestion: Option<String>,
137    source: Option<InlinePromptSuggestionSource>,
138}
139
140pub(crate) enum ActiveOverlay {
141    Modal(Box<ModalState>),
142    Wizard(Box<WizardModalState>),
143}
144
145impl ActiveOverlay {
146    fn as_modal(&self) -> Option<&ModalState> {
147        match self {
148            Self::Modal(state) => Some(state),
149            Self::Wizard(_) => None,
150        }
151    }
152
153    fn as_modal_mut(&mut self) -> Option<&mut ModalState> {
154        match self {
155            Self::Modal(state) => Some(state),
156            Self::Wizard(_) => None,
157        }
158    }
159
160    fn as_wizard(&self) -> Option<&WizardModalState> {
161        match self {
162            Self::Wizard(state) => Some(state),
163            Self::Modal(_) => None,
164        }
165    }
166
167    fn as_wizard_mut(&mut self) -> Option<&mut WizardModalState> {
168        match self {
169            Self::Wizard(state) => Some(state),
170            Self::Modal(_) => None,
171        }
172    }
173
174    fn restore_input(&self) -> bool {
175        match self {
176            Self::Modal(state) => state.restore_input,
177            Self::Wizard(_) => true,
178        }
179    }
180
181    fn restore_cursor(&self) -> bool {
182        match self {
183            Self::Modal(state) => state.restore_cursor,
184            Self::Wizard(_) => true,
185        }
186    }
187}
188
189#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
190pub(crate) enum MouseDragTarget {
191    #[default]
192    None,
193    Transcript,
194    ModalText,
195    Input,
196}
197
198#[derive(Clone, Debug, Default, PartialEq, Eq)]
199pub(crate) struct FullscreenSessionState {
200    pub(crate) active: bool,
201    pub(crate) interaction: FullscreenInteractionSettings,
202}
203
204pub struct Session {
205    // --- Managers (Phase 2) ---
206    /// Manages user input, cursor, and command history
207    pub(crate) input_manager: InputManager,
208    /// Manages scroll state and viewport metrics
209    pub(crate) scroll_manager: ScrollManager,
210    user_scrolled: bool,
211
212    // --- Message Management ---
213    pub(crate) lines: Vec<MessageLine>,
214    collapsed_pastes: Vec<CollapsedPaste>,
215    /// Thinking/reasoning run bookkeeping (collapse overrides + the run
216    /// currently streaming), isolated behind a narrow interface so the policy
217    /// can evolve and be tested independently of transcript storage.
218    thinking_runs: ThinkingRunIndex,
219    pub(crate) theme: InlineTheme,
220    pub(crate) styles: SessionStyles,
221    pub(crate) appearance: AppearanceConfig,
222    /// Follow live terminal light/dark reports (Contour `CSI ? 997` DSR) and
223    /// switch themes automatically. Opt-in via `SetColorSchemeAuto`.
224    pub(crate) auto_color_scheme: bool,
225    header_context: InlineHeaderContext,
226    pub(crate) header_rows: u16,
227    pub(crate) labels: MessageLabels,
228
229    // --- Prompt/Input Display ---
230    prompt_prefix: String,
231    prompt_style: InlineTextStyle,
232    placeholder: Option<String>,
233    placeholder_style: Option<InlineTextStyle>,
234    pub(crate) input_status_left: Option<String>,
235    footer_context_status: Option<String>,
236    footer_context_right: Option<String>,
237    footer_context_configured: bool,
238    pub(crate) input_status_right: Option<String>,
239    /// Transient "copied"/"copy failed" confirmation shown in the input status row.
240    copy_notification_until: Option<Instant>,
241    copy_notification_failed: bool,
242    copy_notification_chars: usize,
243    input_compact_mode: bool,
244
245    // --- UI State ---
246    navigation_state: ListState,
247    input_enabled: bool,
248    pub(crate) program_status: super::program_status::ProgramStatus,
249    pub(crate) activity_state: ActivityState,
250    image_input_enabled: bool,
251    cursor_visible: bool,
252    render_state: RenderState,
253    /// Track whether the transcript viewport must be cleared before repainting.
254    pub(crate) transcript_clear_required: bool,
255    /// Total number of message lines removed by transcript eviction.
256    pub(crate) evicted_message_count: usize,
257    /// Number of new transcript lines that arrived while the user was scrolled
258    /// away from the live bottom edge.
259    pub(crate) pending_new_messages: usize,
260    /// Most recent transcript line index that was created or revised.
261    /// Backs the minimal Jump-to-last-change navigation (`Ctrl+End`).
262    pub(crate) last_change_line_idx: Option<usize>,
263    should_exit: bool,
264    /// Timestamp of the last Ctrl+C press for double-press exit detection.
265    pub(crate) last_interrupt_press: Option<Instant>,
266    /// Timestamp of the last Esc press for double-Escape actions.
267    ///
268    /// First Esc arms the timer; a second Esc within
269    /// `DOUBLE_ESCAPE_WINDOW` clears the current line (multiline) or the
270    /// entire input (single-line) when content is present, or opens the
271    /// rewind picker (`/rewind`) when the composer is empty. Any non-Esc key
272    /// resets it so the double press must be consecutive.
273    pub(crate) last_escape_press: Option<Instant>,
274    scroll_cursor_steady_until: Option<Instant>,
275    last_shimmer_active: bool,
276    view_rows: u16,
277    pub(crate) input_height: u16,
278    pub(crate) transcript_rows: u16,
279    pub(crate) transcript_width: u16,
280    pub(crate) transcript_view_top: usize,
281    areas: SessionAreas,
282    sticky_prompt_target: Option<sticky_prompt::StickyPromptTarget>,
283    sticky_prompt_preview_cache: Option<sticky_prompt::StickyPromptPreviewCache>,
284    leading_user_prompt_truncated: bool,
285    transcript_file_link_targets: Vec<TranscriptFileLinkTarget>,
286    modal_link_targets: Vec<TranscriptFileLinkTarget>,
287    hovered_transcript_file_link: Option<usize>,
288    last_mouse_position: Option<(u16, u16)>,
289    last_link_open: Option<(String, Instant)>,
290    pending_link_open: Option<String>,
291    held_key_modifiers: KeyModifiers,
292
293    // --- Logging ---
294    log_receiver: Option<UnboundedReceiver<LogEntry>>,
295    log_lines: VecDeque<Arc<Text<'static>>>,
296    log_cached_text: Option<Arc<Text<'static>>>,
297    log_evicted: bool,
298    pub(crate) show_logs: bool,
299
300    // --- Rendering ---
301    transcript_cache: Option<TranscriptReflowCache>,
302    /// Cache of visible lines by (scroll_offset, width) - shared via Arc for zero-copy reads
303    /// Avoids expensive clone on cache hits
304    visible_lines_cache: Option<(usize, u16, usize, Arc<Vec<TranscriptLine>>)>,
305    pub(crate) queued_inputs: Vec<String>,
306    pub(crate) local_agents: Vec<LocalAgentEntry>,
307    local_agents_drawer_visible: bool,
308    subprocess_entries: Vec<String>,
309    subagent_preview: Option<String>,
310    queue_overlay_cache: Option<QueueOverlay>,
311    queue_overlay_version: u64,
312    active_overlay: Option<ActiveOverlay>,
313    overlay_queue: VecDeque<OverlayRequest>,
314    last_overlay_list_selection: Option<InlineListSelection>,
315    last_overlay_list_was_last: bool,
316    line_revision_counter: u64,
317    pub(crate) transcript_presentation_revision: u64,
318    /// Track the first line that needs reflow/update to avoid O(N) scans
319    first_dirty_line: Option<usize>,
320    in_tool_code_fence: bool,
321
322    // --- Prompt Suggestions ---
323    suggested_prompt_state: SuggestedPromptState,
324    inline_prompt_suggestion: InlinePromptSuggestionState,
325
326    // --- Thinking Indicator ---
327    pub(crate) thinking_spinner: ThinkingSpinner,
328    pub(crate) shimmer_state: ShimmerState,
329    pub(crate) progress: progress::TransientProgress,
330
331    // --- Reverse Search ---
332    pub(crate) reverse_search_state: reverse_search::ReverseSearchState,
333
334    // --- PTY Session Management ---
335    pub(crate) active_pty_sessions: Option<Arc<std::sync::atomic::AtomicUsize>>,
336
337    // --- Background Activity ---
338    /// Number of live background tasks (managed subagents and retained exec
339    /// sessions) surfaced by the most recent local-agents refresh. Drives the
340    /// global loading shimmer so background work is visible without opening
341    /// the drawer; it never feeds the turn-busy guards.
342    pub(crate) background_activity_count: usize,
343    /// Retained finished background rows (completed/failed/stopped/exited).
344    pub(crate) background_finished_count: usize,
345    /// Absolute hit rects for clickable background-indicator spans on the
346    /// input status line (activity text and the `{key} background` hint only).
347    pub(crate) background_indicator_hits: Vec<Rect>,
348
349    // --- Keybinding store ---
350    bindings: BindingStore,
351
352    // --- Clipboard for yank/paste operations ---
353    clipboard: String,
354    vim_state: VimState,
355
356    // --- Mouse Text Selection ---
357    pub(crate) mouse_selection: MouseSelectionState,
358    pub(crate) mouse_drag_target: MouseDragTarget,
359    pub(crate) drag_auto_scroll: Option<DragAutoScroll>,
360    pub(crate) fullscreen: FullscreenSessionState,
361
362    // --- Performance Caching ---
363    header_lines_cache: Option<Arc<Vec<Line<'static>>>>,
364    header_block_title_cache: Option<Line<'static>>,
365    header_height_cache: hashbrown::HashMap<u16, u16>,
366    pub(crate) queued_inputs_preview_cache: Option<Vec<String>>,
367    subprocess_entries_preview_cache: Option<Vec<String>>,
368    /// Fingerprint of the last `build_input_render` inputs (width, height,
369    /// content_hash, cursor, compact, suggested) plus the built result.
370    input_render_cache: Option<(u16, u16, u64, usize, bool, bool, input::InputRender)>,
371
372    // --- Terminal Title ---
373    /// Product/app name used in terminal title branding
374    app_name: String,
375    /// Workspace root path for dynamic title generation
376    workspace_root: Option<std::path::PathBuf>,
377    /// Raw config items for terminal title rendering (`None` means use defaults).
378    terminal_title_items: Option<Vec<String>>,
379    /// Active thread label shown in terminal title when configured.
380    terminal_title_thread_label: Option<String>,
381    /// Active git branch shown in terminal title when configured.
382    terminal_title_git_branch: Option<String>,
383    /// Latest task tracker progress label extracted from the task panel.
384    terminal_title_task_progress: Option<String>,
385    /// Last set terminal title to avoid redundant updates
386    last_terminal_title: Option<String>,
387
388    // --- Streaming State ---
389    /// Track if the assistant is currently streaming a final answer.
390    /// When true, user input should be queued instead of submitted immediately
391    /// to prevent race conditions with turn completion (see GitHub #12569).
392    is_streaming_final_answer: bool,
393}
394
395/// Per-session index of thinking/reasoning (`Policy`) runs.
396///
397/// Owns the collapse overrides and the start of the run currently streaming,
398/// exposing them through a narrow interface. Keeping this state behind guard
399/// rails isolates thinking-run bookkeeping from transcript storage so the
400/// policy can be tested and evolved without coupling to `Session`.
401#[derive(Debug, Default, Clone)]
402pub(crate) struct ThinkingRunIndex {
403    /// Explicit collapse overrides keyed by run-start line index. Absence means
404    /// "use the config default" (`appearance.thinking_collapsed_by_default()`).
405    collapsed: std::collections::HashMap<usize, bool>,
406    /// Start line index of the reasoning run currently being streamed, if any.
407    /// `None` when no reasoning is actively streaming. Because the transcript
408    /// only ever appends lines, this index stays valid until `clear_screen`.
409    active_start: Option<usize>,
410}
411
412impl ThinkingRunIndex {
413    /// Whether the run starting at `start` should render collapsed, falling
414    /// back to `default` when no explicit override exists.
415    fn is_collapsed(&self, start: usize, default: bool) -> bool {
416        self.collapsed.get(&start).copied().unwrap_or(default)
417    }
418
419    /// Set the explicit collapse state for the run starting at `start`.
420    fn set_collapsed(&mut self, start: usize, collapsed: bool) {
421        self.collapsed.insert(start, collapsed);
422    }
423
424    /// Record the start of a newly begun reasoning run.
425    fn begin_run(&mut self, start: usize) {
426        self.active_start = Some(start);
427    }
428
429    /// End the active reasoning run (a non-reasoning line was appended).
430    fn end_run(&mut self) {
431        self.active_start = None;
432    }
433
434    /// Start line index of the reasoning run currently streaming, if any.
435    fn active_start(&self) -> Option<usize> {
436        self.active_start
437    }
438
439    /// Reset all tracked runs (e.g. on `clear_screen`).
440    fn clear(&mut self) {
441        self.collapsed.clear();
442        self.active_start = None;
443    }
444}