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/// How a busy plain-Enter submission enters the runloop while a turn runs.
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 BusyEnterBehavior {
125    /// Queue the message for the next turn (default; each Enter stays its own turn).
126    #[default]
127    Queue,
128    /// Steer plain-text input into the live turn via the steering channel.
129    Steer,
130}
131
132/// Default collapse state of agent thinking/reasoning blocks in the transcript.
133#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
134#[serde(rename_all = "snake_case")]
135#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
136pub enum ThinkingBlockState {
137    /// Thinking blocks render collapsed (a single summary line) by default.
138    #[default]
139    Collapsed,
140    /// Thinking blocks render fully expanded by default.
141    Extended,
142}
143
144/// Wizard modal behavior variant.
145#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146pub enum WizardModalMode {
147    /// Traditional multi-step wizard behavior (Enter advances/collects answers).
148    MultiStep,
149    /// Tabbed list behavior (tabs switch categories; Enter submits immediately).
150    TabbedList,
151}
152
153/// Diff preview layout for file-edit approval overlays.
154#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
155#[serde(rename_all = "kebab-case")]
156#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
157pub enum DiffPreviewMode {
158    /// Single-column unified view (default).
159    #[default]
160    Inline,
161    /// Old/new columns side by side.
162    SideBySide,
163}
164
165// ---------------------------------------------------------------------------
166// Task tracker row status
167// ---------------------------------------------------------------------------
168
169/// Display status of a single task tracker row, shared between the agent
170/// runloop (which owns checklist state) and the terminal surface (which owns
171/// styling). Keeps status typed across the panel/transcript boundary instead
172/// of re-parsing glyphs out of display strings.
173#[derive(Clone, Copy, Debug, PartialEq, Eq)]
174pub enum TaskItemStatus {
175    Pending,
176    InProgress,
177    Completed,
178    Blocked,
179}
180
181impl TaskItemStatus {
182    /// Canonical wire string, matching `TaskTrackingStatus::as_str`.
183    #[must_use]
184    pub fn as_str(self) -> &'static str {
185        match self {
186            Self::Pending => "pending",
187            Self::InProgress => "in_progress",
188            Self::Completed => "completed",
189            Self::Blocked => "blocked",
190        }
191    }
192}
193
194impl std::str::FromStr for TaskItemStatus {
195    type Err = ();
196
197    /// Parse a canonical status string; unknown shapes fail closed to `Err`.
198    fn from_str(raw: &str) -> Result<Self, Self::Err> {
199        match raw {
200            "pending" => Ok(Self::Pending),
201            "in_progress" => Ok(Self::InProgress),
202            "completed" => Ok(Self::Completed),
203            "blocked" => Ok(Self::Blocked),
204            _ => Err(()),
205        }
206    }
207}
208
209// ---------------------------------------------------------------------------
210// Plan types
211// ---------------------------------------------------------------------------
212
213/// A step in an implementation plan.
214#[derive(Clone, Debug)]
215pub struct PlanStep {
216    pub number: usize,
217    pub description: String,
218    pub details: Option<String>,
219    pub files: Vec<String>,
220    pub completed: bool,
221}
222
223/// A phase in an implementation plan (groups related steps).
224#[derive(Clone, Debug)]
225pub struct PlanPhase {
226    pub name: String,
227    pub steps: Vec<PlanStep>,
228    pub completed: bool,
229}
230
231/// Structured plan content for display in the Implementation Blueprint panel.
232#[derive(Clone, Debug)]
233pub struct PlanContent {
234    pub title: String,
235    pub summary: String,
236    pub file_path: Option<String>,
237    pub phases: Vec<PlanPhase>,
238    pub open_questions: Vec<String>,
239    pub raw_content: String,
240    pub total_steps: usize,
241    pub completed_steps: usize,
242}
243
244impl PlanContent {
245    /// Parse plan content from markdown.
246    pub fn from_markdown(title: String, content: &str, file_path: Option<String>) -> Self {
247        let mut phases = Vec::new();
248        let mut open_questions = Vec::new();
249        let mut current_phase: Option<PlanPhase> = None;
250        let mut total_steps = 0;
251        let mut completed_steps = 0;
252        let mut summary = String::new();
253        let mut reading_summary = false;
254
255        for line in content.lines() {
256            let trimmed = line.trim();
257
258            // The planning prompt emits both conventional markdown headings
259            // (`## Summary`) and sparse section labels (`Summary`). Treat
260            // either form as a section marker so the label itself is not
261            // displayed as the plan summary.
262            if trimmed.eq_ignore_ascii_case("summary") || trimmed.eq_ignore_ascii_case("## summary") {
263                reading_summary = true;
264                continue;
265            }
266
267            if reading_summary {
268                if !trimmed.is_empty() {
269                    if summary.is_empty() {
270                        summary = trimmed.to_string();
271                    }
272                    reading_summary = false;
273                }
274                continue;
275            }
276
277            // Extract summary from first paragraph
278            if summary.is_empty() && !trimmed.is_empty() && !trimmed.starts_with('#') {
279                summary = trimmed.to_string();
280                continue;
281            }
282
283            // Phase headers (## Phase X: ...)
284            if let Some(phase_name) = trimmed.strip_prefix("## ") {
285                if let Some(phase) = current_phase.take() {
286                    phases.push(phase);
287                }
288                current_phase = Some(PlanPhase {
289                    name: phase_name.to_string(),
290                    steps: Vec::new(),
291                    completed: false,
292                });
293                continue;
294            }
295
296            // Open questions section
297            if trimmed == "## Open Questions" {
298                if let Some(phase) = current_phase.take() {
299                    phases.push(phase);
300                }
301                continue;
302            }
303
304            // Step items ([ ] or [x] prefixed)
305            if let Some(rest) = trimmed.strip_prefix("[ ] ") {
306                total_steps += 1;
307                if let Some(ref mut phase) = current_phase {
308                    phase.steps.push(PlanStep {
309                        number: phase.steps.len() + 1,
310                        description: rest.to_string(),
311                        details: None,
312                        files: Vec::new(),
313                        completed: false,
314                    });
315                }
316                continue;
317            }
318
319            if let Some(rest) = trimmed.strip_prefix("[x] ").or_else(|| trimmed.strip_prefix("[X] ")) {
320                total_steps += 1;
321                completed_steps += 1;
322                if let Some(ref mut phase) = current_phase {
323                    phase.steps.push(PlanStep {
324                        number: phase.steps.len() + 1,
325                        description: rest.to_string(),
326                        details: None,
327                        files: Vec::new(),
328                        completed: true,
329                    });
330                }
331                continue;
332            }
333
334            // Numbered steps (1. **Step 1** ...)
335            if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains('.') {
336                total_steps += 1;
337                if let Some(ref mut phase) = current_phase {
338                    let desc = trimmed.split_once('.').map(|x| x.1).unwrap_or("").trim();
339                    phase.steps.push(PlanStep {
340                        number: phase.steps.len() + 1,
341                        description: desc.to_string(),
342                        details: None,
343                        files: Vec::new(),
344                        completed: false,
345                    });
346                }
347                continue;
348            }
349
350            // Question items
351            if trimmed.starts_with("- (") || trimmed.starts_with("- ?") {
352                open_questions.push(trimmed.trim_start_matches("- ").to_string());
353            }
354        }
355
356        // Save last phase
357        if let Some(mut phase) = current_phase.take() {
358            phase.completed = phase.steps.iter().all(|s| s.completed);
359            phases.push(phase);
360        }
361
362        // Update phase completion status
363        for phase in &mut phases {
364            phase.completed = !phase.steps.is_empty() && phase.steps.iter().all(|s| s.completed);
365        }
366
367        Self {
368            title,
369            summary,
370            file_path,
371            phases,
372            open_questions,
373            raw_content: content.to_string(),
374            total_steps,
375            completed_steps,
376        }
377    }
378
379    /// Get progress as a percentage.
380    #[allow(
381        clippy::cast_sign_loss,
382        reason = "Intentional compatibility, platform, or test-only suppression."
383    )]
384    pub fn progress_percent(&self) -> u8 {
385        if self.total_steps == 0 {
386            0
387        } else {
388            ((self.completed_steps as f32 / self.total_steps as f32) * 100.0) as u8
389        }
390    }
391}
392
393// ---------------------------------------------------------------------------
394// App icon preference
395// ---------------------------------------------------------------------------
396
397/// Selected application-icon artwork, shared by the desktop Dock/bundle icon
398/// picker and the CLI terminal tab icon (`ui.app_icon`).
399///
400/// `Dynamic` (default) follows the active light/dark mode, while
401/// `Light`/`Dark`/`Clear`/`Tinted` pin one artwork. `Unknown` is the serde
402/// catch-all for values added by future versions and behaves like `Dynamic`.
403#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
404#[serde(rename_all = "snake_case")]
405#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
406pub enum AppIconPreference {
407    /// Follow the active light/dark mode (default).
408    #[default]
409    Dynamic,
410    /// Always use the light-mode artwork.
411    Light,
412    /// Always use the dark-mode artwork.
413    Dark,
414    /// Always use the clear (glass V on a subtle translucent tile) artwork.
415    Clear,
416    /// Always use the tinted (monochrome slate) artwork.
417    Tinted,
418    /// Catch-all for unknown values added by future versions.
419    #[serde(other)]
420    Unknown,
421}
422
423impl AppIconPreference {
424    /// Selectable variants, in picker order. `Unknown` is never offered.
425    pub const ALL: [Self; 5] = [Self::Dynamic, Self::Light, Self::Dark, Self::Clear, Self::Tinted];
426
427    /// Canonical persisted id.
428    #[must_use]
429    pub const fn id(self) -> &'static str {
430        match self {
431            Self::Dynamic | Self::Unknown => "dynamic",
432            Self::Light => "light",
433            Self::Dark => "dark",
434            Self::Clear => "clear",
435            Self::Tinted => "tinted",
436        }
437    }
438
439    /// Parse a persisted id; missing or unknown values fall back to `Dynamic`.
440    #[must_use]
441    pub fn from_id(id: Option<&str>) -> Self {
442        match id.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
443            Some("light") => Self::Light,
444            Some("dark") => Self::Dark,
445            Some("clear") => Self::Clear,
446            Some("tinted") => Self::Tinted,
447            _ => Self::Dynamic,
448        }
449    }
450
451    /// Resolve to the concrete pinned artwork: `Dynamic`/`Unknown` follow the
452    /// given UI mode, while explicit variants pin their own art.
453    #[must_use]
454    pub const fn resolve(self, dark_mode: bool) -> Self {
455        match self {
456            Self::Dynamic | Self::Unknown => {
457                if dark_mode {
458                    Self::Dark
459                } else {
460                    Self::Light
461                }
462            }
463            pinned => pinned,
464        }
465    }
466
467    /// Whether the dark-mode artwork applies under the given UI mode.
468    ///
469    /// Only meaningful for the light/dark/dynamic family: `Clear` and
470    /// `Tinted` pin their own non-flat artwork, so this is always false for
471    /// them. Prefer [`Self::resolve`] when selecting artwork bytes.
472    #[must_use]
473    pub const fn is_dark(self, dark_mode: bool) -> bool {
474        matches!(self.resolve(dark_mode), Self::Dark)
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::PlanContent;
481    use crate::ui_protocol::AppIconPreference;
482
483    #[test]
484    fn parses_sparse_summary_section_without_displaying_section_label() {
485        let plan = PlanContent::from_markdown(
486            "Implementation Plan".to_string(),
487            "Summary\nFocus on startup latency.\n\n1. Measure startup -> src/startup.rs\n2. Defer refresh -> src/update.rs\n\nValidation\n- cargo check --locked",
488            None,
489        );
490
491        assert_eq!(plan.summary, "Focus on startup latency.");
492        assert_eq!(plan.total_steps, 2);
493        assert_eq!(plan.raw_content.lines().next(), Some("Summary"));
494    }
495
496    #[test]
497    fn app_icon_default_is_dynamic() {
498        assert_eq!(AppIconPreference::default(), AppIconPreference::Dynamic);
499    }
500
501    #[test]
502    fn app_icon_ids_round_trip_through_from_id() {
503        for variant in AppIconPreference::ALL {
504            assert_eq!(AppIconPreference::from_id(Some(variant.id())), variant, "{}", variant.id());
505        }
506        assert_eq!(AppIconPreference::from_id(None), AppIconPreference::Dynamic);
507    }
508
509    #[test]
510    fn app_icon_from_id_is_forgiving_about_case_and_whitespace() {
511        assert_eq!(AppIconPreference::from_id(Some(" Light ")), AppIconPreference::Light);
512        assert_eq!(AppIconPreference::from_id(Some("DARK")), AppIconPreference::Dark);
513        assert_eq!(AppIconPreference::from_id(Some("DYNAMIC")), AppIconPreference::Dynamic);
514        assert_eq!(AppIconPreference::from_id(Some(" Clear ")), AppIconPreference::Clear);
515        assert_eq!(AppIconPreference::from_id(Some("TINTED")), AppIconPreference::Tinted);
516    }
517
518    #[test]
519    fn app_icon_from_id_falls_back_to_dynamic_for_unknown_or_empty() {
520        for raw in ["", "   ", "twilight", "bright", "dynamic-v2"] {
521            assert_eq!(AppIconPreference::from_id(Some(raw)), AppIconPreference::Dynamic, "{raw:?}");
522        }
523    }
524
525    #[test]
526    fn app_icon_is_dark_pins_light_and_dark_but_follows_mode_for_dynamic() {
527        for dark_mode in [false, true] {
528            assert!(!AppIconPreference::Light.is_dark(dark_mode), "light pins light art");
529            assert!(AppIconPreference::Dark.is_dark(dark_mode), "dark pins dark art");
530            assert_eq!(AppIconPreference::Dynamic.is_dark(dark_mode), dark_mode, "dynamic follows mode");
531            assert_eq!(AppIconPreference::Unknown.is_dark(dark_mode), dark_mode, "unknown follows mode");
532            assert!(
533                !AppIconPreference::Clear.is_dark(dark_mode),
534                "clear pins its own glass art, never the dark flat tile"
535            );
536            assert!(
537                !AppIconPreference::Tinted.is_dark(dark_mode),
538                "tinted pins its own slate art, never the dark flat tile"
539            );
540        }
541    }
542
543    #[test]
544    fn app_icon_resolve_pins_explicit_variants_and_follows_mode_for_dynamic() {
545        for dark_mode in [false, true] {
546            for pinned in [
547                AppIconPreference::Light,
548                AppIconPreference::Dark,
549                AppIconPreference::Clear,
550                AppIconPreference::Tinted,
551            ] {
552                assert_eq!(pinned.resolve(dark_mode), pinned, "{pinned:?}");
553            }
554            let expected = if dark_mode {
555                AppIconPreference::Dark
556            } else {
557                AppIconPreference::Light
558            };
559            assert_eq!(AppIconPreference::Dynamic.resolve(dark_mode), expected);
560            assert_eq!(AppIconPreference::Unknown.resolve(dark_mode), expected);
561        }
562    }
563
564    #[test]
565    fn app_icon_serde_uses_ids_and_catches_unknown_variants() {
566        assert_eq!(serde_json::from_str::<AppIconPreference>("\"light\"").unwrap(), AppIconPreference::Light);
567        assert_eq!(serde_json::from_str::<AppIconPreference>("\"clear\"").unwrap(), AppIconPreference::Clear);
568        assert_eq!(serde_json::from_str::<AppIconPreference>("\"tinted\"").unwrap(), AppIconPreference::Tinted);
569        assert_eq!(
570            serde_json::from_str::<AppIconPreference>("\"future-variant\"").unwrap(),
571            AppIconPreference::Unknown
572        );
573        assert_eq!(serde_json::to_string(&AppIconPreference::Dynamic).unwrap(), "\"dynamic\"");
574        assert_eq!(serde_json::to_string(&AppIconPreference::Clear).unwrap(), "\"clear\"");
575        assert_eq!(serde_json::to_string(&AppIconPreference::Tinted).unwrap(), "\"tinted\"");
576        assert_eq!(AppIconPreference::Unknown.id(), "dynamic");
577    }
578}