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_harness::frontend::FrontendAttachment;
6use supercode_harness::frontend::FrontendEvent;
7use supercode_harness::frontend::FrontendOperationInvocation;
8use supercode_harness::frontend::FrontendOperationResult;
9use supercode_harness::frontend::FrontendResponse;
10use supercode_harness::frontend::FrontendRuntime;
11use supercode_harness::frontend::FrontendRuntimeDescriptor;
12use supercode_harness::frontend::FrontendRuntimeError;
13use supercode_harness::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_harness::server::RuntimeSubmitError::Interrupted)
183    )
184}