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