Skip to main content

vtcode_commons/ui_protocol/
types.rs

1//! Pure data types with no dependencies beyond `std`.
2
3/// Message kind tag for inline transcript lines.
4#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5pub enum InlineMessageKind {
6    Agent,
7    Error,
8    Info,
9    Policy,
10    Pty,
11    Tool,
12    User,
13    Warning,
14}
15
16/// A single slash-command entry for the suggestion palette.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct SlashCommandItem {
19    pub name: String,
20    pub description: String,
21}
22
23impl SlashCommandItem {
24    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
25        Self {
26            name: name.into(),
27            description: description.into(),
28        }
29    }
30}
31
32/// Search configuration for a list overlay.
33#[derive(Clone, Debug)]
34pub struct InlineListSearchConfig {
35    pub label: String,
36    pub placeholder: Option<String>,
37}
38
39/// Configuration for a secure (masked) prompt input.
40#[derive(Clone, Debug)]
41pub struct SecurePromptConfig {
42    pub label: String,
43    /// Optional placeholder shown when input is empty.
44    pub placeholder: Option<String>,
45    /// Whether the input should be masked (e.g., API keys).
46    pub mask_input: bool,
47}
48
49/// Standalone surface preference for selecting inline vs alternate rendering.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum SessionSurface {
52    #[default]
53    Auto,
54    Alternate,
55    Inline,
56}
57
58/// Standalone keyboard protocol settings for terminal key event enhancements.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct KeyboardProtocolSettings {
61    pub enabled: bool,
62    pub mode: String,
63    pub disambiguate_escape_codes: bool,
64    pub report_event_types: bool,
65    pub report_alternate_keys: bool,
66    pub report_all_keys: bool,
67}
68
69impl Default for KeyboardProtocolSettings {
70    fn default() -> Self {
71        Self {
72            enabled: true,
73            mode: "default".to_owned(),
74            disambiguate_escape_codes: true,
75            report_event_types: true,
76            report_alternate_keys: true,
77            report_all_keys: false,
78        }
79    }
80}
81
82/// UI mode variants for quick presets.
83#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum UiMode {
86    #[default]
87    Full,
88    Minimal,
89    Focused,
90}
91
92/// Override for responsive layout detection.
93#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum LayoutModeOverride {
96    #[default]
97    Auto,
98    Compact,
99    Standard,
100    Wide,
101}
102
103/// Reasoning visibility behavior in the transcript.
104#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum ReasoningDisplayMode {
107    Always,
108    #[default]
109    Toggle,
110    Hidden,
111}
112
113/// Default collapse state of agent thinking/reasoning blocks in the transcript.
114#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
115#[serde(rename_all = "snake_case")]
116#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
117pub enum ThinkingBlockState {
118    /// Thinking blocks render collapsed (a single summary line) by default.
119    #[default]
120    Collapsed,
121    /// Thinking blocks render fully expanded by default.
122    Extended,
123}
124
125/// Wizard modal behavior variant.
126#[derive(Clone, Copy, Debug, PartialEq, Eq)]
127pub enum WizardModalMode {
128    /// Traditional multi-step wizard behavior (Enter advances/collects answers).
129    MultiStep,
130    /// Tabbed list behavior (tabs switch categories; Enter submits immediately).
131    TabbedList,
132}
133
134// ---------------------------------------------------------------------------
135// Plan types
136// ---------------------------------------------------------------------------
137
138/// A step in an implementation plan.
139#[derive(Clone, Debug)]
140pub struct PlanStep {
141    pub number: usize,
142    pub description: String,
143    pub details: Option<String>,
144    pub files: Vec<String>,
145    pub completed: bool,
146}
147
148/// A phase in an implementation plan (groups related steps).
149#[derive(Clone, Debug)]
150pub struct PlanPhase {
151    pub name: String,
152    pub steps: Vec<PlanStep>,
153    pub completed: bool,
154}
155
156/// Structured plan content for display in the Implementation Blueprint panel.
157#[derive(Clone, Debug)]
158pub struct PlanContent {
159    pub title: String,
160    pub summary: String,
161    pub file_path: Option<String>,
162    pub phases: Vec<PlanPhase>,
163    pub open_questions: Vec<String>,
164    pub raw_content: String,
165    pub total_steps: usize,
166    pub completed_steps: usize,
167}
168
169impl PlanContent {
170    /// Parse plan content from markdown.
171    pub fn from_markdown(title: String, content: &str, file_path: Option<String>) -> Self {
172        let mut phases = Vec::new();
173        let mut open_questions = Vec::new();
174        let mut current_phase: Option<PlanPhase> = None;
175        let mut total_steps = 0;
176        let mut completed_steps = 0;
177        let mut summary = String::new();
178
179        for line in content.lines() {
180            let trimmed = line.trim();
181
182            // Extract summary from first paragraph
183            if summary.is_empty() && !trimmed.is_empty() && !trimmed.starts_with('#') {
184                summary = trimmed.to_string();
185                continue;
186            }
187
188            // Phase headers (## Phase X: ...)
189            if let Some(phase_name) = trimmed.strip_prefix("## ") {
190                if let Some(phase) = current_phase.take() {
191                    phases.push(phase);
192                }
193                current_phase = Some(PlanPhase {
194                    name: phase_name.to_string(),
195                    steps: Vec::new(),
196                    completed: false,
197                });
198                continue;
199            }
200
201            // Open questions section
202            if trimmed == "## Open Questions" {
203                if let Some(phase) = current_phase.take() {
204                    phases.push(phase);
205                }
206                continue;
207            }
208
209            // Step items ([ ] or [x] prefixed)
210            if let Some(rest) = trimmed.strip_prefix("[ ] ") {
211                total_steps += 1;
212                if let Some(ref mut phase) = current_phase {
213                    phase.steps.push(PlanStep {
214                        number: phase.steps.len() + 1,
215                        description: rest.to_string(),
216                        details: None,
217                        files: Vec::new(),
218                        completed: false,
219                    });
220                }
221                continue;
222            }
223
224            if let Some(rest) = trimmed
225                .strip_prefix("[x] ")
226                .or_else(|| trimmed.strip_prefix("[X] "))
227            {
228                total_steps += 1;
229                completed_steps += 1;
230                if let Some(ref mut phase) = current_phase {
231                    phase.steps.push(PlanStep {
232                        number: phase.steps.len() + 1,
233                        description: rest.to_string(),
234                        details: None,
235                        files: Vec::new(),
236                        completed: true,
237                    });
238                }
239                continue;
240            }
241
242            // Numbered steps (1. **Step 1** ...)
243            if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains('.') {
244                total_steps += 1;
245                if let Some(ref mut phase) = current_phase {
246                    let desc = trimmed.split_once('.').map(|x| x.1).unwrap_or("").trim();
247                    phase.steps.push(PlanStep {
248                        number: phase.steps.len() + 1,
249                        description: desc.to_string(),
250                        details: None,
251                        files: Vec::new(),
252                        completed: false,
253                    });
254                }
255                continue;
256            }
257
258            // Question items
259            if trimmed.starts_with("- (") || trimmed.starts_with("- ?") {
260                open_questions.push(trimmed.trim_start_matches("- ").to_string());
261            }
262        }
263
264        // Save last phase
265        if let Some(mut phase) = current_phase.take() {
266            phase.completed = phase.steps.iter().all(|s| s.completed);
267            phases.push(phase);
268        }
269
270        // Update phase completion status
271        for phase in &mut phases {
272            phase.completed = !phase.steps.is_empty() && phase.steps.iter().all(|s| s.completed);
273        }
274
275        Self {
276            title,
277            summary,
278            file_path,
279            phases,
280            open_questions,
281            raw_content: content.to_string(),
282            total_steps,
283            completed_steps,
284        }
285    }
286
287    /// Get progress as a percentage.
288    #[allow(clippy::cast_sign_loss)]
289    pub fn progress_percent(&self) -> u8 {
290        if self.total_steps == 0 {
291            0
292        } else {
293            ((self.completed_steps as f32 / self.total_steps as f32) * 100.0) as u8
294        }
295    }
296}