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