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(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
148#[serde(rename_all = "snake_case")]
149#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
150pub enum CommunicationStyle {
151 #[default]
154 Balanced,
155 Quiet,
158 Verbose,
160 #[serde(other)]
162 Unknown,
163}
164
165impl CommunicationStyle {
166 #[must_use]
170 pub fn parse(value: &str) -> Self {
171 match value.trim().to_ascii_lowercase().as_str() {
172 "balanced" => Self::Balanced,
173 "quiet" => Self::Quiet,
174 "verbose" => Self::Verbose,
175 _ => Self::Unknown,
176 }
177 }
178
179 #[must_use]
183 pub fn resolve(self) -> Self {
184 match self {
185 Self::Unknown => Self::Balanced,
186 resolved => resolved,
187 }
188 }
189
190 #[must_use]
194 pub fn as_str(self) -> &'static str {
195 match self {
196 Self::Balanced => "balanced",
197 Self::Quiet => "quiet",
198 Self::Verbose => "verbose",
199 Self::Unknown => "unknown",
200 }
201 }
202}
203
204#[derive(Clone, Copy, Debug, PartialEq, Eq)]
206pub enum WizardModalMode {
207 MultiStep,
209 TabbedList,
211}
212
213#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
215#[serde(rename_all = "kebab-case")]
216#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
217pub enum DiffPreviewMode {
218 #[default]
220 Inline,
221 SideBySide,
223}
224
225#[derive(Clone, Copy, Debug, PartialEq, Eq)]
234pub enum TaskItemStatus {
235 Pending,
236 InProgress,
237 Completed,
238 Blocked,
239}
240
241impl TaskItemStatus {
242 #[must_use]
244 pub fn as_str(self) -> &'static str {
245 match self {
246 Self::Pending => "pending",
247 Self::InProgress => "in_progress",
248 Self::Completed => "completed",
249 Self::Blocked => "blocked",
250 }
251 }
252}
253
254impl std::str::FromStr for TaskItemStatus {
255 type Err = ();
256
257 fn from_str(raw: &str) -> Result<Self, Self::Err> {
259 match raw {
260 "pending" => Ok(Self::Pending),
261 "in_progress" => Ok(Self::InProgress),
262 "completed" => Ok(Self::Completed),
263 "blocked" => Ok(Self::Blocked),
264 _ => Err(()),
265 }
266 }
267}
268
269#[derive(Clone, Debug)]
275pub struct PlanStep {
276 pub number: usize,
277 pub description: String,
278 pub details: Option<String>,
279 pub files: Vec<String>,
280 pub completed: bool,
281}
282
283#[derive(Clone, Debug)]
285pub struct PlanPhase {
286 pub name: String,
287 pub steps: Vec<PlanStep>,
288 pub completed: bool,
289}
290
291#[derive(Clone, Debug)]
293pub struct PlanContent {
294 pub title: String,
295 pub summary: String,
296 pub file_path: Option<String>,
297 pub phases: Vec<PlanPhase>,
298 pub open_questions: Vec<String>,
299 pub raw_content: String,
300 pub total_steps: usize,
301 pub completed_steps: usize,
302}
303
304impl PlanContent {
305 pub fn from_markdown(title: String, content: &str, file_path: Option<String>) -> Self {
307 let mut phases = Vec::new();
308 let mut open_questions = Vec::new();
309 let mut current_phase: Option<PlanPhase> = None;
310 let mut total_steps = 0;
311 let mut completed_steps = 0;
312 let mut summary = String::new();
313 let mut reading_summary = false;
314
315 for line in content.lines() {
316 let trimmed = line.trim();
317
318 if trimmed.eq_ignore_ascii_case("summary") || trimmed.eq_ignore_ascii_case("## summary") {
323 reading_summary = true;
324 continue;
325 }
326
327 if reading_summary {
328 if !trimmed.is_empty() {
329 if summary.is_empty() {
330 summary = trimmed.to_string();
331 }
332 reading_summary = false;
333 }
334 continue;
335 }
336
337 if summary.is_empty() && !trimmed.is_empty() && !trimmed.starts_with('#') {
339 summary = trimmed.to_string();
340 continue;
341 }
342
343 if let Some(phase_name) = trimmed.strip_prefix("## ") {
345 if let Some(phase) = current_phase.take() {
346 phases.push(phase);
347 }
348 current_phase = Some(PlanPhase {
349 name: phase_name.to_string(),
350 steps: Vec::new(),
351 completed: false,
352 });
353 continue;
354 }
355
356 if trimmed == "## Open Questions" {
358 if let Some(phase) = current_phase.take() {
359 phases.push(phase);
360 }
361 continue;
362 }
363
364 if let Some(rest) = trimmed.strip_prefix("[ ] ") {
366 total_steps += 1;
367 if let Some(ref mut phase) = current_phase {
368 phase.steps.push(PlanStep {
369 number: phase.steps.len() + 1,
370 description: rest.to_string(),
371 details: None,
372 files: Vec::new(),
373 completed: false,
374 });
375 }
376 continue;
377 }
378
379 if let Some(rest) = trimmed.strip_prefix("[x] ").or_else(|| trimmed.strip_prefix("[X] ")) {
380 total_steps += 1;
381 completed_steps += 1;
382 if let Some(ref mut phase) = current_phase {
383 phase.steps.push(PlanStep {
384 number: phase.steps.len() + 1,
385 description: rest.to_string(),
386 details: None,
387 files: Vec::new(),
388 completed: true,
389 });
390 }
391 continue;
392 }
393
394 if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains('.') {
396 total_steps += 1;
397 if let Some(ref mut phase) = current_phase {
398 let desc = trimmed.split_once('.').map(|x| x.1).unwrap_or("").trim();
399 phase.steps.push(PlanStep {
400 number: phase.steps.len() + 1,
401 description: desc.to_string(),
402 details: None,
403 files: Vec::new(),
404 completed: false,
405 });
406 }
407 continue;
408 }
409
410 if trimmed.starts_with("- (") || trimmed.starts_with("- ?") {
412 open_questions.push(trimmed.trim_start_matches("- ").to_string());
413 }
414 }
415
416 if let Some(mut phase) = current_phase.take() {
418 phase.completed = phase.steps.iter().all(|s| s.completed);
419 phases.push(phase);
420 }
421
422 for phase in &mut phases {
424 phase.completed = !phase.steps.is_empty() && phase.steps.iter().all(|s| s.completed);
425 }
426
427 Self {
428 title,
429 summary,
430 file_path,
431 phases,
432 open_questions,
433 raw_content: content.to_string(),
434 total_steps,
435 completed_steps,
436 }
437 }
438
439 #[allow(
441 clippy::cast_sign_loss,
442 reason = "Intentional compatibility, platform, or test-only suppression."
443 )]
444 pub fn progress_percent(&self) -> u8 {
445 if self.total_steps == 0 {
446 0
447 } else {
448 ((self.completed_steps as f32 / self.total_steps as f32) * 100.0) as u8
449 }
450 }
451}
452
453#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
464#[serde(rename_all = "snake_case")]
465#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
466pub enum AppIconPreference {
467 #[default]
469 Dynamic,
470 Light,
472 Dark,
474 Clear,
476 Tinted,
478 #[serde(other)]
480 Unknown,
481}
482
483impl AppIconPreference {
484 pub const ALL: [Self; 5] = [Self::Dynamic, Self::Light, Self::Dark, Self::Clear, Self::Tinted];
486
487 #[must_use]
489 pub const fn id(self) -> &'static str {
490 match self {
491 Self::Dynamic | Self::Unknown => "dynamic",
492 Self::Light => "light",
493 Self::Dark => "dark",
494 Self::Clear => "clear",
495 Self::Tinted => "tinted",
496 }
497 }
498
499 #[must_use]
501 pub fn from_id(id: Option<&str>) -> Self {
502 match id.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
503 Some("light") => Self::Light,
504 Some("dark") => Self::Dark,
505 Some("clear") => Self::Clear,
506 Some("tinted") => Self::Tinted,
507 _ => Self::Dynamic,
508 }
509 }
510
511 #[must_use]
514 pub const fn resolve(self, dark_mode: bool) -> Self {
515 match self {
516 Self::Dynamic | Self::Unknown => {
517 if dark_mode {
518 Self::Dark
519 } else {
520 Self::Light
521 }
522 }
523 pinned => pinned,
524 }
525 }
526
527 #[must_use]
533 pub const fn is_dark(self, dark_mode: bool) -> bool {
534 matches!(self.resolve(dark_mode), Self::Dark)
535 }
536}
537
538#[cfg(test)]
539mod tests {
540 use super::PlanContent;
541 use crate::ui_protocol::AppIconPreference;
542
543 #[test]
544 fn parses_sparse_summary_section_without_displaying_section_label() {
545 let plan = PlanContent::from_markdown(
546 "Implementation Plan".to_string(),
547 "Summary\nFocus on startup latency.\n\n1. Measure startup -> src/startup.rs\n2. Defer refresh -> src/update.rs\n\nValidation\n- cargo check --locked",
548 None,
549 );
550
551 assert_eq!(plan.summary, "Focus on startup latency.");
552 assert_eq!(plan.total_steps, 2);
553 assert_eq!(plan.raw_content.lines().next(), Some("Summary"));
554 }
555
556 #[test]
557 fn app_icon_default_is_dynamic() {
558 assert_eq!(AppIconPreference::default(), AppIconPreference::Dynamic);
559 }
560
561 #[test]
562 fn app_icon_ids_round_trip_through_from_id() {
563 for variant in AppIconPreference::ALL {
564 assert_eq!(AppIconPreference::from_id(Some(variant.id())), variant, "{}", variant.id());
565 }
566 assert_eq!(AppIconPreference::from_id(None), AppIconPreference::Dynamic);
567 }
568
569 #[test]
570 fn app_icon_from_id_is_forgiving_about_case_and_whitespace() {
571 assert_eq!(AppIconPreference::from_id(Some(" Light ")), AppIconPreference::Light);
572 assert_eq!(AppIconPreference::from_id(Some("DARK")), AppIconPreference::Dark);
573 assert_eq!(AppIconPreference::from_id(Some("DYNAMIC")), AppIconPreference::Dynamic);
574 assert_eq!(AppIconPreference::from_id(Some(" Clear ")), AppIconPreference::Clear);
575 assert_eq!(AppIconPreference::from_id(Some("TINTED")), AppIconPreference::Tinted);
576 }
577
578 #[test]
579 fn app_icon_from_id_falls_back_to_dynamic_for_unknown_or_empty() {
580 for raw in ["", " ", "twilight", "bright", "dynamic-v2"] {
581 assert_eq!(AppIconPreference::from_id(Some(raw)), AppIconPreference::Dynamic, "{raw:?}");
582 }
583 }
584
585 #[test]
586 fn app_icon_is_dark_pins_light_and_dark_but_follows_mode_for_dynamic() {
587 for dark_mode in [false, true] {
588 assert!(!AppIconPreference::Light.is_dark(dark_mode), "light pins light art");
589 assert!(AppIconPreference::Dark.is_dark(dark_mode), "dark pins dark art");
590 assert_eq!(AppIconPreference::Dynamic.is_dark(dark_mode), dark_mode, "dynamic follows mode");
591 assert_eq!(AppIconPreference::Unknown.is_dark(dark_mode), dark_mode, "unknown follows mode");
592 assert!(
593 !AppIconPreference::Clear.is_dark(dark_mode),
594 "clear pins its own glass art, never the dark flat tile"
595 );
596 assert!(
597 !AppIconPreference::Tinted.is_dark(dark_mode),
598 "tinted pins its own slate art, never the dark flat tile"
599 );
600 }
601 }
602
603 #[test]
604 fn app_icon_resolve_pins_explicit_variants_and_follows_mode_for_dynamic() {
605 for dark_mode in [false, true] {
606 for pinned in [
607 AppIconPreference::Light,
608 AppIconPreference::Dark,
609 AppIconPreference::Clear,
610 AppIconPreference::Tinted,
611 ] {
612 assert_eq!(pinned.resolve(dark_mode), pinned, "{pinned:?}");
613 }
614 let expected = if dark_mode {
615 AppIconPreference::Dark
616 } else {
617 AppIconPreference::Light
618 };
619 assert_eq!(AppIconPreference::Dynamic.resolve(dark_mode), expected);
620 assert_eq!(AppIconPreference::Unknown.resolve(dark_mode), expected);
621 }
622 }
623
624 #[test]
625 fn app_icon_serde_uses_ids_and_catches_unknown_variants() {
626 assert_eq!(serde_json::from_str::<AppIconPreference>("\"light\"").unwrap(), AppIconPreference::Light);
627 assert_eq!(serde_json::from_str::<AppIconPreference>("\"clear\"").unwrap(), AppIconPreference::Clear);
628 assert_eq!(serde_json::from_str::<AppIconPreference>("\"tinted\"").unwrap(), AppIconPreference::Tinted);
629 assert_eq!(
630 serde_json::from_str::<AppIconPreference>("\"future-variant\"").unwrap(),
631 AppIconPreference::Unknown
632 );
633 assert_eq!(serde_json::to_string(&AppIconPreference::Dynamic).unwrap(), "\"dynamic\"");
634 assert_eq!(serde_json::to_string(&AppIconPreference::Clear).unwrap(), "\"clear\"");
635 assert_eq!(serde_json::to_string(&AppIconPreference::Tinted).unwrap(), "\"tinted\"");
636 assert_eq!(AppIconPreference::Unknown.id(), "dynamic");
637 }
638}