Skip to main content

oino_core/session/
frontend.rs

1//! Frontend command surface (modes.md §4 `_oino/*`, ui-protocol §5–§8): completions, panel
2//! actions, shortcuts, editor intents, service calls and provider sign-in.
3//!
4//! Every frontend (in-process TUI, desktop, RPC clients) reaches these through
5//! `oino-client`; core only routes to the owning extension and never renders anything.
6
7use std::path::Path;
8use std::time::Duration;
9
10use oino_ai::JsonValue;
11use oino_models::auth::credential::{AuthCredential, OAuthCredential};
12use serde::{Deserialize, Serialize};
13use serde_json::json;
14
15use super::{AgentSession, lock};
16use crate::extensions::api::CallKind;
17
18/// Budget of each completion contributor (ui-protocol §7.3).
19const COMPLETION_BUDGET: Duration = Duration::from_millis(100);
20/// Most `@` path candidates returned.
21const MAX_PATH_ITEMS: usize = 50;
22/// Most filesystem entries visited per `@` completion.
23const MAX_PATH_SCAN: usize = 20_000;
24/// Default deadline of a frontend service call.
25const SERVICE_TIMEOUT: Duration = Duration::from_secs(30);
26/// Caller id frontends use for service calls.
27pub const FRONTEND_CALLER: &str = "oino.frontend";
28
29/// Failure of a frontend command.
30#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
31#[non_exhaustive]
32pub enum FrontendError {
33    /// Unknown panel, shortcut, service, command or provider.
34    #[error("{0}")]
35    NotFound(String),
36    /// The target exists but cannot do this.
37    #[error("{0}")]
38    Unsupported(String),
39    /// The extension or the credential store failed.
40    #[error("{0}")]
41    Failed(String),
42}
43
44/// Transcript row a render spec targets (ui-protocol §6.4, §6.7).
45#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub enum RenderTarget {
48    /// Extension message (`customType`).
49    CustomMessage,
50    /// Extension session entry (`customType`).
51    CustomEntry,
52    /// A tool call (extension tool or built-in override).
53    #[default]
54    Tool,
55    /// An end-of-turn footer (§6.7): `render{target:"turnFooter", key, turnEntryId, …}`.
56    TurnFooter,
57}
58
59impl RenderTarget {
60    /// Wire name.
61    #[must_use]
62    pub const fn as_str(self) -> &'static str {
63        match self {
64            Self::CustomMessage => "customMessage",
65            Self::CustomEntry => "customEntry",
66            Self::Tool => "tool",
67            Self::TurnFooter => "turnFooter",
68        }
69    }
70
71    /// Registry spec target (`renderer-target`, ext-api kebab-case).
72    #[must_use]
73    pub const fn spec_str(self) -> &'static str {
74        match self {
75            Self::CustomMessage => "customMessage",
76            Self::CustomEntry => "customEntry",
77            Self::Tool => "tool",
78            Self::TurnFooter => "turn-footer",
79        }
80    }
81
82    /// Parses a wire name.
83    #[must_use]
84    pub fn parse(name: &str) -> Option<Self> {
85        match name {
86            "customMessage" => Some(Self::CustomMessage),
87            "customEntry" => Some(Self::CustomEntry),
88            "tool" => Some(Self::Tool),
89            "turnFooter" => Some(Self::TurnFooter),
90            _ => None,
91        }
92    }
93}
94
95/// Row density the frontend asks for (the embedder's `render.style` utility).
96#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub enum RenderDensity {
99    /// One line per row.
100    #[default]
101    Simple,
102    /// One line plus a short preview.
103    Regular,
104    /// Details expanded.
105    Advanced,
106}
107
108impl RenderDensity {
109    /// Wire name.
110    #[must_use]
111    pub const fn as_str(self) -> &'static str {
112        match self {
113            Self::Simple => "simple",
114            Self::Regular => "regular",
115            Self::Advanced => "advanced",
116        }
117    }
118
119    /// Parses a wire name.
120    #[must_use]
121    pub fn parse(name: &str) -> Option<Self> {
122        match name {
123            "simple" => Some(Self::Simple),
124            "regular" => Some(Self::Regular),
125            "advanced" => Some(Self::Advanced),
126            _ => None,
127        }
128    }
129}
130
131/// Available width class (ui-protocol §6.1 `responsive`).
132#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub enum RenderWidth {
135    /// Narrow (`< 60` columns).
136    Narrow,
137    /// Normal.
138    #[default]
139    Normal,
140    /// Wide (`≥ 100` columns).
141    Wide,
142}
143
144impl RenderWidth {
145    /// Wire name.
146    #[must_use]
147    pub const fn as_str(self) -> &'static str {
148        match self {
149            Self::Narrow => "narrow",
150            Self::Normal => "normal",
151            Self::Wide => "wide",
152        }
153    }
154
155    /// Parses a wire name.
156    #[must_use]
157    pub fn parse(name: &str) -> Option<Self> {
158        match name {
159            "narrow" => Some(Self::Narrow),
160            "normal" => Some(Self::Normal),
161            "wide" => Some(Self::Wide),
162            _ => None,
163        }
164    }
165}
166
167/// `_oino/render` request: one row's render input.
168#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
169#[serde(rename_all = "camelCase")]
170pub struct RenderRequest {
171    /// Row kind.
172    pub target: RenderTarget,
173    /// Custom type or tool name (the renderer key of a turn footer, §6.7).
174    pub key: String,
175    /// Entry or tool-call id (cache key).
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub entry_id: Option<String>,
178    /// Turn id of a `turnFooter` request = the user message entry that started it.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub turn_entry_id: Option<String>,
181    /// Zero-based turn index (0 = first turn).
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub turn_index: Option<u64>,
184    /// Whether the turn is still running (§6.7).
185    #[serde(default)]
186    pub running: bool,
187    /// Density hint.
188    #[serde(default)]
189    pub density: RenderDensity,
190    /// Expanded state.
191    #[serde(default)]
192    pub expanded: bool,
193    /// Width class.
194    #[serde(default)]
195    pub width: RenderWidth,
196    /// Row payload (message / entry / `{toolCall, args, result?, isError?}`).
197    #[serde(default)]
198    pub data: JsonValue,
199}
200
201impl RenderRequest {
202    /// A tool-call row.
203    #[must_use]
204    pub fn tool(key: impl Into<String>, entry_id: Option<String>, data: JsonValue) -> Self {
205        Self {
206            target: RenderTarget::Tool,
207            key: key.into(),
208            entry_id,
209            turn_entry_id: None,
210            turn_index: None,
211            running: false,
212            density: RenderDensity::Simple,
213            expanded: false,
214            width: RenderWidth::Normal,
215            data,
216        }
217    }
218}
219
220/// One completion candidate (ui-protocol §7.3).
221#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(rename_all = "camelCase")]
223pub struct CompletionEntry {
224    /// Text that replaces `text[from..cursor]`.
225    pub value: String,
226    /// Label shown (defaults to `value`).
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub label: Option<String>,
229    /// Extra description.
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub description: Option<String>,
232    /// Short tag (`dir`, `skill`, …).
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub tag: Option<String>,
235    /// Tone (ui-protocol §6.5).
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub tone: Option<String>,
238    /// Lower tiers sort first.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub sort_tier: Option<i64>,
241}
242
243/// Completions from one source.
244#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(rename_all = "camelCase")]
246pub struct CompletionGroup {
247    /// `commands`, `command:<name>`, `files`, or the contributing extension id.
248    pub source: String,
249    /// Character offset where an accepted value starts replacing (up to the cursor); the
250    /// start of the token under the cursor when the contributor omits it.
251    #[serde(default)]
252    pub from: Option<usize>,
253    /// Candidates.
254    pub items: Vec<CompletionEntry>,
255}
256
257/// Editor intents of `editor_event` (ui-protocol §7.2).
258#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
259#[serde(rename_all = "camelCase")]
260pub enum EditorIntent {
261    /// Text changed (debounced by the frontend).
262    Changed,
263    /// Text became empty.
264    Cleared,
265    /// ↓ pressed in an empty composer (consumable).
266    EmptyArrowDown,
267    /// About to send.
268    Submit,
269    /// Backspace next to an attachment token (consumable).
270    TokenBackspace,
271}
272
273impl EditorIntent {
274    /// Whether an extension may consume the intent (the frontend waits for the reply).
275    #[must_use]
276    pub const fn consumable(self) -> bool {
277        matches!(self, Self::EmptyArrowDown | Self::TokenBackspace)
278    }
279}
280
281/// One `editor_event`.
282#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
283#[serde(rename_all = "camelCase")]
284pub struct EditorEvent {
285    /// Frontend event id (echoed to extensions).
286    pub event_id: String,
287    /// Intent.
288    pub intent: EditorIntent,
289    /// Composer text after the change.
290    #[serde(default)]
291    pub text: String,
292    /// Attachment token next to the cursor (`tokenBackspace`).
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub token_id: Option<String>,
295}
296
297/// A provider as `/login` lists it.
298#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(rename_all = "camelCase")]
300pub struct AuthProviderInfo {
301    /// Provider id.
302    pub id: String,
303    /// Display name.
304    pub name: String,
305    /// Supports OAuth sign-in (`/login`).
306    pub oauth: bool,
307    /// Credentials are configured (any source).
308    pub configured: bool,
309    /// Stored credential kind (`api_key` | `oauth`), when `auth.json` has one.
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub stored: Option<String>,
312    /// Environment variables that configure the provider.
313    pub env: Vec<String>,
314}
315
316/// Character index → byte index of `text` (clamped to the end).
317fn byte_index(text: &str, chars: usize) -> usize {
318    text.char_indices()
319        .nth(chars)
320        .map_or(text.len(), |(i, _)| i)
321}
322
323/// Case-insensitive subsequence match (`fuzzyFilter` approximation).
324fn fuzzy(candidate: &str, query: &str) -> bool {
325    let mut chars = candidate.chars().flat_map(char::to_lowercase);
326    query
327        .chars()
328        .flat_map(char::to_lowercase)
329        .all(|q| chars.any(|c| c == q))
330}
331
332/// `@` candidates under `cwd` whose relative path fuzzy-matches `query` (respects
333/// `.gitignore`; directories end with `/`). Prefix matches sort first.
334fn path_candidates(cwd: &Path, query: &str) -> Vec<CompletionEntry> {
335    let (base, needle) = match query.rfind('/') {
336        Some(i) if query.starts_with("~/") || query.starts_with('/') => {
337            (Path::new(&query[..=i]).to_path_buf(), &query[i + 1..])
338        }
339        _ => (cwd.to_path_buf(), query),
340    };
341    let base = match base.strip_prefix("~/") {
342        Ok(rest) => oino_settings::paths::home_dir().map_or(base.clone(), |h| h.join(rest)),
343        Err(_) => base,
344    };
345    let absolute = base != cwd;
346    let mut found: Vec<(bool, String, bool)> = Vec::new();
347    let walker = ignore::WalkBuilder::new(&base)
348        .hidden(!needle.starts_with('.'))
349        .max_depth(Some(if absolute { 1 } else { 8 }))
350        .build();
351    for entry in walker.filter_map(Result::ok).take(MAX_PATH_SCAN) {
352        let Ok(relative) = entry.path().strip_prefix(&base) else {
353            continue;
354        };
355        let relative = relative.to_string_lossy().replace('\\', "/");
356        if relative.is_empty() || !fuzzy(&relative, needle) {
357            continue;
358        }
359        let is_dir = entry.file_type().is_some_and(|t| t.is_dir());
360        let shown = if absolute {
361            format!("{}{relative}", &query[..query.len() - needle.len()])
362        } else {
363            relative.clone()
364        };
365        let prefix = relative.to_lowercase().starts_with(&needle.to_lowercase());
366        found.push((prefix, shown, is_dir));
367    }
368    found.sort_by(|a, b| {
369        b.0.cmp(&a.0)
370            .then(a.1.matches('/').count().cmp(&b.1.matches('/').count()))
371            .then(a.1.cmp(&b.1))
372    });
373    found
374        .into_iter()
375        .take(MAX_PATH_ITEMS)
376        .map(|(_, path, is_dir)| {
377            let path = if is_dir { format!("{path}/") } else { path };
378            CompletionEntry {
379                value: format!("@{path}"),
380                label: Some(path),
381                tag: is_dir.then(|| "dir".to_owned()),
382                ..CompletionEntry::default()
383            }
384        })
385        .collect()
386}
387
388impl AgentSession {
389    /// The composer text the frontend reported last (`ui.editor-text`).
390    #[must_use]
391    pub fn editor_text(&self) -> String {
392        lock(&self.inner.editor_text).clone()
393    }
394
395    /// Merged completions for `text` with the cursor at character `cursor` (ui-protocol
396    /// §7.3): slash commands, command arguments (`complete-command`), `@` paths and every
397    /// extension `complete-input` contributor, each within 100 ms.
398    pub async fn completions(&self, text: &str, cursor: usize) -> Vec<CompletionGroup> {
399        let before = &text[..byte_index(text, cursor)];
400        let cursor = before.chars().count();
401        let token_start = before
402            .char_indices()
403            .rfind(|(_, c)| c.is_whitespace())
404            .map_or(0, |(i, c)| before[..i + c.len_utf8()].chars().count());
405        let token = &before[byte_index(before, token_start)..];
406        let mut groups = Vec::new();
407        if let Some(line) = before.strip_prefix('/') {
408            groups.extend(self.command_completions(line).await);
409        }
410        if let Some(query) = token.strip_prefix('@') {
411            let cwd = self.inner.cwd.clone();
412            let query = query.to_owned();
413            let items = tokio::task::spawn_blocking(move || path_candidates(&cwd, &query))
414                .await
415                .unwrap_or_default();
416            if !items.is_empty() {
417                groups.push(CompletionGroup {
418                    source: "files".to_owned(),
419                    from: Some(token_start),
420                    items,
421                });
422            }
423        }
424        groups.extend(
425            self.contributed_completions(text, cursor, token_start)
426                .await,
427        );
428        groups
429    }
430
431    /// `/name` completion, or `complete-command` arguments after `/name `.
432    async fn command_completions(&self, line: &str) -> Option<CompletionGroup> {
433        let runner = self.runner();
434        let cx = runner.call_context().with_kind(CallKind::Command);
435        match line.split_once(' ') {
436            None => {
437                let items: Vec<CompletionEntry> = self
438                    .commands()
439                    .into_iter()
440                    .filter(|c| fuzzy(&c.name, line))
441                    .map(|c| CompletionEntry {
442                        value: format!("/{}", c.name),
443                        label: Some(format!("/{}", c.name)),
444                        description: c.description,
445                        tag: Some(c.source),
446                        ..CompletionEntry::default()
447                    })
448                    .collect();
449                (!items.is_empty()).then(|| CompletionGroup {
450                    source: "commands".to_owned(),
451                    from: Some(0),
452                    items,
453                })
454            }
455            Some((name, argument)) => {
456                let command = runner.command(name).filter(|c| c.spec.completions)?;
457                let call =
458                    command
459                        .extension
460                        .instance
461                        .complete_command(&command.spec.name, argument, cx);
462                let items = tokio::time::timeout(COMPLETION_BUDGET, call)
463                    .await
464                    .unwrap_or_default();
465                (!items.is_empty()).then(|| CompletionGroup {
466                    source: format!("command:{name}"),
467                    from: Some(name.chars().count() + 2),
468                    items: items
469                        .into_iter()
470                        .map(|item| CompletionEntry {
471                            label: Some(item.label),
472                            value: item.value,
473                            description: item.description,
474                            ..CompletionEntry::default()
475                        })
476                        .collect(),
477                })
478            }
479        }
480    }
481
482    /// Groups of every extension `complete-input` contributor (100 ms each, in parallel);
483    /// groups without `from` replace the token under the cursor.
484    async fn contributed_completions(
485        &self,
486        text: &str,
487        cursor: usize,
488        token_start: usize,
489    ) -> Vec<CompletionGroup> {
490        let runner = self.runner();
491        let cx = runner.call_context().with_kind(CallKind::Command);
492        let request = json!({"text": text, "cursor": cursor});
493        let contributors = runner.set().extensions().iter().map(|extension| {
494            let call = extension
495                .instance
496                .complete_input(request.clone(), cx.clone());
497            let id = extension.instance.id().to_owned();
498            async move {
499                let value = tokio::time::timeout(COMPLETION_BUDGET, call)
500                    .await
501                    .ok()
502                    .flatten()?;
503                let groups: Vec<CompletionGroup> = serde_json::from_value(value).ok()?;
504                Some((id, groups))
505            }
506        });
507        futures::future::join_all(contributors)
508            .await
509            .into_iter()
510            .flatten()
511            .flat_map(|(id, contributed)| {
512                contributed
513                    .into_iter()
514                    .filter(|g| !g.items.is_empty())
515                    .map(move |mut group| {
516                        group.from.get_or_insert(token_start);
517                        if group.source.is_empty() {
518                            group.source.clone_from(&id);
519                        }
520                        group
521                    })
522            })
523            .collect()
524    }
525
526    /// The extension that owns entry `entry_id` (§6.1), from the registry.
527    fn panel_owner(&self, entry_id: &str) -> Option<String> {
528        lock(&self.inner.registry)
529            .get(entry_id)
530            .map(|entry| entry.extension_id.clone())
531            .or_else(|| {
532                entry_id
533                    .rsplit_once("/panel-")
534                    .map(|(owner, _)| owner.to_owned())
535            })
536    }
537
538    /// Delivers a panel action (`oino_ui_action{panelId, actionId, value?}`) to the panel's
539    /// owner in a command-capable context. Returns once dispatched; handler failures become
540    /// `extension_error`.
541    ///
542    /// # Errors
543    ///
544    /// [`FrontendError::NotFound`] for an unknown panel.
545    pub fn ui_action(
546        &self,
547        panel_id: &str,
548        action_id: &str,
549        value: Option<JsonValue>,
550    ) -> Result<(), FrontendError> {
551        self.dispatch_action(
552            &crate::ui::UiAction {
553                panel_id: panel_id.to_owned(),
554                action_id: action_id.to_owned(),
555                value,
556                ..crate::ui::UiAction::default()
557            },
558            false,
559        )
560    }
561
562    /// Delivers a v2 action payload (`_oino/ui_action`, §4.6); `$close` also deletes the
563    /// entry after the event is delivered (§6.6).
564    ///
565    /// # Errors
566    ///
567    /// [`FrontendError::NotFound`] for an unknown or disposed entry.
568    pub fn ui_action_with(&self, action: &crate::ui::UiAction) -> Result<(), FrontendError> {
569        self.dispatch_action(action, action.action_id == "$close")
570    }
571
572    /// `_oino/ui_visible` (§6.5): aggregates over frontends and sends `$show`/`$hide` to the
573    /// owner when the aggregate changes.
574    pub fn ui_visible(&self, frontend: &str, entry_id: &str, visible: bool) {
575        let change = lock(&self.inner.registry).set_visible(frontend, entry_id, visible);
576        if let Some(change) = change {
577            let action = crate::ui::UiAction {
578                panel_id: change.entry_id,
579                action_id: if change.visible { "$show" } else { "$hide" }.to_owned(),
580                ..crate::ui::UiAction::default()
581            };
582            let _ = self.dispatch_action(&action, false);
583        }
584    }
585
586    /// The current registry snapshot (`_oino/ui_snapshot`, `_oino/ui_resync`, §9.3).
587    #[must_use]
588    pub fn ui_snapshot(&self) -> crate::ui::UiSnapshot {
589        lock(&self.inner.registry).snapshot()
590    }
591
592    /// `_oino/ui_resync{rev}`: the frontend saw a gap, so it always gets a full snapshot.
593    #[must_use]
594    pub fn ui_resync(&self) -> crate::ui::UiSnapshot {
595        self.ui_snapshot()
596    }
597
598    /// Applies one retained producer call (`ui.fire`, §6.3): the registry records it and
599    /// publishes a patch; the legacy record is kept for v1 clients and the v1 replay.
600    ///
601    /// # Errors
602    ///
603    /// Invalid trees/patches, foreign ids and limit failures (§9.1); the failure is also
604    /// published as `extension_error`.
605    pub fn ui_fire(
606        &self,
607        extension: &str,
608        method: &str,
609        params: oino_ai::JsonObject,
610    ) -> Result<crate::ui::ProduceOutcome, crate::ui::RegistryError> {
611        let outcome = {
612            let mut registry = lock(&self.inner.registry);
613            let outcome = registry.produce(extension, method, &params);
614            if outcome.is_ok() {
615                registry.flush();
616            }
617            outcome
618        };
619        match outcome {
620            Ok(outcome) => {
621                let effect = matches!(outcome, crate::ui::ProduceOutcome::Effect)
622                    && !crate::ui::registry::is_meta_method(method);
623                if let Some(request) = self.inner.ui.fire(Some(extension), method, params)
624                    && effect
625                {
626                    let mut registry = lock(&self.inner.registry);
627                    registry.effect(request.to_registry());
628                    registry.flush();
629                }
630                Ok(outcome)
631            }
632            Err(error) => {
633                self.inner.hub.publish(crate::events::ExtensionErrorEvent {
634                    extension_path: extension.to_owned(),
635                    event: method.to_owned(),
636                    error: error.to_string(),
637                    stack: None,
638                });
639                Err(error)
640            }
641        }
642    }
643
644    /// Creates the turn-footer registry entry of a `turn-footer` renderer (§6.7).
645    pub fn ui_register_turn_footer(&self, extension: &str, key: &str) {
646        let mut registry = lock(&self.inner.registry);
647        if let Err(error) = registry.register_turn_footer(extension, key) {
648            drop(registry);
649            self.inner.hub.publish(crate::events::ExtensionErrorEvent {
650                extension_path: extension.to_owned(),
651                event: "register-renderer".to_owned(),
652                error: error.to_string(),
653                stack: None,
654            });
655        } else {
656            registry.flush();
657        }
658    }
659
660    fn dispatch_action(
661        &self,
662        action: &crate::ui::UiAction,
663        close: bool,
664    ) -> Result<(), FrontendError> {
665        let runner = self.runner();
666        let extension = self
667            .panel_owner(&action.panel_id)
668            .and_then(|owner| runner.set().get(&owner).cloned())
669            .ok_or_else(|| {
670                FrontendError::NotFound(format!("Unknown panel: {}", action.panel_id))
671            })?;
672        let mut payload = json!({
673            "type": "oino_ui_action",
674            "panelId": action.panel_id,
675            "actionId": action.action_id,
676        });
677        if let Some(value) = &action.value {
678            payload["value"] = value.clone();
679        }
680        if let Some(values) = &action.values {
681            payload["values"] = JsonValue::Object(values.clone());
682        }
683        if let Some(path) = &action.path {
684            payload["path"] = json!(path);
685        }
686        if let Some(source) = action.source {
687            let name = match source {
688                crate::ui::ActionSource::Tui => "tui",
689                crate::ui::ActionSource::App => "app",
690                crate::ui::ActionSource::Rpc => "rpc",
691            };
692            payload["source"] = json!(name);
693        }
694        let cx = runner.call_context().with_kind(CallKind::Command);
695        let session = self.clone();
696        let entry_id = action.panel_id.clone();
697        self.spawn_task(async move {
698            if let Err(error) = extension
699                .instance
700                .on_event("oino_ui_action", payload, cx)
701                .await
702            {
703                runner.emit_error(&extension.instance.path(), "oino_ui_action", error.message);
704            }
705            if close {
706                let mut registry = lock(&session.inner.registry);
707                registry.close(&entry_id);
708                registry.flush();
709            }
710        });
711        Ok(())
712    }
713
714    /// Runs an extension shortcut (`run-shortcut`) in a shortcut context. Returns once
715    /// dispatched; failures become `extension_error`.
716    ///
717    /// # Errors
718    ///
719    /// [`FrontendError::NotFound`] for an unknown shortcut id.
720    pub fn invoke_shortcut(&self, id: &str) -> Result<(), FrontendError> {
721        let runner = self.runner();
722        let extension = runner
723            .shortcuts()
724            .into_iter()
725            .find(|(_, spec)| spec.id == id)
726            .and_then(|(owner, _)| runner.set().get(&owner).cloned())
727            .ok_or_else(|| FrontendError::NotFound(format!("Unknown shortcut: {id}")))?;
728        let cx = runner.call_context().with_kind(CallKind::Shortcut);
729        let id = id.to_owned();
730        self.spawn_task(async move {
731            if let Err(error) = extension.instance.run_shortcut(&id, cx).await {
732                runner.emit_error(&extension.instance.path(), "shortcut", error.message);
733            }
734        });
735        Ok(())
736    }
737
738    /// Fires `setShortcuts` (ui-protocol §4) with every extension shortcut, so frontends bind
739    /// the keys (GA-43). Retained for late frontends.
740    pub(crate) fn publish_shortcuts(&self) {
741        let shortcuts: Vec<JsonValue> = self
742            .runner()
743            .shortcuts()
744            .into_iter()
745            .map(|(extension, spec)| {
746                json!({
747                    "id": spec.id,
748                    "keys": spec.default_keys,
749                    "description": spec.description,
750                    "extensionId": extension,
751                })
752            })
753            .collect();
754        let mut params = oino_ai::JsonObject::new();
755        params.insert("shortcuts".into(), JsonValue::Array(shortcuts));
756        let _ = self.inner.ui.fire(None, "setShortcuts", params);
757    }
758
759    /// Records the composer text and delivers `oino_editor` to subscribed extensions.
760    /// Consumable intents wait for the handlers and report whether one returned
761    /// `{consume: true}` (first wins); the others are dispatched in the background and
762    /// return `false`.
763    pub async fn editor_event(&self, event: EditorEvent) -> bool {
764        lock(&self.inner.editor_text).clone_from(&event.text);
765        let runner = self.runner();
766        let mut payload = json!({
767            "type": "oino_editor",
768            "eventId": event.event_id,
769            "intent": event.intent,
770            "text": event.text,
771        });
772        if let Some(token) = &event.token_id {
773            payload["tokenId"] = json!(token);
774        }
775        if !runner.has_handlers("oino_editor") {
776            return false;
777        }
778        if !event.intent.consumable() {
779            self.spawn_task(async move {
780                runner.emit(payload).await;
781            });
782            return false;
783        }
784        runner.emit_consumable(payload).await
785    }
786
787    /// Calls service `name` (of `target`, or of the first extension serving it) as the
788    /// frontend (`caller = "oino.frontend"`).
789    ///
790    /// # Errors
791    ///
792    /// [`FrontendError::NotFound`] when nobody serves it, [`FrontendError::Failed`] for
793    /// handler errors and the 30 s timeout.
794    pub async fn service_call(
795        &self,
796        target: Option<&str>,
797        name: &str,
798        payload: JsonValue,
799    ) -> Result<JsonValue, FrontendError> {
800        let runner = self.runner();
801        let extension = runner
802            .set()
803            .extensions()
804            .iter()
805            .filter(|e| target.is_none_or(|t| e.instance.id() == t))
806            .find(|e| e.instance.services().iter().any(|s| s == name))
807            .cloned()
808            .ok_or_else(|| {
809                FrontendError::NotFound(match target {
810                    Some(target) => format!("Unknown service: {target}/{name}"),
811                    None => format!("Unknown service: {name}"),
812                })
813            })?;
814        let cx = runner.call_context().with_kind(CallKind::Service);
815        let call = extension
816            .instance
817            .handle_service(name, payload, FRONTEND_CALLER, cx);
818        match tokio::time::timeout(SERVICE_TIMEOUT, call).await {
819            Ok(Ok(value)) => Ok(value),
820            Ok(Err(error)) => Err(FrontendError::Failed(error.message)),
821            Err(_) => Err(FrontendError::Failed(format!(
822                "Service {name} timed out after {}s",
823                SERVICE_TIMEOUT.as_secs()
824            ))),
825        }
826    }
827
828    /// Registered providers with their sign-in support and credential state (`/login`).
829    #[must_use]
830    pub fn auth_providers(&self) -> Vec<AuthProviderInfo> {
831        let models = &self.inner.models;
832        let mut providers: Vec<AuthProviderInfo> = models
833            .registered_providers()
834            .into_iter()
835            .filter_map(|id| {
836                let registration = models.registration(&id)?;
837                let stored = models
838                    .auth_storage()
839                    .stored(&id)
840                    .ok()
841                    .flatten()
842                    .map(|c| match c {
843                        AuthCredential::ApiKey(_) => "api_key".to_owned(),
844                        AuthCredential::Oauth(_) => "oauth".to_owned(),
845                    });
846                Some(AuthProviderInfo {
847                    name: registration.display_name().to_owned(),
848                    oauth: registration.oauth,
849                    configured: models.has_configured_auth(&id),
850                    stored,
851                    env: registration.auth_env.clone(),
852                    id,
853                })
854            })
855            .collect();
856        providers.sort_by_key(|p| p.name.to_lowercase());
857        providers
858    }
859
860    /// Signs in to `provider` with its component's OAuth flow (dialogs go to this session's
861    /// frontend), stores the credential in `auth.json` and refreshes the provider's models.
862    ///
863    /// # Errors
864    ///
865    /// Unknown provider, no OAuth support, flow failure or credential store failure.
866    pub async fn login(&self, provider: &str) -> Result<(), FrontendError> {
867        let models = &self.inner.models;
868        let registration = models
869            .registration(provider)
870            .ok_or_else(|| FrontendError::NotFound(format!("Unknown provider: {provider}")))?;
871        if !registration.oauth {
872            return Err(FrontendError::Unsupported(format!(
873                "{} does not support OAuth sign-in; set an API key instead",
874                registration.display_name()
875            )));
876        }
877        let runner = self.runner();
878        let extension = runner.provider_for_api(&registration.api).ok_or_else(|| {
879            FrontendError::NotFound(format!("No provider component for {}", registration.api))
880        })?;
881        let component = extension
882            .instance
883            .provider()
884            .ok_or_else(|| FrontendError::Unsupported(format!("{provider}: not a provider")))?;
885        let cx = runner.call_context().with_kind(CallKind::Command);
886        let mut value = component
887            .oauth_login(provider, cx)
888            .await
889            .map_err(|e| FrontendError::Failed(e.message))?;
890        if let Some(object) = value.as_object_mut() {
891            object.remove("type");
892        }
893        let credential: OAuthCredential = serde_json::from_value(value.clone())
894            .map_err(|e| FrontendError::Failed(format!("invalid OAuth credential: {e}")))?;
895        models
896            .auth_storage()
897            .set(provider, &AuthCredential::Oauth(credential))
898            .map_err(|e| FrontendError::Failed(e.to_string()))?;
899        value["type"] = json!("oauth");
900        crate::services::refresh_provider_models(runner.set(), models, provider, Some(value)).await;
901        self.refresh_current_model();
902        Ok(())
903    }
904
905    /// Removes the stored credential of `provider` (`/logout`).
906    ///
907    /// # Errors
908    ///
909    /// Unknown provider or credential store failure.
910    pub fn logout(&self, provider: &str) -> Result<(), FrontendError> {
911        let models = &self.inner.models;
912        if models.registration(provider).is_none() && !models.auth_storage().has_stored(provider) {
913            return Err(FrontendError::NotFound(format!(
914                "Unknown provider: {provider}"
915            )));
916        }
917        models
918            .auth_storage()
919            .remove(provider)
920            .map_err(|e| FrontendError::Failed(e.to_string()))
921    }
922
923    /// `_oino/render`: the OUS node the owner of `request.key` renders for one transcript row
924    /// (ui-protocol §6.4), or `None` for the frontend's own rendering.
925    ///
926    /// Render specs registered for a built-in tool (`read`/`write`/`edit`/`bash`/…) are
927    /// *render-only overrides*: the extension needs the manifest's `tools.override` for that
928    /// name but does not register the tool itself (U1–U2).
929    ///
930    /// # Errors
931    ///
932    /// [`FrontendError::Failed`] when the owning instance cannot be reached.
933    pub fn render_spec(&self, request: &RenderRequest) -> Result<Option<JsonValue>, FrontendError> {
934        let runner = self.runner();
935        let target = request.target.as_str();
936        let spec_target = request.target.spec_str();
937        let key = request.key.as_str();
938        let owner = runner
939            .renderers()
940            .into_iter()
941            .find(|(_, spec)| {
942                spec.get("target").and_then(JsonValue::as_str) == Some(spec_target)
943                    && spec.get("key").and_then(JsonValue::as_str) == Some(key)
944            })
945            .map(|(id, _)| id);
946        let Some(owner) = owner else {
947            return Ok(None);
948        };
949        let extension = runner
950            .set()
951            .get(&owner)
952            .cloned()
953            .ok_or_else(|| FrontendError::Failed(format!("unknown extension {owner}")))?;
954        // §6.7: a turn footer gets the turn identity instead of a row payload; `null`
955        // means "nothing to render" (not an error).
956        let payload = if request.target == RenderTarget::TurnFooter {
957            json!({
958                "target": target,
959                "key": key,
960                "turnEntryId": request.turn_entry_id,
961                "turnIndex": request.turn_index,
962                "running": request.running,
963            })
964        } else {
965            json!({
966                "target": target,
967                "key": key,
968                "entryId": request.entry_id,
969                "density": request.density.as_str(),
970                "expanded": request.expanded,
971                "width": request.width.as_str(),
972                "data": request.data.clone(),
973            })
974        };
975        let cx = runner.call_context().with_kind(CallKind::Render);
976        Ok(extension
977            .instance
978            .render(payload, cx)
979            .filter(|v| !v.is_null()))
980    }
981
982    /// Stores an API key for `provider` in `auth.json`.
983    ///
984    /// # Errors
985    ///
986    /// Empty key or credential store failure.
987    pub async fn set_api_key(&self, provider: &str, key: &str) -> Result<(), FrontendError> {
988        let key = key.trim();
989        if key.is_empty() {
990            return Err(FrontendError::Failed("API key cannot be empty".to_owned()));
991        }
992        let models = &self.inner.models;
993        let credential = AuthCredential::ApiKey(oino_models::auth::credential::ApiKeyCredential {
994            key: Some(key.to_owned()),
995            env: None,
996            extra: oino_ai::JsonObject::new(),
997        });
998        models
999            .auth_storage()
1000            .set(provider, &credential)
1001            .map_err(|e| FrontendError::Failed(e.to_string()))?;
1002        crate::services::refresh_provider_models(
1003            self.runner().set(),
1004            models,
1005            provider,
1006            Some(json!({"type": "api_key", "key": key})),
1007        )
1008        .await;
1009        self.refresh_current_model();
1010        Ok(())
1011    }
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016    use super::*;
1017
1018    #[test]
1019    fn fuzzy_and_indices() {
1020        assert!(fuzzy("kanboard-add", "kbad"));
1021        assert!(!fuzzy("model", "x"));
1022        assert_eq!(byte_index("añb", 2), 3);
1023        assert_eq!(byte_index("ab", 9), 2);
1024        assert!(EditorIntent::EmptyArrowDown.consumable());
1025        assert!(!EditorIntent::Changed.consumable());
1026    }
1027
1028    #[test]
1029    fn path_candidates_prefer_prefix_matches() {
1030        let dir = tempfile::tempdir().unwrap();
1031        std::fs::create_dir(dir.path().join("src")).unwrap();
1032        std::fs::write(dir.path().join("src/lib.rs"), "").unwrap();
1033        std::fs::write(dir.path().join("README.md"), "").unwrap();
1034        let items = path_candidates(dir.path(), "src");
1035        assert_eq!(items[0].value, "@src/");
1036        assert_eq!(items[0].tag.as_deref(), Some("dir"));
1037        assert!(items.iter().any(|i| i.value == "@src/lib.rs"));
1038        let readme = path_candidates(dir.path(), "rdm");
1039        assert_eq!(readme[0].value, "@README.md");
1040    }
1041}