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