返回 CodeWhale
user_input.rs
根目录 / crates / tui / src / tools / user_input.rs
1 //! Tool and types for requesting user input via the TUI.
2
3 use super::spec::{
4 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
5 };
6 use async_trait::async_trait;
7 use serde::{Deserialize, Serialize};
8 use serde_json::{Value, json};
9
10 #[derive(Debug, Clone, Serialize, Deserialize)]
11 pub struct UserInputOption {
12 pub label: String,
13 pub description: String,
14 }
15
16 #[derive(Debug, Clone, Serialize, Deserialize)]
17 pub struct UserInputQuestion {
18 pub header: String,
19 pub id: String,
20 pub question: String,
21 pub options: Vec<UserInputOption>,
22 /// When `true`, the modal offers a free-text "Other" response in addition
23 /// to the fixed options. Defaults to `false` for backwards compatibility
24 /// (older payloads omitting the field get the previous behavior).
25 #[serde(default)]
26 pub allow_free_text: bool,
27 /// When `true`, the user may select more than one option before confirming.
28 #[serde(default)]
29 pub multi_select: bool,
30 }
31
32 #[derive(Debug, Clone, Serialize, Deserialize)]
33 pub struct UserInputRequest {
34 pub questions: Vec<UserInputQuestion>,
35 }
36
37 impl UserInputRequest {
38 pub fn from_value(value: &Value) -> Result<Self, ToolError> {
39 let request: UserInputRequest = serde_json::from_value(value.clone()).map_err(|e| {
40 ToolError::invalid_input(format!("Invalid request_user_input payload: {e}"))
41 })?;
42 request.validate()?;
43 Ok(request)
44 }
45
46 pub fn validate(&self) -> Result<(), ToolError> {
47 if self.questions.is_empty() {
48 return Err(ToolError::invalid_input(
49 "request_user_input.questions must be non-empty",
50 ));
51 }
52 if self.questions.len() > 3 {
53 return Err(ToolError::invalid_input(
54 "request_user_input.questions must contain 1 to 3 items",
55 ));
56 }
57 for q in &self.questions {
58 if q.header.trim().is_empty() {
59 return Err(ToolError::invalid_input(
60 "request_user_input.questions.header cannot be empty",
61 ));
62 }
63 if q.id.trim().is_empty() {
64 return Err(ToolError::invalid_input(
65 "request_user_input.questions.id cannot be empty",
66 ));
67 }
68 if q.question.trim().is_empty() {
69 return Err(ToolError::invalid_input(
70 "request_user_input.questions.question cannot be empty",
71 ));
72 }
73 if q.options.len() < 2 || q.options.len() > 4 {
74 return Err(ToolError::invalid_input(
75 "request_user_input.questions.options must contain 2 to 4 items",
76 ));
77 }
78 for opt in &q.options {
79 if opt.label.trim().is_empty() {
80 return Err(ToolError::invalid_input(
81 "request_user_input option label cannot be empty",
82 ));
83 }
84 if opt.description.trim().is_empty() {
85 return Err(ToolError::invalid_input(
86 "request_user_input option description cannot be empty",
87 ));
88 }
89 }
90 }
91 Ok(())
92 }
93 }
94
95 #[derive(Debug, Clone, Serialize, Deserialize)]
96 pub struct UserInputAnswer {
97 pub id: String,
98 pub label: String,
99 pub value: String,
100 }
101
102 #[derive(Debug, Clone, Serialize, Deserialize)]
103 pub struct UserInputResponse {
104 pub answers: Vec<UserInputAnswer>,
105 }
106
107 pub struct RequestUserInputTool;
108
109 #[async_trait]
110 impl ToolSpec for RequestUserInputTool {
111 fn name(&self) -> &'static str {
112 "request_user_input"
113 }
114
115 fn description(&self) -> &'static str {
116 "Ask the user 1-3 short questions and return their selections."
117 }
118
119 fn input_schema(&self) -> Value {
120 json!({
121 "type": "object",
122 "properties": {
123 "questions": {
124 "type": "array",
125 "items": {
126 "type": "object",
127 "properties": {
128 "header": { "type": "string" },
129 "id": { "type": "string" },
130 "question": { "type": "string" },
131 "options": {
132 "type": "array",
133 "items": {
134 "type": "object",
135 "properties": {
136 "label": { "type": "string" },
137 "description": { "type": "string" }
138 },
139 "required": ["label", "description"]
140 },
141 "minItems": 2,
142 "maxItems": 4
143 },
144 "allow_free_text": {
145 "type": "boolean",
146 "description": "When true, also offer a free-text 'Other' response. Defaults to false.",
147 "default": false
148 },
149 "multi_select": {
150 "type": "boolean",
151 "description": "When true, allow selecting more than one option. Defaults to false.",
152 "default": false
153 }
154 },
155 "required": ["header", "id", "question", "options"]
156 },
157 "minItems": 1,
158 "maxItems": 3
159 }
160 },
161 "required": ["questions"]
162 })
163 }
164
165 fn capabilities(&self) -> Vec<ToolCapability> {
166 vec![ToolCapability::ReadOnly]
167 }
168
169 fn approval_requirement(&self) -> ApprovalRequirement {
170 ApprovalRequirement::Auto
171 }
172
173 async fn execute(
174 &self,
175 _input: Value,
176 _context: &ToolContext,
177 ) -> Result<ToolResult, ToolError> {
178 Err(ToolError::execution_failed(
179 "request_user_input must be handled by the engine",
180 ))
181 }
182 }
183
184 #[cfg(test)]
185 mod tests {
186 use super::*;
187
188 #[test]
189 fn validates_request_shape() {
190 let request = UserInputRequest {
191 questions: vec![UserInputQuestion {
192 header: "Pick".to_string(),
193 id: "choice".to_string(),
194 question: "Which option?".to_string(),
195 options: vec![
196 UserInputOption {
197 label: "A".to_string(),
198 description: "Option A".to_string(),
199 },
200 UserInputOption {
201 label: "B".to_string(),
202 description: "Option B".to_string(),
203 },
204 ],
205 allow_free_text: false,
206 multi_select: false,
207 }],
208 };
209 assert!(request.validate().is_ok());
210 }
211
212 #[test]
213 fn from_value_accepts_four_options_and_flags() {
214 // Mirrors the json!-literal style used in tools/subagent/tests.rs and
215 // exercises the schema-loosening from issue #3102: 4 options (was capped
216 // at 3) plus the new allow_free_text / multi_select flags.
217 let input = json!({
218 "questions": [{
219 "header": "Scope",
220 "id": "scope",
221 "question": "Which surfaces should this change affect?",
222 "options": [
223 { "label": "TUI", "description": "Visible modal flow only" },
224 { "label": "Headless", "description": "Protocol event only" },
225 { "label": "All surfaces", "description": "TUI and headless" },
226 { "label": "CLI", "description": "Command-line surface" }
227 ],
228 "allow_free_text": true,
229 "multi_select": true
230 }]
231 });
232 let request = UserInputRequest::from_value(&input).expect("4 options + flags parse");
233 assert_eq!(request.questions.len(), 1);
234 assert_eq!(request.questions[0].options.len(), 4);
235 assert!(request.questions[0].allow_free_text);
236 assert!(request.questions[0].multi_select);
237 }
238
239 #[test]
240 fn from_value_defaults_flags_when_omitted() {
241 // Backwards compatibility: a legacy payload omitting the new boolean
242 // fields must still parse, defaulting both to false.
243 let input = json!({
244 "questions": [{
245 "header": "Pick",
246 "id": "choice",
247 "question": "Which?",
248 "options": [
249 { "label": "A", "description": "a" },
250 { "label": "B", "description": "b" }
251 ]
252 }]
253 });
254 let request = UserInputRequest::from_value(&input).expect("legacy payload parses");
255 assert!(!request.questions[0].allow_free_text);
256 assert!(!request.questions[0].multi_select);
257 }
258
259 #[test]
260 fn rejects_five_options() {
261 let input = json!({
262 "questions": [{
263 "header": "Pick",
264 "id": "choice",
265 "question": "Which?",
266 "options": [
267 { "label": "A", "description": "a" },
268 { "label": "B", "description": "b" },
269 { "label": "C", "description": "c" },
270 { "label": "D", "description": "d" },
271 { "label": "E", "description": "e" }
272 ]
273 }]
274 });
275 let err = UserInputRequest::from_value(&input).expect_err("5 options must fail");
276 assert!(err.to_string().contains("2 to 4 items"));
277 }
278
279 fn yes_no_question(header: &str, id: &str) -> UserInputQuestion {
280 UserInputQuestion {
281 header: header.to_string(),
282 id: id.to_string(),
283 question: "?".to_string(),
284 options: vec![
285 UserInputOption {
286 label: "A".to_string(),
287 description: "A".to_string(),
288 },
289 UserInputOption {
290 label: "B".to_string(),
291 description: "B".to_string(),
292 },
293 ],
294 allow_free_text: false,
295 multi_select: false,
296 }
297 }
298
299 #[test]
300 fn rejects_too_many_questions() {
301 let request = UserInputRequest {
302 questions: vec![
303 yes_no_question("Q1", "q1"),
304 yes_no_question("Q2", "q2"),
305 yes_no_question("Q3", "q3"),
306 yes_no_question("Q4", "q4"),
307 ],
308 };
309 assert!(request.validate().is_err());
310 }
311 }
312
312 lines RUST