Skip to main content

supercode_frontend_tui/
runtime.rs

1//! Thin terminal consumer of the SDK-owned frontend runtime contract.
2
3use std::sync::Arc;
4
5use supercode::frontend::FrontendAttachment;
6use supercode::frontend::FrontendEvent;
7use supercode::frontend::FrontendOperationInvocation;
8use supercode::frontend::FrontendOperationResult;
9use supercode::frontend::FrontendResponse;
10use supercode::frontend::FrontendRuntime;
11use supercode::frontend::FrontendRuntimeDescriptor;
12use supercode::frontend::FrontendRuntimeError;
13use supercode::ChatMessage;
14
15use crate::composer::ComposerAction;
16use crate::composer::ComposerModel;
17use crate::transcript::TranscriptModel;
18
19/// One terminal attachment to either a local or authenticated remote runtime.
20///
21/// Dropping this value detaches the terminal. It does not own or stop the SDK
22/// runtime, continuation loop, scheduler, or persistence.
23pub struct TerminalRuntimeView {
24    runtime: Arc<dyn FrontendRuntime>,
25    attachment: FrontendAttachment,
26}
27
28impl TerminalRuntimeView {
29    pub async fn attach(
30        runtime: Arc<dyn FrontendRuntime>,
31        history_limit: usize,
32    ) -> Result<Self, FrontendRuntimeError> {
33        let attachment = runtime.attach(history_limit).await?;
34        Ok(Self {
35            runtime,
36            attachment,
37        })
38    }
39
40    pub fn descriptor(&self) -> &FrontendRuntimeDescriptor {
41        &self.attachment.descriptor
42    }
43
44    /// Clone the protocol-neutral controller without duplicating the live
45    /// attachment. Long-running actions can be spawned while this view keeps
46    /// consuming runtime events.
47    pub fn controller(&self) -> Arc<dyn FrontendRuntime> {
48        self.runtime.clone()
49    }
50
51    pub fn history(&self) -> &[ChatMessage] {
52        &self.attachment.history
53    }
54
55    pub fn history_cursor(&self) -> u64 {
56        self.attachment.history_cursor
57    }
58
59    /// Build the normalized transcript at this attachment's atomic
60    /// history/live boundary.
61    pub fn transcript_model(&self) -> TranscriptModel {
62        TranscriptModel::from_history(self.history(), self.history_cursor())
63    }
64
65    pub async fn next_event(&mut self) -> Result<FrontendEvent, FrontendRuntimeError> {
66        self.attachment.next_event().await
67    }
68
69    /// Return the next finite replay item without waiting for live runtime
70    /// traffic. Full-screen consumers drain this snapshot before enabling
71    /// input so resolved historical requests are projected atomically.
72    pub fn next_replay_event(&mut self) -> Option<FrontendEvent> {
73        self.attachment.next_replay_event()
74    }
75
76    /// Receive and apply the next non-duplicate runtime event.
77    pub async fn update_transcript(
78        &mut self,
79        transcript: &mut TranscriptModel,
80    ) -> Result<bool, FrontendRuntimeError> {
81        let event = self.next_event().await?;
82        Ok(transcript.apply_event(&event))
83    }
84
85    /// Receive one event and apply it to both terminal projections. The same
86    /// lossless SDK event drives transcript and composer state.
87    pub async fn update_ui(
88        &mut self,
89        transcript: &mut TranscriptModel,
90        composer: &mut ComposerModel,
91    ) -> Result<bool, FrontendRuntimeError> {
92        let event = self.next_event().await?;
93        let transcript_changed = transcript.apply_event(&event);
94        let composer_changed = composer.apply_event(&event);
95        Ok(transcript_changed || composer_changed)
96    }
97
98    /// Route a pure composer action through the protocol-neutral runtime.
99    /// Typed responses complete their existing transcript request cell only
100    /// after the runtime accepts the exactly-once response.
101    pub async fn dispatch_composer_action(
102        &self,
103        action: ComposerAction,
104        composer: &mut ComposerModel,
105        transcript: &mut TranscriptModel,
106    ) -> Result<(), FrontendRuntimeError> {
107        dispatch_runtime_action(self.runtime.as_ref(), action, composer, transcript).await
108    }
109
110    pub async fn submit(&self, prompt: impl Into<String>) -> Result<String, FrontendRuntimeError> {
111        self.runtime.submit(prompt.into()).await
112    }
113
114    pub async fn interrupt(&self) -> Result<bool, FrontendRuntimeError> {
115        self.runtime.interrupt().await
116    }
117
118    pub async fn steer(&self, prompt: impl Into<String>) -> Result<(), FrontendRuntimeError> {
119        self.runtime.steer(prompt.into()).await
120    }
121
122    pub async fn respond(&self, response: FrontendResponse) -> Result<(), FrontendRuntimeError> {
123        self.runtime.respond(response).await
124    }
125
126    pub async fn invoke(
127        &self,
128        operation: FrontendOperationInvocation,
129    ) -> Result<FrontendOperationResult, FrontendRuntimeError> {
130        self.runtime.invoke(operation).await
131    }
132}
133
134async fn dispatch_runtime_action(
135    runtime: &dyn FrontendRuntime,
136    action: ComposerAction,
137    composer: &mut ComposerModel,
138    transcript: &mut TranscriptModel,
139) -> Result<(), FrontendRuntimeError> {
140    match action {
141        ComposerAction::Submit(prompt) => {
142            if let Err(error) = runtime.submit(prompt).await {
143                if !is_interrupted_submit_error(&error) {
144                    composer.record_dispatch_failure("submit");
145                }
146                return Err(error);
147            }
148        }
149        ComposerAction::Invoke(operation) => {
150            if let Err(error) = runtime.invoke(operation).await {
151                composer.record_dispatch_failure("invoke");
152                return Err(error);
153            }
154        }
155        ComposerAction::Steer(prompt) => runtime.steer(prompt).await?,
156        ComposerAction::Interrupt => {
157            runtime.interrupt().await?;
158        }
159        ComposerAction::Respond {
160            response,
161            request,
162            resolution,
163        } => {
164            let request_id = match &response {
165                FrontendResponse::Approval { request_id, .. }
166                | FrontendResponse::Elicitation { request_id, .. }
167                | FrontendResponse::Other { request_id, .. } => *request_id,
168            };
169            if let Err(error) = runtime.respond(response).await {
170                composer.restore_request(request);
171                return Err(error);
172            }
173            transcript.resolve_frontend_request(request_id, &resolution);
174        }
175    }
176    Ok(())
177}
178
179pub(crate) fn is_interrupted_submit_error(error: &FrontendRuntimeError) -> bool {
180    matches!(
181        error,
182        FrontendRuntimeError::Submit(supercode::server::RuntimeSubmitError::Interrupted)
183    )
184}
185
186#[cfg(test)]
187mod tests {
188    use std::sync::atomic::AtomicUsize;
189    use std::sync::atomic::Ordering;
190
191    use async_trait::async_trait;
192    use crossterm::event::KeyCode;
193    use crossterm::event::KeyEvent;
194    use crossterm::event::KeyModifiers;
195    use serde_json::json;
196    use supercode::frontend::FrontendActions;
197    use supercode::frontend::FrontendConnectionState;
198    use supercode::frontend::FrontendDisplayCapabilities;
199    use supercode::frontend::FrontendRequest;
200    use supercode::frontend::FrontendRequestKind;
201    use supercode::frontend::FrontendTurnState;
202    use supercode::frontend::FRONTEND_RUNTIME_SCHEMA_VERSION;
203
204    use super::*;
205
206    struct RejectOnceRuntime {
207        responses: AtomicUsize,
208    }
209
210    struct InterruptedRuntime;
211
212    #[async_trait]
213    impl FrontendRuntime for RejectOnceRuntime {
214        async fn describe(&self) -> Result<FrontendRuntimeDescriptor, FrontendRuntimeError> {
215            Err(FrontendRuntimeError::UnsupportedAction("describe"))
216        }
217
218        async fn attach(
219            &self,
220            _history_limit: usize,
221        ) -> Result<FrontendAttachment, FrontendRuntimeError> {
222            Err(FrontendRuntimeError::UnsupportedAction("attach"))
223        }
224
225        async fn send_input(self: Arc<Self>, _prompt: String) -> Result<(), FrontendRuntimeError> {
226            Err(FrontendRuntimeError::UnsupportedAction("input"))
227        }
228
229        async fn submit(&self, _prompt: String) -> Result<String, FrontendRuntimeError> {
230            Err(FrontendRuntimeError::UnsupportedAction("submit"))
231        }
232
233        async fn interrupt(&self) -> Result<bool, FrontendRuntimeError> {
234            Err(FrontendRuntimeError::UnsupportedAction("interrupt"))
235        }
236
237        async fn steer(&self, _prompt: String) -> Result<(), FrontendRuntimeError> {
238            Err(FrontendRuntimeError::UnsupportedAction("steer"))
239        }
240
241        async fn respond(&self, _response: FrontendResponse) -> Result<(), FrontendRuntimeError> {
242            if self.responses.fetch_add(1, Ordering::SeqCst) == 0 {
243                Err(FrontendRuntimeError::Transport("retry me".into()))
244            } else {
245                Ok(())
246            }
247        }
248
249        async fn invoke(
250            &self,
251            operation: FrontendOperationInvocation,
252        ) -> Result<FrontendOperationResult, FrontendRuntimeError> {
253            Err(FrontendRuntimeError::UnsupportedOperation(
254                operation.operation_id().to_string(),
255            ))
256        }
257    }
258
259    #[async_trait]
260    impl FrontendRuntime for InterruptedRuntime {
261        async fn describe(&self) -> Result<FrontendRuntimeDescriptor, FrontendRuntimeError> {
262            Err(FrontendRuntimeError::UnsupportedAction("describe"))
263        }
264
265        async fn attach(
266            &self,
267            _history_limit: usize,
268        ) -> Result<FrontendAttachment, FrontendRuntimeError> {
269            Err(FrontendRuntimeError::UnsupportedAction("attach"))
270        }
271
272        async fn send_input(self: Arc<Self>, _prompt: String) -> Result<(), FrontendRuntimeError> {
273            Err(FrontendRuntimeError::Submit(
274                supercode::server::RuntimeSubmitError::Interrupted,
275            ))
276        }
277
278        async fn submit(&self, _prompt: String) -> Result<String, FrontendRuntimeError> {
279            Err(FrontendRuntimeError::Submit(
280                supercode::server::RuntimeSubmitError::Interrupted,
281            ))
282        }
283
284        async fn interrupt(&self) -> Result<bool, FrontendRuntimeError> {
285            Ok(true)
286        }
287
288        async fn steer(&self, _prompt: String) -> Result<(), FrontendRuntimeError> {
289            Ok(())
290        }
291
292        async fn respond(&self, _response: FrontendResponse) -> Result<(), FrontendRuntimeError> {
293            Err(FrontendRuntimeError::UnsupportedAction("respond"))
294        }
295
296        async fn invoke(
297            &self,
298            operation: FrontendOperationInvocation,
299        ) -> Result<FrontendOperationResult, FrontendRuntimeError> {
300            Err(FrontendRuntimeError::UnsupportedOperation(
301                operation.operation_id().to_string(),
302            ))
303        }
304    }
305
306    fn descriptor() -> FrontendRuntimeDescriptor {
307        FrontendRuntimeDescriptor {
308            schema_version: FRONTEND_RUNTIME_SCHEMA_VERSION,
309            session_id: "retry".into(),
310            source_harness: None,
311            emulation_profile: None,
312            active_modules: vec!["permissions".into()],
313            commands: vec![],
314            operations: vec![],
315            actions: FrontendActions {
316                submit: true,
317                interrupt: true,
318                steer: true,
319                respond: true,
320                detach: true,
321                close: true,
322            },
323            display: FrontendDisplayCapabilities {
324                event_kinds: vec!["request".into()],
325                opaque_fallback: true,
326            },
327            model: "test".into(),
328            turn_state: FrontendTurnState::Busy,
329            connection_state: FrontendConnectionState::Connected,
330            extensions: Default::default(),
331        }
332    }
333
334    #[tokio::test]
335    async fn rejected_typed_response_restores_overlay_and_retry_completes_transcript() {
336        let runtime = RejectOnceRuntime {
337            responses: AtomicUsize::new(0),
338        };
339        let request = FrontendRequest {
340            id: 77,
341            kind: FrontendRequestKind::Approval,
342            payload: json!({"tool":"bash"}),
343        };
344        let event = FrontendEvent {
345            sequence: 1,
346            kind: "request".into(),
347            payload: json!({"type":"request", "request":request}),
348        };
349        let mut composer = ComposerModel::new(&descriptor());
350        let mut transcript = TranscriptModel::default();
351        assert!(composer.apply_event(&event));
352        assert!(transcript.apply_event(&event));
353        let action = composer
354            .handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE))
355            .unwrap();
356        assert!(
357            dispatch_runtime_action(&runtime, action, &mut composer, &mut transcript)
358                .await
359                .is_err()
360        );
361        assert_eq!(composer.overlay().unwrap().request.id, 77);
362        assert_eq!(
363            transcript.cells()[0].state,
364            crate::transcript::CellState::Pending
365        );
366
367        let retry = composer
368            .handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE))
369            .unwrap();
370        dispatch_runtime_action(&runtime, retry, &mut composer, &mut transcript)
371            .await
372            .unwrap();
373        assert!(composer.overlay().is_none());
374        assert_eq!(
375            transcript.cells()[0].state,
376            crate::transcript::CellState::Complete
377        );
378        assert!(transcript.cells()[0]
379            .body
380            .contains("Resolution: allowed once"));
381        assert_eq!(runtime.responses.load(Ordering::SeqCst), 2);
382    }
383
384    #[tokio::test]
385    async fn public_dispatch_does_not_label_interruption_as_submit_failure() {
386        let mut composer = ComposerModel::new(&descriptor());
387        let mut transcript = TranscriptModel::default();
388        let error = dispatch_runtime_action(
389            &InterruptedRuntime,
390            ComposerAction::Submit("stop".into()),
391            &mut composer,
392            &mut transcript,
393        )
394        .await
395        .unwrap_err();
396
397        assert!(is_interrupted_submit_error(&error));
398        assert_eq!(composer.last_failure(), None);
399    }
400}