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(Clone, Debug)]
142pub struct PlanStep {
143 pub number: usize,
144 pub description: String,
145 pub details: Option<String>,
146 pub files: Vec<String>,
147 pub completed: bool,
148}
149
150#[derive(Clone, Debug)]
152pub struct PlanPhase {
153 pub name: String,
154 pub steps: Vec<PlanStep>,
155 pub completed: bool,
156}
157
158#[derive(Clone, Debug)]
160pub struct PlanContent {
161 pub title: String,
162 pub summary: String,
163 pub file_path: Option<String>,
164 pub phases: Vec<PlanPhase>,
165 pub open_questions: Vec<String>,
166 pub raw_content: String,
167 pub total_steps: usize,
168 pub completed_steps: usize,
169}
170
171impl PlanContent {
172 pub fn from_markdown(title: String, content: &str, file_path: Option<String>) -> Self {
174 let mut phases = Vec::new();
175 let mut open_questions = Vec::new();
176 let mut current_phase: Option<PlanPhase> = None;
177 let mut total_steps = 0;
178 let mut completed_steps = 0;
179 let mut summary = String::new();
180 let mut reading_summary = false;
181
182 for line in content.lines() {
183 let trimmed = line.trim();
184
185 if trimmed.eq_ignore_ascii_case("summary") || trimmed.eq_ignore_ascii_case("## summary") {
190 reading_summary = true;
191 continue;
192 }
193
194 if reading_summary {
195 if !trimmed.is_empty() {
196 if summary.is_empty() {
197 summary = trimmed.to_string();
198 }
199 reading_summary = false;
200 }
201 continue;
202 }
203
204 if summary.is_empty() && !trimmed.is_empty() && !trimmed.starts_with('#') {
206 summary = trimmed.to_string();
207 continue;
208 }
209
210 if let Some(phase_name) = trimmed.strip_prefix("## ") {
212 if let Some(phase) = current_phase.take() {
213 phases.push(phase);
214 }
215 current_phase = Some(PlanPhase {
216 name: phase_name.to_string(),
217 steps: Vec::new(),
218 completed: false,
219 });
220 continue;
221 }
222
223 if trimmed == "## Open Questions" {
225 if let Some(phase) = current_phase.take() {
226 phases.push(phase);
227 }
228 continue;
229 }
230
231 if let Some(rest) = trimmed.strip_prefix("[ ] ") {
233 total_steps += 1;
234 if let Some(ref mut phase) = current_phase {
235 phase.steps.push(PlanStep {
236 number: phase.steps.len() + 1,
237 description: rest.to_string(),
238 details: None,
239 files: Vec::new(),
240 completed: false,
241 });
242 }
243 continue;
244 }
245
246 if let Some(rest) = trimmed.strip_prefix("[x] ").or_else(|| trimmed.strip_prefix("[X] ")) {
247 total_steps += 1;
248 completed_steps += 1;
249 if let Some(ref mut phase) = current_phase {
250 phase.steps.push(PlanStep {
251 number: phase.steps.len() + 1,
252 description: rest.to_string(),
253 details: None,
254 files: Vec::new(),
255 completed: true,
256 });
257 }
258 continue;
259 }
260
261 if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains('.') {
263 total_steps += 1;
264 if let Some(ref mut phase) = current_phase {
265 let desc = trimmed.split_once('.').map(|x| x.1).unwrap_or("").trim();
266 phase.steps.push(PlanStep {
267 number: phase.steps.len() + 1,
268 description: desc.to_string(),
269 details: None,
270 files: Vec::new(),
271 completed: false,
272 });
273 }
274 continue;
275 }
276
277 if trimmed.starts_with("- (") || trimmed.starts_with("- ?") {
279 open_questions.push(trimmed.trim_start_matches("- ").to_string());
280 }
281 }
282
283 if let Some(mut phase) = current_phase.take() {
285 phase.completed = phase.steps.iter().all(|s| s.completed);
286 phases.push(phase);
287 }
288
289 for phase in &mut phases {
291 phase.completed = !phase.steps.is_empty() && phase.steps.iter().all(|s| s.completed);
292 }
293
294 Self {
295 title,
296 summary,
297 file_path,
298 phases,
299 open_questions,
300 raw_content: content.to_string(),
301 total_steps,
302 completed_steps,
303 }
304 }
305
306 #[allow(
308 clippy::cast_sign_loss,
309 reason = "Intentional compatibility, platform, or test-only suppression."
310 )]
311 pub fn progress_percent(&self) -> u8 {
312 if self.total_steps == 0 {
313 0
314 } else {
315 ((self.completed_steps as f32 / self.total_steps as f32) * 100.0) as u8
316 }
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::PlanContent;
323
324 #[test]
325 fn parses_sparse_summary_section_without_displaying_section_label() {
326 let plan = PlanContent::from_markdown(
327 "Implementation Plan".to_string(),
328 "Summary\nFocus on startup latency.\n\n1. Measure startup -> src/startup.rs\n2. Defer refresh -> src/update.rs\n\nValidation\n- cargo check --locked",
329 None,
330 );
331
332 assert_eq!(plan.summary, "Focus on startup latency.");
333 assert_eq!(plan.total_steps, 2);
334 assert_eq!(plan.raw_content.lines().next(), Some("Summary"));
335 }
336}