1use 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
18const COMPLETION_BUDGET: Duration = Duration::from_millis(100);
20const MAX_PATH_ITEMS: usize = 50;
22const MAX_PATH_SCAN: usize = 20_000;
24const SERVICE_TIMEOUT: Duration = Duration::from_secs(30);
26pub const FRONTEND_CALLER: &str = "oino.frontend";
28
29#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
31#[non_exhaustive]
32pub enum FrontendError {
33 #[error("{0}")]
35 NotFound(String),
36 #[error("{0}")]
38 Unsupported(String),
39 #[error("{0}")]
41 Failed(String),
42}
43
44#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub enum RenderTarget {
48 CustomMessage,
50 CustomEntry,
52 #[default]
54 Tool,
55 TurnFooter,
57}
58
59impl RenderTarget {
60 #[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 #[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 #[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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub enum RenderDensity {
99 #[default]
101 Simple,
102 Regular,
104 Advanced,
106}
107
108impl RenderDensity {
109 #[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 #[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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub enum RenderWidth {
135 Narrow,
137 #[default]
139 Normal,
140 Wide,
142}
143
144impl RenderWidth {
145 #[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 #[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#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
169#[serde(rename_all = "camelCase")]
170pub struct RenderRequest {
171 pub target: RenderTarget,
173 pub key: String,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub entry_id: Option<String>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub turn_entry_id: Option<String>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub turn_index: Option<u64>,
184 #[serde(default)]
186 pub running: bool,
187 #[serde(default)]
189 pub density: RenderDensity,
190 #[serde(default)]
192 pub expanded: bool,
193 #[serde(default)]
195 pub width: RenderWidth,
196 #[serde(default)]
198 pub data: JsonValue,
199}
200
201impl RenderRequest {
202 #[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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(rename_all = "camelCase")]
223pub struct CompletionEntry {
224 pub value: String,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub label: Option<String>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub description: Option<String>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub tag: Option<String>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub tone: Option<String>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub sort_tier: Option<i64>,
241}
242
243#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(rename_all = "camelCase")]
246pub struct CompletionGroup {
247 pub source: String,
249 #[serde(default)]
252 pub from: Option<usize>,
253 pub items: Vec<CompletionEntry>,
255}
256
257#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
259#[serde(rename_all = "camelCase")]
260pub enum EditorIntent {
261 Changed,
263 Cleared,
265 EmptyArrowDown,
267 Submit,
269 TokenBackspace,
271}
272
273impl EditorIntent {
274 #[must_use]
276 pub const fn consumable(self) -> bool {
277 matches!(self, Self::EmptyArrowDown | Self::TokenBackspace)
278 }
279}
280
281#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
283#[serde(rename_all = "camelCase")]
284pub struct EditorEvent {
285 pub event_id: String,
287 pub intent: EditorIntent,
289 #[serde(default)]
291 pub text: String,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub token_id: Option<String>,
295}
296
297#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(rename_all = "camelCase")]
300pub struct AuthProviderInfo {
301 pub id: String,
303 pub name: String,
305 pub oauth: bool,
307 pub configured: bool,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub stored: Option<String>,
312 pub env: Vec<String>,
314}
315
316fn byte_index(text: &str, chars: usize) -> usize {
318 text.char_indices()
319 .nth(chars)
320 .map_or(text.len(), |(i, _)| i)
321}
322
323fn 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
332fn 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 #[must_use]
391 pub fn editor_text(&self) -> String {
392 lock(&self.inner.editor_text).clone()
393 }
394
395 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 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 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 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 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 pub fn ui_action_with(&self, action: &crate::ui::UiAction) -> Result<(), FrontendError> {
569 self.dispatch_action(action, action.action_id == "$close")
570 }
571
572 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 #[must_use]
588 pub fn ui_snapshot(&self) -> crate::ui::UiSnapshot {
589 lock(&self.inner.registry).snapshot()
590 }
591
592 #[must_use]
594 pub fn ui_resync(&self) -> crate::ui::UiSnapshot {
595 self.ui_snapshot()
596 }
597
598 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, ¶ms);
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 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 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 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 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 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 #[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 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(®istration.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 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 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 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 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}