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 transcriber_model: String,
733 pub ocr_model: String,
735 pub ocr_engine: String,
738 pub local_ocr_model: String,
740 pub embedding_model: String,
742 pub image_gen_model: String,
744 pub video_gen_model: String,
746 pub searxng_url: String,
749 pub langsearch_key: String,
751 pub search_provider: String,
753 pub base_system_prompt: String,
756 pub verbosity: String,
759 pub memory_rx: Option<mpsc::UnboundedReceiver<(String, Vec<MemoryOp>)>>,
760 pub compact_rx: Option<mpsc::UnboundedReceiver<(String, String, i64, u64)>>,
762
763 pub skills: Vec<crate::skills::Skill>,
766 pub forced_skill: Option<String>,
768 pub web_mode: bool,
770 pub incognito: bool,
771 pub incognito_img_dir: Option<std::path::PathBuf>,
773 pub survey_gate: Option<SurveyGate>,
777 pub survey_reply_tx: Option<mpsc::UnboundedSender<String>>,
781 pub research_steer_log: Vec<(usize, String)>,
787 pub research_steer_acked: std::collections::HashSet<usize>,
792 pub research_stage_rows: Vec<String>,
796 pub research_incognito: bool,
800 pub research_steer_tx: Option<mpsc::UnboundedSender<String>>,
803 pub research_topic_rx: Option<mpsc::UnboundedReceiver<Result<String, String>>>,
805 pub research_live_input: String,
807 pub toolbox: std::sync::Arc<dyn crate::tools::ToolExecutor>,
808 pub app_server: Option<crate::appserver::AppServer>,
810 pub skills_rx: Option<mpsc::UnboundedReceiver<Result<String, String>>>,
811 pub ocr_rx: Option<mpsc::UnboundedReceiver<(String, String, files::OcrUpdate)>>,
813 pub embed_rx: Option<mpsc::UnboundedReceiver<EmbedMsg>>,
815 pub ocr_pull_rx: Option<mpsc::UnboundedReceiver<Result<String, String>>>,
817 pub research_rx: Option<mpsc::UnboundedReceiver<ResearchMsg>>,
819 pub research_abort: Option<tokio::task::AbortHandle>,
820 pub login_rx: Option<mpsc::UnboundedReceiver<LoginMsg>>,
821 pub update_rx: Option<mpsc::UnboundedReceiver<Option<String>>>,
823 pub swarm_rx: Option<mpsc::UnboundedReceiver<swarm::SwarmMsg>>,
826 pub swarm_abort: Option<tokio::task::AbortHandle>,
827 pub swarm_session: Option<String>,
828 pub swarm_cache: Vec<crate::db::Persona>,
831 pub research_running: Option<(String, String)>,
834
835 pub files_cache: Vec<crate::db::FileRow>,
837 pub apps_cache: Vec<String>,
839 pub images_cache: Vec<ImageMeta>,
841
842 pub scripts_cache: Vec<ScriptMeta>,
844 pub watches_cache: Vec<crate::db::Watch>,
846 pub usage_range: crate::db::UsageRange,
849
850 pub models: Vec<Model>,
852 pub current_model: Option<String>,
853 pub models_rx: Option<mpsc::UnboundedReceiver<ModelsResult>>,
854 pub favorites: HashSet<String>,
856 pub last_used: HashMap<String, String>,
857 pub reasoning: HashMap<String, String>,
860
861 pub session: Option<Session>,
862 pub messages: Vec<Message>,
863
864 pub chat_event_tx: mpsc::UnboundedSender<ChatEvent>,
866 pub chat_event_rx: mpsc::UnboundedReceiver<ChatEvent>,
867 pub chat_tasks: HashMap<ChatTaskId, ChatTask>,
868 pub next_chat_task_id: ChatTaskId,
869 pub notifications: VecDeque<ChatNotification>,
871 pub(crate) pending_events: VecDeque<AppEvent>,
874 pub unread: std::collections::HashSet<String>,
876 pub context_total: Option<u64>,
878 pub last_cache_rate: Option<f64>,
882
883 pub settings: Settings,
884 pub model_pick_target: ModelPickTarget,
887 pub spinner_frame: usize,
889 pub thinking_idx: usize,
890 pub spinner_color: SpinnerColor,
891
892 pub sessions_cache: Vec<Session>,
893 pub(crate) title_rx: Option<mpsc::UnboundedReceiver<(String, String, String)>>,
895}
896
897impl App {
898 #[allow(clippy::too_many_lines)]
900 pub fn new(db: Db, key: Option<&str>, space: Space) -> Self {
901 let provider = key.map(|k| OpenRouter::from_key_auto(k.to_string()));
902 let mut backends = Backends::default();
908 let mut saved = crate::config::SavedCreds::default();
909 if let (Some(k), Some(p)) = (&key, &provider) {
910 let tag = p.backend_tag();
911 backends.set(tag, p.clone());
912 match tag {
913 crate::provider::BackendTag::OpenRouter => {
914 saved.openrouter_key = Some((*k).to_string());
915 }
916 crate::provider::BackendTag::OpenAi => saved.openai_key = Some((*k).to_string()),
917 crate::provider::BackendTag::OpencodeGo => {
918 saved.opencode_key = Some((*k).to_string());
919 }
920 crate::provider::BackendTag::Codex => {}
923 }
924 }
925 let status = if key.is_some() {
926 "loading models… (/model to pick, /help for commands)".to_string()
927 } else {
928 "no API key — set it with /key (or $OPENROUTER_API_KEY/$OPENAI_API_KEY)".to_string()
929 };
930 let active_space = db
933 .default_space_id()
934 .ok()
935 .and_then(|id| {
936 db.list_spaces()
937 .ok()
938 .and_then(|s| s.into_iter().find(|s| s.id == id))
939 })
940 .unwrap_or_else(|| SpaceRow {
941 id: String::new(),
942 name: DEFAULT_SPACE.to_string(),
943 created_at: Utc::now().to_rfc3339(),
944 });
945 let _ = space.ensure_space_dir(&active_space.name);
946 let default_model_id = |f: fn(&OpenRouter) -> &'static str, fallback: &'static str| {
947 provider.as_ref().map_or_else(
948 || fallback.to_string(),
949 |p| {
950 let model = f(p);
951 if model.is_empty() {
952 String::new()
953 } else {
954 format!("{}{}", p.backend_tag().key_prefix(), model)
955 }
956 },
957 )
958 };
959 let utility_model = default_model_id(
960 OpenRouter::default_utility_model,
961 "google/gemini-2.5-flash-lite",
962 );
963 let embedding_model = default_model_id(
964 OpenRouter::default_embedding_model,
965 "openai/text-embedding-3-small",
966 );
967 let image_gen_model =
968 default_model_id(OpenRouter::default_image_gen_model, "openai/gpt-image-2");
969 let video_gen_model =
970 default_model_id(OpenRouter::default_video_gen_model, "google/veo-3.1");
971 let skills_dir = crate::skills::skills_dir(&space.root);
972 let skills = crate::skills::load_skills(&skills_dir);
973 let (chat_event_tx, chat_event_rx) = mpsc::unbounded_channel();
974 let toolbox = std::sync::Arc::new(crate::tools::ToolBox::new(
977 skills_dir,
978 None,
979 None,
980 "auto".to_string(),
981 Vec::new(),
982 Some(space.db_path()),
983 Some(crate::tools::FilesCtx {
984 db_path: space.db_path(),
985 space_id: active_space.id.clone(),
986 embedder: (!embedding_model.is_empty())
987 .then(|| backends.resolve(&embedding_model))
988 .flatten(),
989 }),
990 None,
993 ));
994 let mut app = Self {
995 db,
996 space,
997 backends,
998 saved,
999 skills,
1000 searxng_url: String::new(),
1001 langsearch_key: String::new(),
1002 search_provider: "auto".to_string(),
1003 forced_skill: None,
1004 web_mode: false,
1005 incognito: false,
1006 incognito_img_dir: None,
1007 survey_gate: None,
1008 survey_reply_tx: None,
1009 research_steer_log: Vec::new(),
1010 research_steer_acked: std::collections::HashSet::new(),
1011 research_stage_rows: Vec::new(),
1012 research_incognito: false,
1013 research_steer_tx: None,
1014 research_topic_rx: None,
1015 research_live_input: String::new(),
1016 toolbox,
1017 app_server: None,
1018 skills_rx: None,
1019 ocr_rx: None,
1020 embed_rx: None,
1021 ocr_pull_rx: None,
1022 research_rx: None,
1023 research_abort: None,
1024 login_rx: None,
1025 update_rx: None,
1026 swarm_rx: None,
1027 swarm_abort: None,
1028 swarm_session: None,
1029 swarm_cache: Vec::new(),
1030 research_running: None,
1031 files_cache: Vec::new(),
1032 apps_cache: Vec::new(),
1033 watches_cache: Vec::new(),
1034 images_cache: Vec::new(),
1035 scripts_cache: Vec::new(),
1036 usage_range: crate::db::UsageRange::default(),
1037 active_space,
1038 memory_model: utility_model.clone(),
1039 transcriber_model: utility_model.clone(),
1040 ocr_model: utility_model,
1041 ocr_engine: "auto".to_string(),
1042 local_ocr_model: "glm-ocr".to_string(),
1043 embedding_model,
1044 image_gen_model,
1045 video_gen_model,
1046 base_system_prompt: config::load_system_prompt().unwrap_or_default(),
1047 verbosity: "concise".to_string(),
1048 memory_rx: None,
1049 compact_rx: None,
1050 models: Vec::new(),
1051 current_model: None,
1052 models_rx: None,
1053 favorites: HashSet::new(),
1054 last_used: HashMap::new(),
1055 reasoning: HashMap::new(),
1056 session: None,
1057 messages: Vec::new(),
1058 chat_event_tx,
1059 chat_event_rx,
1060 chat_tasks: HashMap::new(),
1061 next_chat_task_id: 0,
1062 notifications: VecDeque::new(),
1063 pending_events: VecDeque::new(),
1064 unread: std::collections::HashSet::new(),
1065 context_total: None,
1066 last_cache_rate: None,
1067 settings: Settings::default(),
1068 model_pick_target: ModelPickTarget::Session,
1069 spinner_frame: 0,
1070 thinking_idx: 0,
1071 spinner_color: SpinnerColor::Green,
1072 sessions_cache: Vec::new(),
1073 title_rx: None,
1074 };
1075 app.pending_events.push_back(AppEvent::Status(status));
1076 app.load_prefs();
1077 app.load_settings();
1078 app
1079 }
1080
1081 fn load_prefs(&mut self) {
1085 if let Ok(prefs) = self.db.load_model_prefs() {
1086 for p in prefs {
1087 if p.favorite {
1088 self.favorites.insert(p.id.clone());
1089 }
1090 if let Some(t) = p.last_used {
1091 self.last_used.insert(p.id.clone(), t);
1092 }
1093 if let Some(r) = p.reasoning {
1094 self.reasoning.insert(p.id, r);
1095 }
1096 }
1097 }
1098 if let Some((id, _)) = self.last_used.iter().max_by(|a, b| a.1.cmp(b.1)) {
1099 let id = id.clone();
1100 if self.backends.any() {
1101 self.push_status(format!("model: {id} — type a message, /model to change"));
1102 }
1103 self.current_model = Some(id);
1104 }
1105 }
1106
1107 fn load_settings(&mut self) {
1109 let Ok(kv) = self.db.load_settings() else {
1110 return;
1111 };
1112 for (k, v) in kv {
1113 self.apply_setting(&k, &v);
1114 }
1115 self.refresh_toolbox();
1116 }
1117
1118 fn apply_setting(&mut self, k: &str, v: &str) {
1121 match k {
1122 "show_stats" => self.settings.show_stats = v == "1",
1123 "show_reasoning" => self.settings.show_reasoning = v == "1",
1124 "hide_hints" => self.settings.hide_hints = v == "1",
1125 "usage_range" => self.usage_range = crate::db::UsageRange::from_key(v),
1126 "temperature" => self.settings.temperature = v.parse().ok(),
1127 "top_p" => self.settings.top_p = v.parse().ok(),
1128 "max_tokens" => self.settings.max_tokens = v.parse().ok(),
1129 "memory_model" => self.memory_model = v.to_string(),
1130 "transcriber_model" => self.transcriber_model = v.to_string(),
1131 "ocr_model" => self.ocr_model = v.to_string(),
1132 "ocr_engine" if OCR_ENGINES.contains(&v) => self.ocr_engine = v.to_string(),
1133 "local_ocr_model" => self.local_ocr_model = v.to_string(),
1134 "embedding_model" => self.embedding_model = v.to_string(),
1135 "image_gen_model" => {
1139 self.image_gen_model = match v {
1140 "black-forest-labs/flux-dev" => "openai/gpt-image-2".to_string(),
1141 _ => v.to_string(),
1142 }
1143 }
1144 "video_gen_model" => {
1145 self.video_gen_model = match v {
1146 "google/veo-3.1-lite" => "google/veo-3.1".to_string(),
1147 _ => v.to_string(),
1148 }
1149 }
1150 "compact_threshold" => {
1151 if let Ok(t) = v.parse() {
1152 self.settings.compact_threshold = t;
1153 }
1154 }
1155 "searxng_url" => self.searxng_url = v.to_string(),
1156 "verbosity" if VERBOSITY_LEVELS.contains(&v) => self.verbosity = v.to_string(),
1157 "langsearch_key" => self.langsearch_key = v.to_string(),
1158 "search_provider" if SEARCH_PROVIDERS.contains(&v) => {
1159 self.search_provider = v.to_string();
1160 }
1161 _ => {}
1162 }
1163 }
1164
1165 pub fn refresh_toolbox(&mut self) {
1169 let url =
1170 (!self.searxng_url.trim().is_empty()).then(|| self.searxng_url.trim().to_string());
1171 let key = (!self.langsearch_key.trim().is_empty())
1172 .then(|| self.langsearch_key.trim().to_string());
1173 let skills_dir = crate::skills::skills_dir(&self.space.root);
1176 crate::skills::install_builtin(&skills_dir);
1177 let mut toolbox = crate::tools::ToolBox::new(
1178 skills_dir.clone(),
1179 url,
1180 key,
1181 self.search_provider.clone(),
1182 self.blocked_domains(),
1183 Some(self.space.db_path()),
1184 Some(crate::tools::FilesCtx {
1185 db_path: self.space.db_path(),
1186 space_id: self.active_space.id.clone(),
1187 embedder: (!self.embedding_model.trim().is_empty())
1188 .then(|| self.backends.resolve(self.embedding_model.trim()))
1189 .flatten(),
1190 }),
1191 self.app_server
1194 .as_ref()
1195 .filter(|_| !self.incognito)
1196 .map(|s| crate::tools::AppsCtx {
1197 dir: self.space.apps_dir(&self.active_space.name),
1198 server_port: s.port(),
1199 public_base: s.public_base().map(str::to_string),
1200 registry: s.registry().clone(),
1201 space_name: self.active_space.name.clone(),
1202 space_id: self.active_space.id.clone(),
1203 space_db_path: self.space.db_path(),
1204 files_dir: self.space.files_dir(&self.active_space.name),
1205 session_id: self
1206 .session
1207 .as_ref()
1208 .map(|s| s.id.clone())
1209 .unwrap_or_default(),
1210 }),
1211 );
1212 if self.is_research_session()
1213 && let Some(session_id) = self.session.as_ref().map(|s| s.id.clone())
1214 {
1215 toolbox = toolbox.with_research_session(session_id);
1216 }
1217 toolbox.image_gen_backend = (!self.image_gen_model.trim().is_empty())
1218 .then(|| self.backends.resolve(self.image_gen_model.trim()))
1219 .flatten();
1220 toolbox.video_gen_backend = (!self.video_gen_model.trim().is_empty())
1221 .then(|| self.backends.resolve(self.video_gen_model.trim()))
1222 .flatten();
1223 toolbox.space_files_dir = self.space.files_dir(&self.active_space.name);
1224 toolbox.space_apps_dir = self.space.apps_dir(&self.active_space.name);
1225 toolbox.space_scripts_dir = self.space.scripts_dir(&self.active_space.name);
1226 toolbox.supports_images = self.current_model_supports_images();
1227 toolbox.session_id = self
1228 .session
1229 .as_ref()
1230 .map(|s| s.id.clone())
1231 .unwrap_or_default();
1232 self.toolbox = std::sync::Arc::new(toolbox);
1233 self.reload_skills();
1234 }
1235
1236 pub fn is_research_session(&self) -> bool {
1238 self.session.as_ref().is_some_and(|s| s.kind == "research")
1239 }
1240
1241 pub fn blocked_domains(&self) -> Vec<String> {
1244 std::fs::read_to_string(self.space.blocked_domains_path(&self.active_space.name))
1245 .unwrap_or_default()
1246 .split(',')
1247 .map(|d| d.trim().to_string())
1248 .filter(|d| !d.is_empty())
1249 .collect()
1250 }
1251
1252 pub fn init(&mut self) {
1255 if self.backends.any() {
1256 self.fetch_models();
1257 }
1258 self.rescan_files();
1259 }
1260
1261 pub fn spawn_update_check(&mut self) {
1267 if !self.db.update_check_due() {
1268 return;
1269 }
1270 let (tx, rx) = mpsc::unbounded_channel();
1271 self.update_rx = Some(rx);
1272 tokio::spawn(async move {
1273 let latest = crate::update::latest_version().await;
1274 let _ = tx.send(latest);
1275 });
1276 }
1277
1278 pub fn on_update_check(&mut self, latest: Option<String>) {
1285 let Some(latest) = latest else {
1286 return;
1287 };
1288 if !crate::update::version_gt(&latest, crate::update::CURRENT) {
1289 return;
1290 }
1291 let notice = |app: &mut Self| {
1292 app.push_status(format!(
1293 "update available: v{latest} (you have {}) — run `cargo install nexus-chat`",
1294 crate::update::CURRENT
1295 ));
1296 app.notifications.push_back(ChatNotification {
1297 session_id: String::new(),
1298 title: "update available".to_string(),
1299 text: format!("v{latest} is out — you have {}", crate::update::CURRENT),
1300 success: true,
1301 });
1302 };
1303 match crate::update::try_start_auto_update(&self.space.root, &latest) {
1304 crate::update::AutoUpdateOutcome::Started => {
1305 self.push_status(format!(
1306 "auto-updating to v{latest} in the background — restart nexus to apply"
1307 ));
1308 self.notifications.push_back(ChatNotification {
1309 session_id: String::new(),
1310 title: format!("updating to v{latest}"),
1311 text: "installing via `cargo install` in the background — the new \
1312 version is live on your next launch"
1313 .to_string(),
1314 success: true,
1315 });
1316 }
1317 crate::update::AutoUpdateOutcome::InFlight => {
1318 }
1321 crate::update::AutoUpdateOutcome::Unavailable => notice(self),
1322 }
1323 }
1324
1325 pub fn is_streaming(&self) -> bool {
1326 !self.chat_tasks.is_empty()
1327 }
1328
1329 pub fn chat_task_count(&self) -> usize {
1330 self.chat_tasks.len()
1331 }
1332
1333 pub fn chat_task_for_session(&self, session_id: &str) -> Option<&ChatTask> {
1334 self.chat_tasks
1335 .values()
1336 .find(|task| task.session_id == session_id)
1337 }
1338
1339 pub fn active_chat_task(&self) -> Option<&ChatTask> {
1340 self.session
1341 .as_ref()
1342 .and_then(|session| self.chat_task_for_session(&session.id))
1343 }
1344
1345 pub fn active_streaming_text(&self) -> Option<&str> {
1346 self.active_chat_task().map(|task| task.buffer.as_str())
1347 }
1348
1349 pub fn viewing_stream(&self) -> bool {
1352 self.active_chat_task().is_some()
1353 }
1354
1355 pub fn is_welcome(&self) -> bool {
1358 self.messages.is_empty() && !self.viewing_stream()
1359 }
1360
1361 pub const fn tick_spinner(&mut self) {
1363 self.spinner_frame = self.spinner_frame.wrapping_add(1);
1364 }
1365
1366 pub const fn spinner_char(&self) -> &'static str {
1368 SPINNER[self.spinner_frame % SPINNER.len()]
1369 }
1370
1371 pub fn spinner_color(&self) -> SpinnerColor {
1373 self.active_chat_task()
1374 .map_or(self.spinner_color, |task| task.spinner_color)
1375 }
1376
1377 pub fn thinking_phrase(&self) -> &'static str {
1379 self.active_chat_task()
1380 .map_or(THINKING[self.thinking_idx].0, |task| {
1381 THINKING[task.thinking_idx].0
1382 })
1383 }
1384
1385 pub fn thinking_text(&self) -> Option<&str> {
1387 self.active_chat_task()
1388 .and_then(|task| (!task.thinking.is_empty()).then_some(task.thinking.as_str()))
1389 }
1390
1391 pub fn push_status(&mut self, s: impl Into<String>) {
1397 self.pending_events.push_back(AppEvent::Status(s.into()));
1398 }
1399
1400 pub fn push_composer_set(&mut self, text: impl Into<String>) {
1403 self.pending_events
1404 .push_back(AppEvent::ComposerSet(text.into()));
1405 }
1406
1407 pub fn push_composer_clear(&mut self) {
1409 self.pending_events.push_back(AppEvent::ComposerClear);
1410 }
1411
1412 pub fn push_viewport_reset(&mut self) {
1416 self.pending_events.push_back(AppEvent::ViewportReset);
1417 }
1418
1419 pub fn push_history_invalidated(&mut self) {
1422 self.pending_events.push_back(AppEvent::HistoryInvalidated);
1423 }
1424
1425 pub fn pop_pending_event(&mut self) -> Option<AppEvent> {
1430 self.pending_events.pop_front()
1431 }
1432
1433 #[cfg(feature = "test-helpers")]
1437 pub fn drain_ui_events(&mut self) -> (Vec<String>, String) {
1438 let mut sets = Vec::new();
1439 let mut last = String::new();
1440 while let Some(ev) = self.pop_pending_event() {
1441 match ev {
1442 AppEvent::ComposerSet(s) => sets.push(s),
1443 AppEvent::Status(s) => last = s,
1444 _ => {}
1445 }
1446 }
1447 (sets, last)
1448 }
1449
1450 #[cfg(feature = "test-helpers")]
1452 pub fn last_status(&mut self) -> String {
1453 self.drain_ui_events().1
1454 }
1455
1456 #[cfg(feature = "test-helpers")]
1459 pub fn drain_composer_sets(&mut self) -> Vec<String> {
1460 self.drain_ui_events().0
1461 }
1462
1463 pub async fn next_event(&mut self) -> AppEvent {
1466 if let Some(ev) = self.pending_events.pop_front() {
1468 return ev;
1469 }
1470 tokio::select! {
1471 ev = self.chat_event_rx.recv() => {
1472 AppEvent::Stream(ev.map(|event| (event.task_id, event.event)))
1473 },
1474 res = async {
1475 match self.models_rx.as_mut() {
1476 Some(rx) => rx.recv().await,
1477 None => std::future::pending().await,
1478 }
1479 } => AppEvent::Models(res),
1480 t = async {
1481 match self.title_rx.as_mut() {
1482 Some(rx) => rx.recv().await,
1483 None => std::future::pending().await,
1484 }
1485 } => AppEvent::Title(t),
1486 m = async {
1487 match self.memory_rx.as_mut() {
1488 Some(rx) => rx.recv().await,
1489 None => std::future::pending().await,
1490 }
1491 } => AppEvent::Memory(m),
1492 c = async {
1493 match self.compact_rx.as_mut() {
1494 Some(rx) => rx.recv().await,
1495 None => std::future::pending().await,
1496 }
1497 } => AppEvent::Compact(c),
1498 r = async {
1499 match self.skills_rx.as_mut() {
1500 Some(rx) => rx.recv().await,
1501 None => std::future::pending().await,
1502 }
1503 } => AppEvent::SkillInstall(r),
1504 r = async {
1505 match self.ocr_rx.as_mut() {
1506 Some(rx) => rx.recv().await,
1507 None => std::future::pending().await,
1508 }
1509 } => AppEvent::Ocr(r),
1510 r = async {
1511 match self.embed_rx.as_mut() {
1512 Some(rx) => rx.recv().await,
1513 None => std::future::pending().await,
1514 }
1515 } => AppEvent::Embed(r),
1516 r = async {
1517 match self.ocr_pull_rx.as_mut() {
1518 Some(rx) => rx.recv().await,
1519 None => std::future::pending().await,
1520 }
1521 } => AppEvent::OcrPull(r),
1522 r = async {
1523 match self.research_rx.as_mut() {
1524 Some(rx) => rx.recv().await,
1525 None => std::future::pending().await,
1526 }
1527 } => AppEvent::Research(r),
1528 r = async {
1529 match self.research_topic_rx.as_mut() {
1530 Some(rx) => rx.recv().await,
1531 None => std::future::pending().await,
1532 }
1533 } => AppEvent::ResearchTopic(r),
1534 r = async {
1535 match self.login_rx.as_mut() {
1536 Some(rx) => rx.recv().await,
1537 None => std::future::pending().await,
1538 }
1539 } => AppEvent::Login(r),
1540 r = async {
1541 match self.update_rx.as_mut() {
1542 Some(rx) => rx.recv().await,
1543 None => std::future::pending().await,
1544 }
1545 } => AppEvent::UpdateCheck(r.flatten()),
1546 r = async {
1547 match self.swarm_rx.as_mut() {
1548 Some(rx) => rx.recv().await,
1549 None => std::future::pending().await,
1550 }
1551 } => AppEvent::Swarm(r),
1552 }
1553 }
1554
1555 pub fn on_models_result(&mut self, result: Option<ModelsResult>) {
1556 self.models_rx = None;
1557 let result = match result {
1558 Some(r) => r,
1559 None => Err("model fetch cancelled".to_string()),
1560 };
1561 match result {
1562 Ok(models) => {
1563 let n = models.len();
1564 let prices: Vec<_> = models
1570 .iter()
1571 .filter_map(|m| {
1572 m.pricing
1573 .map(|price| (m.id.clone(), m.backend.name().to_string(), price))
1574 })
1575 .collect();
1576 let _ = self.db.upsert_model_prices(&prices);
1577 let _ = self.db.backfill_usage_costs();
1581 self.models = models;
1582 self.push_status(format!("loaded {n} models"));
1583 }
1587 Err(e) => self.push_status(format!("model fetch failed: {e}")),
1588 }
1589 }
1590
1591 pub fn vlm_ocr_enabled(&self) -> bool {
1598 !self.ocr_model.trim().is_empty() && matches!(self.ocr_engine.as_str(), "vlm" | "auto")
1599 }
1600}
1601
1602impl App {
1603 pub fn cancel_chat_tasks(&mut self) {
1606 for task in self.chat_tasks.values() {
1607 task.abort.abort();
1608 }
1609 self.chat_tasks.clear();
1610 }
1611}
1612
1613pub fn send_system_notification(title: &str, body: &str) {
1617 #[cfg(all(target_os = "linux", not(test)))]
1618 {
1619 let _ = std::process::Command::new("notify-send")
1620 .args([
1621 "--app-name=nexus-chat",
1622 "--urgency=normal",
1623 "--expire-time=5000",
1624 "--hint=int:transient:1",
1625 title,
1626 body,
1627 ])
1628 .spawn();
1629 }
1630 #[cfg(any(not(target_os = "linux"), test))]
1631 let _ = (title, body);
1632}
1633
1634#[allow(clippy::too_many_lines)]
1636pub fn tool_call_summary(name: &str, args: &str, result: &str) -> String {
1639 let v: serde_json::Value = serde_json::from_str(args).unwrap_or_default();
1640 let f = |k: &str| {
1641 v.get(k)
1642 .and_then(|x| x.as_str())
1643 .unwrap_or_default()
1644 .to_string()
1645 };
1646 let f_or = |first: &str, second: &str| {
1647 let value = f(first);
1648 if value.is_empty() { f(second) } else { value }
1649 };
1650 match name {
1651 "skills" => {
1652 let target = if f("action") == "install" {
1653 f("source")
1654 } else {
1655 f("name")
1656 };
1657 format!("skills/{} {}", f("action"), target)
1658 }
1659 "scripts" => {
1660 let target = match f("action").as_str() {
1661 "install" => {
1662 let n = v
1663 .get("packages")
1664 .and_then(|a| a.as_array())
1665 .map_or(0, Vec::len);
1666 format!("{n} packages")
1667 }
1668 "python" => f("name"),
1669 _ => f("path"),
1670 };
1671 format!("scripts/{} {}", f("action"), target)
1672 }
1673 "app" => format!("app/{} {}", f("action"), f("app")),
1674 "media" => {
1675 let target = [f("video_id"), f("name"), f("image_id")]
1676 .into_iter()
1677 .find(|t| !t.is_empty())
1678 .unwrap_or_else(|| f("prompt").chars().take(30).collect());
1679 format!("media/{} {}", f("action"), target)
1680 }
1681 "skill" => format!("skill {}", f("name")),
1682 "skill_admin" => format!("skill_admin {} → {}", f("action"), first_line(result)),
1683 "search" => {
1684 let failed = result.starts_with("no results") || result.contains("failed");
1685 let hits = if failed { "no hits" } else { "hits" };
1686 format!("search/{} \"{}\" → {hits}", f("mode"), f("query"))
1687 }
1688 "research_lookup" => format!("research_lookup/{} \"{}\"", f("scope"), f("query")),
1689 "batch" => {
1690 let calls = v.get("calls").and_then(|c| c.as_array());
1691 let n = calls.map_or(0, Vec::len);
1692 let tools: Vec<&str> = calls
1693 .map(|arr| {
1694 arr.iter()
1695 .filter_map(|item| item.get("tool").and_then(|t| t.as_str()))
1696 .collect()
1697 })
1698 .unwrap_or_default();
1699 format!("batch [{n} ops: {}]", tools.join(", "))
1700 }
1701 "fetch_url" => format!("fetch_url {} → {}", f("url"), first_line(result)),
1702 "files" => format!(
1703 "files/{} {} → {}",
1704 f("action"),
1705 f_or("name", "query"),
1706 first_line(result)
1707 ),
1708 "app_inspect" => format!(
1709 "app_inspect/{} {}/{}",
1710 f("action"),
1711 f("app"),
1712 f_or("path", "pattern")
1713 ),
1714 "app_modify" => format!("app_modify/{} {}/{}", f("action"), f("app"), f("path")),
1715 "app_assets" => format!("app_assets/{} {}", f("action"), f("app")),
1716 "script_files" => format!("script_files/{} {}", f("action"), f("path")),
1717 "video_transform" => format!("video_transform/{} {}", f("action"), f("video_id")),
1718 "video_references" => format!("video_references/{} {}", f("action"), f("name")),
1719 "install_skill" => format!("install_skill {} → {}", f("source"), first_line(result)),
1720 "create_skill" => format!("create_skill {} → {}", f("name"), first_line(result)),
1721 "run_script" => {
1722 let path = f_or("path", "script");
1723 if v.get("space")
1724 .and_then(serde_json::Value::as_bool)
1725 .unwrap_or(false)
1726 {
1727 format!("run_script space/{path}")
1728 } else {
1729 format!("run_script {}/{}", f("skill"), path)
1730 }
1731 }
1732 "run_python" => format!("run_python ({} lines)", f("code").lines().count().max(1)),
1733 "grep_app" => {
1734 let hits = if result.starts_with("no matches") {
1735 "no hits".to_string()
1736 } else {
1737 format!(
1738 "{} files",
1739 result.lines().filter(|line| !line.starts_with('…')).count()
1740 )
1741 };
1742 format!("grep_app {} \"{}\" → {hits}", f("app"), f("pattern"))
1743 }
1744 "install_packages" => {
1745 let pkgs = v
1746 .get("packages")
1747 .and_then(|a| a.as_array())
1748 .map(|a| {
1749 a.iter()
1750 .filter_map(|x| x.as_str())
1751 .collect::<Vec<_>>()
1752 .join(" ")
1753 })
1754 .unwrap_or_default();
1755 let target = [f("skill"), f("app")]
1756 .into_iter()
1757 .find(|t| !t.is_empty())
1758 .unwrap_or_default();
1759 format!("install_packages {pkgs} → {target}")
1760 }
1761 "web_search" | "search_files" => {
1762 let failed = result.starts_with("no results")
1763 || result.starts_with("no matches")
1764 || result.contains("failed");
1765 let hits = if failed {
1766 "no hits".to_string()
1767 } else {
1768 format!("{} hits", result.lines().count())
1769 };
1770 format!("{name} \"{}\" → {hits}", f("query"))
1771 }
1772 "read_file" => format!("read_file {} → {}", f("name"), first_line(result)),
1773 "read_app_file" => format!("read_app_file {}/{}", f("app"), f("path")),
1774 "diff_app" => format!("diff_app {}/{}", f("app"), f("path")),
1775 "write_file" => {
1776 format!(
1777 "write_file {}/{} ({} bytes)",
1778 f("app"),
1779 f("path"),
1780 f("content").len()
1781 )
1782 }
1783 "edit_file" => format!("edit_file {}/{}", f("app"), f("path")),
1784 "generate_video" => format!("generate_video \"{}\"", f("prompt")),
1785 "edit_video" => format!("edit_video {} {}", f("video_id"), f("lighting")),
1786 "extract_frame" => format!("extract_frame {} @ {:.1}s", f("video_id"), f("time_sec")),
1787 "stitch_videos" => format!("stitch_videos {}", f("video_ids")),
1788 "save_reference" => format!("save_reference {}", f("name")),
1789 "list_references" => "list_references".to_string(),
1790 "delete_reference" => format!("delete_reference {}", f("name")),
1791 "generate_image" => format!("generate_image \"{}\"", f("prompt")),
1792 _ => {
1793 let mut a: String = args.chars().take(60).collect();
1794 if args.chars().count() > 60 {
1795 a.push('…');
1796 }
1797 format!("{name} {a}")
1798 }
1799 }
1800}
1801
1802fn first_line(result: &str) -> String {
1804 result
1805 .lines()
1806 .next()
1807 .unwrap_or("")
1808 .trim_end_matches(':')
1809 .to_string()
1810}