Skip to main content

oino_client/
frontend.rs

1//! [`FrontendApi`]: typed `_oino/*` frontend commands over any [`SessionBackend`].
2//!
3//! The same calls work in-process ([`crate::InProcessBackend`]) and remotely (`oino-rpc`'s
4//! `RpcBackend`), so the TUI and the desktop app share one surface.
5
6use futures::FutureExt;
7use futures::future::BoxFuture;
8use oino_ai::JsonValue;
9use oino_core::extensions::settings::SettingsLayer;
10use serde::de::DeserializeOwned;
11use serde::{Deserialize, Serialize};
12
13use crate::backend::{ClientError, SessionBackend};
14use crate::protocol::{
15    AgentInfo, AuthProviderInfo, Checkpoint, CompletionGroup, EditorEvent, ExtensionInfo,
16    OinoCommand, QueueEdit, QueueKind, RewindResult, RpcResponseData, RunningBash, SettingsScope,
17    TopicValue,
18};
19
20/// An extension shortcut (`_oino/get_shortcuts`).
21#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct ShortcutInfo {
24    /// Action id.
25    pub id: String,
26    /// Default key ids (`KeyId` syntax).
27    #[serde(default)]
28    pub default_keys: Vec<String>,
29    /// Description.
30    #[serde(default)]
31    pub description: String,
32    /// Owning extension.
33    #[serde(default)]
34    pub extension_id: String,
35}
36
37/// What `_oino/rewind` restores.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub struct RewindOptions {
40    /// Restore the files changed at and after the message.
41    pub files: bool,
42    /// Fork the conversation before the message.
43    pub conversation: bool,
44}
45
46impl Default for RewindOptions {
47    fn default() -> Self {
48        Self {
49            files: true,
50            conversation: true,
51        }
52    }
53}
54
55/// `_oino/agents` reply.
56#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
57pub struct AgentTree {
58    /// Open agents, parents before children.
59    pub agents: Vec<AgentInfo>,
60    /// Live `agent_event` forwarding is on.
61    pub follow: bool,
62}
63
64fn whole<T: DeserializeOwned>(data: RpcResponseData) -> Result<T, ClientError> {
65    serde_json::from_value(data.unwrap_or(JsonValue::Null))
66        .map_err(|e| ClientError::Protocol(e.to_string()))
67}
68
69fn field<T: DeserializeOwned>(data: RpcResponseData, name: &str) -> Result<T, ClientError> {
70    let value = data
71        .and_then(|mut d| d.get_mut(name).map(JsonValue::take))
72        .unwrap_or(JsonValue::Null);
73    serde_json::from_value(value).map_err(|e| ClientError::Protocol(format!("{name}: {e}")))
74}
75
76/// Typed frontend commands (modes.md §4); implemented for every [`SessionBackend`].
77pub trait FrontendApi {
78    /// `_oino/get_completions`.
79    fn completions(
80        &self,
81        text: &str,
82        cursor: usize,
83    ) -> BoxFuture<'_, Result<Vec<CompletionGroup>, ClientError>>;
84    /// `_oino/ui_action` (v1 shape; the v2 payload rides the same command).
85    fn ui_action(
86        &self,
87        panel_id: &str,
88        action_id: &str,
89        value: Option<JsonValue>,
90    ) -> BoxFuture<'_, Result<(), ClientError>>;
91    /// `_oino/ui_visible`: report an entry visible/hidden in this frontend (ui-protocol
92    /// §6.5); core aggregates and sends `$show`/`$hide`.
93    fn ui_visible(&self, entry_id: &str, visible: bool) -> BoxFuture<'_, Result<(), ClientError>>;
94    /// `_oino/ui_resync`: a fresh registry snapshot after a `rev` gap (§9.2).
95    fn ui_resync(&self, rev: u64) -> BoxFuture<'_, Result<JsonValue, ClientError>>;
96    /// `_oino/editor_event` → whether an extension consumed it.
97    fn editor_event(&self, event: EditorEvent) -> BoxFuture<'_, Result<bool, ClientError>>;
98    /// `_oino/invoke_shortcut`.
99    fn invoke_shortcut(&self, id: &str) -> BoxFuture<'_, Result<(), ClientError>>;
100    /// `_oino/get_shortcuts`.
101    fn shortcuts(&self) -> BoxFuture<'_, Result<Vec<ShortcutInfo>, ClientError>>;
102    /// `_oino/get_settings_schema` (ui-protocol §6.6 namespaces).
103    fn settings_schema(&self) -> BoxFuture<'_, Result<Vec<JsonValue>, ClientError>>;
104    /// `_oino/get_extension_settings`.
105    fn extension_settings(
106        &self,
107        namespace: &str,
108        layer: SettingsLayer,
109    ) -> BoxFuture<'_, Result<JsonValue, ClientError>>;
110    /// `_oino/set_extension_setting` (`None` deletes).
111    fn set_extension_setting(
112        &self,
113        namespace: &str,
114        key: &str,
115        value: Option<JsonValue>,
116        scope: SettingsScope,
117    ) -> BoxFuture<'_, Result<(), ClientError>>;
118    /// `_oino/get_settings` (merged core settings).
119    fn settings(&self) -> BoxFuture<'_, Result<JsonValue, ClientError>>;
120    /// `_oino/set_setting` (`None` deletes).
121    fn set_setting(
122        &self,
123        key: &str,
124        value: Option<JsonValue>,
125        scope: SettingsScope,
126    ) -> BoxFuture<'_, Result<(), ClientError>>;
127    /// `_oino/service_call`.
128    fn service_call(
129        &self,
130        target: Option<&str>,
131        name: &str,
132        payload: JsonValue,
133    ) -> BoxFuture<'_, Result<JsonValue, ClientError>>;
134    /// `_oino/auth_providers`.
135    fn auth_providers(&self) -> BoxFuture<'_, Result<Vec<AuthProviderInfo>, ClientError>>;
136    /// `_oino/login` (runs the OAuth flow; resolves when it finished).
137    fn login(&self, provider: &str) -> BoxFuture<'_, Result<(), ClientError>>;
138    /// `_oino/logout`.
139    fn logout(&self, provider: &str) -> BoxFuture<'_, Result<(), ClientError>>;
140    /// `_oino/set_api_key`.
141    fn set_api_key(&self, provider: &str, key: &str) -> BoxFuture<'_, Result<(), ClientError>>;
142    /// `_oino/topics`: changes the subscription; returns the sticky values it now matches.
143    fn topics(
144        &self,
145        subscribe: &[&str],
146        unsubscribe: &[&str],
147    ) -> BoxFuture<'_, Result<Vec<TopicValue>, ClientError>>;
148    /// `_oino/bus_get`: last sticky value of `topic`.
149    fn bus_get(&self, topic: &str) -> BoxFuture<'_, Result<Option<JsonValue>, ClientError>>;
150    /// `_oino/agents`: the nested-agent tree; `follow` toggles `agent_event` forwarding.
151    fn agents(&self, follow: Option<bool>) -> BoxFuture<'_, Result<AgentTree, ClientError>>;
152    /// `_oino/agent_messages`: transcript of one nested agent.
153    fn agent_messages(
154        &self,
155        agent_id: &str,
156    ) -> BoxFuture<'_, Result<Vec<oino_ai::AgentMessage>, ClientError>>;
157    /// `_oino/agent_abort`.
158    fn agent_abort(&self, agent_id: &str) -> BoxFuture<'_, Result<(), ClientError>>;
159    /// `_oino/queue_edit`.
160    fn queue_edit(&self, edit: QueueEdit) -> BoxFuture<'_, Result<(), ClientError>>;
161    /// `_oino/queue_edit{op: remove}`.
162    fn queue_remove(
163        &self,
164        queue: QueueKind,
165        index: usize,
166    ) -> BoxFuture<'_, Result<(), ClientError>>;
167    /// `_oino/queue_edit{op: sendNow}`.
168    fn queue_send_now(
169        &self,
170        queue: QueueKind,
171        index: usize,
172    ) -> BoxFuture<'_, Result<(), ClientError>>;
173    /// `_oino/queue_edit{op: move}`.
174    fn queue_move(
175        &self,
176        queue: QueueKind,
177        from: usize,
178        to: usize,
179    ) -> BoxFuture<'_, Result<(), ClientError>>;
180    /// `_oino/queue_edit{op: edit}`.
181    fn queue_set_text(
182        &self,
183        queue: QueueKind,
184        index: usize,
185        text: &str,
186    ) -> BoxFuture<'_, Result<(), ClientError>>;
187    /// `_oino/checkpoints`.
188    fn checkpoints(&self) -> BoxFuture<'_, Result<Vec<Checkpoint>, ClientError>>;
189    /// `_oino/rewind`.
190    fn rewind(
191        &self,
192        entry_id: &str,
193        options: RewindOptions,
194    ) -> BoxFuture<'_, Result<RewindResult, ClientError>>;
195    /// `_oino/running_bash`.
196    fn running_bash(&self) -> BoxFuture<'_, Result<Vec<RunningBash>, ClientError>>;
197    /// `_oino/extensions`.
198    fn extensions(&self) -> BoxFuture<'_, Result<Vec<ExtensionInfo>, ClientError>>;
199    /// `_oino/set_extension_enabled`.
200    fn set_extension_enabled(
201        &self,
202        id: &str,
203        enabled: bool,
204    ) -> BoxFuture<'_, Result<(), ClientError>>;
205    /// `_oino/render`: the OUS node an extension renders for one transcript row, or `None`
206    /// (ui-protocol §6.4; built-in tool rows included).
207    fn render(&self, request: RenderRow) -> BoxFuture<'_, Result<Option<JsonValue>, ClientError>>;
208}
209
210/// `_oino/render` request as frontends build it.
211#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
212#[serde(rename_all = "camelCase")]
213pub struct RenderRow {
214    /// `customMessage` | `customEntry` | `tool` | `turnFooter` (ui-protocol §6.7).
215    pub target: String,
216    /// Custom type, tool name or turn-footer renderer key.
217    pub key: String,
218    /// Entry or tool-call id.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub entry_id: Option<String>,
221    /// Turn id of a `turnFooter` request (the user message entry).
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub turn_entry_id: Option<String>,
224    /// Zero-based turn index.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub turn_index: Option<u64>,
227    /// Whether the turn is still running.
228    #[serde(default)]
229    pub running: bool,
230    /// `simple` | `regular` | `advanced`.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub density: Option<String>,
233    /// Expanded row.
234    #[serde(default)]
235    pub expanded: bool,
236    /// `narrow` | `normal` | `wide`.
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub width: Option<String>,
239    /// Row payload.
240    #[serde(default)]
241    pub data: JsonValue,
242}
243
244impl<B: SessionBackend + ?Sized> FrontendApi for B {
245    fn completions(
246        &self,
247        text: &str,
248        cursor: usize,
249    ) -> BoxFuture<'_, Result<Vec<CompletionGroup>, ClientError>> {
250        let command = OinoCommand::GetCompletions {
251            text: text.to_owned(),
252            cursor,
253        };
254        self.send(command.into())
255            .map(|r| field(r?, "groups"))
256            .boxed()
257    }
258
259    fn ui_action(
260        &self,
261        panel_id: &str,
262        action_id: &str,
263        value: Option<JsonValue>,
264    ) -> BoxFuture<'_, Result<(), ClientError>> {
265        let command = OinoCommand::UiAction {
266            panel_id: panel_id.to_owned(),
267            action_id: action_id.to_owned(),
268            value,
269            values: None,
270            path: None,
271            source: None,
272        };
273        self.send(command.into()).map(|r| r.map(drop)).boxed()
274    }
275
276    fn ui_visible(&self, entry_id: &str, visible: bool) -> BoxFuture<'_, Result<(), ClientError>> {
277        let command = OinoCommand::UiVisible {
278            entry_id: entry_id.to_owned(),
279            visible,
280            frontend: None,
281        };
282        self.send(command.into()).map(|r| r.map(drop)).boxed()
283    }
284
285    fn ui_resync(&self, rev: u64) -> BoxFuture<'_, Result<JsonValue, ClientError>> {
286        let command = OinoCommand::UiResync { scope: None, rev };
287        self.send(command.into()).map(|r| whole(r?)).boxed()
288    }
289
290    fn editor_event(&self, event: EditorEvent) -> BoxFuture<'_, Result<bool, ClientError>> {
291        self.send(OinoCommand::EditorEvent(event).into())
292            .map(|r| field(r?, "consumed"))
293            .boxed()
294    }
295
296    fn invoke_shortcut(&self, id: &str) -> BoxFuture<'_, Result<(), ClientError>> {
297        let command = OinoCommand::InvokeShortcut {
298            shortcut_id: id.to_owned(),
299        };
300        self.send(command.into()).map(|r| r.map(drop)).boxed()
301    }
302
303    fn shortcuts(&self) -> BoxFuture<'_, Result<Vec<ShortcutInfo>, ClientError>> {
304        self.send(OinoCommand::GetShortcuts.into())
305            .map(|r| field(r?, "shortcuts"))
306            .boxed()
307    }
308
309    fn settings_schema(&self) -> BoxFuture<'_, Result<Vec<JsonValue>, ClientError>> {
310        self.send(OinoCommand::GetSettingsSchema.into())
311            .map(|r| field(r?, "namespaces"))
312            .boxed()
313    }
314
315    fn extension_settings(
316        &self,
317        namespace: &str,
318        layer: SettingsLayer,
319    ) -> BoxFuture<'_, Result<JsonValue, ClientError>> {
320        let command = OinoCommand::GetExtensionSettings {
321            namespace: namespace.to_owned(),
322            scope: layer,
323        };
324        self.send(command.into())
325            .map(|r| field(r?, "values"))
326            .boxed()
327    }
328
329    fn set_extension_setting(
330        &self,
331        namespace: &str,
332        key: &str,
333        value: Option<JsonValue>,
334        scope: SettingsScope,
335    ) -> BoxFuture<'_, Result<(), ClientError>> {
336        let command = match value {
337            Some(value) => OinoCommand::SetExtensionSetting {
338                namespace: namespace.to_owned(),
339                key: key.to_owned(),
340                value: Some(value),
341                scope,
342            },
343            None => OinoCommand::UnsetExtensionSetting {
344                namespace: namespace.to_owned(),
345                key: key.to_owned(),
346                scope,
347            },
348        };
349        self.send(command.into()).map(|r| r.map(drop)).boxed()
350    }
351
352    fn settings(&self) -> BoxFuture<'_, Result<JsonValue, ClientError>> {
353        self.send(OinoCommand::GetSettings { key: None }.into())
354            .map(|r| field(r?, "settings"))
355            .boxed()
356    }
357
358    fn set_setting(
359        &self,
360        key: &str,
361        value: Option<JsonValue>,
362        scope: SettingsScope,
363    ) -> BoxFuture<'_, Result<(), ClientError>> {
364        let command = OinoCommand::SetSetting {
365            key: key.to_owned(),
366            value,
367            scope,
368        };
369        self.send(command.into()).map(|r| r.map(drop)).boxed()
370    }
371
372    fn service_call(
373        &self,
374        target: Option<&str>,
375        name: &str,
376        payload: JsonValue,
377    ) -> BoxFuture<'_, Result<JsonValue, ClientError>> {
378        let command = OinoCommand::ServiceCall {
379            target: target.map(str::to_owned),
380            name: name.to_owned(),
381            payload,
382        };
383        self.send(command.into())
384            .map(|r| r.map(Option::unwrap_or_default))
385            .boxed()
386    }
387
388    fn auth_providers(&self) -> BoxFuture<'_, Result<Vec<AuthProviderInfo>, ClientError>> {
389        self.send(OinoCommand::AuthProviders.into())
390            .map(|r| field(r?, "providers"))
391            .boxed()
392    }
393
394    fn login(&self, provider: &str) -> BoxFuture<'_, Result<(), ClientError>> {
395        let command = OinoCommand::Login {
396            provider: provider.to_owned(),
397        };
398        self.send(command.into()).map(|r| r.map(drop)).boxed()
399    }
400
401    fn logout(&self, provider: &str) -> BoxFuture<'_, Result<(), ClientError>> {
402        let command = OinoCommand::Logout {
403            provider: provider.to_owned(),
404        };
405        self.send(command.into()).map(|r| r.map(drop)).boxed()
406    }
407
408    fn set_api_key(&self, provider: &str, key: &str) -> BoxFuture<'_, Result<(), ClientError>> {
409        let command = OinoCommand::SetApiKey {
410            provider: provider.to_owned(),
411            key: key.to_owned(),
412        };
413        self.send(command.into()).map(|r| r.map(drop)).boxed()
414    }
415
416    fn topics(
417        &self,
418        subscribe: &[&str],
419        unsubscribe: &[&str],
420    ) -> BoxFuture<'_, Result<Vec<TopicValue>, ClientError>> {
421        let command = OinoCommand::Topics {
422            subscribe: subscribe.iter().map(|s| (*s).to_owned()).collect(),
423            unsubscribe: unsubscribe.iter().map(|s| (*s).to_owned()).collect(),
424        };
425        self.send(command.into())
426            .map(|r| field(r?, "topics"))
427            .boxed()
428    }
429
430    fn bus_get(&self, topic: &str) -> BoxFuture<'_, Result<Option<JsonValue>, ClientError>> {
431        let command = OinoCommand::BusGet {
432            topic: topic.to_owned(),
433        };
434        self.send(command.into())
435            .map(|r| field::<JsonValue>(r?, "data").map(|v| (!v.is_null()).then_some(v)))
436            .boxed()
437    }
438
439    fn agents(&self, follow: Option<bool>) -> BoxFuture<'_, Result<AgentTree, ClientError>> {
440        self.send(OinoCommand::Agents { follow }.into())
441            .map(|r| whole(r?))
442            .boxed()
443    }
444
445    fn agent_messages(
446        &self,
447        agent_id: &str,
448    ) -> BoxFuture<'_, Result<Vec<oino_ai::AgentMessage>, ClientError>> {
449        let command = OinoCommand::AgentMessages {
450            agent_id: agent_id.to_owned(),
451        };
452        self.send(command.into())
453            .map(|r| field(r?, "messages"))
454            .boxed()
455    }
456
457    fn agent_abort(&self, agent_id: &str) -> BoxFuture<'_, Result<(), ClientError>> {
458        let command = OinoCommand::AgentAbort {
459            agent_id: agent_id.to_owned(),
460        };
461        self.send(command.into()).map(|r| r.map(drop)).boxed()
462    }
463
464    fn queue_edit(&self, edit: QueueEdit) -> BoxFuture<'_, Result<(), ClientError>> {
465        self.send(OinoCommand::QueueEdit(edit).into())
466            .map(|r| r.map(drop))
467            .boxed()
468    }
469
470    fn queue_remove(
471        &self,
472        queue: QueueKind,
473        index: usize,
474    ) -> BoxFuture<'_, Result<(), ClientError>> {
475        self.queue_edit(QueueEdit::Remove { queue, index })
476    }
477
478    fn queue_send_now(
479        &self,
480        queue: QueueKind,
481        index: usize,
482    ) -> BoxFuture<'_, Result<(), ClientError>> {
483        self.queue_edit(QueueEdit::SendNow { queue, index })
484    }
485
486    fn queue_move(
487        &self,
488        queue: QueueKind,
489        from: usize,
490        to: usize,
491    ) -> BoxFuture<'_, Result<(), ClientError>> {
492        self.queue_edit(QueueEdit::Move { queue, from, to })
493    }
494
495    fn queue_set_text(
496        &self,
497        queue: QueueKind,
498        index: usize,
499        text: &str,
500    ) -> BoxFuture<'_, Result<(), ClientError>> {
501        self.queue_edit(QueueEdit::Edit {
502            queue,
503            index,
504            text: text.to_owned(),
505        })
506    }
507
508    fn checkpoints(&self) -> BoxFuture<'_, Result<Vec<Checkpoint>, ClientError>> {
509        self.send(OinoCommand::Checkpoints.into())
510            .map(|r| field(r?, "checkpoints"))
511            .boxed()
512    }
513
514    fn rewind(
515        &self,
516        entry_id: &str,
517        options: RewindOptions,
518    ) -> BoxFuture<'_, Result<RewindResult, ClientError>> {
519        let command = OinoCommand::Rewind {
520            entry_id: entry_id.to_owned(),
521            files: options.files,
522            conversation: options.conversation,
523        };
524        self.send(command.into()).map(|r| whole(r?)).boxed()
525    }
526
527    fn running_bash(&self) -> BoxFuture<'_, Result<Vec<RunningBash>, ClientError>> {
528        self.send(OinoCommand::RunningBash.into())
529            .map(|r| field(r?, "calls"))
530            .boxed()
531    }
532
533    fn extensions(&self) -> BoxFuture<'_, Result<Vec<ExtensionInfo>, ClientError>> {
534        self.send(OinoCommand::Extensions.into())
535            .map(|r| field(r?, "extensions"))
536            .boxed()
537    }
538
539    fn render(&self, request: RenderRow) -> BoxFuture<'_, Result<Option<JsonValue>, ClientError>> {
540        let command = OinoCommand::Render {
541            target: request.target,
542            key: request.key,
543            entry_id: request.entry_id,
544            turn_entry_id: request.turn_entry_id,
545            turn_index: request.turn_index,
546            running: request.running,
547            density: request.density,
548            expanded: request.expanded,
549            width: request.width,
550            data: request.data,
551        };
552        self.send(command.into())
553            .map(|r| field::<JsonValue>(r?, "node").map(|v| (!v.is_null()).then_some(v)))
554            .boxed()
555    }
556
557    fn set_extension_enabled(
558        &self,
559        id: &str,
560        enabled: bool,
561    ) -> BoxFuture<'_, Result<(), ClientError>> {
562        let command = OinoCommand::SetExtensionEnabled {
563            id: id.to_owned(),
564            enabled,
565        };
566        self.send(command.into()).map(|r| r.map(drop)).boxed()
567    }
568}