Skip to main content

mobius/protocol/
frontend.rs

1//! Frontend-neutral contribution and presentation records.
2
3use serde::Deserialize;
4use serde::Serialize;
5
6use super::EventMsg;
7use super::ModelStepOutcome;
8use super::Op;
9use super::SessionFileReference;
10use super::WebSearchAction;
11
12/// A frontend command declared by a capability.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct FrontendCommand {
15    pub name: String,
16    pub arguments: String,
17    pub description: String,
18    /// Whether the frontend must wait for the current turn to finish before submitting this command.
19    pub requires_idle: bool,
20}
21
22/// UI metadata exported by one capability.
23#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
24pub struct FrontendContribution {
25    pub capability: String,
26    /// Whether the composed runtime installs session-bound file attachment endpoints.
27    pub accepts_file_attachments: bool,
28    /// Optional capability-owned item count for generic summaries.
29    pub count: Option<usize>,
30    pub commands: Vec<FrontendCommand>,
31    pub widgets: Vec<FrontendWidget>,
32    pub references: Vec<FrontendReference>,
33}
34
35/// One middleware entry and its frontend-neutral configuration controls.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct MiddlewareFeature {
38    pub id: String,
39    pub label: String,
40    pub description: String,
41    pub required: bool,
42    pub settings: Vec<FrontendSetting>,
43}
44
45/// One schema-advertised setting rendered by a thin frontend.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct FrontendSetting {
48    pub id: String,
49    pub label: String,
50    pub description: String,
51    /// Whether thin frontends should expose this setting beside the message composer.
52    pub composer: bool,
53    #[serde(flatten)]
54    pub kind: FrontendSettingKind,
55}
56
57/// Generic control metadata for a schema-advertised setting.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
60pub enum FrontendSettingKind {
61    Integer {
62        min: i64,
63        #[serde(default, skip_serializing_if = "Option::is_none")]
64        max: Option<i64>,
65        step: i64,
66    },
67    Select {
68        options: Vec<FrontendSettingOption>,
69        #[serde(default, skip_serializing_if = "Option::is_none")]
70        unset_label: Option<String>,
71    },
72}
73
74/// One exact value in a schema-advertised select control.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct FrontendSettingOption {
77    pub value: String,
78    pub label: String,
79    pub description: String,
80    pub symbol: Option<FrontendSymbol>,
81    pub tone: FrontendTone,
82    /// Optional capabilities excluded while this choice is active.
83    pub disables: Vec<String>,
84}
85
86/// Scalar value accepted by the generic setting controls.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(untagged)]
89pub enum FrontendSettingValue {
90    Integer(i64),
91    String(String),
92}
93
94/// One chat reference supplied by a capability.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct FrontendReference {
97    pub trigger: char,
98    pub value: String,
99    pub description: String,
100}
101
102/// One capability-rendered view mounted into a standard frontend slot.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct FrontendWidget {
105    pub id: String,
106    pub slot: FrontendSlot,
107    pub text: String,
108    pub tone: FrontendTone,
109    pub symbol: Option<FrontendSymbol>,
110    pub icon_only: bool,
111    pub progress: Option<FrontendProgress>,
112    pub content: Option<FrontendWidgetContent>,
113    /// Optional operation invoked when a frontend activates this widget.
114    pub action: Option<Op>,
115}
116
117/// Determinate progress rendered by a frontend widget.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
119pub struct FrontendProgress {
120    pub completed: usize,
121    pub total: usize,
122}
123
124/// Capability-owned content shown when a frontend widget is opened.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(tag = "type", rename_all = "snake_case")]
127pub enum FrontendWidgetContent {
128    Blocks {
129        title: String,
130        blocks: Vec<FrontendBlock>,
131    },
132    Picker {
133        title: String,
134        options: Vec<FrontendPickerOption>,
135    },
136    ActionList {
137        title: String,
138        items: Vec<FrontendActionListItem>,
139        /// Actions on the whole list, such as adding an item.
140        actions: Vec<FrontendAction>,
141    },
142}
143
144/// Stable locations a thin frontend shell makes available to capabilities.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
146#[serde(rename_all = "snake_case")]
147pub enum FrontendSlot {
148    Header,
149    ComposerHeader,
150    ComposerFooter,
151    MessageActions,
152    /// A transient capability-owned item after the live transcript.
153    TranscriptTail,
154    /// A capability destination mounted by the frontend shell.
155    Navigation,
156    /// A capability action mounted in the current chat's menu.
157    ChatMenu,
158}
159
160/// Capability-rendered transcript content with frontend-neutral formatting and tone.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct FrontendBlock {
163    pub id: Option<String>,
164    pub group: Option<String>,
165    pub update: FrontendBlockUpdate,
166    pub state: FrontendBlockState,
167    pub role: FrontendBlockRole,
168    /// Compact, standalone row label. Frontends must not derive this from `text`.
169    pub title: String,
170    /// Expandable body or artifact content.
171    pub text: String,
172    pub symbol: Option<FrontendSymbol>,
173    /// Downloadable files owned by the session rendering this block.
174    pub files: Vec<SessionFileReference>,
175    pub format: FrontendBlockFormat,
176    pub tone: FrontendTone,
177}
178
179/// A block together with its explicit semantic owner.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181pub struct RenderedBlock {
182    pub capability: String,
183    pub block: FrontendBlock,
184}
185
186/// How a block changes the matching capability-scoped ID.
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case")]
189pub enum FrontendBlockUpdate {
190    Replace,
191    /// Append a block, separating nonempty text with a newline unless a boundary already has one.
192    Append,
193}
194
195impl FrontendBlockUpdate {
196    /// Applies a rendered text block without removing meaningful whitespace.
197    pub fn apply(self, current: &mut String, text: &str) {
198        if self == Self::Replace {
199            current.clear();
200        } else if !current.is_empty()
201            && !text.is_empty()
202            && !current.ends_with('\n')
203            && !text.starts_with('\n')
204        {
205            current.push('\n');
206        }
207        current.push_str(text);
208    }
209}
210
211/// Lifecycle state of one rendered transcript block.
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(rename_all = "snake_case")]
214pub enum FrontendBlockState {
215    Pending,
216    Complete,
217}
218
219/// Semantic category used for grouping, summaries, filtering, and icons.
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(rename_all = "snake_case")]
222pub enum FrontendBlockRole {
223    Activity,
224    Tool,
225    WebSearch,
226    Artifact,
227    Approval,
228    Notice,
229}
230
231/// Frontend-neutral structure carried by a transcript block.
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(rename_all = "snake_case")]
234pub enum FrontendBlockFormat {
235    PlainText,
236    UnifiedDiff,
237}
238
239/// One selectable action supplied by a capability.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241pub struct FrontendPickerOption {
242    pub label: String,
243    pub description: String,
244    pub detail: String,
245    pub symbol: Option<FrontendSymbol>,
246    pub shows_detail: bool,
247    pub op: Op,
248}
249
250/// One compact status row with optional trailing actions.
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct FrontendActionListItem {
253    pub id: String,
254    pub text: String,
255    pub state: FrontendListItemState,
256    pub actions: Vec<FrontendAction>,
257}
258
259/// Semantic state for one compact list row.
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(rename_all = "snake_case")]
262pub enum FrontendListItemState {
263    Plain,
264    Pending,
265    InProgress,
266    Completed,
267}
268
269/// One labeled, icon-forward action attached to a list item.
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271pub struct FrontendAction {
272    pub id: String,
273    pub label: String,
274    pub symbol: FrontendSymbol,
275    pub tone: FrontendTone,
276    pub op: Op,
277    /// Optional capability-owned copy for editing input before submitting the action.
278    pub editor: Option<FrontendEditor>,
279}
280
281/// Labels for a frontend-native single-text-input editor.
282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283pub struct FrontendEditor {
284    pub title: String,
285    pub label: String,
286    pub description: String,
287    pub submit_label: String,
288}
289
290/// One timestamped semantic event shown inside a capability preview.
291#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
292pub struct FrontendPreviewEvent {
293    /// Canonical message identity retained from the recorded event.
294    pub submission_id: Option<String>,
295    pub recorded_at_ms: i64,
296    pub event: EventMsg,
297}
298
299/// Generic capability UI updates understood by every frontend.
300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
301#[serde(tag = "frontend_type", rename_all = "snake_case")]
302pub enum FrontendEvent {
303    Render {
304        capability: String,
305        block: FrontendBlock,
306    },
307    Widget {
308        capability: String,
309        item: FrontendWidget,
310    },
311    RemoveWidget {
312        capability: String,
313        id: String,
314    },
315    Picker {
316        title: String,
317        options: Vec<FrontendPickerOption>,
318    },
319    Preview {
320        id: String,
321        title: String,
322        subtitle: String,
323        page_id: String,
324        update: FrontendPreviewUpdate,
325        events: Vec<FrontendPreviewEvent>,
326        next: Option<Op>,
327    },
328}
329
330/// How one preview page changes the matching frontend preview.
331#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
332#[serde(rename_all = "snake_case")]
333pub enum FrontendPreviewUpdate {
334    Replace,
335    Prepend,
336}
337
338/// A presentation hint rather than a terminal-specific color.
339#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
340#[serde(rename_all = "snake_case")]
341pub enum FrontendTone {
342    Neutral,
343    Success,
344    Warning,
345    Error,
346}
347
348impl EventMsg {
349    /// Renders framework-owned semantic events without frontend prose parsing.
350    #[must_use]
351    pub fn presentation(&self) -> Option<RenderedBlock> {
352        let block = match self {
353            Self::Error(error) => FrontendBlock {
354                id: None,
355                group: None,
356                update: FrontendBlockUpdate::Replace,
357                state: FrontendBlockState::Complete,
358                role: FrontendBlockRole::Notice,
359                title: "Error".into(),
360                text: error.message.clone(),
361                symbol: None,
362                files: Vec::new(),
363                format: FrontendBlockFormat::PlainText,
364                tone: FrontendTone::Error,
365            },
366            Self::Warning(warning) => FrontendBlock {
367                id: None,
368                group: None,
369                update: FrontendBlockUpdate::Replace,
370                state: FrontendBlockState::Complete,
371                role: FrontendBlockRole::Notice,
372                title: "Warning".into(),
373                text: warning.message.clone(),
374                symbol: None,
375                files: Vec::new(),
376                format: FrontendBlockFormat::PlainText,
377                tone: FrontendTone::Warning,
378            },
379            Self::TurnAborted(turn) => FrontendBlock {
380                id: None,
381                group: Some(turn.turn_id.clone()),
382                update: FrontendBlockUpdate::Replace,
383                state: FrontendBlockState::Complete,
384                role: FrontendBlockRole::Notice,
385                title: "Turn aborted".into(),
386                text: turn.reason.clone(),
387                symbol: None,
388                files: Vec::new(),
389                format: FrontendBlockFormat::PlainText,
390                tone: FrontendTone::Warning,
391            },
392            Self::ModelStepCompleted(step) if step.outcome == ModelStepOutcome::Retrying => {
393                FrontendBlock {
394                    id: Some(format!("{}/retry", step.model_step_id)),
395                    group: Some(step.turn_id.clone()),
396                    update: FrontendBlockUpdate::Replace,
397                    state: FrontendBlockState::Complete,
398                    role: FrontendBlockRole::Notice,
399                    title: "Reconnecting…".into(),
400                    text: String::new(),
401                    symbol: None,
402                    files: Vec::new(),
403                    format: FrontendBlockFormat::PlainText,
404                    tone: FrontendTone::Warning,
405                }
406            }
407            Self::WebSearchBegin(search) => FrontendBlock {
408                id: Some(format!("{}/{}", search.model_step_id, search.call_id)),
409                group: Some(search.turn_id.clone()),
410                update: FrontendBlockUpdate::Replace,
411                state: FrontendBlockState::Pending,
412                role: FrontendBlockRole::WebSearch,
413                title: "Searching the web".into(),
414                text: String::new(),
415                symbol: Some(FrontendSymbol::Search),
416                files: Vec::new(),
417                format: FrontendBlockFormat::PlainText,
418                tone: FrontendTone::Neutral,
419            },
420            Self::WebSearchEnd(search) => {
421                let (title, text, tone) = match &search.action {
422                    WebSearchAction::Search { queries } => (
423                        "Searched the web",
424                        queries.join("\n"),
425                        FrontendTone::Success,
426                    ),
427                    WebSearchAction::OpenPage { url } => (
428                        "Opened a web page",
429                        url.clone().unwrap_or_default(),
430                        FrontendTone::Success,
431                    ),
432                    WebSearchAction::FindInPage { url, pattern } => {
433                        let text = match (url, pattern) {
434                            (Some(url), Some(pattern)) => format!("{pattern}\n{url}"),
435                            (Some(url), None) => url.clone(),
436                            (None, Some(pattern)) => pattern.clone(),
437                            (None, None) => String::new(),
438                        };
439                        ("Searched a web page", text, FrontendTone::Success)
440                    }
441                    WebSearchAction::Interrupted => (
442                        "Web search interrupted",
443                        String::new(),
444                        FrontendTone::Warning,
445                    ),
446                    WebSearchAction::Other => {
447                        ("Web search complete", String::new(), FrontendTone::Success)
448                    }
449                };
450                FrontendBlock {
451                    id: Some(format!("{}/{}", search.model_step_id, search.call_id)),
452                    group: Some(search.turn_id.clone()),
453                    update: FrontendBlockUpdate::Replace,
454                    state: FrontendBlockState::Complete,
455                    role: FrontendBlockRole::WebSearch,
456                    title: title.into(),
457                    text,
458                    symbol: Some(FrontendSymbol::Search),
459                    files: Vec::new(),
460                    format: FrontendBlockFormat::PlainText,
461                    tone,
462                }
463            }
464            Self::Frontend(FrontendEvent::Render { capability, block }) => {
465                return Some(RenderedBlock {
466                    capability: capability.clone(),
467                    block: block.clone(),
468                });
469            }
470            _ => return None,
471        };
472        Some(RenderedBlock {
473            capability: match self {
474                Self::WebSearchBegin(_) | Self::WebSearchEnd(_) => "web_search",
475                _ => "agent",
476            }
477            .into(),
478            block,
479        })
480    }
481}
482
483/// A presentation hint rather than a name from any one icon set, the same way
484/// [`FrontendTone`] names a role instead of a color.
485///
486/// A gateway does not know whether the frontend draws SF Symbols, terminal glyphs, or
487/// SVGs, so it names what a glyph stands for and each frontend supplies its own artwork.
488/// [`Self::Custom`] carries anything outside this list so a plugin can still ship a glyph
489/// this enum has never heard of. It is explicitly best-effort: a frontend that cannot
490/// resolve the name falls back to a placeholder. Provider manifests use it for their own
491/// brand tokens so adding a provider does not expand this semantic enum.
492#[derive(Debug, Clone, PartialEq, Eq)]
493pub enum FrontendSymbol {
494    Agent,
495    Brain,
496    Branch,
497    Chat,
498    Delete,
499    Edit,
500    Promote,
501    Route,
502    Search,
503    Shield,
504    ShieldAlert,
505    ShieldCheck,
506    ShieldOff,
507    Sparkle,
508    Storage,
509    Task,
510    Custom(String),
511}
512
513impl FrontendSymbol {
514    /// The wire name. Also the stable token capabilities build action ids from.
515    pub fn as_str(&self) -> &str {
516        match self {
517            Self::Agent => "agent",
518            Self::Brain => "brain",
519            Self::Branch => "branch",
520            Self::Chat => "chat",
521            Self::Delete => "delete",
522            Self::Edit => "edit",
523            Self::Promote => "promote",
524            Self::Route => "route",
525            Self::Search => "search",
526            Self::Shield => "shield",
527            Self::ShieldAlert => "shield_alert",
528            Self::ShieldCheck => "shield_check",
529            Self::ShieldOff => "shield_off",
530            Self::Sparkle => "sparkle",
531            Self::Storage => "storage",
532            Self::Task => "task",
533            Self::Custom(name) => name,
534        }
535    }
536
537    /// Unknown names become [`Self::Custom`] rather than an error: a frontend rendering a
538    /// placeholder is a better outcome than a gateway refusing to decode a whole frame.
539    pub(crate) fn from_wire(name: &str) -> Self {
540        match name {
541            "agent" => Self::Agent,
542            "brain" => Self::Brain,
543            "branch" => Self::Branch,
544            "chat" => Self::Chat,
545            "delete" => Self::Delete,
546            "edit" => Self::Edit,
547            "promote" => Self::Promote,
548            "route" => Self::Route,
549            "search" => Self::Search,
550            "shield" => Self::Shield,
551            "shield_alert" => Self::ShieldAlert,
552            "shield_check" => Self::ShieldCheck,
553            "shield_off" => Self::ShieldOff,
554            "sparkle" => Self::Sparkle,
555            "storage" => Self::Storage,
556            "task" => Self::Task,
557            other => Self::Custom(other.to_owned()),
558        }
559    }
560}
561
562impl std::fmt::Display for FrontendSymbol {
563    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
564        formatter.write_str(self.as_str())
565    }
566}
567
568impl Serialize for FrontendSymbol {
569    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
570        serializer.serialize_str(self.as_str())
571    }
572}
573
574impl<'de> Deserialize<'de> for FrontendSymbol {
575    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
576        // A known name round-trips out of `Custom` on the way back in, so the two spellings
577        // of the same glyph cannot drift apart once a frame has crossed the wire.
578        String::deserialize(deserializer).map(|name| Self::from_wire(&name))
579    }
580}