Skip to main content

rustyclaw_tui/
gateway_client.rs

1//! Client-side protocol helpers.
2//!
3//! This module provides helpers for the TUI client to convert server frames
4//! into application actions.
5
6use crate::action::Action;
7use rustyclaw_core::gateway::{ServerFrame, ServerPayload, StatusType};
8
9/// Result of processing a server frame - includes optional action and whether to update UI.
10pub struct FrameAction {
11    pub action: Option<Action>,
12    pub update_ui: bool,
13}
14
15impl FrameAction {
16    pub fn none() -> Self {
17        Self {
18            action: None,
19            update_ui: false,
20        }
21    }
22    pub fn update(action: Action) -> Self {
23        Self {
24            action: Some(action),
25            update_ui: true,
26        }
27    }
28    pub fn just_action(action: Action) -> Self {
29        Self {
30            action: Some(action),
31            update_ui: false,
32        }
33    }
34}
35
36/// Convert a server frame into TUI actions.
37/// This encapsulates all the protocol parsing logic in one place.
38pub fn server_frame_to_action(frame: &ServerFrame) -> FrameAction {
39    use ServerPayload;
40
41    match &frame.payload {
42        ServerPayload::Hello { .. } => {
43            FrameAction::just_action(Action::Info("Gateway connected.".into()))
44        }
45        ServerPayload::Status { status, detail } => {
46            use StatusType::*;
47            match status {
48                ModelConfigured => {
49                    FrameAction::just_action(Action::Info(format!("Model: {detail}")))
50                }
51                CredentialsLoaded => FrameAction::just_action(Action::Info(detail.clone())),
52                CredentialsMissing => FrameAction::just_action(Action::Warning(detail.clone())),
53                ModelConnecting => FrameAction::just_action(Action::Info(detail.clone())),
54                ModelReady => FrameAction::just_action(Action::Success(detail.clone())),
55                ModelError => FrameAction::just_action(Action::Error(detail.clone())),
56                NoModel => FrameAction::just_action(Action::Warning(detail.clone())),
57                VaultLocked => FrameAction::just_action(Action::GatewayVaultLocked),
58            }
59        }
60        ServerPayload::AuthChallenge { .. } => {
61            FrameAction::just_action(Action::GatewayAuthChallenge)
62        }
63        ServerPayload::AuthResult { ok, message, retry } => {
64            if *ok {
65                FrameAction::update(Action::GatewayAuthenticated)
66            } else if retry.unwrap_or(false) {
67                FrameAction::just_action(Action::Warning(
68                    message
69                        .clone()
70                        .unwrap_or_else(|| "Invalid code. Try again.".into()),
71                ))
72            } else {
73                FrameAction::just_action(Action::Error(
74                    message
75                        .clone()
76                        .unwrap_or_else(|| "Authentication failed.".into()),
77                ))
78            }
79        }
80        ServerPayload::AuthLocked { message, .. } => {
81            FrameAction::just_action(Action::Error(message.clone()))
82        }
83        ServerPayload::VaultUnlocked { ok, message } => {
84            if *ok {
85                FrameAction::update(Action::GatewayVaultUnlocked)
86            } else {
87                FrameAction::just_action(Action::Error(
88                    message
89                        .clone()
90                        .unwrap_or_else(|| "Failed to unlock vault.".into()),
91                ))
92            }
93        }
94        ServerPayload::ReloadResult {
95            ok,
96            provider,
97            model,
98            message,
99        } => {
100            if *ok {
101                FrameAction::just_action(Action::GatewayReloaded {
102                    provider: provider.clone(),
103                    model: model.clone(),
104                })
105            } else {
106                FrameAction::just_action(Action::Error(format!(
107                    "Reload failed: {}",
108                    message.as_deref().unwrap_or("Unknown error")
109                )))
110            }
111        }
112        ServerPayload::SecretsListResult { ok: _, entries } => {
113            FrameAction::just_action(Action::SecretsListResult {
114                entries: entries.clone(),
115            })
116        }
117        ServerPayload::SecretsStoreResult { ok, message } => {
118            FrameAction::just_action(Action::SecretsStoreResult {
119                ok: *ok,
120                message: message.clone(),
121            })
122        }
123        ServerPayload::SecretsGetResult {
124            ok: _, key, value, ..
125        } => FrameAction::just_action(Action::SecretsGetResult {
126            key: key.clone(),
127            value: value.clone(),
128        }),
129        ServerPayload::SecretsPeekResult {
130            ok,
131            fields,
132            message,
133        } => FrameAction::just_action(Action::SecretsPeekResult {
134            name: String::new(),
135            ok: *ok,
136            fields: fields.clone(),
137            message: message.clone(),
138        }),
139        ServerPayload::SecretsSetPolicyResult { ok, message } => {
140            FrameAction::just_action(Action::SecretsSetPolicyResult {
141                ok: *ok,
142                message: message.clone(),
143            })
144        }
145        ServerPayload::SecretsSetDisabledResult { ok, message: _, .. } => {
146            FrameAction::just_action(Action::SecretsSetDisabledResult {
147                ok: *ok,
148                cred_name: String::new(),
149                disabled: false,
150            })
151        }
152        ServerPayload::SecretsDeleteResult { ok, .. } => {
153            FrameAction::just_action(Action::SecretsDeleteCredentialResult {
154                ok: *ok,
155                cred_name: String::new(),
156            })
157        }
158        ServerPayload::SecretsDeleteCredentialResult { ok, .. } => {
159            FrameAction::just_action(Action::SecretsDeleteCredentialResult {
160                ok: *ok,
161                cred_name: String::new(),
162            })
163        }
164        ServerPayload::SecretsHasTotpResult { has_totp } => {
165            FrameAction::just_action(Action::SecretsHasTotpResult {
166                has_totp: *has_totp,
167            })
168        }
169        ServerPayload::SecretsSetupTotpResult { ok, uri, message } => {
170            FrameAction::just_action(Action::SecretsSetupTotpResult {
171                ok: *ok,
172                uri: uri.clone(),
173                message: message.clone(),
174            })
175        }
176        ServerPayload::SecretsVerifyTotpResult { ok, .. } => {
177            FrameAction::just_action(Action::SecretsVerifyTotpResult { ok: *ok })
178        }
179        ServerPayload::SecretsRemoveTotpResult { ok, .. } => {
180            FrameAction::just_action(Action::SecretsRemoveTotpResult { ok: *ok })
181        }
182        ServerPayload::StreamStart => FrameAction::just_action(Action::GatewayStreamStart),
183        ServerPayload::ThinkingStart => FrameAction::just_action(Action::GatewayThinkingStart),
184        ServerPayload::ThinkingDelta { .. } => {
185            FrameAction::just_action(Action::GatewayThinkingDelta)
186        }
187        ServerPayload::ThinkingEnd => FrameAction::just_action(Action::GatewayThinkingEnd),
188        ServerPayload::Chunk { delta } => {
189            FrameAction::just_action(Action::GatewayChunk(delta.clone()))
190        }
191        ServerPayload::ResponseDone { .. } => FrameAction::just_action(Action::GatewayResponseDone),
192        ServerPayload::ToolCall {
193            id,
194            name,
195            arguments,
196        } => FrameAction::just_action(Action::GatewayToolCall {
197            id: id.clone(),
198            name: name.clone(),
199            arguments: arguments.clone(),
200        }),
201        ServerPayload::ToolResult {
202            id,
203            name,
204            result,
205            is_error,
206        } => FrameAction::just_action(Action::GatewayToolResult {
207            id: id.clone(),
208            name: name.clone(),
209            result: result.clone(),
210            is_error: *is_error,
211        }),
212        ServerPayload::Error { message, .. } => {
213            FrameAction::just_action(Action::Error(message.clone()))
214        }
215        ServerPayload::Info { message } => FrameAction::just_action(Action::Info(message.clone())),
216        ServerPayload::ToolApprovalRequest {
217            id,
218            name,
219            arguments,
220        } => FrameAction::just_action(Action::ToolApprovalRequest {
221            id: id.clone(),
222            name: name.clone(),
223            arguments: arguments.clone(),
224        }),
225        ServerPayload::UserPromptRequest { id, prompt } => {
226            let mut prompt = prompt.clone();
227            prompt.id = id.clone();
228            FrameAction::just_action(Action::UserPromptRequest(prompt))
229        }
230        ServerPayload::TasksUpdate { tasks: _ } => {
231            // Legacy — tasks are now unified with threads
232            FrameAction::none()
233        }
234        ServerPayload::ThreadsUpdate {
235            threads,
236            foreground_id,
237        } => FrameAction::just_action(Action::ThreadsUpdate {
238            threads: threads
239                .iter()
240                .map(|t| crate::action::ThreadInfo {
241                    id: t.id,
242                    label: t.label.clone(),
243                    description: t.description.clone(),
244                    status: t.status.clone(),
245                    kind_icon: t.kind_icon.clone(),
246                    status_icon: t.status_icon.clone(),
247                    is_foreground: t.is_foreground,
248                    message_count: t.message_count,
249                    has_summary: t.has_summary,
250                })
251                .collect(),
252            foreground_id: *foreground_id,
253        }),
254        ServerPayload::ThreadCreated {
255            thread_id: _,
256            label: _,
257        } => {
258            // Thread was created — we'll get a ThreadsUpdate too
259            FrameAction::none()
260        }
261        ServerPayload::ThreadSwitched {
262            thread_id,
263            context_summary,
264        } => {
265            // Thread was switched — clear messages and show context summary if available
266            FrameAction::just_action(Action::ThreadSwitched {
267                thread_id: *thread_id,
268                context_summary: context_summary.clone(),
269            })
270        }
271        ServerPayload::Empty => FrameAction::none(),
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::action::Action;
279
280    #[test]
281    fn test_frame_action_none() {
282        let action = FrameAction::none();
283        assert!(action.action.is_none());
284        assert!(!action.update_ui);
285    }
286
287    #[test]
288    fn test_frame_action_update() {
289        let action = FrameAction::update(Action::Update);
290        assert!(matches!(action.action, Some(Action::Update)));
291        assert!(action.update_ui);
292    }
293
294    #[test]
295    fn test_frame_action_just_action() {
296        let action = FrameAction::just_action(Action::Tick);
297        assert!(matches!(action.action, Some(Action::Tick)));
298        assert!(!action.update_ui);
299    }
300
301    mod action_conversion {
302        use super::*;
303        use crate::action::Action;
304        use rustyclaw_core::gateway::{SecretEntryDto, ServerFrameType};
305
306        #[test]
307        fn test_hello_frame_to_action() {
308            let frame = ServerFrame {
309                frame_type: ServerFrameType::Hello,
310                payload: ServerPayload::Hello {
311                    agent: "test".into(),
312                    settings_dir: "/tmp".into(),
313                    vault_locked: false,
314                    provider: None,
315                    model: None,
316                },
317            };
318
319            let result = server_frame_to_action(&frame);
320            assert!(matches!(result.action, Some(Action::Info(_))));
321        }
322
323        #[test]
324        fn test_status_model_ready_to_action() {
325            let frame = ServerFrame {
326                frame_type: ServerFrameType::Status,
327                payload: ServerPayload::Status {
328                    status: StatusType::ModelReady,
329                    detail: "Claude 3.5 Sonnet".into(),
330                },
331            };
332
333            let result = server_frame_to_action(&frame);
334            assert!(matches!(result.action, Some(Action::Success(_))));
335        }
336
337        #[test]
338        fn test_status_vault_locked_to_action() {
339            let frame = ServerFrame {
340                frame_type: ServerFrameType::Status,
341                payload: ServerPayload::Status {
342                    status: StatusType::VaultLocked,
343                    detail: "Vault is locked".into(),
344                },
345            };
346
347            let result = server_frame_to_action(&frame);
348            assert!(matches!(result.action, Some(Action::GatewayVaultLocked)));
349        }
350
351        #[test]
352        fn test_chunk_frame_to_action() {
353            let frame = ServerFrame {
354                frame_type: ServerFrameType::Chunk,
355                payload: ServerPayload::Chunk {
356                    delta: "Hello".into(),
357                },
358            };
359
360            let result = server_frame_to_action(&frame);
361            match result.action {
362                Some(Action::GatewayChunk(text)) => assert_eq!(text, "Hello"),
363                _ => panic!("Expected GatewayChunk action"),
364            }
365        }
366
367        #[test]
368        fn test_tool_call_frame_to_action() {
369            let frame = ServerFrame {
370                frame_type: ServerFrameType::ToolCall,
371                payload: ServerPayload::ToolCall {
372                    id: "call_001".into(),
373                    name: "read_file".into(),
374                    arguments: r#"{"path":"/tmp/test"}"#.into(),
375                },
376            };
377
378            let result = server_frame_to_action(&frame);
379            match result.action {
380                Some(Action::GatewayToolCall {
381                    id,
382                    name,
383                    arguments: _,
384                }) => {
385                    assert_eq!(id, "call_001");
386                    assert_eq!(name, "read_file");
387                }
388                _ => panic!("Expected GatewayToolCall action"),
389            }
390        }
391
392        #[test]
393        fn test_error_frame_to_action() {
394            let frame = ServerFrame {
395                frame_type: ServerFrameType::Error,
396                payload: ServerPayload::Error {
397                    ok: false,
398                    message: "Connection failed".into(),
399                },
400            };
401
402            let result = server_frame_to_action(&frame);
403            match result.action {
404                Some(Action::Error(msg)) => assert_eq!(msg, "Connection failed"),
405                _ => panic!("Expected Error action"),
406            }
407        }
408
409        #[test]
410        fn test_secrets_list_result_to_action() {
411            let frame = ServerFrame {
412                frame_type: ServerFrameType::SecretsListResult,
413                payload: ServerPayload::SecretsListResult {
414                    ok: true,
415                    entries: vec![SecretEntryDto {
416                        name: "api_key".into(),
417                        label: "API Key".into(),
418                        kind: "ApiKey".into(),
419                        policy: "always".into(),
420                        disabled: false,
421                    }],
422                },
423            };
424
425            let result = server_frame_to_action(&frame);
426            match result.action {
427                Some(Action::SecretsListResult { entries }) => {
428                    assert_eq!(entries.len(), 1);
429                }
430                _ => panic!("Expected SecretsListResult action"),
431            }
432        }
433
434        #[test]
435        fn test_auth_challenge_to_action() {
436            let frame = ServerFrame {
437                frame_type: ServerFrameType::AuthChallenge,
438                payload: ServerPayload::AuthChallenge {
439                    method: "totp".into(),
440                },
441            };
442
443            let result = server_frame_to_action(&frame);
444            assert!(matches!(result.action, Some(Action::GatewayAuthChallenge)));
445        }
446
447        #[test]
448        fn test_response_done_to_action() {
449            let frame = ServerFrame {
450                frame_type: ServerFrameType::ResponseDone,
451                payload: ServerPayload::ResponseDone { ok: true },
452            };
453
454            let result = server_frame_to_action(&frame);
455            assert!(matches!(result.action, Some(Action::GatewayResponseDone)));
456        }
457
458        #[test]
459        fn test_streaming_frames_to_actions() {
460            let start_frame = ServerFrame {
461                frame_type: ServerFrameType::StreamStart,
462                payload: ServerPayload::StreamStart,
463            };
464            assert!(matches!(
465                server_frame_to_action(&start_frame).action,
466                Some(Action::GatewayStreamStart)
467            ));
468
469            let thinking_frame = ServerFrame {
470                frame_type: ServerFrameType::ThinkingStart,
471                payload: ServerPayload::ThinkingStart,
472            };
473            assert!(matches!(
474                server_frame_to_action(&thinking_frame).action,
475                Some(Action::GatewayThinkingStart)
476            ));
477
478            let end_frame = ServerFrame {
479                frame_type: ServerFrameType::ThinkingEnd,
480                payload: ServerPayload::ThinkingEnd,
481            };
482            assert!(matches!(
483                server_frame_to_action(&end_frame).action,
484                Some(Action::GatewayThinkingEnd)
485            ));
486        }
487    }
488}