vtcode_commons/ui_protocol/
types.rs1#![expect(
2 clippy::cast_possible_truncation,
3 reason = "Progress percentages are clamped to the documented byte-sized display range."
4)]
5
6#[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#[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#[derive(Clone, Debug)]
36pub struct InlineListSearchConfig {
37 pub label: String,
38 pub placeholder: Option<String>,
39}
40
41#[derive(Clone, Debug)]
43pub struct SecurePromptConfig {
44 pub label: String,
45 pub placeholder: Option<String>,
47 pub mask_input: bool,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum SessionSurface {
54 Auto,
55 Alternate,
56 #[default]
57 Inline,
58}
59
60#[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#[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#[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#[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#[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 #[default]
122 Collapsed,
123 Extended,
125}
126
127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
129pub enum WizardModalMode {
130 MultiStep,
132 TabbedList,
134}
135
136#[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 #[default]
143 Inline,
144 SideBySide,
146}
147
148#[derive(Clone, Debug)]
154pub struct PlanStep {
155 pub number: usize,
156 pub description: String,
157 pub details: Option<String>,
158 pub files: Vec<String>,
159 pub completed: bool,
160}
161
162#[derive(Clone, Debug)]
164pub struct PlanPhase {
165 pub name: String,
166 pub steps: Vec<PlanStep>,
167 pub completed: bool,
168}
169
170#[derive(Clone, Debug)]
172pub struct PlanContent {
173 pub title: String,
174 pub summary: String,
175 pub file_path: Option<String>,
176 pub phases: Vec<PlanPhase>,
177 pub open_questions: Vec<String>,
178 pub raw_content: String,
179 pub total_steps: usize,
180 pub completed_steps: usize,
181}
182
183impl PlanContent {
184 pub fn from_markdown(title: String, content: &str, file_path: Option<String>) -> Self {
186 let mut phases = Vec::new();
187 let mut open_questions = Vec::new();
188 let mut current_phase: Option<PlanPhase> = None;
189 let mut total_steps = 0;
190 let mut completed_steps = 0;
191 let mut summary = String::new();
192 let mut reading_summary = false;
193
194 for line in content.lines() {
195 let trimmed = line.trim();
196
197 if trimmed.eq_ignore_ascii_case("summary") || trimmed.eq_ignore_ascii_case("## summary") {
202 reading_summary = true;
203 continue;
204 }
205
206 if reading_summary {
207 if !trimmed.is_empty() {
208 if summary.is_empty() {
209 summary = trimmed.to_string();
210 }
211 reading_summary = false;
212 }
213 continue;
214 }
215
216 if summary.is_empty() && !trimmed.is_empty() && !trimmed.starts_with('#') {
218 summary = trimmed.to_string();
219 continue;
220 }
221
222 if let Some(phase_name) = trimmed.strip_prefix("## ") {
224 if let Some(phase) = current_phase.take() {
225 phases.push(phase);
226 }
227 current_phase = Some(PlanPhase {
228 name: phase_name.to_string(),
229 steps: Vec::new(),
230 completed: false,
231 });
232 continue;
233 }
234
235 if trimmed == "## Open Questions" {
237 if let Some(phase) = current_phase.take() {
238 phases.push(phase);
239 }
240 continue;
241 }
242
243 if let Some(rest) = trimmed.strip_prefix("[ ] ") {
245 total_steps += 1;
246 if let Some(ref mut phase) = current_phase {
247 phase.steps.push(PlanStep {
248 number: phase.steps.len() + 1,
249 description: rest.to_string(),
250 details: None,
251 files: Vec::new(),
252 completed: false,
253 });
254 }
255 continue;
256 }
257
258 if let Some(rest) = trimmed.strip_prefix("[x] ").or_else(|| trimmed.strip_prefix("[X] ")) {
259 total_steps += 1;
260 completed_steps += 1;
261 if let Some(ref mut phase) = current_phase {
262 phase.steps.push(PlanStep {
263 number: phase.steps.len() + 1,
264 description: rest.to_string(),
265 details: None,
266 files: Vec::new(),
267 completed: true,
268 });
269 }
270 continue;
271 }
272
273 if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains('.') {
275 total_steps += 1;
276 if let Some(ref mut phase) = current_phase {
277 let desc = trimmed.split_once('.').map(|x| x.1).unwrap_or("").trim();
278 phase.steps.push(PlanStep {
279 number: phase.steps.len() + 1,
280 description: desc.to_string(),
281 details: None,
282 files: Vec::new(),
283 completed: false,
284 });
285 }
286 continue;
287 }
288
289 if trimmed.starts_with("- (") || trimmed.starts_with("- ?") {
291 open_questions.push(trimmed.trim_start_matches("- ").to_string());
292 }
293 }
294
295 if let Some(mut phase) = current_phase.take() {
297 phase.completed = phase.steps.iter().all(|s| s.completed);
298 phases.push(phase);
299 }
300
301 for phase in &mut phases {
303 phase.completed = !phase.steps.is_empty() && phase.steps.iter().all(|s| s.completed);
304 }
305
306 Self {
307 title,
308 summary,
309 file_path,
310 phases,
311 open_questions,
312 raw_content: content.to_string(),
313 total_steps,
314 completed_steps,
315 }
316 }
317
318 #[allow(
320 clippy::cast_sign_loss,
321 reason = "Intentional compatibility, platform, or test-only suppression."
322 )]
323 pub fn progress_percent(&self) -> u8 {
324 if self.total_steps == 0 {
325 0
326 } else {
327 ((self.completed_steps as f32 / self.total_steps as f32) * 100.0) as u8
328 }
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::PlanContent;
335
336 #[test]
337 fn parses_sparse_summary_section_without_displaying_section_label() {
338 let plan = PlanContent::from_markdown(
339 "Implementation Plan".to_string(),
340 "Summary\nFocus on startup latency.\n\n1. Measure startup -> src/startup.rs\n2. Defer refresh -> src/update.rs\n\nValidation\n- cargo check --locked",
341 None,
342 );
343
344 assert_eq!(plan.summary, "Focus on startup latency.");
345 assert_eq!(plan.total_steps, 2);
346 assert_eq!(plan.raw_content.lines().next(), Some("Summary"));
347 }
348}