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 BusyEnterBehavior {
125 #[default]
127 Queue,
128 Steer,
130}
131
132#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
134#[serde(rename_all = "snake_case")]
135#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
136pub enum ThinkingBlockState {
137 #[default]
139 Collapsed,
140 Extended,
142}
143
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146pub enum WizardModalMode {
147 MultiStep,
149 TabbedList,
151}
152
153#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
155#[serde(rename_all = "kebab-case")]
156#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
157pub enum DiffPreviewMode {
158 #[default]
160 Inline,
161 SideBySide,
163}
164
165#[derive(Clone, Copy, Debug, PartialEq, Eq)]
174pub enum TaskItemStatus {
175 Pending,
176 InProgress,
177 Completed,
178 Blocked,
179}
180
181impl TaskItemStatus {
182 #[must_use]
184 pub fn as_str(self) -> &'static str {
185 match self {
186 Self::Pending => "pending",
187 Self::InProgress => "in_progress",
188 Self::Completed => "completed",
189 Self::Blocked => "blocked",
190 }
191 }
192}
193
194impl std::str::FromStr for TaskItemStatus {
195 type Err = ();
196
197 fn from_str(raw: &str) -> Result<Self, Self::Err> {
199 match raw {
200 "pending" => Ok(Self::Pending),
201 "in_progress" => Ok(Self::InProgress),
202 "completed" => Ok(Self::Completed),
203 "blocked" => Ok(Self::Blocked),
204 _ => Err(()),
205 }
206 }
207}
208
209#[derive(Clone, Debug)]
215pub struct PlanStep {
216 pub number: usize,
217 pub description: String,
218 pub details: Option<String>,
219 pub files: Vec<String>,
220 pub completed: bool,
221}
222
223#[derive(Clone, Debug)]
225pub struct PlanPhase {
226 pub name: String,
227 pub steps: Vec<PlanStep>,
228 pub completed: bool,
229}
230
231#[derive(Clone, Debug)]
233pub struct PlanContent {
234 pub title: String,
235 pub summary: String,
236 pub file_path: Option<String>,
237 pub phases: Vec<PlanPhase>,
238 pub open_questions: Vec<String>,
239 pub raw_content: String,
240 pub total_steps: usize,
241 pub completed_steps: usize,
242}
243
244impl PlanContent {
245 pub fn from_markdown(title: String, content: &str, file_path: Option<String>) -> Self {
247 let mut phases = Vec::new();
248 let mut open_questions = Vec::new();
249 let mut current_phase: Option<PlanPhase> = None;
250 let mut total_steps = 0;
251 let mut completed_steps = 0;
252 let mut summary = String::new();
253 let mut reading_summary = false;
254
255 for line in content.lines() {
256 let trimmed = line.trim();
257
258 if trimmed.eq_ignore_ascii_case("summary") || trimmed.eq_ignore_ascii_case("## summary") {
263 reading_summary = true;
264 continue;
265 }
266
267 if reading_summary {
268 if !trimmed.is_empty() {
269 if summary.is_empty() {
270 summary = trimmed.to_string();
271 }
272 reading_summary = false;
273 }
274 continue;
275 }
276
277 if summary.is_empty() && !trimmed.is_empty() && !trimmed.starts_with('#') {
279 summary = trimmed.to_string();
280 continue;
281 }
282
283 if let Some(phase_name) = trimmed.strip_prefix("## ") {
285 if let Some(phase) = current_phase.take() {
286 phases.push(phase);
287 }
288 current_phase = Some(PlanPhase {
289 name: phase_name.to_string(),
290 steps: Vec::new(),
291 completed: false,
292 });
293 continue;
294 }
295
296 if trimmed == "## Open Questions" {
298 if let Some(phase) = current_phase.take() {
299 phases.push(phase);
300 }
301 continue;
302 }
303
304 if let Some(rest) = trimmed.strip_prefix("[ ] ") {
306 total_steps += 1;
307 if let Some(ref mut phase) = current_phase {
308 phase.steps.push(PlanStep {
309 number: phase.steps.len() + 1,
310 description: rest.to_string(),
311 details: None,
312 files: Vec::new(),
313 completed: false,
314 });
315 }
316 continue;
317 }
318
319 if let Some(rest) = trimmed.strip_prefix("[x] ").or_else(|| trimmed.strip_prefix("[X] ")) {
320 total_steps += 1;
321 completed_steps += 1;
322 if let Some(ref mut phase) = current_phase {
323 phase.steps.push(PlanStep {
324 number: phase.steps.len() + 1,
325 description: rest.to_string(),
326 details: None,
327 files: Vec::new(),
328 completed: true,
329 });
330 }
331 continue;
332 }
333
334 if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains('.') {
336 total_steps += 1;
337 if let Some(ref mut phase) = current_phase {
338 let desc = trimmed.split_once('.').map(|x| x.1).unwrap_or("").trim();
339 phase.steps.push(PlanStep {
340 number: phase.steps.len() + 1,
341 description: desc.to_string(),
342 details: None,
343 files: Vec::new(),
344 completed: false,
345 });
346 }
347 continue;
348 }
349
350 if trimmed.starts_with("- (") || trimmed.starts_with("- ?") {
352 open_questions.push(trimmed.trim_start_matches("- ").to_string());
353 }
354 }
355
356 if let Some(mut phase) = current_phase.take() {
358 phase.completed = phase.steps.iter().all(|s| s.completed);
359 phases.push(phase);
360 }
361
362 for phase in &mut phases {
364 phase.completed = !phase.steps.is_empty() && phase.steps.iter().all(|s| s.completed);
365 }
366
367 Self {
368 title,
369 summary,
370 file_path,
371 phases,
372 open_questions,
373 raw_content: content.to_string(),
374 total_steps,
375 completed_steps,
376 }
377 }
378
379 #[allow(
381 clippy::cast_sign_loss,
382 reason = "Intentional compatibility, platform, or test-only suppression."
383 )]
384 pub fn progress_percent(&self) -> u8 {
385 if self.total_steps == 0 {
386 0
387 } else {
388 ((self.completed_steps as f32 / self.total_steps as f32) * 100.0) as u8
389 }
390 }
391}
392
393#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
404#[serde(rename_all = "snake_case")]
405#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
406pub enum AppIconPreference {
407 #[default]
409 Dynamic,
410 Light,
412 Dark,
414 Clear,
416 Tinted,
418 #[serde(other)]
420 Unknown,
421}
422
423impl AppIconPreference {
424 pub const ALL: [Self; 5] = [Self::Dynamic, Self::Light, Self::Dark, Self::Clear, Self::Tinted];
426
427 #[must_use]
429 pub const fn id(self) -> &'static str {
430 match self {
431 Self::Dynamic | Self::Unknown => "dynamic",
432 Self::Light => "light",
433 Self::Dark => "dark",
434 Self::Clear => "clear",
435 Self::Tinted => "tinted",
436 }
437 }
438
439 #[must_use]
441 pub fn from_id(id: Option<&str>) -> Self {
442 match id.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
443 Some("light") => Self::Light,
444 Some("dark") => Self::Dark,
445 Some("clear") => Self::Clear,
446 Some("tinted") => Self::Tinted,
447 _ => Self::Dynamic,
448 }
449 }
450
451 #[must_use]
454 pub const fn resolve(self, dark_mode: bool) -> Self {
455 match self {
456 Self::Dynamic | Self::Unknown => {
457 if dark_mode {
458 Self::Dark
459 } else {
460 Self::Light
461 }
462 }
463 pinned => pinned,
464 }
465 }
466
467 #[must_use]
473 pub const fn is_dark(self, dark_mode: bool) -> bool {
474 matches!(self.resolve(dark_mode), Self::Dark)
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use super::PlanContent;
481 use crate::ui_protocol::AppIconPreference;
482
483 #[test]
484 fn parses_sparse_summary_section_without_displaying_section_label() {
485 let plan = PlanContent::from_markdown(
486 "Implementation Plan".to_string(),
487 "Summary\nFocus on startup latency.\n\n1. Measure startup -> src/startup.rs\n2. Defer refresh -> src/update.rs\n\nValidation\n- cargo check --locked",
488 None,
489 );
490
491 assert_eq!(plan.summary, "Focus on startup latency.");
492 assert_eq!(plan.total_steps, 2);
493 assert_eq!(plan.raw_content.lines().next(), Some("Summary"));
494 }
495
496 #[test]
497 fn app_icon_default_is_dynamic() {
498 assert_eq!(AppIconPreference::default(), AppIconPreference::Dynamic);
499 }
500
501 #[test]
502 fn app_icon_ids_round_trip_through_from_id() {
503 for variant in AppIconPreference::ALL {
504 assert_eq!(AppIconPreference::from_id(Some(variant.id())), variant, "{}", variant.id());
505 }
506 assert_eq!(AppIconPreference::from_id(None), AppIconPreference::Dynamic);
507 }
508
509 #[test]
510 fn app_icon_from_id_is_forgiving_about_case_and_whitespace() {
511 assert_eq!(AppIconPreference::from_id(Some(" Light ")), AppIconPreference::Light);
512 assert_eq!(AppIconPreference::from_id(Some("DARK")), AppIconPreference::Dark);
513 assert_eq!(AppIconPreference::from_id(Some("DYNAMIC")), AppIconPreference::Dynamic);
514 assert_eq!(AppIconPreference::from_id(Some(" Clear ")), AppIconPreference::Clear);
515 assert_eq!(AppIconPreference::from_id(Some("TINTED")), AppIconPreference::Tinted);
516 }
517
518 #[test]
519 fn app_icon_from_id_falls_back_to_dynamic_for_unknown_or_empty() {
520 for raw in ["", " ", "twilight", "bright", "dynamic-v2"] {
521 assert_eq!(AppIconPreference::from_id(Some(raw)), AppIconPreference::Dynamic, "{raw:?}");
522 }
523 }
524
525 #[test]
526 fn app_icon_is_dark_pins_light_and_dark_but_follows_mode_for_dynamic() {
527 for dark_mode in [false, true] {
528 assert!(!AppIconPreference::Light.is_dark(dark_mode), "light pins light art");
529 assert!(AppIconPreference::Dark.is_dark(dark_mode), "dark pins dark art");
530 assert_eq!(AppIconPreference::Dynamic.is_dark(dark_mode), dark_mode, "dynamic follows mode");
531 assert_eq!(AppIconPreference::Unknown.is_dark(dark_mode), dark_mode, "unknown follows mode");
532 assert!(
533 !AppIconPreference::Clear.is_dark(dark_mode),
534 "clear pins its own glass art, never the dark flat tile"
535 );
536 assert!(
537 !AppIconPreference::Tinted.is_dark(dark_mode),
538 "tinted pins its own slate art, never the dark flat tile"
539 );
540 }
541 }
542
543 #[test]
544 fn app_icon_resolve_pins_explicit_variants_and_follows_mode_for_dynamic() {
545 for dark_mode in [false, true] {
546 for pinned in [
547 AppIconPreference::Light,
548 AppIconPreference::Dark,
549 AppIconPreference::Clear,
550 AppIconPreference::Tinted,
551 ] {
552 assert_eq!(pinned.resolve(dark_mode), pinned, "{pinned:?}");
553 }
554 let expected = if dark_mode {
555 AppIconPreference::Dark
556 } else {
557 AppIconPreference::Light
558 };
559 assert_eq!(AppIconPreference::Dynamic.resolve(dark_mode), expected);
560 assert_eq!(AppIconPreference::Unknown.resolve(dark_mode), expected);
561 }
562 }
563
564 #[test]
565 fn app_icon_serde_uses_ids_and_catches_unknown_variants() {
566 assert_eq!(serde_json::from_str::<AppIconPreference>("\"light\"").unwrap(), AppIconPreference::Light);
567 assert_eq!(serde_json::from_str::<AppIconPreference>("\"clear\"").unwrap(), AppIconPreference::Clear);
568 assert_eq!(serde_json::from_str::<AppIconPreference>("\"tinted\"").unwrap(), AppIconPreference::Tinted);
569 assert_eq!(
570 serde_json::from_str::<AppIconPreference>("\"future-variant\"").unwrap(),
571 AppIconPreference::Unknown
572 );
573 assert_eq!(serde_json::to_string(&AppIconPreference::Dynamic).unwrap(), "\"dynamic\"");
574 assert_eq!(serde_json::to_string(&AppIconPreference::Clear).unwrap(), "\"clear\"");
575 assert_eq!(serde_json::to_string(&AppIconPreference::Tinted).unwrap(), "\"tinted\"");
576 assert_eq!(AppIconPreference::Unknown.id(), "dynamic");
577 }
578}