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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
392#[serde(rename_all = "snake_case")]
393#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
394pub enum AppIconPreference {
395 #[default]
397 Dynamic,
398 Light,
400 Dark,
402 Clear,
404 Tinted,
406 #[serde(other)]
408 Unknown,
409}
410
411impl AppIconPreference {
412 pub const ALL: [Self; 5] = [Self::Dynamic, Self::Light, Self::Dark, Self::Clear, Self::Tinted];
414
415 #[must_use]
417 pub const fn id(self) -> &'static str {
418 match self {
419 Self::Dynamic | Self::Unknown => "dynamic",
420 Self::Light => "light",
421 Self::Dark => "dark",
422 Self::Clear => "clear",
423 Self::Tinted => "tinted",
424 }
425 }
426
427 #[must_use]
429 pub fn from_id(id: Option<&str>) -> Self {
430 match id.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
431 Some("light") => Self::Light,
432 Some("dark") => Self::Dark,
433 Some("clear") => Self::Clear,
434 Some("tinted") => Self::Tinted,
435 _ => Self::Dynamic,
436 }
437 }
438
439 #[must_use]
442 pub const fn resolve(self, dark_mode: bool) -> Self {
443 match self {
444 Self::Dynamic | Self::Unknown => {
445 if dark_mode {
446 Self::Dark
447 } else {
448 Self::Light
449 }
450 }
451 pinned => pinned,
452 }
453 }
454
455 #[must_use]
461 pub const fn is_dark(self, dark_mode: bool) -> bool {
462 matches!(self.resolve(dark_mode), Self::Dark)
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::PlanContent;
469 use crate::ui_protocol::AppIconPreference;
470
471 #[test]
472 fn parses_sparse_summary_section_without_displaying_section_label() {
473 let plan = PlanContent::from_markdown(
474 "Implementation Plan".to_string(),
475 "Summary\nFocus on startup latency.\n\n1. Measure startup -> src/startup.rs\n2. Defer refresh -> src/update.rs\n\nValidation\n- cargo check --locked",
476 None,
477 );
478
479 assert_eq!(plan.summary, "Focus on startup latency.");
480 assert_eq!(plan.total_steps, 2);
481 assert_eq!(plan.raw_content.lines().next(), Some("Summary"));
482 }
483
484 #[test]
485 fn app_icon_default_is_dynamic() {
486 assert_eq!(AppIconPreference::default(), AppIconPreference::Dynamic);
487 }
488
489 #[test]
490 fn app_icon_ids_round_trip_through_from_id() {
491 for variant in AppIconPreference::ALL {
492 assert_eq!(AppIconPreference::from_id(Some(variant.id())), variant, "{}", variant.id());
493 }
494 assert_eq!(AppIconPreference::from_id(None), AppIconPreference::Dynamic);
495 }
496
497 #[test]
498 fn app_icon_from_id_is_forgiving_about_case_and_whitespace() {
499 assert_eq!(AppIconPreference::from_id(Some(" Light ")), AppIconPreference::Light);
500 assert_eq!(AppIconPreference::from_id(Some("DARK")), AppIconPreference::Dark);
501 assert_eq!(AppIconPreference::from_id(Some("DYNAMIC")), AppIconPreference::Dynamic);
502 assert_eq!(AppIconPreference::from_id(Some(" Clear ")), AppIconPreference::Clear);
503 assert_eq!(AppIconPreference::from_id(Some("TINTED")), AppIconPreference::Tinted);
504 }
505
506 #[test]
507 fn app_icon_from_id_falls_back_to_dynamic_for_unknown_or_empty() {
508 for raw in ["", " ", "twilight", "bright", "dynamic-v2"] {
509 assert_eq!(AppIconPreference::from_id(Some(raw)), AppIconPreference::Dynamic, "{raw:?}");
510 }
511 }
512
513 #[test]
514 fn app_icon_is_dark_pins_light_and_dark_but_follows_mode_for_dynamic() {
515 for dark_mode in [false, true] {
516 assert!(!AppIconPreference::Light.is_dark(dark_mode), "light pins light art");
517 assert!(AppIconPreference::Dark.is_dark(dark_mode), "dark pins dark art");
518 assert_eq!(AppIconPreference::Dynamic.is_dark(dark_mode), dark_mode, "dynamic follows mode");
519 assert_eq!(AppIconPreference::Unknown.is_dark(dark_mode), dark_mode, "unknown follows mode");
520 assert!(
521 !AppIconPreference::Clear.is_dark(dark_mode),
522 "clear pins its own glass art, never the dark flat tile"
523 );
524 assert!(
525 !AppIconPreference::Tinted.is_dark(dark_mode),
526 "tinted pins its own slate art, never the dark flat tile"
527 );
528 }
529 }
530
531 #[test]
532 fn app_icon_resolve_pins_explicit_variants_and_follows_mode_for_dynamic() {
533 for dark_mode in [false, true] {
534 for pinned in [
535 AppIconPreference::Light,
536 AppIconPreference::Dark,
537 AppIconPreference::Clear,
538 AppIconPreference::Tinted,
539 ] {
540 assert_eq!(pinned.resolve(dark_mode), pinned, "{pinned:?}");
541 }
542 let expected = if dark_mode {
543 AppIconPreference::Dark
544 } else {
545 AppIconPreference::Light
546 };
547 assert_eq!(AppIconPreference::Dynamic.resolve(dark_mode), expected);
548 assert_eq!(AppIconPreference::Unknown.resolve(dark_mode), expected);
549 }
550 }
551
552 #[test]
553 fn app_icon_serde_uses_ids_and_catches_unknown_variants() {
554 assert_eq!(serde_json::from_str::<AppIconPreference>("\"light\"").unwrap(), AppIconPreference::Light);
555 assert_eq!(serde_json::from_str::<AppIconPreference>("\"clear\"").unwrap(), AppIconPreference::Clear);
556 assert_eq!(serde_json::from_str::<AppIconPreference>("\"tinted\"").unwrap(), AppIconPreference::Tinted);
557 assert_eq!(
558 serde_json::from_str::<AppIconPreference>("\"future-variant\"").unwrap(),
559 AppIconPreference::Unknown
560 );
561 assert_eq!(serde_json::to_string(&AppIconPreference::Dynamic).unwrap(), "\"dynamic\"");
562 assert_eq!(serde_json::to_string(&AppIconPreference::Clear).unwrap(), "\"clear\"");
563 assert_eq!(serde_json::to_string(&AppIconPreference::Tinted).unwrap(), "\"tinted\"");
564 assert_eq!(AppIconPreference::Unknown.id(), "dynamic");
565 }
566}