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)]
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, Copy, Debug, PartialEq, Eq)]
157pub enum TaskItemStatus {
158 Pending,
159 InProgress,
160 Completed,
161 Blocked,
162}
163
164impl TaskItemStatus {
165 #[must_use]
167 pub fn as_str(self) -> &'static str {
168 match self {
169 Self::Pending => "pending",
170 Self::InProgress => "in_progress",
171 Self::Completed => "completed",
172 Self::Blocked => "blocked",
173 }
174 }
175}
176
177impl std::str::FromStr for TaskItemStatus {
178 type Err = ();
179
180 fn from_str(raw: &str) -> Result<Self, Self::Err> {
182 match raw {
183 "pending" => Ok(Self::Pending),
184 "in_progress" => Ok(Self::InProgress),
185 "completed" => Ok(Self::Completed),
186 "blocked" => Ok(Self::Blocked),
187 _ => Err(()),
188 }
189 }
190}
191
192#[derive(Clone, Debug)]
198pub struct PlanStep {
199 pub number: usize,
200 pub description: String,
201 pub details: Option<String>,
202 pub files: Vec<String>,
203 pub completed: bool,
204}
205
206#[derive(Clone, Debug)]
208pub struct PlanPhase {
209 pub name: String,
210 pub steps: Vec<PlanStep>,
211 pub completed: bool,
212}
213
214#[derive(Clone, Debug)]
216pub struct PlanContent {
217 pub title: String,
218 pub summary: String,
219 pub file_path: Option<String>,
220 pub phases: Vec<PlanPhase>,
221 pub open_questions: Vec<String>,
222 pub raw_content: String,
223 pub total_steps: usize,
224 pub completed_steps: usize,
225}
226
227impl PlanContent {
228 pub fn from_markdown(title: String, content: &str, file_path: Option<String>) -> Self {
230 let mut phases = Vec::new();
231 let mut open_questions = Vec::new();
232 let mut current_phase: Option<PlanPhase> = None;
233 let mut total_steps = 0;
234 let mut completed_steps = 0;
235 let mut summary = String::new();
236 let mut reading_summary = false;
237
238 for line in content.lines() {
239 let trimmed = line.trim();
240
241 if trimmed.eq_ignore_ascii_case("summary") || trimmed.eq_ignore_ascii_case("## summary") {
246 reading_summary = true;
247 continue;
248 }
249
250 if reading_summary {
251 if !trimmed.is_empty() {
252 if summary.is_empty() {
253 summary = trimmed.to_string();
254 }
255 reading_summary = false;
256 }
257 continue;
258 }
259
260 if summary.is_empty() && !trimmed.is_empty() && !trimmed.starts_with('#') {
262 summary = trimmed.to_string();
263 continue;
264 }
265
266 if let Some(phase_name) = trimmed.strip_prefix("## ") {
268 if let Some(phase) = current_phase.take() {
269 phases.push(phase);
270 }
271 current_phase = Some(PlanPhase {
272 name: phase_name.to_string(),
273 steps: Vec::new(),
274 completed: false,
275 });
276 continue;
277 }
278
279 if trimmed == "## Open Questions" {
281 if let Some(phase) = current_phase.take() {
282 phases.push(phase);
283 }
284 continue;
285 }
286
287 if let Some(rest) = trimmed.strip_prefix("[ ] ") {
289 total_steps += 1;
290 if let Some(ref mut phase) = current_phase {
291 phase.steps.push(PlanStep {
292 number: phase.steps.len() + 1,
293 description: rest.to_string(),
294 details: None,
295 files: Vec::new(),
296 completed: false,
297 });
298 }
299 continue;
300 }
301
302 if let Some(rest) = trimmed.strip_prefix("[x] ").or_else(|| trimmed.strip_prefix("[X] ")) {
303 total_steps += 1;
304 completed_steps += 1;
305 if let Some(ref mut phase) = current_phase {
306 phase.steps.push(PlanStep {
307 number: phase.steps.len() + 1,
308 description: rest.to_string(),
309 details: None,
310 files: Vec::new(),
311 completed: true,
312 });
313 }
314 continue;
315 }
316
317 if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains('.') {
319 total_steps += 1;
320 if let Some(ref mut phase) = current_phase {
321 let desc = trimmed.split_once('.').map(|x| x.1).unwrap_or("").trim();
322 phase.steps.push(PlanStep {
323 number: phase.steps.len() + 1,
324 description: desc.to_string(),
325 details: None,
326 files: Vec::new(),
327 completed: false,
328 });
329 }
330 continue;
331 }
332
333 if trimmed.starts_with("- (") || trimmed.starts_with("- ?") {
335 open_questions.push(trimmed.trim_start_matches("- ").to_string());
336 }
337 }
338
339 if let Some(mut phase) = current_phase.take() {
341 phase.completed = phase.steps.iter().all(|s| s.completed);
342 phases.push(phase);
343 }
344
345 for phase in &mut phases {
347 phase.completed = !phase.steps.is_empty() && phase.steps.iter().all(|s| s.completed);
348 }
349
350 Self {
351 title,
352 summary,
353 file_path,
354 phases,
355 open_questions,
356 raw_content: content.to_string(),
357 total_steps,
358 completed_steps,
359 }
360 }
361
362 #[allow(
364 clippy::cast_sign_loss,
365 reason = "Intentional compatibility, platform, or test-only suppression."
366 )]
367 pub fn progress_percent(&self) -> u8 {
368 if self.total_steps == 0 {
369 0
370 } else {
371 ((self.completed_steps as f32 / self.total_steps as f32) * 100.0) as u8
372 }
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::PlanContent;
379
380 #[test]
381 fn parses_sparse_summary_section_without_displaying_section_label() {
382 let plan = PlanContent::from_markdown(
383 "Implementation Plan".to_string(),
384 "Summary\nFocus on startup latency.\n\n1. Measure startup -> src/startup.rs\n2. Defer refresh -> src/update.rs\n\nValidation\n- cargo check --locked",
385 None,
386 );
387
388 assert_eq!(plan.summary, "Focus on startup latency.");
389 assert_eq!(plan.total_steps, 2);
390 assert_eq!(plan.raw_content.lines().next(), Some("Summary"));
391 }
392}