Skip to main content

vtcode_commons/ui_protocol/
types.rs

1#![expect(
2    clippy::cast_possible_truncation,
3    reason = "Progress percentages are clamped to the documented byte-sized display range."
4)]
5
6//! Pure data types with no dependencies beyond `std`.
7
8/// Message kind tag for inline transcript lines.
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum InlineMessageKind {
11    Agent,
12    Error,
13    Info,
14    Policy,
15    Pty,
16    Tool,
17    User,
18    Warning,
19}
20
21/// A single slash-command entry for the suggestion palette.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct SlashCommandItem {
24    pub name: String,
25    pub description: String,
26}
27
28impl SlashCommandItem {
29    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
30        Self { name: name.into(), description: description.into() }
31    }
32}
33
34/// Search configuration for a list overlay.
35#[derive(Clone, Debug)]
36pub struct InlineListSearchConfig {
37    pub label: String,
38    pub placeholder: Option<String>,
39}
40
41/// Configuration for a secure (masked) prompt input.
42#[derive(Clone, Debug)]
43pub struct SecurePromptConfig {
44    pub label: String,
45    /// Optional placeholder shown when input is empty.
46    pub placeholder: Option<String>,
47    /// Whether the input should be masked (e.g., API keys).
48    pub mask_input: bool,
49}
50
51/// Standalone surface preference for selecting inline vs alternate rendering.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum SessionSurface {
54    Auto,
55    Alternate,
56    #[default]
57    Inline,
58}
59
60/// Standalone keyboard protocol settings for terminal key event enhancements.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct KeyboardProtocolSettings {
63    pub enabled: bool,
64    pub mode: String,
65    pub disambiguate_escape_codes: bool,
66    pub report_event_types: bool,
67    pub report_alternate_keys: bool,
68    pub report_all_keys: bool,
69}
70
71impl Default for KeyboardProtocolSettings {
72    fn default() -> Self {
73        Self {
74            enabled: true,
75            mode: "default".to_owned(),
76            disambiguate_escape_codes: true,
77            report_event_types: true,
78            report_alternate_keys: true,
79            report_all_keys: false,
80        }
81    }
82}
83
84/// UI mode variants for quick presets.
85#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum UiMode {
88    #[default]
89    Full,
90    Minimal,
91    Focused,
92}
93
94/// Override for responsive layout detection.
95#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum LayoutModeOverride {
98    #[default]
99    Auto,
100    Compact,
101    Standard,
102    Wide,
103}
104
105/// Reasoning visibility behavior in the transcript.
106#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum ReasoningDisplayMode {
109    Always,
110    #[default]
111    Toggle,
112    Hidden,
113}
114
115/// Default collapse state of agent thinking/reasoning blocks in the transcript.
116#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
117#[serde(rename_all = "snake_case")]
118#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
119pub enum ThinkingBlockState {
120    /// Thinking blocks render collapsed (a single summary line) by default.
121    #[default]
122    Collapsed,
123    /// Thinking blocks render fully expanded by default.
124    Extended,
125}
126
127/// Wizard modal behavior variant.
128#[derive(Clone, Copy, Debug, PartialEq, Eq)]
129pub enum WizardModalMode {
130    /// Traditional multi-step wizard behavior (Enter advances/collects answers).
131    MultiStep,
132    /// Tabbed list behavior (tabs switch categories; Enter submits immediately).
133    TabbedList,
134}
135
136/// Diff preview layout for file-edit approval overlays.
137#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
138#[serde(rename_all = "kebab-case")]
139#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
140pub enum DiffPreviewMode {
141    /// Single-column unified view (default).
142    #[default]
143    Inline,
144    /// Old/new columns side by side.
145    SideBySide,
146}
147
148// ---------------------------------------------------------------------------
149// Task tracker row status
150// ---------------------------------------------------------------------------
151
152/// Display status of a single task tracker row, shared between the agent
153/// runloop (which owns checklist state) and the terminal surface (which owns
154/// styling). Keeps status typed across the panel/transcript boundary instead
155/// of re-parsing glyphs out of display strings.
156#[derive(Clone, Copy, Debug, PartialEq, Eq)]
157pub enum TaskItemStatus {
158    Pending,
159    InProgress,
160    Completed,
161    Blocked,
162}
163
164impl TaskItemStatus {
165    /// Canonical wire string, matching `TaskTrackingStatus::as_str`.
166    #[must_use]
167    pub fn as_str(self) -> &'static str {
168        match self {
169            Self::Pending => "pending",
170            Self::InProgress => "in_progress",
171            Self::Completed => "completed",
172            Self::Blocked => "blocked",
173        }
174    }
175}
176
177impl std::str::FromStr for TaskItemStatus {
178    type Err = ();
179
180    /// Parse a canonical status string; unknown shapes fail closed to `Err`.
181    fn from_str(raw: &str) -> Result<Self, Self::Err> {
182        match raw {
183            "pending" => Ok(Self::Pending),
184            "in_progress" => Ok(Self::InProgress),
185            "completed" => Ok(Self::Completed),
186            "blocked" => Ok(Self::Blocked),
187            _ => Err(()),
188        }
189    }
190}
191
192// ---------------------------------------------------------------------------
193// Plan types
194// ---------------------------------------------------------------------------
195
196/// A step in an implementation plan.
197#[derive(Clone, Debug)]
198pub struct PlanStep {
199    pub number: usize,
200    pub description: String,
201    pub details: Option<String>,
202    pub files: Vec<String>,
203    pub completed: bool,
204}
205
206/// A phase in an implementation plan (groups related steps).
207#[derive(Clone, Debug)]
208pub struct PlanPhase {
209    pub name: String,
210    pub steps: Vec<PlanStep>,
211    pub completed: bool,
212}
213
214/// Structured plan content for display in the Implementation Blueprint panel.
215#[derive(Clone, Debug)]
216pub struct PlanContent {
217    pub title: String,
218    pub summary: String,
219    pub file_path: Option<String>,
220    pub phases: Vec<PlanPhase>,
221    pub open_questions: Vec<String>,
222    pub raw_content: String,
223    pub total_steps: usize,
224    pub completed_steps: usize,
225}
226
227impl PlanContent {
228    /// Parse plan content from markdown.
229    pub fn from_markdown(title: String, content: &str, file_path: Option<String>) -> Self {
230        let mut phases = Vec::new();
231        let mut open_questions = Vec::new();
232        let mut current_phase: Option<PlanPhase> = None;
233        let mut total_steps = 0;
234        let mut completed_steps = 0;
235        let mut summary = String::new();
236        let mut reading_summary = false;
237
238        for line in content.lines() {
239            let trimmed = line.trim();
240
241            // The planning prompt emits both conventional markdown headings
242            // (`## Summary`) and sparse section labels (`Summary`). Treat
243            // either form as a section marker so the label itself is not
244            // displayed as the plan summary.
245            if trimmed.eq_ignore_ascii_case("summary") || trimmed.eq_ignore_ascii_case("## summary") {
246                reading_summary = true;
247                continue;
248            }
249
250            if reading_summary {
251                if !trimmed.is_empty() {
252                    if summary.is_empty() {
253                        summary = trimmed.to_string();
254                    }
255                    reading_summary = false;
256                }
257                continue;
258            }
259
260            // Extract summary from first paragraph
261            if summary.is_empty() && !trimmed.is_empty() && !trimmed.starts_with('#') {
262                summary = trimmed.to_string();
263                continue;
264            }
265
266            // Phase headers (## Phase X: ...)
267            if let Some(phase_name) = trimmed.strip_prefix("## ") {
268                if let Some(phase) = current_phase.take() {
269                    phases.push(phase);
270                }
271                current_phase = Some(PlanPhase {
272                    name: phase_name.to_string(),
273                    steps: Vec::new(),
274                    completed: false,
275                });
276                continue;
277            }
278
279            // Open questions section
280            if trimmed == "## Open Questions" {
281                if let Some(phase) = current_phase.take() {
282                    phases.push(phase);
283                }
284                continue;
285            }
286
287            // Step items ([ ] or [x] prefixed)
288            if let Some(rest) = trimmed.strip_prefix("[ ] ") {
289                total_steps += 1;
290                if let Some(ref mut phase) = current_phase {
291                    phase.steps.push(PlanStep {
292                        number: phase.steps.len() + 1,
293                        description: rest.to_string(),
294                        details: None,
295                        files: Vec::new(),
296                        completed: false,
297                    });
298                }
299                continue;
300            }
301
302            if let Some(rest) = trimmed.strip_prefix("[x] ").or_else(|| trimmed.strip_prefix("[X] ")) {
303                total_steps += 1;
304                completed_steps += 1;
305                if let Some(ref mut phase) = current_phase {
306                    phase.steps.push(PlanStep {
307                        number: phase.steps.len() + 1,
308                        description: rest.to_string(),
309                        details: None,
310                        files: Vec::new(),
311                        completed: true,
312                    });
313                }
314                continue;
315            }
316
317            // Numbered steps (1. **Step 1** ...)
318            if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains('.') {
319                total_steps += 1;
320                if let Some(ref mut phase) = current_phase {
321                    let desc = trimmed.split_once('.').map(|x| x.1).unwrap_or("").trim();
322                    phase.steps.push(PlanStep {
323                        number: phase.steps.len() + 1,
324                        description: desc.to_string(),
325                        details: None,
326                        files: Vec::new(),
327                        completed: false,
328                    });
329                }
330                continue;
331            }
332
333            // Question items
334            if trimmed.starts_with("- (") || trimmed.starts_with("- ?") {
335                open_questions.push(trimmed.trim_start_matches("- ").to_string());
336            }
337        }
338
339        // Save last phase
340        if let Some(mut phase) = current_phase.take() {
341            phase.completed = phase.steps.iter().all(|s| s.completed);
342            phases.push(phase);
343        }
344
345        // Update phase completion status
346        for phase in &mut phases {
347            phase.completed = !phase.steps.is_empty() && phase.steps.iter().all(|s| s.completed);
348        }
349
350        Self {
351            title,
352            summary,
353            file_path,
354            phases,
355            open_questions,
356            raw_content: content.to_string(),
357            total_steps,
358            completed_steps,
359        }
360    }
361
362    /// Get progress as a percentage.
363    #[allow(
364        clippy::cast_sign_loss,
365        reason = "Intentional compatibility, platform, or test-only suppression."
366    )]
367    pub fn progress_percent(&self) -> u8 {
368        if self.total_steps == 0 {
369            0
370        } else {
371            ((self.completed_steps as f32 / self.total_steps as f32) * 100.0) as u8
372        }
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::PlanContent;
379
380    #[test]
381    fn parses_sparse_summary_section_without_displaying_section_label() {
382        let plan = PlanContent::from_markdown(
383            "Implementation Plan".to_string(),
384            "Summary\nFocus on startup latency.\n\n1. Measure startup -> src/startup.rs\n2. Defer refresh -> src/update.rs\n\nValidation\n- cargo check --locked",
385            None,
386        );
387
388        assert_eq!(plan.summary, "Focus on startup latency.");
389        assert_eq!(plan.total_steps, 2);
390        assert_eq!(plan.raw_content.lines().next(), Some("Summary"));
391    }
392}