vtcode_commons/ui_protocol/
types.rs1#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5pub enum InlineMessageKind {
6 Agent,
7 Error,
8 Info,
9 Policy,
10 Pty,
11 Tool,
12 User,
13 Warning,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct SlashCommandItem {
19 pub name: String,
20 pub description: String,
21}
22
23impl SlashCommandItem {
24 pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
25 Self { name: name.into(), description: description.into() }
26 }
27}
28
29#[derive(Clone, Debug)]
31pub struct InlineListSearchConfig {
32 pub label: String,
33 pub placeholder: Option<String>,
34}
35
36#[derive(Clone, Debug)]
38pub struct SecurePromptConfig {
39 pub label: String,
40 pub placeholder: Option<String>,
42 pub mask_input: bool,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum SessionSurface {
49 Auto,
50 Alternate,
51 #[default]
52 Inline,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct KeyboardProtocolSettings {
58 pub enabled: bool,
59 pub mode: String,
60 pub disambiguate_escape_codes: bool,
61 pub report_event_types: bool,
62 pub report_alternate_keys: bool,
63 pub report_all_keys: bool,
64}
65
66impl Default for KeyboardProtocolSettings {
67 fn default() -> Self {
68 Self {
69 enabled: true,
70 mode: "default".to_owned(),
71 disambiguate_escape_codes: true,
72 report_event_types: true,
73 report_alternate_keys: true,
74 report_all_keys: false,
75 }
76 }
77}
78
79#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum UiMode {
83 #[default]
84 Full,
85 Minimal,
86 Focused,
87}
88
89#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum LayoutModeOverride {
93 #[default]
94 Auto,
95 Compact,
96 Standard,
97 Wide,
98}
99
100#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum ReasoningDisplayMode {
104 Always,
105 #[default]
106 Toggle,
107 Hidden,
108}
109
110#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
112#[serde(rename_all = "snake_case")]
113#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
114pub enum ThinkingBlockState {
115 #[default]
117 Collapsed,
118 Extended,
120}
121
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub enum WizardModalMode {
125 MultiStep,
127 TabbedList,
129}
130
131#[derive(Clone, Debug)]
137pub struct PlanStep {
138 pub number: usize,
139 pub description: String,
140 pub details: Option<String>,
141 pub files: Vec<String>,
142 pub completed: bool,
143}
144
145#[derive(Clone, Debug)]
147pub struct PlanPhase {
148 pub name: String,
149 pub steps: Vec<PlanStep>,
150 pub completed: bool,
151}
152
153#[derive(Clone, Debug)]
155pub struct PlanContent {
156 pub title: String,
157 pub summary: String,
158 pub file_path: Option<String>,
159 pub phases: Vec<PlanPhase>,
160 pub open_questions: Vec<String>,
161 pub raw_content: String,
162 pub total_steps: usize,
163 pub completed_steps: usize,
164}
165
166impl PlanContent {
167 pub fn from_markdown(title: String, content: &str, file_path: Option<String>) -> Self {
169 let mut phases = Vec::new();
170 let mut open_questions = Vec::new();
171 let mut current_phase: Option<PlanPhase> = None;
172 let mut total_steps = 0;
173 let mut completed_steps = 0;
174 let mut summary = String::new();
175 let mut reading_summary = false;
176
177 for line in content.lines() {
178 let trimmed = line.trim();
179
180 if trimmed.eq_ignore_ascii_case("summary") || trimmed.eq_ignore_ascii_case("## summary") {
185 reading_summary = true;
186 continue;
187 }
188
189 if reading_summary {
190 if !trimmed.is_empty() {
191 if summary.is_empty() {
192 summary = trimmed.to_string();
193 }
194 reading_summary = false;
195 }
196 continue;
197 }
198
199 if summary.is_empty() && !trimmed.is_empty() && !trimmed.starts_with('#') {
201 summary = trimmed.to_string();
202 continue;
203 }
204
205 if let Some(phase_name) = trimmed.strip_prefix("## ") {
207 if let Some(phase) = current_phase.take() {
208 phases.push(phase);
209 }
210 current_phase = Some(PlanPhase {
211 name: phase_name.to_string(),
212 steps: Vec::new(),
213 completed: false,
214 });
215 continue;
216 }
217
218 if trimmed == "## Open Questions" {
220 if let Some(phase) = current_phase.take() {
221 phases.push(phase);
222 }
223 continue;
224 }
225
226 if let Some(rest) = trimmed.strip_prefix("[ ] ") {
228 total_steps += 1;
229 if let Some(ref mut phase) = current_phase {
230 phase.steps.push(PlanStep {
231 number: phase.steps.len() + 1,
232 description: rest.to_string(),
233 details: None,
234 files: Vec::new(),
235 completed: false,
236 });
237 }
238 continue;
239 }
240
241 if let Some(rest) = trimmed.strip_prefix("[x] ").or_else(|| trimmed.strip_prefix("[X] ")) {
242 total_steps += 1;
243 completed_steps += 1;
244 if let Some(ref mut phase) = current_phase {
245 phase.steps.push(PlanStep {
246 number: phase.steps.len() + 1,
247 description: rest.to_string(),
248 details: None,
249 files: Vec::new(),
250 completed: true,
251 });
252 }
253 continue;
254 }
255
256 if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains('.') {
258 total_steps += 1;
259 if let Some(ref mut phase) = current_phase {
260 let desc = trimmed.split_once('.').map(|x| x.1).unwrap_or("").trim();
261 phase.steps.push(PlanStep {
262 number: phase.steps.len() + 1,
263 description: desc.to_string(),
264 details: None,
265 files: Vec::new(),
266 completed: false,
267 });
268 }
269 continue;
270 }
271
272 if trimmed.starts_with("- (") || trimmed.starts_with("- ?") {
274 open_questions.push(trimmed.trim_start_matches("- ").to_string());
275 }
276 }
277
278 if let Some(mut phase) = current_phase.take() {
280 phase.completed = phase.steps.iter().all(|s| s.completed);
281 phases.push(phase);
282 }
283
284 for phase in &mut phases {
286 phase.completed = !phase.steps.is_empty() && phase.steps.iter().all(|s| s.completed);
287 }
288
289 Self {
290 title,
291 summary,
292 file_path,
293 phases,
294 open_questions,
295 raw_content: content.to_string(),
296 total_steps,
297 completed_steps,
298 }
299 }
300
301 #[allow(clippy::cast_sign_loss)]
303 pub fn progress_percent(&self) -> u8 {
304 if self.total_steps == 0 {
305 0
306 } else {
307 ((self.completed_steps as f32 / self.total_steps as f32) * 100.0) as u8
308 }
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::PlanContent;
315
316 #[test]
317 fn parses_sparse_summary_section_without_displaying_section_label() {
318 let plan = PlanContent::from_markdown(
319 "Implementation Plan".to_string(),
320 "Summary\nFocus on startup latency.\n\n1. Measure startup -> src/startup.rs\n2. Defer refresh -> src/update.rs\n\nValidation\n- cargo check --locked",
321 None,
322 );
323
324 assert_eq!(plan.summary, "Focus on startup latency.");
325 assert_eq!(plan.total_steps, 2);
326 assert_eq!(plan.raw_content.lines().next(), Some("Summary"));
327 }
328}