Skip to main content

zeroclaw_runtime/onboard/ui/
term.rs

1//! Terminal `OnboardUi` backend built on `dialoguer`.
2//!
3//! Dialoguer is blocking, so every async trait method wraps its call in
4//! `tokio::task::spawn_blocking`. Each prompt uses dialoguer's `_opt`
5//! variants where available: `Esc` makes dialoguer return `None`, which we
6//! map to `Answer::Back` so the orchestrator can rewind.
7
8use anyhow::Result;
9use async_trait::async_trait;
10use dialoguer::{Confirm, Editor, FuzzySelect, Input, Password};
11use zeroclaw_config::traits::{Answer, OnboardUi, SelectItem};
12
13pub struct TermUi;
14
15#[async_trait]
16impl OnboardUi for TermUi {
17    async fn confirm(&mut self, prompt: &str, default: bool) -> Result<Answer<bool>> {
18        let prompt = prompt.to_string();
19        tokio::task::spawn_blocking(move || -> Result<Answer<bool>> {
20            let v = Confirm::new()
21                .with_prompt(prompt)
22                .default(default)
23                .interact_opt()?;
24            Ok(match v {
25                Some(b) => Answer::Value(b),
26                None => Answer::Back,
27            })
28        })
29        .await?
30    }
31
32    async fn string(
33        &mut self,
34        prompt: &str,
35        current: Option<&str>,
36        placeholder: Option<&str>,
37    ) -> Result<Answer<String>> {
38        // dialoguer 0.12 dropped `_opt` variants for Input, so Esc on a text
39        // prompt is a no-op here (the ratatui backend supports Back fully).
40        // The main navigation points — Confirm / FuzzySelect — still honor
41        // Esc, which is where Back matters most.
42        let prompt = prompt.to_string();
43        // dialoguer doesn't distinguish a pre-filled buffer from a
44        // shown-but-not-committed default; either way the value lands
45        // in `input.default(...)` and Enter accepts it. Prefer `current`
46        // when set, otherwise fall back to the placeholder so dialoguer
47        // users at least see the default and can press Enter to take it.
48        let default = current
49            .map(ToOwned::to_owned)
50            .or_else(|| placeholder.map(ToOwned::to_owned));
51        tokio::task::spawn_blocking(move || -> Result<Answer<String>> {
52            let mut input = Input::<String>::new().with_prompt(prompt).allow_empty(true);
53            if let Some(value) = default {
54                input = input.default(value);
55            }
56            Ok(Answer::Value(input.interact_text()?))
57        })
58        .await?
59    }
60
61    async fn secret(&mut self, prompt: &str, has_current: bool) -> Result<Answer<Option<String>>> {
62        let prompt = prompt.to_string();
63        tokio::task::spawn_blocking(move || -> Result<Answer<Option<String>>> {
64            if has_current {
65                let replace = Confirm::new()
66                    .with_prompt(format!("{prompt} (stored, replace?)"))
67                    .default(false)
68                    .interact_opt()?;
69                match replace {
70                    Some(false) => return Ok(Answer::Value(None)),
71                    None => return Ok(Answer::Back),
72                    Some(true) => {}
73                }
74            }
75            let value = Password::new().with_prompt(prompt).interact()?;
76            Ok(Answer::Value(Some(value)))
77        })
78        .await?
79    }
80
81    async fn select(
82        &mut self,
83        prompt: &str,
84        items: &[SelectItem],
85        current: Option<usize>,
86    ) -> Result<Answer<usize>> {
87        let prompt = prompt.to_string();
88        let labels: Vec<String> = items
89            .iter()
90            .map(|item| match &item.badge {
91                Some(badge) => format!("{}  {badge}", item.label),
92                None => item.label.clone(),
93            })
94            .collect();
95        tokio::task::spawn_blocking(move || -> Result<Answer<usize>> {
96            let mut select = FuzzySelect::new().with_prompt(prompt).items(&labels);
97            if let Some(index) = current {
98                select = select.default(index);
99            }
100            Ok(match select.interact_opt()? {
101                Some(i) => Answer::Value(i),
102                None => Answer::Back,
103            })
104        })
105        .await?
106    }
107
108    async fn editor(&mut self, hint: &str, initial: &str) -> Result<Answer<String>> {
109        let hint = hint.to_string();
110        let buffer = initial.to_string();
111        tokio::task::spawn_blocking(move || -> Result<Answer<String>> {
112            if !hint.is_empty() {
113                println!("  {hint}");
114            }
115            // Editor close-without-save returns None — treat as Back so users
116            // who bail out of $EDITOR can rewind instead of accepting the
117            // unchanged buffer silently.
118            match Editor::new().edit(&buffer)? {
119                Some(edited) => Ok(Answer::Value(edited)),
120                None => Ok(Answer::Back),
121            }
122        })
123        .await?
124    }
125
126    fn heading(&mut self, level: u8, text: &str) {
127        // Render like a Markdown heading: `# Section`, `## Subsection`.
128        // Section gets a horizontal rule underneath so visual separation
129        // between phases is unambiguous.
130        let marker = "#".repeat(level.clamp(1, 6) as usize);
131        println!("\n{marker} {text}");
132        if level == 1 {
133            let rule_width = text.chars().count().saturating_add(2).max(20);
134            println!("{}", "─".repeat(rule_width));
135        }
136    }
137
138    fn note(&mut self, msg: &str) {
139        println!("\n{msg}\n");
140    }
141
142    fn status(&mut self, msg: &str) {
143        println!("  {msg}");
144    }
145
146    fn warn(&mut self, msg: &str) {
147        eprintln!("⚠️  {msg}");
148    }
149}