rustyclaw_tui/components/user_prompt_dialog.rs
1// ── User prompt dialog — agent asks user a question ─────────────────────────
2
3use crate::theme;
4use iocraft::prelude::*;
5use rustyclaw_core::user_prompt_types::PromptType;
6
7#[derive(Default, Props)]
8pub struct UserPromptDialogProps {
9 /// The question title from the agent.
10 pub title: String,
11 /// Optional longer description.
12 pub description: String,
13 /// Current user input text (for text input types).
14 pub input: String,
15 /// Selected option index (for Select/MultiSelect).
16 pub selected: usize,
17 /// The prompt type (serialized for prop passing).
18 pub prompt_type: Option<PromptType>,
19}
20
21#[component]
22pub fn UserPromptDialog(props: &UserPromptDialogProps) -> impl Into<AnyElement<'static>> {
23 let has_desc = !props.description.is_empty();
24 let cursor = "▏";
25
26 // Determine what kind of input to show
27 let prompt_type = props.prompt_type.clone().unwrap_or(PromptType::TextInput {
28 placeholder: None,
29 default: None,
30 });
31
32 element! {
33 View(
34 width: 100pct,
35 height: 100pct,
36 justify_content: JustifyContent::Center,
37 align_items: AlignItems::Center,
38 ) {
39 View(
40 width: 70,
41 flex_direction: FlexDirection::Column,
42 border_style: BorderStyle::Round,
43 border_color: theme::ACCENT_BRIGHT,
44 background_color: theme::BG_SURFACE,
45 padding_left: 2,
46 padding_right: 2,
47 padding_top: 1,
48 padding_bottom: 1,
49 ) {
50 // Title
51 Text(
52 content: "❓ Agent Question",
53 color: theme::ACCENT_BRIGHT,
54 weight: Weight::Bold,
55 )
56
57 View(height: 1)
58
59 // Question text
60 Text(
61 content: props.title.clone(),
62 color: theme::TEXT,
63 weight: Weight::Bold,
64 wrap: TextWrap::Wrap,
65 )
66
67 // Description (if any)
68 #(if has_desc {
69 element! {
70 View(margin_top: 1) {
71 Text(
72 content: props.description.clone(),
73 color: theme::MUTED,
74 wrap: TextWrap::Wrap,
75 )
76 }
77 }.into_any()
78 } else {
79 element! { View() }.into_any()
80 })
81
82 View(height: 1)
83
84 // Render input based on prompt type
85 #(match &prompt_type {
86 PromptType::Select { options, .. } => {
87 element! {
88 View(flex_direction: FlexDirection::Column) {
89 Text(content: "Select an option (↑/↓ to navigate, Enter to select):", color: theme::MUTED)
90 View(height: 1)
91 #(options.iter().enumerate().map(|(i, opt)| {
92 let is_selected = i == props.selected;
93 let prefix = if is_selected { "▶ " } else { " " };
94 let fg = if is_selected { theme::ACCENT_BRIGHT } else { theme::TEXT };
95 element! {
96 View(key: i as u64, flex_direction: FlexDirection::Column) {
97 Text(
98 content: format!("{}{}", prefix, opt.label),
99 color: fg,
100 weight: if is_selected { Weight::Bold } else { Weight::Normal },
101 )
102 #(if let Some(ref desc) = opt.description {
103 element! {
104 Text(
105 content: format!(" {}", desc),
106 color: theme::MUTED,
107 )
108 }.into_any()
109 } else {
110 element! { View() }.into_any()
111 })
112 }
113 }
114 }))
115 }
116 }.into_any()
117 }
118 PromptType::Confirm { default: _ } => {
119 let yes_selected = props.selected == 0;
120 element! {
121 View(flex_direction: FlexDirection::Row, gap: 2u32) {
122 Text(
123 content: if yes_selected { "▶ Yes" } else { " Yes" },
124 color: if yes_selected { theme::SUCCESS } else { theme::TEXT },
125 weight: if yes_selected { Weight::Bold } else { Weight::Normal },
126 )
127 Text(
128 content: if !yes_selected { "▶ No" } else { " No" },
129 color: if !yes_selected { theme::ERROR } else { theme::TEXT },
130 weight: if !yes_selected { Weight::Bold } else { Weight::Normal },
131 )
132 }
133 }.into_any()
134 }
135 PromptType::TextInput { placeholder, .. } => {
136 let placeholder_text = placeholder.clone();
137 let display = if props.input.is_empty() {
138 placeholder_text.unwrap_or_default()
139 } else {
140 props.input.clone()
141 };
142 let is_placeholder = props.input.is_empty();
143 element! {
144 View(flex_direction: FlexDirection::Column) {
145 Text(content: "Your answer:", color: theme::MUTED)
146 View(
147 flex_direction: FlexDirection::Row,
148 border_style: BorderStyle::Single,
149 border_color: theme::ACCENT,
150 padding_left: 1,
151 padding_right: 1,
152 min_height: 1u32,
153 ) {
154 Text(
155 content: format!("{}{}", display, cursor),
156 color: if is_placeholder { theme::MUTED } else { theme::TEXT },
157 )
158 }
159 }
160 }.into_any()
161 }
162 PromptType::Form { .. } => {
163 // Form: show generic text input for now
164 element! {
165 View(flex_direction: FlexDirection::Column) {
166 Text(content: "Your answer:", color: theme::MUTED)
167 View(
168 flex_direction: FlexDirection::Row,
169 border_style: BorderStyle::Single,
170 border_color: theme::ACCENT,
171 padding_left: 1,
172 padding_right: 1,
173 min_height: 1u32,
174 ) {
175 Text(
176 content: format!("{}{}", props.input, cursor),
177 color: theme::TEXT,
178 )
179 }
180 }
181 }.into_any()
182 }
183 PromptType::MultiSelect { options, .. } => {
184 // For multi-select, we'd need a Vec<bool> for checked state
185 // For now, render as single-select
186 element! {
187 View(flex_direction: FlexDirection::Column) {
188 Text(content: "Select options (Space to toggle, Enter to confirm):", color: theme::MUTED)
189 View(height: 1)
190 #(options.iter().enumerate().map(|(i, opt)| {
191 let is_selected = i == props.selected;
192 let prefix = if is_selected { "▶ [ ] " } else { " [ ] " };
193 let fg = if is_selected { theme::ACCENT_BRIGHT } else { theme::TEXT };
194 element! {
195 View(key: i as u64) {
196 Text(
197 content: format!("{}{}", prefix, opt.label),
198 color: fg,
199 )
200 }
201 }
202 }))
203 }
204 }.into_any()
205 }
206 })
207
208 View(height: 1)
209
210 // Hint based on prompt type
211 #(match &prompt_type {
212 PromptType::Select { .. } => {
213 element! {
214 Text(content: "↑↓ navigate · Enter ↩ select · Esc dismiss", color: theme::MUTED)
215 }.into_any()
216 }
217 PromptType::Confirm { .. } => {
218 element! {
219 Text(content: "←→ or Y/N · Enter ↩ confirm · Esc dismiss", color: theme::MUTED)
220 }.into_any()
221 }
222 _ => {
223 element! {
224 Text(content: "Enter ↩ submit · Esc dismiss", color: theme::MUTED)
225 }.into_any()
226 }
227 })
228 }
229 }
230 }
231}