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    #[default]
50    Auto,
51    Alternate,
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
176        for line in content.lines() {
177            let trimmed = line.trim();
178
179            // Extract summary from first paragraph
180            if summary.is_empty() && !trimmed.is_empty() && !trimmed.starts_with('#') {
181                summary = trimmed.to_string();
182                continue;
183            }
184
185            // Phase headers (## Phase X: ...)
186            if let Some(phase_name) = trimmed.strip_prefix("## ") {
187                if let Some(phase) = current_phase.take() {
188                    phases.push(phase);
189                }
190                current_phase = Some(PlanPhase {
191                    name: phase_name.to_string(),
192                    steps: Vec::new(),
193                    completed: false,
194                });
195                continue;
196            }
197
198            // Open questions section
199            if trimmed == "## Open Questions" {
200                if let Some(phase) = current_phase.take() {
201                    phases.push(phase);
202                }
203                continue;
204            }
205
206            // Step items ([ ] or [x] prefixed)
207            if let Some(rest) = trimmed.strip_prefix("[ ] ") {
208                total_steps += 1;
209                if let Some(ref mut phase) = current_phase {
210                    phase.steps.push(PlanStep {
211                        number: phase.steps.len() + 1,
212                        description: rest.to_string(),
213                        details: None,
214                        files: Vec::new(),
215                        completed: false,
216                    });
217                }
218                continue;
219            }
220
221            if let Some(rest) =
222                trimmed.strip_prefix("[x] ").or_else(|| trimmed.strip_prefix("[X] "))
223            {
224                total_steps += 1;
225                completed_steps += 1;
226                if let Some(ref mut phase) = current_phase {
227                    phase.steps.push(PlanStep {
228                        number: phase.steps.len() + 1,
229                        description: rest.to_string(),
230                        details: None,
231                        files: Vec::new(),
232                        completed: true,
233                    });
234                }
235                continue;
236            }
237
238            // Numbered steps (1. **Step 1** ...)
239            if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains('.') {
240                total_steps += 1;
241                if let Some(ref mut phase) = current_phase {
242                    let desc = trimmed.split_once('.').map(|x| x.1).unwrap_or("").trim();
243                    phase.steps.push(PlanStep {
244                        number: phase.steps.len() + 1,
245                        description: desc.to_string(),
246                        details: None,
247                        files: Vec::new(),
248                        completed: false,
249                    });
250                }
251                continue;
252            }
253
254            // Question items
255            if trimmed.starts_with("- (") || trimmed.starts_with("- ?") {
256                open_questions.push(trimmed.trim_start_matches("- ").to_string());
257            }
258        }
259
260        // Save last phase
261        if let Some(mut phase) = current_phase.take() {
262            phase.completed = phase.steps.iter().all(|s| s.completed);
263            phases.push(phase);
264        }
265
266        // Update phase completion status
267        for phase in &mut phases {
268            phase.completed = !phase.steps.is_empty() && phase.steps.iter().all(|s| s.completed);
269        }
270
271        Self {
272            title,
273            summary,
274            file_path,
275            phases,
276            open_questions,
277            raw_content: content.to_string(),
278            total_steps,
279            completed_steps,
280        }
281    }
282
283    /// Get progress as a percentage.
284    #[allow(clippy::cast_sign_loss)]
285    pub fn progress_percent(&self) -> u8 {
286        if self.total_steps == 0 {
287            0
288        } else {
289            ((self.completed_steps as f32 / self.total_steps as f32) * 100.0) as u8
290        }
291    }
292}