Skip to main content

vtcode_core/ui/
tui.rs

1//! TUI protocol types and session interface.
2//!
3//! When the `tui` feature is enabled, this module re-exports the full app-layer
4//! protocol from `vtcode-ui`.  When the feature is disabled (headless build),
5//! it re-exports the shared data types from `vtcode-commons` and provides
6//! lightweight no-op stubs for `InlineHandle`, `InlineSession`, and
7//! `InlineEvent`.
8
9// ── Shared data types (always available from vtcode-commons) ────────────────
10
11pub use vtcode_commons::ui_protocol::{
12    CompactActivityMetadata, InlineHeaderContext, InlineHeaderHighlight, InlineHeaderStatusBadge,
13    InlineHeaderStatusTone, InlineLinkRange, InlineLinkTarget, InlineListItem, InlineListSearchConfig,
14    InlineListSelection, InlineMessageKind, InlineSegment, InlineTextStyle, InlineTheme, LayoutModeOverride,
15    PlanContent, PlanPhase, PlanStep, ReasoningDisplayMode, RewindAction, SecurePromptConfig, SessionSurface,
16    SlashCommandItem, ThinkingBlockState, ToolOutputId, UiMode, WizardModalMode, WizardStep, convert_style,
17    theme_from_color_fields,
18};
19
20pub use vtcode_commons::ui_protocol::KeyboardProtocolSettings;
21
22// ── Full TUI re-exports (feature = "tui") ───────────────────────────────────
23
24#[cfg(feature = "tui")]
25pub use vtcode_ui::tui::app::*;
26
27// ── Headless stubs (feature = "tui" disabled) ───────────────────────────────
28
29#[cfg(not(feature = "tui"))]
30mod headless {
31    use std::collections::VecDeque;
32    use std::sync::atomic::{AtomicU64, Ordering};
33    use std::sync::{Arc, Mutex};
34
35    use hashbrown::HashMap;
36    use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
37
38    use super::{
39        CompactActivityMetadata, InlineListItem, InlineListSearchConfig, InlineListSelection, InlineMessageKind,
40        InlineSegment, SecurePromptConfig, ToolOutputId,
41    };
42
43    use crate::ui::theme::ThemeStyles;
44    pub use vtcode_ui::tui::app::{ContentPart, SubmittedInput};
45
46    /// Headless `InlineEvent` — all variants present so match arms compile.
47    #[derive(Clone, Debug, PartialEq, Eq)]
48    pub enum InlineEvent {
49        Submit(SubmittedInput),
50        /// Submit text from the WebMCP bridge without interpreting slash commands.
51        WebmcpSubmit(SubmittedInput),
52        QueueSubmit(SubmittedInput),
53        Steer(SubmittedInput),
54        ProcessLatestQueued,
55        EditQueue,
56        Cancel,
57        Exit,
58        Interrupt,
59        Pause,
60        Resume,
61        BackgroundOperation,
62        ScrollLineUp,
63        ScrollLineDown,
64        ScrollPageUp,
65        ScrollPageDown,
66        JumpToLastChange,
67        OpenFileInEditor(String),
68        OpenUrl(String),
69        LaunchEditor,
70        ForceCancelPtySession,
71        RequestInlinePromptSuggestion(String),
72        HistoryPrevious,
73        HistoryNext,
74    }
75
76    /// Minimal command surface used by tests and headless sinks.
77    #[derive(Clone, Debug)]
78    pub enum InlineCommand {
79        AppendLine {
80            kind: InlineMessageKind,
81            segments: Vec<InlineSegment>,
82        },
83        AppendPastedMessage {
84            kind: InlineMessageKind,
85            text: String,
86            line_count: usize,
87        },
88        Inline {
89            kind: InlineMessageKind,
90            segment: InlineSegment,
91        },
92        ReplaceLast {
93            count: usize,
94            kind: InlineMessageKind,
95            lines: Vec<Vec<InlineSegment>>,
96        },
97        RecordToolOutput {
98            id: ToolOutputId,
99            lines: Vec<String>,
100        },
101        AppendToolOutputLine {
102            id: ToolOutputId,
103            kind: InlineMessageKind,
104            segments: Vec<InlineSegment>,
105        },
106        AppendCompactActivity(CompactActivityMetadata),
107        RecordDiffReview(vtcode_commons::ui_protocol::DiffReviewAnchor),
108        ReplaceCompactActivity(CompactActivityMetadata),
109        CollapsePtyBlock(CompactActivityMetadata),
110        SetKeyBindings {
111            bindings: HashMap<String, Vec<String>>,
112        },
113        SetImageInputEnabled(bool),
114        RestoreInputDraft(SubmittedInput),
115        ForceRedraw,
116        Shutdown,
117        ClearScreen,
118        CloseModal,
119        SetReasoningStage(Option<String>),
120    }
121
122    /// Headless handle; commands go to an optional test sink and otherwise discard.
123    #[derive(Clone, Debug)]
124    pub struct InlineHandle {
125        sender: Option<UnboundedSender<InlineCommand>>,
126        deferred_events: Arc<Mutex<VecDeque<InlineEvent>>>,
127        next_tool_output_id: Arc<AtomicU64>,
128    }
129
130    const MAX_DEFERRED_EVENTS: usize = 32;
131
132    impl InlineHandle {
133        pub fn new_for_tests(sender: UnboundedSender<InlineCommand>) -> Self {
134            Self {
135                sender: Some(sender),
136                deferred_events: Arc::new(Mutex::new(VecDeque::new())),
137                next_tool_output_id: Arc::new(AtomicU64::new(0)),
138            }
139        }
140
141        pub fn defer_event(&self, event: InlineEvent) -> anyhow::Result<()> {
142            let mut deferred_events = self
143                .deferred_events
144                .lock()
145                .map_err(|error| anyhow::anyhow!("deferred input queue poisoned: {error}"))?;
146            if deferred_events.len() >= MAX_DEFERRED_EVENTS {
147                return Err(anyhow::anyhow!("deferred input queue is full"));
148            }
149            deferred_events.push_back(event);
150            Ok(())
151        }
152
153        fn take_deferred_event(&self) -> Option<InlineEvent> {
154            self.deferred_events.lock().ok()?.pop_front()
155        }
156
157        pub fn has_deferred_event(&self) -> bool {
158            self.deferred_events.lock().is_ok_and(|events| !events.is_empty())
159        }
160
161        fn send_command(&self, command: InlineCommand) {
162            if let Some(sender) = &self.sender {
163                let _ = sender.send(command);
164            }
165        }
166
167        pub fn append_line(&self, kind: InlineMessageKind, segments: Vec<InlineSegment>) {
168            self.send_command(InlineCommand::AppendLine { kind, segments });
169        }
170        pub fn append_pasted_message(&self, kind: InlineMessageKind, text: String, line_count: usize) {
171            self.send_command(InlineCommand::AppendPastedMessage { kind, text, line_count });
172        }
173        pub fn inline(&self, kind: InlineMessageKind, segment: InlineSegment) {
174            self.send_command(InlineCommand::Inline { kind, segment });
175        }
176        pub fn replace_last(&self, count: usize, kind: InlineMessageKind, lines: Vec<Vec<InlineSegment>>) {
177            self.send_command(InlineCommand::ReplaceLast { count, kind, lines });
178        }
179        pub fn record_tool_output(&self, lines: Vec<String>) -> ToolOutputId {
180            let id = self.next_tool_output_id.fetch_add(1, Ordering::Relaxed);
181            self.send_command(InlineCommand::RecordToolOutput { id, lines });
182            id
183        }
184        pub fn append_tool_output_line(&self, id: ToolOutputId, kind: InlineMessageKind, segments: Vec<InlineSegment>) {
185            self.send_command(InlineCommand::AppendToolOutputLine { id, kind, segments });
186        }
187        pub fn append_compact_activity(&self, activity: CompactActivityMetadata) {
188            self.send_command(InlineCommand::AppendCompactActivity(activity));
189        }
190        pub fn record_diff_review(&self, anchor: vtcode_commons::ui_protocol::DiffReviewAnchor) {
191            // Headless sinks have no transcript expand affordance, but tests
192            // still need to observe that an anchor was recorded.
193            self.send_command(InlineCommand::RecordDiffReview(anchor));
194        }
195        pub fn replace_compact_activity(&self, activity: CompactActivityMetadata) {
196            self.send_command(InlineCommand::ReplaceCompactActivity(activity));
197        }
198        pub fn collapse_pty_block(&self, activity: CompactActivityMetadata) {
199            self.send_command(InlineCommand::CollapsePtyBlock(activity));
200        }
201        pub fn set_key_bindings(&self, bindings: HashMap<String, Vec<String>>) {
202            self.send_command(InlineCommand::SetKeyBindings { bindings });
203        }
204        pub fn force_redraw(&self) {
205            self.send_command(InlineCommand::ForceRedraw);
206        }
207        pub fn shutdown(&self) {
208            self.send_command(InlineCommand::Shutdown);
209        }
210        pub fn clear_screen(&self) {
211            self.send_command(InlineCommand::ClearScreen);
212        }
213        pub fn show_modal(&self, _title: String, _lines: Vec<String>, _secure_prompt: Option<SecurePromptConfig>) {}
214        pub fn show_list_modal(
215            &self,
216            _title: String,
217            _lines: Vec<String>,
218            _items: Vec<InlineListItem>,
219            _selected: Option<InlineListSelection>,
220            _search: Option<InlineListSearchConfig>,
221        ) {
222        }
223        pub fn show_list_modal_with_footer(
224            &self,
225            _title: String,
226            _lines: Vec<String>,
227            _items: Vec<InlineListItem>,
228            _selected: Option<InlineListSelection>,
229            _search: Option<InlineListSearchConfig>,
230            _footer_hint: Option<String>,
231        ) {
232        }
233        pub fn show_list_modal_with_status(
234            &self,
235            _title: String,
236            _lines: Vec<String>,
237            _items: Vec<InlineListItem>,
238            _selected: Option<InlineListSelection>,
239            _search: Option<InlineListSearchConfig>,
240            _footer_hint: Option<String>,
241            _status: Option<vtcode_commons::ui_protocol::InlineStatus>,
242        ) {
243        }
244        pub fn close_modal(&self) {
245            self.send_command(InlineCommand::CloseModal);
246        }
247        pub fn restore_input_draft(&self, input: SubmittedInput) {
248            self.send_command(InlineCommand::RestoreInputDraft(input));
249        }
250        pub fn set_image_input_enabled(&self, enabled: bool) {
251            self.send_command(InlineCommand::SetImageInputEnabled(enabled));
252        }
253        pub fn set_reasoning_stage(&self, stage: Option<String>) {
254            self.send_command(InlineCommand::SetReasoningStage(stage));
255        }
256    }
257
258    /// Headless session — events never arrive.
259    pub struct InlineSession {
260        pub handle: InlineHandle,
261        pub events: UnboundedReceiver<InlineEvent>,
262    }
263
264    impl InlineSession {
265        pub async fn next_event(&mut self) -> Option<InlineEvent> {
266            if let Some(event) = self.handle.take_deferred_event() {
267                return Some(event);
268            }
269            self.events.recv().await
270        }
271
272        /// Headless stub: there is no TUI task to await, so shutdown is
273        /// always considered complete immediately.
274        pub async fn wait_for_exit(&mut self, _timeout: std::time::Duration) -> bool {
275            true
276        }
277
278        pub fn clone_inline_handle(&self) -> InlineHandle {
279            self.handle.clone()
280        }
281    }
282
283    /// Headless appearance config with sensible defaults.
284    #[derive(Debug, Clone, Default)]
285    pub struct SessionAppearanceConfig {
286        pub theme: String,
287        pub ui_mode: super::UiMode,
288        pub show_sidebar: bool,
289        pub min_content_width: u16,
290        pub min_navigation_width: u16,
291        pub navigation_width_percent: u8,
292        /// Bottom padding rows under the transcript.
293        ///
294        /// Retained for config compatibility only; the live transcript reads
295        /// `vtcode_ui::tui::config::constants::ui::INLINE_TRANSCRIPT_BOTTOM_PADDING`,
296        /// so a non-zero value set here has no render effect.
297        pub transcript_bottom_padding: u16,
298        pub dim_completed_todos: bool,
299        pub message_block_spacing: u8,
300        pub layout_mode: super::LayoutModeOverride,
301        pub reasoning_display_mode: super::ReasoningDisplayMode,
302        pub reasoning_visible_default: bool,
303        pub thinking_display: super::ThinkingBlockState,
304        pub vim_mode: bool,
305        pub screen_reader_mode: bool,
306        pub reduce_motion_mode: bool,
307        pub reduce_motion_keep_progress_animation: bool,
308        pub show_transcript_review_hints: bool,
309        pub show_transcript_review_shortcut_guide: bool,
310        pub show_transcript_review_close_button: bool,
311        pub diff_preview_mode: vtcode_commons::ui_protocol::DiffPreviewMode,
312        pub customization: (),
313    }
314
315    /// Build an [`InlineTheme`](super::InlineTheme) from core theme styles.
316    pub fn theme_from_styles(styles: &ThemeStyles) -> super::InlineTheme {
317        super::theme_from_color_fields(
318            styles.foreground,
319            styles.background,
320            styles.primary,
321            styles.secondary,
322            styles.tool,
323            styles.tool_detail,
324            styles.pty_output,
325            styles.error,
326            styles.warning,
327        )
328    }
329}
330
331#[cfg(not(feature = "tui"))]
332pub use headless::*;