Skip to main content

agentmux_srv/backend/
userinput.rs

1#![allow(dead_code)]
2// Copyright 2025, Command Line Inc.
3// Copyright 2025-2026, AgentMux Corp.
4// SPDX-License-Identifier: Apache-2.0
5
6//! User input: modal dialogs for interactive user prompts.
7//! Port of Go's pkg/userinput/userinput.go.
8//!
9//! Provides request/response types for:
10//! - Text input prompts
11//! - Confirmation dialogs
12//! - Checkbox-bearing dialogs
13//!
14//! The actual display is handled by the frontend (the CEF renderer);
15//! this module defines the wire format and a registry for pending requests.
16
17
18use std::collections::HashMap;
19use std::sync::Mutex;
20use std::time::Duration;
21
22use serde::{Deserialize, Serialize};
23use tokio::sync::oneshot;
24use tokio::time;
25
26// ---- Request/Response types ----
27
28/// Request for user input, sent to the frontend for display.
29/// Matches Go's `userinput.UserInputRequest` JSON tags.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct UserInputRequest {
32    /// Unique request ID.
33    #[serde(rename = "requestid")]
34    pub request_id: String,
35
36    /// Prompt text to display.
37    #[serde(rename = "querytext")]
38    pub query_text: String,
39
40    /// Expected response type: "text", "confirm".
41    #[serde(rename = "responsetype")]
42    pub response_type: String,
43
44    /// Dialog title.
45    pub title: String,
46
47    /// Whether to render title as markdown.
48    #[serde(default)]
49    pub markdown: bool,
50
51    /// Timeout in milliseconds (0 = no timeout).
52    #[serde(rename = "timeoutms", default)]
53    pub timeout_ms: i64,
54
55    /// Optional checkbox label.
56    #[serde(rename = "checkboxmsg", default, skip_serializing_if = "String::is_empty")]
57    pub checkbox_msg: String,
58
59    /// Whether the input text is public (can be logged).
60    #[serde(rename = "publictext", default)]
61    pub public_text: bool,
62
63    /// Custom "OK" button label.
64    #[serde(rename = "oklabel", default, skip_serializing_if = "String::is_empty")]
65    pub ok_label: String,
66
67    /// Custom "Cancel" button label.
68    #[serde(rename = "cancellabel", default, skip_serializing_if = "String::is_empty")]
69    pub cancel_label: String,
70}
71
72/// Response from the frontend after user interaction.
73/// Matches Go's `userinput.UserInputResponse` JSON tags.
74#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75pub struct UserInputResponse {
76    /// Response type.
77    #[serde(rename = "type", default)]
78    pub response_type: String,
79
80    /// Request ID this responds to.
81    #[serde(rename = "requestid")]
82    pub request_id: String,
83
84    /// Text input value (for text responses).
85    #[serde(default, skip_serializing_if = "String::is_empty")]
86    pub text: String,
87
88    /// Confirmation result (for confirm responses).
89    #[serde(default)]
90    pub confirm: bool,
91
92    /// Error message (if the dialog was cancelled or errored).
93    #[serde(rename = "errormsg", default, skip_serializing_if = "String::is_empty")]
94    pub error_msg: String,
95
96    /// Checkbox state.
97    #[serde(rename = "checkboxstat", default)]
98    pub checkbox_stat: bool,
99}
100
101impl UserInputResponse {
102    /// Check if the response indicates an error.
103    pub fn is_error(&self) -> bool {
104        !self.error_msg.is_empty()
105    }
106
107    /// Check if the response is a confirmation.
108    pub fn is_confirmed(&self) -> bool {
109        self.confirm && self.error_msg.is_empty()
110    }
111}
112
113// ---- Response type constants ----
114
115pub const RESPONSE_TYPE_TEXT: &str = "text";
116pub const RESPONSE_TYPE_CONFIRM: &str = "confirm";
117
118// ---- Default timeout ----
119
120/// Default timeout for user input prompts (30 seconds).
121pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
122
123/// Timeout used for SSH passphrase/password prompts (60 seconds).
124pub const SSH_PROMPT_TIMEOUT: Duration = Duration::from_secs(60);
125
126// ---- Input handler registry ----
127
128/// Registry for pending user input requests.
129/// Routes responses from the frontend back to waiting async tasks.
130pub struct UserInputHandler {
131    channels: Mutex<HashMap<String, oneshot::Sender<UserInputResponse>>>,
132}
133
134impl UserInputHandler {
135    pub fn new() -> Self {
136        Self {
137            channels: Mutex::new(HashMap::new()),
138        }
139    }
140
141    /// Register a new user input request.
142    /// Returns the request ID and a receiver for the response.
143    pub fn register(&self, request_id: &str) -> oneshot::Receiver<UserInputResponse> {
144        let (tx, rx) = oneshot::channel();
145        let mut channels = self.channels.lock().unwrap();
146        channels.insert(request_id.to_string(), tx);
147        rx
148    }
149
150    /// Deliver a response from the frontend.
151    /// Returns error if no pending request with the given ID.
152    pub fn deliver(&self, response: UserInputResponse) -> Result<(), String> {
153        let mut channels = self.channels.lock().unwrap();
154        let tx = channels
155            .remove(&response.request_id)
156            .ok_or_else(|| format!("no pending request: {}", response.request_id))?;
157        tx.send(response)
158            .map_err(|_| "receiver dropped".to_string())
159    }
160
161    /// Cancel a pending request (removes it from the registry).
162    pub fn cancel(&self, request_id: &str) {
163        let mut channels = self.channels.lock().unwrap();
164        channels.remove(request_id);
165    }
166
167    /// Check if there's a pending request with the given ID.
168    pub fn has_pending(&self, request_id: &str) -> bool {
169        let channels = self.channels.lock().unwrap();
170        channels.contains_key(request_id)
171    }
172
173    /// Get count of pending requests.
174    pub fn pending_count(&self) -> usize {
175        let channels = self.channels.lock().unwrap();
176        channels.len()
177    }
178}
179
180impl Default for UserInputHandler {
181    fn default() -> Self {
182        Self::new()
183    }
184}
185
186/// Wait for a user input response with timeout.
187pub async fn wait_for_response(
188    rx: oneshot::Receiver<UserInputResponse>,
189    timeout: Duration,
190) -> Result<UserInputResponse, String> {
191    match time::timeout(timeout, rx).await {
192        Ok(Ok(response)) => {
193            if response.is_error() {
194                Err(response.error_msg)
195            } else {
196                Ok(response)
197            }
198        }
199        Ok(Err(_)) => Err("user input handler was cancelled".to_string()),
200        Err(_) => Err("timed out waiting for user input".to_string()),
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_user_input_request_serde() {
210        let req = UserInputRequest {
211            request_id: "req-1".to_string(),
212            query_text: "Enter password:".to_string(),
213            response_type: RESPONSE_TYPE_TEXT.to_string(),
214            title: "SSH Authentication".to_string(),
215            markdown: true,
216            timeout_ms: 60000,
217            checkbox_msg: String::new(),
218            public_text: false,
219            ok_label: String::new(),
220            cancel_label: String::new(),
221        };
222        let json = serde_json::to_string(&req).unwrap();
223        assert!(json.contains("\"requestid\":\"req-1\""));
224        assert!(json.contains("\"querytext\":\"Enter password:\""));
225        assert!(json.contains("\"responsetype\":\"text\""));
226        assert!(json.contains("\"timeoutms\":60000"));
227        // Empty fields should be omitted
228        assert!(!json.contains("\"checkboxmsg\""));
229        assert!(!json.contains("\"oklabel\""));
230
231        let parsed: UserInputRequest = serde_json::from_str(&json).unwrap();
232        assert_eq!(parsed.request_id, "req-1");
233        assert!(parsed.markdown);
234    }
235
236    #[test]
237    fn test_user_input_response_serde() {
238        let resp = UserInputResponse {
239            response_type: RESPONSE_TYPE_TEXT.to_string(),
240            request_id: "req-1".to_string(),
241            text: "my_password".to_string(),
242            confirm: false,
243            error_msg: String::new(),
244            checkbox_stat: false,
245        };
246        let json = serde_json::to_string(&resp).unwrap();
247        assert!(json.contains("\"requestid\":\"req-1\""));
248        assert!(json.contains("\"text\":\"my_password\""));
249        assert!(!json.contains("\"errormsg\"")); // empty, omitted
250
251        let parsed: UserInputResponse = serde_json::from_str(&json).unwrap();
252        assert_eq!(parsed.text, "my_password");
253    }
254
255    #[test]
256    fn test_response_is_error() {
257        let resp = UserInputResponse {
258            error_msg: "cancelled".to_string(),
259            ..Default::default()
260        };
261        assert!(resp.is_error());
262        assert!(!resp.is_confirmed());
263    }
264
265    #[test]
266    fn test_response_is_confirmed() {
267        let resp = UserInputResponse {
268            confirm: true,
269            ..Default::default()
270        };
271        assert!(resp.is_confirmed());
272        assert!(!resp.is_error());
273    }
274
275    #[test]
276    fn test_handler_register_deliver() {
277        let handler = UserInputHandler::new();
278        let mut rx = handler.register("req-1");
279        assert!(handler.has_pending("req-1"));
280        assert_eq!(handler.pending_count(), 1);
281
282        let response = UserInputResponse {
283            request_id: "req-1".to_string(),
284            text: "hello".to_string(),
285            ..Default::default()
286        };
287        handler.deliver(response).unwrap();
288        assert!(!handler.has_pending("req-1"));
289
290        let received = rx.try_recv().unwrap();
291        assert_eq!(received.text, "hello");
292    }
293
294    #[test]
295    fn test_handler_deliver_no_pending() {
296        let handler = UserInputHandler::new();
297        let response = UserInputResponse {
298            request_id: "nonexistent".to_string(),
299            ..Default::default()
300        };
301        assert!(handler.deliver(response).is_err());
302    }
303
304    #[test]
305    fn test_handler_cancel() {
306        let handler = UserInputHandler::new();
307        let _rx = handler.register("req-1");
308        assert!(handler.has_pending("req-1"));
309        handler.cancel("req-1");
310        assert!(!handler.has_pending("req-1"));
311    }
312
313    #[tokio::test]
314    async fn test_wait_for_response_success() {
315        let handler = UserInputHandler::new();
316        let rx = handler.register("req-1");
317
318        // Deliver response in background
319        tokio::spawn(async move {
320            tokio::time::sleep(Duration::from_millis(10)).await;
321            handler
322                .deliver(UserInputResponse {
323                    request_id: "req-1".to_string(),
324                    text: "password123".to_string(),
325                    ..Default::default()
326                })
327                .unwrap();
328        });
329
330        let result = wait_for_response(rx, Duration::from_secs(5)).await;
331        assert!(result.is_ok());
332        assert_eq!(result.unwrap().text, "password123");
333    }
334
335    #[tokio::test]
336    async fn test_wait_for_response_error() {
337        let handler = UserInputHandler::new();
338        let rx = handler.register("req-1");
339
340        tokio::spawn(async move {
341            handler
342                .deliver(UserInputResponse {
343                    request_id: "req-1".to_string(),
344                    error_msg: "user cancelled".to_string(),
345                    ..Default::default()
346                })
347                .unwrap();
348        });
349
350        let result = wait_for_response(rx, Duration::from_secs(5)).await;
351        assert!(result.is_err());
352        assert!(result.unwrap_err().contains("user cancelled"));
353    }
354
355    #[tokio::test]
356    async fn test_wait_for_response_timeout() {
357        let (_tx, rx) = oneshot::channel::<UserInputResponse>();
358        let result = wait_for_response(rx, Duration::from_millis(10)).await;
359        assert!(result.is_err());
360        assert!(result.unwrap_err().contains("timed out"));
361    }
362
363    #[test]
364    fn test_confirm_request_serde() {
365        let req = UserInputRequest {
366            request_id: "req-2".to_string(),
367            query_text: "Add host key?".to_string(),
368            response_type: RESPONSE_TYPE_CONFIRM.to_string(),
369            title: "SSH Host Key".to_string(),
370            markdown: true,
371            timeout_ms: 30000,
372            checkbox_msg: "Don't ask again".to_string(),
373            public_text: true,
374            ok_label: "Accept".to_string(),
375            cancel_label: "Reject".to_string(),
376        };
377        let json = serde_json::to_string(&req).unwrap();
378        assert!(json.contains("\"checkboxmsg\":\"Don't ask again\""));
379        assert!(json.contains("\"oklabel\":\"Accept\""));
380        assert!(json.contains("\"cancellabel\":\"Reject\""));
381    }
382}