1#![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, Default)]
36pub struct InlineListSearchConfig {
37 pub label: String,
38 pub placeholder: Option<String>,
39 pub fuzzy: bool,
44}
45
46#[derive(Clone, Debug)]
48pub struct SecurePromptConfig {
49 pub label: String,
50 pub placeholder: Option<String>,
52 pub mask_input: bool,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum SessionSurface {
59 Auto,
60 Alternate,
61 #[default]
62 Inline,
63}
64
65#[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#[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#[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#[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#[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 ThinkingBlockState {
125 #[default]
127 Collapsed,
128 Extended,
130}
131
132#[derive(Clone, Copy, Debug, PartialEq, Eq)]
134pub enum WizardModalMode {
135 MultiStep,
137 TabbedList,
139}
140
141#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
143#[serde(rename_all = "kebab-case")]
144#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
145pub enum DiffPreviewMode {
146 #[default]
148 Inline,
149 SideBySide,
151}
152
153#[derive(Clone, Copy, Debug, PartialEq, Eq)]
162pub enum TaskItemStatus {
163 Pending,
164 InProgress,
165 Completed,
166 Blocked,
167}
168
169impl TaskItemStatus {
170 #[must_use]
172 pub fn as_str(self) -> &'static str {
173 match self {
174 Self::Pending => "pending",
175 Self::InProgress => "in_progress",
176 Self::Completed => "completed",
177 Self::Blocked => "blocked",
178 }
179 }
180}
181
182impl std::str::FromStr for TaskItemStatus {
183 type Err = ();
184
185 fn from_str(raw: &str) -> Result<Self, Self::Err> {
187 match raw {
188 "pending" => Ok(Self::Pending),
189 "in_progress" => Ok(Self::InProgress),
190 "completed" => Ok(Self::Completed),
191 "blocked" => Ok(Self::Blocked),
192 _ => Err(()),
193 }
194 }
195}
196
197#[derive(Clone, Debug)]
203pub struct PlanStep {
204 pub number: usize,
205 pub description: String,
206 pub details: Option<String>,
207 pub files: Vec<String>,
208 pub completed: bool,
209}
210
211#[derive(Clone, Debug)]
213pub struct PlanPhase {
214 pub name: String,
215 pub steps: Vec<PlanStep>,
216 pub completed: bool,
217}
218
219#[derive(Clone, Debug)]
221pub struct PlanContent {
222 pub title: String,
223 pub summary: String,
224 pub file_path: Option<String>,
225 pub phases: Vec<PlanPhase>,
226 pub open_questions: Vec<String>,
227 pub raw_content: String,
228 pub total_steps: usize,
229 pub completed_steps: usize,
230}
231
232impl PlanContent {
233 pub fn from_markdown(title: String, content: &str, file_path: Option<String>) -> Self {
235 let mut phases = Vec::new();
236 let mut open_questions = Vec::new();
237 let mut current_phase: Option<PlanPhase> = None;
238 let mut total_steps = 0;
239 let mut completed_steps = 0;
240 let mut summary = String::new();
241 let mut reading_summary = false;
242
243 for line in content.lines() {
244 let trimmed = line.trim();
245
246 if trimmed.eq_ignore_ascii_case("summary") || trimmed.eq_ignore_ascii_case("## summary") {
251 reading_summary = true;
252 continue;
253 }
254
255 if reading_summary {
256 if !trimmed.is_empty() {
257 if summary.is_empty() {
258 summary = trimmed.to_string();
259 }
260 reading_summary = false;
261 }
262 continue;
263 }
264
265 if summary.is_empty() && !trimmed.is_empty() && !trimmed.starts_with('#') {
267 summary = trimmed.to_string();
268 continue;
269 }
270
271 if let Some(phase_name) = trimmed.strip_prefix("## ") {
273 if let Some(phase) = current_phase.take() {
274 phases.push(phase);
275 }
276 current_phase = Some(PlanPhase {
277 name: phase_name.to_string(),
278 steps: Vec::new(),
279 completed: false,
280 });
281 continue;
282 }
283
284 if trimmed == "## Open Questions" {
286 if let Some(phase) = current_phase.take() {
287 phases.push(phase);
288 }
289 continue;
290 }
291
292 if let Some(rest) = trimmed.strip_prefix("[ ] ") {
294 total_steps += 1;
295 if let Some(ref mut phase) = current_phase {
296 phase.steps.push(PlanStep {
297 number: phase.steps.len() + 1,
298 description: rest.to_string(),
299 details: None,
300 files: Vec::new(),
301 completed: false,
302 });
303 }
304 continue;
305 }
306
307 if let Some(rest) = trimmed.strip_prefix("[x] ").or_else(|| trimmed.strip_prefix("[X] ")) {
308 total_steps += 1;
309 completed_steps += 1;
310 if let Some(ref mut phase) = current_phase {
311 phase.steps.push(PlanStep {
312 number: phase.steps.len() + 1,
313 description: rest.to_string(),
314 details: None,
315 files: Vec::new(),
316 completed: true,
317 });
318 }
319 continue;
320 }
321
322 if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains('.') {
324 total_steps += 1;
325 if let Some(ref mut phase) = current_phase {
326 let desc = trimmed.split_once('.').map(|x| x.1).unwrap_or("").trim();
327 phase.steps.push(PlanStep {
328 number: phase.steps.len() + 1,
329 description: desc.to_string(),
330 details: None,
331 files: Vec::new(),
332 completed: false,
333 });
334 }
335 continue;
336 }
337
338 if trimmed.starts_with("- (") || trimmed.starts_with("- ?") {
340 open_questions.push(trimmed.trim_start_matches("- ").to_string());
341 }
342 }
343
344 if let Some(mut phase) = current_phase.take() {
346 phase.completed = phase.steps.iter().all(|s| s.completed);
347 phases.push(phase);
348 }
349
350 for phase in &mut phases {
352 phase.completed = !phase.steps.is_empty() && phase.steps.iter().all(|s| s.completed);
353 }
354
355 Self {
356 title,
357 summary,
358 file_path,
359 phases,
360 open_questions,
361 raw_content: content.to_string(),
362 total_steps,
363 completed_steps,
364 }
365 }
366
367 #[allow(
369 clippy::cast_sign_loss,
370 reason = "Intentional compatibility, platform, or test-only suppression."
371 )]
372 pub fn progress_percent(&self) -> u8 {
373 if self.total_steps == 0 {
374 0
375 } else {
376 ((self.completed_steps as f32 / self.total_steps as f32) * 100.0) as u8
377 }
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::PlanContent;
384
385 #[test]
386 fn parses_sparse_summary_section_without_displaying_section_label() {
387 let plan = PlanContent::from_markdown(
388 "Implementation Plan".to_string(),
389 "Summary\nFocus on startup latency.\n\n1. Measure startup -> src/startup.rs\n2. Defer refresh -> src/update.rs\n\nValidation\n- cargo check --locked",
390 None,
391 );
392
393 assert_eq!(plan.summary, "Focus on startup latency.");
394 assert_eq!(plan.total_steps, 2);
395 assert_eq!(plan.raw_content.lines().next(), Some("Summary"));
396 }
397}