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