1#![allow(
5 clippy::cast_possible_truncation,
6 clippy::cast_possible_wrap,
7 clippy::cast_precision_loss,
8 clippy::cast_sign_loss
9)]
10use std::collections::{HashMap, HashSet, VecDeque};
11
12use anyhow::Result;
13use chrono::Utc;
14use tokio::sync::mpsc;
15
16use crate::config;
17use crate::db::{DEFAULT_SPACE, Db, Message, Session, Space as SpaceRow};
18use crate::provider::openrouter::OpenRouter;
19use crate::provider::{BackendTag, Model, StreamEvent, Usage};
20use crate::space::Space;
21
22mod apps;
23mod backends;
24mod chat;
25mod commands;
26mod compaction;
27pub mod export;
28mod files;
29pub mod headless;
30mod images;
31mod memory;
32mod models;
33mod research;
34mod scripts;
35mod sessions;
36mod settings;
37mod skills_popup;
38mod snapshot;
39mod spaces;
40mod swarm;
41#[cfg(test)]
42mod tests;
43mod transcribe;
44pub mod usage;
45mod watches;
46pub use backends::{Backends, composite_id};
47pub use chat::{code_blocks, human_size, pick_greeting};
48pub use commands::{AppCommand, COMMANDS, Command, Match, command_score, fuzzy_score};
49pub use files::OcrUpdate;
50pub use research::{PlanQuestion, ResearchUpdate};
51pub use sessions::session_score;
52pub use snapshot::{CoreSnapshot, ModelSnapshot, SessionSnapshot, SettingsSnapshot, TaskSnapshot};
53pub use swarm::{SwarmUpdate, parse_persona_editor};
54
55#[cfg(test)]
56use chat::split_inline_reasoning;
57#[cfg(test)]
58use memory::parse_memory_ops;
59use sessions::parse_topic;
60
61pub fn clamp_cursor(current: usize, len: usize, delta: i32) -> usize {
65 if len == 0 {
66 return 0;
67 }
68 (current as i32 + delta).clamp(0, len as i32 - 1) as usize
69}
70
71pub fn fuzzy_filter_sorted<T>(items: &[T], score_fn: impl Fn(&T) -> Option<i32>) -> Vec<&T> {
75 let mut scored: Vec<(i32, &T)> = items
76 .iter()
77 .filter_map(|item| score_fn(item).map(|sc| (sc, item)))
78 .collect();
79 scored.sort_by_key(|(sc, _)| std::cmp::Reverse(*sc));
80 scored.into_iter().map(|(_, item)| item).collect()
81}
82
83#[derive(Debug, PartialEq, Eq, Clone, Copy, serde::Serialize, serde::Deserialize)]
85pub enum FilesTab {
86 Files,
87 Images,
88 Scripts,
89}
90
91#[derive(Debug, PartialEq, Eq)]
93pub enum Popup {
94 None,
95 Model,
96 Session,
97 Key,
98 Settings,
99 Copy,
100 Space,
101 Context,
102 Skills,
103 Files,
104 Apps,
105 Watch,
106 ResearchLive,
107 Swarm,
108 Usage,
110 Login,
112}
113
114#[derive(PartialEq, Eq, Clone, Copy)]
118pub enum KeyTarget {
119 OpenRouter,
120 OpenAi,
121 OpencodeGo,
122}
123
124#[derive(PartialEq, Eq, Clone, Copy)]
126pub enum SwarmPopupMode {
127 Browse,
128 ConfirmDelete,
129}
130
131#[derive(PartialEq, Eq, Clone, Copy)]
134pub enum AppsMode {
135 Browse,
136 ConfirmDelete,
137 EditFile,
138}
139
140#[derive(Debug, PartialEq, Eq, Clone, Copy)]
143pub enum WatchMode {
144 Browse,
145 ConfirmDelete,
146}
147
148#[derive(PartialEq, Eq, Clone, Copy)]
151pub enum SkillsMode {
152 Browse,
153 Install,
154 ConfirmRemove,
155}
156
157#[derive(PartialEq, Eq, Clone, Copy)]
160pub enum FilesMode {
161 Browse,
162 Add,
163 Rename,
164 ConfirmDelete,
165 Pick,
166}
167
168#[derive(PartialEq, Eq, Clone, Copy)]
170pub enum ImagesMode {
171 Browse,
172 ConfirmDelete,
173}
174
175#[derive(PartialEq, Eq, Clone, Copy)]
178pub enum ScriptsMode {
179 Browse,
180 Create,
181 Rename,
182 ConfirmDelete,
183}
184
185#[derive(PartialEq, Eq, Clone, Copy)]
188pub enum SpaceMode {
189 Browse,
190 Create,
191 Rename,
192 ConfirmDelete,
193}
194
195pub struct CopyOption {
197 pub label: String,
198 pub text: String,
199}
200
201pub struct ContextBreakdown {
203 pub system_tokens: u64,
204 pub memory_tokens: u64,
205 pub skills_tokens: u64,
206 pub conversation_tokens: u64,
207 pub limit: Option<u64>,
208 pub compacted: bool,
210}
211
212#[derive(PartialEq, Eq, Clone, Copy)]
215pub enum SessionMode {
216 Browse,
217 Rename,
218 ConfirmDelete,
219}
220
221#[derive(PartialEq, Eq, Clone, Copy)]
223pub enum MouseTarget {
224 None,
225 Input,
226 History,
227}
228
229#[derive(PartialEq, Eq, Clone, Copy)]
231pub enum ModelPanel {
232 Favorites,
233 Available,
234}
235
236#[derive(PartialEq, Eq, Clone, Copy, Default)]
239pub enum ModelPickTarget {
240 #[default]
241 Session,
242 Memory,
243 Transcriber,
244 Ocr,
245 SwarmPersona(usize),
247 ImageGen,
249 VideoGen,
251}
252
253#[derive(PartialEq, Eq, Clone, Copy)]
255pub enum SettingsField {
256 ShowStats,
257 ShowReasoning,
258 HideHints,
259 Temperature,
260 TopP,
261 MaxTokens,
262 MemoryModel,
263 CompactThreshold,
264 SearxngUrl,
265 Verbosity,
266 LangsearchKey,
267 SearchProvider,
268 TranscriberModel,
269 OcrModel,
270 OcrEngine,
271 EmbeddingModel,
272 BlockedDomains,
273 ImageGenModel,
274 VideoGenModel,
275}
276
277impl SettingsField {
278 pub const ALL: [Self; 19] = [
279 Self::ShowStats,
280 Self::ShowReasoning,
281 Self::HideHints,
282 Self::Temperature,
283 Self::TopP,
284 Self::MaxTokens,
285 Self::MemoryModel,
286 Self::CompactThreshold,
287 Self::SearxngUrl,
288 Self::Verbosity,
289 Self::LangsearchKey,
290 Self::SearchProvider,
291 Self::TranscriberModel,
292 Self::OcrModel,
293 Self::OcrEngine,
294 Self::EmbeddingModel,
295 Self::BlockedDomains,
296 Self::ImageGenModel,
297 Self::VideoGenModel,
298 ];
299
300 pub const fn label(self) -> &'static str {
301 match self {
302 Self::ShowStats => "show stats (model · TPS footer)",
303 Self::ShowReasoning => "expand reasoning (Ctrl+R)",
304 Self::HideHints => "hide hints (keybind labels)",
305 Self::Temperature => "temperature",
306 Self::TopP => "top_p",
307 Self::MaxTokens => "max_tokens",
308 Self::MemoryModel => "memory model (Enter to pick, Backspace clears)",
309 Self::CompactThreshold => "auto-compact at (% of context, 0 disables)",
310 Self::SearxngUrl => "web search URL (SearXNG instance, blank disables)",
311 Self::Verbosity => "answer length (Space cycles normal/concise/caveman)",
312 Self::LangsearchKey => "LangSearch API key (langsearch.com/dashboard, free)",
313 Self::SearchProvider => {
314 "search provider (Space cycles auto/langsearch/searxng/duckduckgo)"
315 }
316 Self::TranscriberModel => "image model (Enter to pick, Backspace clears)",
317 Self::OcrModel => "OCR model (Enter to pick, Backspace clears)",
318 Self::OcrEngine => {
319 "OCR engine (Space cycles auto/tesseract/vlm/local; local pulls via ollama)"
320 }
321 Self::EmbeddingModel => "embedding model (file search, blank disables)",
322 Self::BlockedDomains => "blocked domains (comma-separated, always excluded; per-space)",
323 Self::ImageGenModel => {
324 "image gen model (Enter to pick, Backspace clears; blank = disabled)"
325 }
326 Self::VideoGenModel => {
327 "video gen model (Enter to pick, Backspace clears; blank = disabled)"
328 }
329 }
330 }
331}
332
333pub struct SettingsGroup {
338 pub name: &'static str,
339 pub fields: &'static [SettingsField],
340}
341
342pub const SETTINGS_GROUPS: &[SettingsGroup] = &[
343 SettingsGroup {
344 name: "Interface",
345 fields: &[
346 SettingsField::ShowStats,
347 SettingsField::ShowReasoning,
348 SettingsField::HideHints,
349 SettingsField::Verbosity,
350 ],
351 },
352 SettingsGroup {
353 name: "Generation",
354 fields: &[
355 SettingsField::Temperature,
356 SettingsField::TopP,
357 SettingsField::MaxTokens,
358 ],
359 },
360 SettingsGroup {
361 name: "Memory & Context",
362 fields: &[
363 SettingsField::MemoryModel,
364 SettingsField::CompactThreshold,
365 SettingsField::EmbeddingModel,
366 ],
367 },
368 SettingsGroup {
369 name: "Web Search",
370 fields: &[
371 SettingsField::SearchProvider,
372 SettingsField::SearxngUrl,
373 SettingsField::LangsearchKey,
374 SettingsField::BlockedDomains,
375 ],
376 },
377 SettingsGroup {
378 name: "Voice & Vision",
379 fields: &[
380 SettingsField::TranscriberModel,
381 SettingsField::OcrModel,
382 SettingsField::OcrEngine,
383 ],
384 },
385 SettingsGroup {
386 name: "Image Generation",
387 fields: &[SettingsField::ImageGenModel],
388 },
389 SettingsGroup {
390 name: "Video Generation",
391 fields: &[SettingsField::VideoGenModel],
392 },
393];
394
395#[derive(Clone, Copy, PartialEq, Eq)]
398pub enum SettingsRow {
399 Group(usize),
400 Field(SettingsField),
401}
402
403pub const VERBOSITY_LEVELS: [&str; 3] = ["normal", "concise", "caveman"];
404pub const OCR_ENGINES: [&str; 4] = ["auto", "tesseract", "vlm", "local"];
405pub const SEARCH_PROVIDERS: [&str; 4] = ["auto", "langsearch", "searxng", "duckduckgo"];
406
407#[derive(Debug, Clone)]
409#[allow(clippy::struct_excessive_bools)]
411pub struct Settings {
412 pub show_stats: bool,
414 pub show_reasoning: bool,
416 pub hide_hints: bool,
418 pub temperature: Option<f32>,
419 pub top_p: Option<f32>,
420 pub max_tokens: Option<u32>,
421 pub compact_threshold: u8,
424}
425
426impl Default for Settings {
427 fn default() -> Self {
428 Self {
429 show_stats: false,
430 show_reasoning: false,
431 hide_hints: false,
432 temperature: None,
433 top_p: None,
434 max_tokens: None,
435 compact_threshold: 60,
436 }
437 }
438}
439
440fn verbosity_clause(level: &str) -> &'static str {
445 match level {
446 "normal" => {
447 "Answer at whatever length the question deserves — don't optimize for brevity over completeness."
448 }
449 "caveman" => {
450 "Talk caveman-terse. Drop articles (a/an/the) and filler words. Short fragments over full sentences. No politeness, no hedging, no restating the question. Symbols over words where clear (→, =, vs). Full explanations ONLY when explicitly asked — otherwise state the fact/answer and stop. Preserve numbers, code, names, and technical terms exactly."
451 }
452 _ => {
453 "Answer style: default to short. Say the answer, then stop — don't restate the question, don't add a summary, don't hedge (\"it's worth noting...\", \"generally speaking...\"). No preamble (\"Great question!\", \"I'd be happy to help with that\"), no postamble (\"Let me know if you have questions!\"). One clear sentence beats three vague ones.\nThis is a floor, not a ceiling: if the user asks to explain, teach, or go deep, or the topic genuinely needs multiple steps to be correct (debugging, multi-part instructions, tradeoffs), give it the room it needs. Brevity for its own sake that omits a needed step is wrong, not concise.\nKeep full grammar — this isn't telegraphic shorthand. Drop filler, not clarity."
454 }
455 }
456}
457
458const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
459
460const GREETINGS: [&str; 8] = [
462 "What are we building today?",
463 "Ask me anything.",
464 "Fresh session, fresh ideas.",
465 "The terminal is your canvas.",
466 "Ready when you are.",
467 "Type / to see commands.",
468 "Let's get to work.",
469 "How can I help?",
470];
471
472const THINKING: [(&str, &str); 16] = [
475 ("Thinking", "Thought"),
476 ("Pondering", "Pondered"),
477 ("Noodling", "Noodled"),
478 ("Ruminating", "Ruminated"),
479 ("Cogitating", "Cogitated"),
480 ("Marinating", "Marinated"),
481 ("Percolating", "Percolated"),
482 ("Mulling", "Mulled"),
483 ("Conjuring", "Conjured"),
484 ("Vibing", "Vibed"),
485 ("Scheming", "Schemed"),
486 ("Wrangling tokens", "Wrangled tokens"),
487 ("Brewing", "Brewed"),
488 ("Musing", "Mused"),
489 ("Galaxy-braining", "Galaxy-brained"),
490 ("Doing the thing", "Did the thing"),
491];
492
493#[derive(Clone, Copy, PartialEq, Eq)]
497pub enum SpinnerColor {
498 Green,
499 Cyan,
500 Magenta,
501}
502
503const SPINNER_COLORS: [SpinnerColor; 3] = [
505 SpinnerColor::Green,
506 SpinnerColor::Cyan,
507 SpinnerColor::Magenta,
508];
509
510type ModelsResult = std::result::Result<Vec<Model>, String>;
511
512#[derive(Clone)]
514pub enum MemoryOp {
515 Add(String),
516 Update(usize, String),
517 Delete(usize),
518}
519
520#[derive(Clone)]
522pub struct ImageMeta {
523 pub name: String,
524 pub size: u64,
525 pub modified: String,
526}
527
528#[derive(Clone)]
530pub struct ScriptMeta {
531 pub name: String,
532 pub size: u64,
533 pub modified: String,
534}
535
536pub const MAX_CHAT_TASKS: usize = 10;
538
539pub type ChatTaskId = u64;
540
541#[derive(Clone)]
543pub struct GateState {
544 pub session_id: String,
546 pub phase: SurveyPhase,
548}
549
550pub struct ChatEvent {
552 pub task_id: ChatTaskId,
553 pub event: StreamEvent,
554}
555
556pub struct ChatTask {
559 pub id: ChatTaskId,
560 pub session_id: String,
561 pub session_title: String,
562 pub space_id: String,
563 pub model: String,
564 pub model_id: String,
566 pub backend: BackendTag,
568 pub incognito: bool,
569 pub buffer: String,
570 pub thinking: String,
571 pub tool_status: Option<String>,
572 pub usage: Option<Usage>,
573 pub usage_row_id: Option<i64>,
576 pub started: std::time::Instant,
577 pub thinking_idx: usize,
578 pub spinner_color: SpinnerColor,
579 pub abort: tokio::task::AbortHandle,
580}
581
582pub struct ChatNotification {
584 pub session_id: String,
585 pub title: String,
586 pub text: String,
587 pub success: bool,
588}
589
590pub type EmbedMsg = (
594 String,
595 String,
596 std::result::Result<Vec<(i64, Vec<f32>)>, String>,
597);
598
599pub type ResearchMsg = (String, String, String, research::ResearchUpdate);
602
603pub struct SurveyGate {
610 pub session_id: String,
611 pub reply_tx: mpsc::UnboundedSender<String>,
612 pub phase: SurveyPhase,
613 pub prompt_role: String,
617 pub prompt_content: String,
618}
619
620#[derive(Clone)]
624pub enum SurveyPhase {
625 Clarify { round: u8 },
627 Approve { rework: bool },
630}
631
632#[derive(Clone)]
633pub enum LoginMsg {
634 Status(String),
635 Done(Result<crate::config::CodexCredentials, String>),
636}
637
638pub enum PendingEditor {
641 AppFile(std::path::PathBuf),
642 Persona(std::path::PathBuf),
643 ScriptFile(std::path::PathBuf),
644}
645
646pub struct AbortOnDrop(pub tokio::task::AbortHandle);
649
650impl Drop for AbortOnDrop {
651 fn drop(&mut self) {
652 self.0.abort();
653 }
654}
655
656#[derive(Clone)]
657pub enum AppEvent {
658 Status(String),
661 ComposerSet(String),
665 ComposerClear,
667 ViewportReset,
671 HistoryInvalidated,
674 OpenLoginPopup,
677 Gate(Option<GateState>),
682 Stream(Option<(ChatTaskId, StreamEvent)>),
683 Models(Option<ModelsResult>),
684 Title(Option<(String, String, String)>),
686 Memory(Option<(String, Vec<MemoryOp>)>),
689 Compact(Option<(String, String, i64, u64)>),
691 SkillInstall(Option<Result<String, String>>),
693
694 Ocr(Option<(String, String, files::OcrUpdate)>),
697 Embed(Option<EmbedMsg>),
699 OcrPull(Option<Result<String, String>>),
701 Research(Option<ResearchMsg>),
703 ResearchTopic(Option<Result<String, String>>),
705 UpdateCheck(Option<String>),
708 Login(Option<LoginMsg>),
710 Swarm(Option<swarm::SwarmMsg>),
712}
713
714#[allow(clippy::struct_field_names, clippy::struct_excessive_bools)]
717pub struct App {
718 pub db: Db,
719 pub space: Space,
720 pub backends: Backends,
724 pub saved: crate::config::SavedCreds,
726
727 pub active_space: SpaceRow,
729 pub memory_model: String,
731 pub(crate) memory_snapshot: String,
735 pub(crate) cache_epoch: u64,
738 pub(crate) prompt_datetime: String,
742 pub transcriber_model: String,
744 pub ocr_model: String,
746 pub ocr_engine: String,
749 pub local_ocr_model: String,
751 pub embedding_model: String,
753 pub image_gen_model: String,
755 pub video_gen_model: String,
757 pub searxng_url: String,
760 pub langsearch_key: String,
762 pub search_provider: String,
764 pub base_system_prompt: String,
767 pub verbosity: String,
770 pub memory_rx: Option<mpsc::UnboundedReceiver<(String, Vec<MemoryOp>)>>,
771 pub compact_rx: Option<mpsc::UnboundedReceiver<(String, String, i64, u64)>>,
773 pub compacting_session_id: Option<String>,
776
777 pub skills: Vec<crate::skills::Skill>,
780 pub forced_skill: Option<String>,
782 pub web_mode: bool,
784 pub incognito: bool,
785 pub incognito_img_dir: Option<std::path::PathBuf>,
787 pub survey_gate: Option<SurveyGate>,
791 pub survey_reply_tx: Option<mpsc::UnboundedSender<String>>,
795 pub research_steer_log: Vec<(usize, String)>,
801 pub research_steer_acked: std::collections::HashSet<usize>,
806 pub research_stage_rows: Vec<String>,
810 pub research_incognito: bool,
814 pub research_steer_tx: Option<mpsc::UnboundedSender<String>>,
817 pub research_topic_rx: Option<mpsc::UnboundedReceiver<Result<String, String>>>,
819 pub research_live_input: String,
821 pub toolbox: std::sync::Arc<dyn crate::tools::ToolExecutor>,
822 pub app_server: Option<crate::appserver::AppServer>,
824 pub skills_rx: Option<mpsc::UnboundedReceiver<Result<String, String>>>,
825 pub ocr_rx: Option<mpsc::UnboundedReceiver<(String, String, files::OcrUpdate)>>,
827 pub embed_rx: Option<mpsc::UnboundedReceiver<EmbedMsg>>,
829 pub ocr_pull_rx: Option<mpsc::UnboundedReceiver<Result<String, String>>>,
831 pub research_rx: Option<mpsc::UnboundedReceiver<ResearchMsg>>,
833 pub research_abort: Option<tokio::task::AbortHandle>,
834 pub login_rx: Option<mpsc::UnboundedReceiver<LoginMsg>>,
835 pub update_rx: Option<mpsc::UnboundedReceiver<Option<String>>>,
837 pub swarm_rx: Option<mpsc::UnboundedReceiver<swarm::SwarmMsg>>,
840 pub swarm_abort: Option<tokio::task::AbortHandle>,
841 pub swarm_session: Option<String>,
842 pub swarm_cache: Vec<crate::db::Persona>,
845 pub research_running: Option<(String, String)>,
848
849 pub files_cache: Vec<crate::db::FileRow>,
851 pub apps_cache: Vec<String>,
853 pub images_cache: Vec<ImageMeta>,
855
856 pub scripts_cache: Vec<ScriptMeta>,
858 pub watches_cache: Vec<crate::db::Watch>,
860 pub usage_range: crate::db::UsageRange,
863
864 pub models: Vec<Model>,
866 pub current_model: Option<String>,
867 pub models_rx: Option<mpsc::UnboundedReceiver<ModelsResult>>,
868 pub favorites: HashSet<String>,
870 pub last_used: HashMap<String, String>,
871 pub reasoning: HashMap<String, String>,
874
875 pub session: Option<Session>,
876 pub messages: Vec<Message>,
877
878 pub chat_event_tx: mpsc::UnboundedSender<ChatEvent>,
880 pub chat_event_rx: mpsc::UnboundedReceiver<ChatEvent>,
881 pub chat_tasks: HashMap<ChatTaskId, ChatTask>,
882 pub next_chat_task_id: ChatTaskId,
883 pub notifications: VecDeque<ChatNotification>,
885 pub(crate) pending_events: VecDeque<AppEvent>,
888 pub unread: std::collections::HashSet<String>,
890 pub context_total: Option<u64>,
892 pub last_cache_rate: Option<f64>,
896
897 pub settings: Settings,
898 pub model_pick_target: ModelPickTarget,
901 pub spinner_frame: usize,
903 pub thinking_idx: usize,
904 pub spinner_color: SpinnerColor,
905
906 pub sessions_cache: Vec<Session>,
907 pub(crate) title_rx: Option<mpsc::UnboundedReceiver<(String, String, String)>>,
909}
910
911impl App {
912 #[allow(clippy::too_many_lines)]
914 pub fn new(db: Db, key: Option<&str>, space: Space) -> Self {
915 let provider = key.map(|k| OpenRouter::from_key_auto(k.to_string()));
916 let mut backends = Backends::default();
922 let mut saved = crate::config::SavedCreds::default();
923 if let (Some(k), Some(p)) = (&key, &provider) {
924 let tag = p.backend_tag();
925 backends.set(tag, p.clone());
926 match tag {
927 crate::provider::BackendTag::OpenRouter => {
928 saved.openrouter_key = Some((*k).to_string());
929 }
930 crate::provider::BackendTag::OpenAi => saved.openai_key = Some((*k).to_string()),
931 crate::provider::BackendTag::OpencodeGo => {
932 saved.opencode_key = Some((*k).to_string());
933 }
934 crate::provider::BackendTag::Codex => {}
937 }
938 }
939 let status = if key.is_some() {
940 "loading models… (/model to pick, /help for commands)".to_string()
941 } else {
942 "no API key — set it with /key (or $OPENROUTER_API_KEY/$OPENAI_API_KEY)".to_string()
943 };
944 let active_space = db
947 .default_space_id()
948 .ok()
949 .and_then(|id| {
950 db.list_spaces()
951 .ok()
952 .and_then(|s| s.into_iter().find(|s| s.id == id))
953 })
954 .unwrap_or_else(|| SpaceRow {
955 id: String::new(),
956 name: DEFAULT_SPACE.to_string(),
957 created_at: Utc::now().to_rfc3339(),
958 });
959 let _ = space.ensure_space_dir(&active_space.name);
960 let default_model_id = |f: fn(&OpenRouter) -> &'static str, fallback: &'static str| {
961 provider.as_ref().map_or_else(
962 || fallback.to_string(),
963 |p| {
964 let model = f(p);
965 if model.is_empty() {
966 String::new()
967 } else {
968 format!("{}{}", p.backend_tag().key_prefix(), model)
969 }
970 },
971 )
972 };
973 let utility_model = default_model_id(
974 OpenRouter::default_utility_model,
975 "google/gemini-2.5-flash-lite",
976 );
977 let embedding_model = default_model_id(
978 OpenRouter::default_embedding_model,
979 "openai/text-embedding-3-small",
980 );
981 let image_gen_model =
982 default_model_id(OpenRouter::default_image_gen_model, "openai/gpt-image-2");
983 let video_gen_model =
984 default_model_id(OpenRouter::default_video_gen_model, "google/veo-3.1");
985 let skills_dir = crate::skills::skills_dir(&space.root);
986 let skill_dirs = crate::skills::app_skill_roots(&space.root);
987 let skills = crate::skills::load_skills_from_dirs(&skill_dirs);
988 let (chat_event_tx, chat_event_rx) = mpsc::unbounded_channel();
989 let toolbox = std::sync::Arc::new(
992 crate::tools::ToolBox::new(
993 skills_dir,
994 None,
995 None,
996 "auto".to_string(),
997 Vec::new(),
998 Some(space.db_path()),
999 Some(crate::tools::FilesCtx {
1000 db_path: space.db_path(),
1001 space_id: active_space.id.clone(),
1002 embedder: (!embedding_model.is_empty())
1003 .then(|| backends.resolve(&embedding_model))
1004 .flatten(),
1005 }),
1006 None,
1009 )
1010 .with_skill_dirs(skill_dirs),
1011 );
1012 let mut app = Self {
1013 db,
1014 space,
1015 backends,
1016 saved,
1017 skills,
1018 searxng_url: String::new(),
1019 langsearch_key: String::new(),
1020 search_provider: "auto".to_string(),
1021 forced_skill: None,
1022 web_mode: false,
1023 incognito: false,
1024 incognito_img_dir: None,
1025 survey_gate: None,
1026 survey_reply_tx: None,
1027 research_steer_log: Vec::new(),
1028 research_steer_acked: std::collections::HashSet::new(),
1029 research_stage_rows: Vec::new(),
1030 research_incognito: false,
1031 research_steer_tx: None,
1032 research_topic_rx: None,
1033 research_live_input: String::new(),
1034 toolbox,
1035 app_server: None,
1036 skills_rx: None,
1037 ocr_rx: None,
1038 embed_rx: None,
1039 ocr_pull_rx: None,
1040 research_rx: None,
1041 research_abort: None,
1042 login_rx: None,
1043 update_rx: None,
1044 swarm_rx: None,
1045 swarm_abort: None,
1046 swarm_session: None,
1047 swarm_cache: Vec::new(),
1048 research_running: None,
1049 files_cache: Vec::new(),
1050 apps_cache: Vec::new(),
1051 watches_cache: Vec::new(),
1052 images_cache: Vec::new(),
1053 scripts_cache: Vec::new(),
1054 usage_range: crate::db::UsageRange::default(),
1055 active_space,
1056 memory_model: utility_model.clone(),
1057 memory_snapshot: String::new(),
1058 cache_epoch: 0,
1059 prompt_datetime: Utc::now().format("%Y-%m-%d %H:%M UTC, %A").to_string(),
1060 transcriber_model: utility_model.clone(),
1061 ocr_model: utility_model,
1062 ocr_engine: "auto".to_string(),
1063 local_ocr_model: "glm-ocr".to_string(),
1064 embedding_model,
1065 image_gen_model,
1066 video_gen_model,
1067 base_system_prompt: config::load_system_prompt().unwrap_or_default(),
1068 verbosity: "concise".to_string(),
1069 memory_rx: None,
1070 compact_rx: None,
1071 compacting_session_id: None,
1072 models: Vec::new(),
1073 current_model: None,
1074 models_rx: None,
1075 favorites: HashSet::new(),
1076 last_used: HashMap::new(),
1077 reasoning: HashMap::new(),
1078 session: None,
1079 messages: Vec::new(),
1080 chat_event_tx,
1081 chat_event_rx,
1082 chat_tasks: HashMap::new(),
1083 next_chat_task_id: 0,
1084 notifications: VecDeque::new(),
1085 pending_events: VecDeque::new(),
1086 unread: std::collections::HashSet::new(),
1087 context_total: None,
1088 last_cache_rate: None,
1089 settings: Settings::default(),
1090 model_pick_target: ModelPickTarget::Session,
1091 spinner_frame: 0,
1092 thinking_idx: 0,
1093 spinner_color: SpinnerColor::Green,
1094 sessions_cache: Vec::new(),
1095 title_rx: None,
1096 };
1097 app.memory_snapshot = app.read_memory();
1100 app.pending_events.push_back(AppEvent::Status(status));
1101 app.load_prefs();
1102 app.load_settings();
1103 app
1104 }
1105
1106 fn load_prefs(&mut self) {
1110 if let Ok(prefs) = self.db.load_model_prefs() {
1111 for p in prefs {
1112 if p.favorite {
1113 self.favorites.insert(p.id.clone());
1114 }
1115 if let Some(t) = p.last_used {
1116 self.last_used.insert(p.id.clone(), t);
1117 }
1118 if let Some(r) = p.reasoning {
1119 self.reasoning.insert(p.id, r);
1120 }
1121 }
1122 }
1123 if let Some((id, _)) = self.last_used.iter().max_by(|a, b| a.1.cmp(b.1)) {
1124 let id = id.clone();
1125 if self.backends.any() {
1126 self.push_status(format!("model: {id} — type a message, /model to change"));
1127 }
1128 self.current_model = Some(id);
1129 }
1130 }
1131
1132 fn load_settings(&mut self) {
1134 let Ok(kv) = self.db.load_settings() else {
1135 return;
1136 };
1137 for (k, v) in kv {
1138 self.apply_setting(&k, &v);
1139 }
1140 self.refresh_toolbox();
1141 }
1142
1143 fn apply_setting(&mut self, k: &str, v: &str) {
1146 match k {
1147 "show_stats" => self.settings.show_stats = v == "1",
1148 "show_reasoning" => self.settings.show_reasoning = v == "1",
1149 "hide_hints" => self.settings.hide_hints = v == "1",
1150 "usage_range" => self.usage_range = crate::db::UsageRange::from_key(v),
1151 "temperature" => self.settings.temperature = v.parse().ok(),
1152 "top_p" => self.settings.top_p = v.parse().ok(),
1153 "max_tokens" => self.settings.max_tokens = v.parse().ok(),
1154 "memory_model" => self.memory_model = v.to_string(),
1155 "transcriber_model" => self.transcriber_model = v.to_string(),
1156 "ocr_model" => self.ocr_model = v.to_string(),
1157 "ocr_engine" if OCR_ENGINES.contains(&v) => self.ocr_engine = v.to_string(),
1158 "local_ocr_model" => self.local_ocr_model = v.to_string(),
1159 "embedding_model" => self.embedding_model = v.to_string(),
1160 "image_gen_model" => {
1164 self.image_gen_model = match v {
1165 "black-forest-labs/flux-dev" => "openai/gpt-image-2".to_string(),
1166 _ => v.to_string(),
1167 }
1168 }
1169 "video_gen_model" => {
1170 self.video_gen_model = match v {
1171 "google/veo-3.1-lite" => "google/veo-3.1".to_string(),
1172 _ => v.to_string(),
1173 }
1174 }
1175 "compact_threshold" => {
1176 if let Ok(t) = v.parse() {
1177 self.settings.compact_threshold = t;
1178 }
1179 }
1180 "searxng_url" => self.searxng_url = v.to_string(),
1181 "verbosity" if VERBOSITY_LEVELS.contains(&v) => {
1182 if self.verbosity != v {
1183 self.verbosity = v.to_string();
1184 self.bump_cache_epoch();
1185 }
1186 }
1187 "langsearch_key" => self.langsearch_key = v.to_string(),
1188 "search_provider" if SEARCH_PROVIDERS.contains(&v) => {
1189 self.search_provider = v.to_string();
1190 }
1191 _ => {}
1192 }
1193 }
1194
1195 pub fn refresh_toolbox(&mut self) {
1199 let url =
1200 (!self.searxng_url.trim().is_empty()).then(|| self.searxng_url.trim().to_string());
1201 let key = (!self.langsearch_key.trim().is_empty())
1202 .then(|| self.langsearch_key.trim().to_string());
1203 let skills_dir = crate::skills::skills_dir(&self.space.root);
1206 crate::skills::install_builtin(&skills_dir);
1207 let skill_dirs = crate::skills::app_skill_roots(&self.space.root);
1208 let mut toolbox = crate::tools::ToolBox::new(
1209 skills_dir.clone(),
1210 url,
1211 key,
1212 self.search_provider.clone(),
1213 self.blocked_domains(),
1214 Some(self.space.db_path()),
1215 Some(crate::tools::FilesCtx {
1216 db_path: self.space.db_path(),
1217 space_id: self.active_space.id.clone(),
1218 embedder: (!self.embedding_model.trim().is_empty())
1219 .then(|| self.backends.resolve(self.embedding_model.trim()))
1220 .flatten(),
1221 }),
1222 self.app_server
1225 .as_ref()
1226 .filter(|_| !self.incognito)
1227 .map(|s| crate::tools::AppsCtx {
1228 dir: self.space.apps_dir(&self.active_space.name),
1229 server_port: s.port(),
1230 public_base: s.public_base().map(str::to_string),
1231 registry: s.registry().clone(),
1232 space_name: self.active_space.name.clone(),
1233 space_id: self.active_space.id.clone(),
1234 space_db_path: self.space.db_path(),
1235 files_dir: self.space.files_dir(&self.active_space.name),
1236 session_id: self
1237 .session
1238 .as_ref()
1239 .map(|s| s.id.clone())
1240 .unwrap_or_default(),
1241 }),
1242 )
1243 .with_skill_dirs(skill_dirs);
1244 if self.is_research_session()
1245 && let Some(session_id) = self.session.as_ref().map(|s| s.id.clone())
1246 {
1247 toolbox = toolbox.with_research_session(session_id);
1248 }
1249 toolbox.image_gen_backend = (!self.image_gen_model.trim().is_empty())
1250 .then(|| self.backends.resolve(self.image_gen_model.trim()))
1251 .flatten();
1252 toolbox.video_gen_backend = (!self.video_gen_model.trim().is_empty())
1253 .then(|| self.backends.resolve(self.video_gen_model.trim()))
1254 .flatten();
1255 toolbox.space_files_dir = self.space.files_dir(&self.active_space.name);
1256 toolbox.space_apps_dir = self.space.apps_dir(&self.active_space.name);
1257 toolbox.space_scripts_dir = self.space.scripts_dir(&self.active_space.name);
1258 toolbox.supports_images = self.current_model_supports_images();
1259 toolbox.session_id = self
1260 .session
1261 .as_ref()
1262 .map(|s| s.id.clone())
1263 .unwrap_or_default();
1264 self.toolbox = std::sync::Arc::new(toolbox);
1265 self.reload_skills();
1266 }
1267
1268 pub fn is_research_session(&self) -> bool {
1270 self.session.as_ref().is_some_and(|s| s.kind == "research")
1271 }
1272
1273 pub fn blocked_domains(&self) -> Vec<String> {
1276 std::fs::read_to_string(self.space.blocked_domains_path(&self.active_space.name))
1277 .unwrap_or_default()
1278 .split(',')
1279 .map(|d| d.trim().to_string())
1280 .filter(|d| !d.is_empty())
1281 .collect()
1282 }
1283
1284 pub fn init(&mut self) {
1287 if self.backends.any() {
1288 self.fetch_models();
1289 }
1290 self.rescan_files();
1291 }
1292
1293 pub fn spawn_update_check(&mut self) {
1299 if !self.db.update_check_due() {
1300 return;
1301 }
1302 let (tx, rx) = mpsc::unbounded_channel();
1303 self.update_rx = Some(rx);
1304 tokio::spawn(async move {
1305 let latest = crate::update::latest_version().await;
1306 let _ = tx.send(latest);
1307 });
1308 }
1309
1310 pub fn on_update_check(&mut self, latest: Option<String>) {
1317 let Some(latest) = latest else {
1318 return;
1319 };
1320 if !crate::update::version_gt(&latest, crate::update::CURRENT) {
1321 return;
1322 }
1323 let notice = |app: &mut Self| {
1324 app.push_status(format!(
1325 "update available: v{latest} (you have {}) — run `cargo install nexus-chat`",
1326 crate::update::CURRENT
1327 ));
1328 app.notifications.push_back(ChatNotification {
1329 session_id: String::new(),
1330 title: "update available".to_string(),
1331 text: format!("v{latest} is out — you have {}", crate::update::CURRENT),
1332 success: true,
1333 });
1334 };
1335 match crate::update::try_start_auto_update(&self.space.root, &latest) {
1336 crate::update::AutoUpdateOutcome::Started => {
1337 self.push_status(format!(
1338 "auto-updating to v{latest} in the background — restart nexus to apply"
1339 ));
1340 self.notifications.push_back(ChatNotification {
1341 session_id: String::new(),
1342 title: format!("updating to v{latest}"),
1343 text: "installing via `cargo install` in the background — the new \
1344 version is live on your next launch"
1345 .to_string(),
1346 success: true,
1347 });
1348 }
1349 crate::update::AutoUpdateOutcome::InFlight => {
1350 }
1353 crate::update::AutoUpdateOutcome::Unavailable => notice(self),
1354 }
1355 }
1356
1357 pub fn is_streaming(&self) -> bool {
1358 !self.chat_tasks.is_empty()
1359 }
1360
1361 pub fn chat_task_count(&self) -> usize {
1362 self.chat_tasks.len()
1363 }
1364
1365 pub fn chat_task_for_session(&self, session_id: &str) -> Option<&ChatTask> {
1366 self.chat_tasks
1367 .values()
1368 .find(|task| task.session_id == session_id)
1369 }
1370
1371 pub fn active_chat_task(&self) -> Option<&ChatTask> {
1372 self.session
1373 .as_ref()
1374 .and_then(|session| self.chat_task_for_session(&session.id))
1375 }
1376
1377 pub fn active_streaming_text(&self) -> Option<&str> {
1378 self.active_chat_task().map(|task| task.buffer.as_str())
1379 }
1380
1381 pub fn viewing_stream(&self) -> bool {
1384 self.active_chat_task().is_some()
1385 }
1386
1387 pub fn is_welcome(&self) -> bool {
1390 self.messages.is_empty() && !self.viewing_stream()
1391 }
1392
1393 pub const fn tick_spinner(&mut self) {
1395 self.spinner_frame = self.spinner_frame.wrapping_add(1);
1396 }
1397
1398 pub const fn spinner_char(&self) -> &'static str {
1400 SPINNER[self.spinner_frame % SPINNER.len()]
1401 }
1402
1403 pub fn spinner_color(&self) -> SpinnerColor {
1405 self.active_chat_task()
1406 .map_or(self.spinner_color, |task| task.spinner_color)
1407 }
1408
1409 pub fn thinking_phrase(&self) -> &'static str {
1411 self.active_chat_task()
1412 .map_or(THINKING[self.thinking_idx].0, |task| {
1413 THINKING[task.thinking_idx].0
1414 })
1415 }
1416
1417 pub fn thinking_text(&self) -> Option<&str> {
1419 self.active_chat_task()
1420 .and_then(|task| (!task.thinking.is_empty()).then_some(task.thinking.as_str()))
1421 }
1422
1423 pub fn push_status(&mut self, s: impl Into<String>) {
1429 self.pending_events.push_back(AppEvent::Status(s.into()));
1430 }
1431
1432 pub fn push_composer_set(&mut self, text: impl Into<String>) {
1435 self.pending_events
1436 .push_back(AppEvent::ComposerSet(text.into()));
1437 }
1438
1439 pub fn push_composer_clear(&mut self) {
1441 self.pending_events.push_back(AppEvent::ComposerClear);
1442 }
1443
1444 pub fn push_viewport_reset(&mut self) {
1448 self.pending_events.push_back(AppEvent::ViewportReset);
1449 }
1450
1451 pub fn push_history_invalidated(&mut self) {
1454 self.pending_events.push_back(AppEvent::HistoryInvalidated);
1455 }
1456
1457 pub fn pop_pending_event(&mut self) -> Option<AppEvent> {
1462 self.pending_events.pop_front()
1463 }
1464
1465 #[cfg(feature = "test-helpers")]
1469 pub fn drain_ui_events(&mut self) -> (Vec<String>, String) {
1470 let mut sets = Vec::new();
1471 let mut last = String::new();
1472 while let Some(ev) = self.pop_pending_event() {
1473 match ev {
1474 AppEvent::ComposerSet(s) => sets.push(s),
1475 AppEvent::Status(s) => last = s,
1476 _ => {}
1477 }
1478 }
1479 (sets, last)
1480 }
1481
1482 #[cfg(feature = "test-helpers")]
1484 pub fn last_status(&mut self) -> String {
1485 self.drain_ui_events().1
1486 }
1487
1488 #[cfg(feature = "test-helpers")]
1491 pub fn drain_composer_sets(&mut self) -> Vec<String> {
1492 self.drain_ui_events().0
1493 }
1494
1495 pub async fn next_event(&mut self) -> AppEvent {
1498 if let Some(ev) = self.pending_events.pop_front() {
1500 return ev;
1501 }
1502 tokio::select! {
1503 ev = self.chat_event_rx.recv() => {
1504 AppEvent::Stream(ev.map(|event| (event.task_id, event.event)))
1505 },
1506 res = async {
1507 match self.models_rx.as_mut() {
1508 Some(rx) => rx.recv().await,
1509 None => std::future::pending().await,
1510 }
1511 } => AppEvent::Models(res),
1512 t = async {
1513 match self.title_rx.as_mut() {
1514 Some(rx) => rx.recv().await,
1515 None => std::future::pending().await,
1516 }
1517 } => AppEvent::Title(t),
1518 m = async {
1519 match self.memory_rx.as_mut() {
1520 Some(rx) => rx.recv().await,
1521 None => std::future::pending().await,
1522 }
1523 } => AppEvent::Memory(m),
1524 c = async {
1525 match self.compact_rx.as_mut() {
1526 Some(rx) => rx.recv().await,
1527 None => std::future::pending().await,
1528 }
1529 } => AppEvent::Compact(c),
1530 r = async {
1531 match self.skills_rx.as_mut() {
1532 Some(rx) => rx.recv().await,
1533 None => std::future::pending().await,
1534 }
1535 } => AppEvent::SkillInstall(r),
1536 r = async {
1537 match self.ocr_rx.as_mut() {
1538 Some(rx) => rx.recv().await,
1539 None => std::future::pending().await,
1540 }
1541 } => AppEvent::Ocr(r),
1542 r = async {
1543 match self.embed_rx.as_mut() {
1544 Some(rx) => rx.recv().await,
1545 None => std::future::pending().await,
1546 }
1547 } => AppEvent::Embed(r),
1548 r = async {
1549 match self.ocr_pull_rx.as_mut() {
1550 Some(rx) => rx.recv().await,
1551 None => std::future::pending().await,
1552 }
1553 } => AppEvent::OcrPull(r),
1554 r = async {
1555 match self.research_rx.as_mut() {
1556 Some(rx) => rx.recv().await,
1557 None => std::future::pending().await,
1558 }
1559 } => AppEvent::Research(r),
1560 r = async {
1561 match self.research_topic_rx.as_mut() {
1562 Some(rx) => rx.recv().await,
1563 None => std::future::pending().await,
1564 }
1565 } => AppEvent::ResearchTopic(r),
1566 r = async {
1567 match self.login_rx.as_mut() {
1568 Some(rx) => rx.recv().await,
1569 None => std::future::pending().await,
1570 }
1571 } => AppEvent::Login(r),
1572 r = async {
1573 match self.update_rx.as_mut() {
1574 Some(rx) => rx.recv().await,
1575 None => std::future::pending().await,
1576 }
1577 } => AppEvent::UpdateCheck(r.flatten()),
1578 r = async {
1579 match self.swarm_rx.as_mut() {
1580 Some(rx) => rx.recv().await,
1581 None => std::future::pending().await,
1582 }
1583 } => AppEvent::Swarm(r),
1584 }
1585 }
1586
1587 pub fn on_models_result(&mut self, result: Option<ModelsResult>) {
1588 self.models_rx = None;
1589 let result = match result {
1590 Some(r) => r,
1591 None => Err("model fetch cancelled".to_string()),
1592 };
1593 match result {
1594 Ok(models) => {
1595 let n = models.len();
1596 let prices: Vec<_> = models
1602 .iter()
1603 .filter_map(|m| {
1604 m.pricing
1605 .map(|price| (m.id.clone(), m.backend.name().to_string(), price))
1606 })
1607 .collect();
1608 let _ = self.db.upsert_model_prices(&prices);
1609 let _ = self.db.backfill_usage_costs();
1613 self.models = models;
1614 self.push_status(format!("loaded {n} models"));
1615 self.maybe_compact();
1619 }
1623 Err(e) => self.push_status(format!("model fetch failed: {e}")),
1624 }
1625 }
1626
1627 pub fn vlm_ocr_enabled(&self) -> bool {
1634 !self.ocr_model.trim().is_empty() && matches!(self.ocr_engine.as_str(), "vlm" | "auto")
1635 }
1636}
1637
1638impl App {
1639 pub fn cancel_chat_tasks(&mut self) {
1642 for task in self.chat_tasks.values() {
1643 task.abort.abort();
1644 }
1645 self.chat_tasks.clear();
1646 }
1647}
1648
1649pub fn send_system_notification(title: &str, body: &str) {
1653 #[cfg(all(target_os = "linux", not(test)))]
1654 {
1655 let _ = std::process::Command::new("notify-send")
1656 .args([
1657 "--app-name=nexus-chat",
1658 "--urgency=normal",
1659 "--expire-time=5000",
1660 "--hint=int:transient:1",
1661 title,
1662 body,
1663 ])
1664 .spawn();
1665 }
1666 #[cfg(any(not(target_os = "linux"), test))]
1667 let _ = (title, body);
1668}
1669
1670#[allow(clippy::too_many_lines)]
1672pub fn tool_call_summary(name: &str, args: &str, result: &str) -> String {
1675 let v: serde_json::Value = serde_json::from_str(args).unwrap_or_default();
1676 let f = |k: &str| {
1677 v.get(k)
1678 .and_then(|x| x.as_str())
1679 .unwrap_or_default()
1680 .to_string()
1681 };
1682 let f_or = |first: &str, second: &str| {
1683 let value = f(first);
1684 if value.is_empty() { f(second) } else { value }
1685 };
1686 match name {
1687 "skills" => {
1688 let target = if f("action") == "install" {
1689 f("source")
1690 } else {
1691 f("name")
1692 };
1693 format!("skills/{} {}", f("action"), target)
1694 }
1695 "scripts" => {
1696 let target = match f("action").as_str() {
1697 "install" => {
1698 let n = v
1699 .get("packages")
1700 .and_then(|a| a.as_array())
1701 .map_or(0, Vec::len);
1702 format!("{n} packages")
1703 }
1704 "python" => f("name"),
1705 _ => f("path"),
1706 };
1707 format!("scripts/{} {}", f("action"), target)
1708 }
1709 "app" => format!("app/{} {}", f("action"), f("app")),
1710 "media" => {
1711 let target = [f("video_id"), f("name"), f("image_id")]
1712 .into_iter()
1713 .find(|t| !t.is_empty())
1714 .unwrap_or_else(|| f("prompt").chars().take(30).collect());
1715 format!("media/{} {}", f("action"), target)
1716 }
1717 "skill" => format!("skill {}", f("name")),
1718 "skill_admin" => format!("skill_admin {} → {}", f("action"), first_line(result)),
1719 "search" => {
1720 let failed = result.starts_with("no results") || result.contains("failed");
1721 let hits = if failed { "no hits" } else { "hits" };
1722 format!("search/{} \"{}\" → {hits}", f("mode"), f("query"))
1723 }
1724 "research_lookup" => format!("research_lookup/{} \"{}\"", f("scope"), f("query")),
1725 "batch" => {
1726 let calls = v.get("calls").and_then(|c| c.as_array());
1727 let n = calls.map_or(0, Vec::len);
1728 let tools: Vec<&str> = calls
1729 .map(|arr| {
1730 arr.iter()
1731 .filter_map(|item| item.get("tool").and_then(|t| t.as_str()))
1732 .collect()
1733 })
1734 .unwrap_or_default();
1735 format!("batch [{n} ops: {}]", tools.join(", "))
1736 }
1737 "fetch_url" => format!("fetch_url {} → {}", f("url"), first_line(result)),
1738 "files" => format!(
1739 "files/{} {} → {}",
1740 f("action"),
1741 f_or("name", "query"),
1742 first_line(result)
1743 ),
1744 "app_inspect" => format!(
1745 "app_inspect/{} {}/{}",
1746 f("action"),
1747 f("app"),
1748 f_or("path", "pattern")
1749 ),
1750 "app_modify" => format!("app_modify/{} {}/{}", f("action"), f("app"), f("path")),
1751 "app_assets" => format!("app_assets/{} {}", f("action"), f("app")),
1752 "script_files" => format!("script_files/{} {}", f("action"), f("path")),
1753 "video_transform" => format!("video_transform/{} {}", f("action"), f("video_id")),
1754 "video_references" => format!("video_references/{} {}", f("action"), f("name")),
1755 "install_skill" => format!("install_skill {} → {}", f("source"), first_line(result)),
1756 "create_skill" => format!("create_skill {} → {}", f("name"), first_line(result)),
1757 "run_script" => {
1758 let path = f_or("path", "script");
1759 if v.get("space")
1760 .and_then(serde_json::Value::as_bool)
1761 .unwrap_or(false)
1762 {
1763 format!("run_script space/{path}")
1764 } else {
1765 format!("run_script {}/{}", f("skill"), path)
1766 }
1767 }
1768 "run_python" => format!("run_python ({} lines)", f("code").lines().count().max(1)),
1769 "grep_app" => {
1770 let hits = if result.starts_with("no matches") {
1771 "no hits".to_string()
1772 } else {
1773 format!(
1774 "{} files",
1775 result.lines().filter(|line| !line.starts_with('…')).count()
1776 )
1777 };
1778 format!("grep_app {} \"{}\" → {hits}", f("app"), f("pattern"))
1779 }
1780 "install_packages" => {
1781 let pkgs = v
1782 .get("packages")
1783 .and_then(|a| a.as_array())
1784 .map(|a| {
1785 a.iter()
1786 .filter_map(|x| x.as_str())
1787 .collect::<Vec<_>>()
1788 .join(" ")
1789 })
1790 .unwrap_or_default();
1791 let target = [f("skill"), f("app")]
1792 .into_iter()
1793 .find(|t| !t.is_empty())
1794 .unwrap_or_default();
1795 format!("install_packages {pkgs} → {target}")
1796 }
1797 "web_search" | "search_files" => {
1798 let failed = result.starts_with("no results")
1799 || result.starts_with("no matches")
1800 || result.contains("failed");
1801 let hits = if failed {
1802 "no hits".to_string()
1803 } else {
1804 format!("{} hits", result.lines().count())
1805 };
1806 format!("{name} \"{}\" → {hits}", f("query"))
1807 }
1808 "read_file" => format!("read_file {} → {}", f("name"), first_line(result)),
1809 "read_app_file" => format!("read_app_file {}/{}", f("app"), f("path")),
1810 "diff_app" => format!("diff_app {}/{}", f("app"), f("path")),
1811 "write_file" => {
1812 format!(
1813 "write_file {}/{} ({} bytes)",
1814 f("app"),
1815 f("path"),
1816 f("content").len()
1817 )
1818 }
1819 "edit_file" => format!("edit_file {}/{}", f("app"), f("path")),
1820 "generate_video" => format!("generate_video \"{}\"", f("prompt")),
1821 "edit_video" => format!("edit_video {} {}", f("video_id"), f("lighting")),
1822 "extract_frame" => format!("extract_frame {} @ {:.1}s", f("video_id"), f("time_sec")),
1823 "stitch_videos" => format!("stitch_videos {}", f("video_ids")),
1824 "save_reference" => format!("save_reference {}", f("name")),
1825 "list_references" => "list_references".to_string(),
1826 "delete_reference" => format!("delete_reference {}", f("name")),
1827 "generate_image" => format!("generate_image \"{}\"", f("prompt")),
1828 _ => {
1829 let mut a: String = args.chars().take(60).collect();
1830 if args.chars().count() > 60 {
1831 a.push('…');
1832 }
1833 format!("{name} {a}")
1834 }
1835 }
1836}
1837
1838fn first_line(result: &str) -> String {
1840 result
1841 .lines()
1842 .next()
1843 .unwrap_or("")
1844 .trim_end_matches(':')
1845 .to_string()
1846}