Skip to main content

nexus_core/app/
chat.rs

1// Casts here are on bounded values: token counts, byte sizes, and
2// selection indices — never on unbounded input. JSON-derived indices in
3// provider/tools go through try_from instead.
4#![allow(
5    clippy::cast_possible_truncation,
6    clippy::cast_possible_wrap,
7    clippy::cast_precision_loss,
8    clippy::cast_sign_loss
9)]
10use anyhow::Result;
11use chrono::Utc;
12use std::fmt::Write as _;
13use tokio::sync::mpsc;
14
15use crate::db::Message;
16use crate::provider::{ChatMessage, ChatParams, StreamEvent, ToolCall, Usage};
17
18use super::{App, SPINNER_COLORS, SpinnerColor, THINKING, parse_topic, verbosity_clause};
19
20impl App {
21    /// Send one chat message. `AppView::submit` is the composer front — it
22    /// reads the `TextArea` and routes through `run_command` / `Send` — so this
23    /// is the domain half: validation, session auto-creation, persistence.
24    pub fn send_message(&mut self, text: String) -> Result<()> {
25        if let Some(session) = self.session.as_ref()
26            && let Some(task) = self.chat_task_for_session(&session.id)
27        {
28            self.push_status(format!(
29                "wait — response still streaming in: {}",
30                task.session_title
31            ));
32            self.push_composer_set(&text);
33            return Ok(());
34        }
35        if self.chat_task_count() >= super::MAX_CHAT_TASKS {
36            self.push_status(format!(
37                "chat task limit reached ({})",
38                super::MAX_CHAT_TASKS
39            ));
40            self.push_composer_set(&text);
41            return Ok(());
42        }
43        if !self.backends.any() {
44            self.push_status("set your API key first with /login".to_string());
45            self.push_composer_set(&text);
46            return Ok(());
47        }
48        let Some(model) = self.current_model.clone() else {
49            self.push_status("pick a model first with /model".to_string());
50            self.push_composer_set(&text);
51            return Ok(());
52        };
53
54        // Auto-create a session on the first message.
55        if self.session.is_none() {
56            let title = title_from(&text);
57            let s = if self.incognito {
58                crate::db::Session {
59                    id: uuid::Uuid::new_v4().to_string(),
60                    title,
61                    model,
62                    slug: None,
63                    created_at: Utc::now().to_rfc3339(),
64                    compact_summary: None,
65                    compact_through: 0,
66                    web_mode: self.web_mode,
67                    swarm_mode: false,
68                    kind: "chat".to_string(),
69                    research_parent_id: None,
70                }
71            } else {
72                let mut s =
73                    self.db
74                        .create_session(&title, &model, &self.active_space.id, "chat")?;
75                // Carry a pre-session `/web` toggle onto the session it creates.
76                if self.web_mode {
77                    s.web_mode = true;
78                    let _ = self.db.set_session_web_mode(&s.id, true);
79                }
80                s
81            };
82            self.session = Some(s);
83        }
84        let Some(session_id) = self.session.as_ref().map(|s| s.id.clone()) else {
85            return Ok(());
86        };
87
88        if !self.incognito {
89            self.db.add_user_message(&session_id, &text)?;
90        }
91        self.messages.push(Message {
92            role: "user".to_string(),
93            content: text,
94            model: None,
95            reasoning: None,
96            tokens: None,
97            secs: None,
98            cost: None,
99            phrase: None,
100            persona: None,
101            created_at: None,
102        });
103
104        if self.session.as_ref().is_some_and(|s| s.swarm_mode) {
105            self.start_swarm_turn();
106            return Ok(());
107        }
108        self.start_stream()
109    }
110
111    /// The exact message list a completion request will carry: system prompt,
112    /// compaction digest, forced skill, then the effective conversation tail.
113    /// User messages with images become multimodal parts for vision models, or
114    /// get their stored descriptions appended as text for everything else.
115    pub fn build_history(&mut self) -> Vec<ChatMessage> {
116        let mut history: Vec<ChatMessage> = Vec::with_capacity(self.messages.len() + 3);
117        history.push(ChatMessage::text("system", self.system_prompt()));
118        // Include space-file images inline for vision models.
119        if self.current_model_supports_images()
120            && let Some(img_msg) = self.space_images_message()
121        {
122            history.push(img_msg);
123        }
124        // If this session has been auto-compacted, send the digest instead of
125        // the raw messages it covers — only the tail after it goes verbatim.
126        if let Some(summary) = self
127            .session
128            .as_ref()
129            .and_then(|s| s.compact_summary.clone())
130        {
131            history.push(ChatMessage::text(
132                "system",
133                format!("Summary of earlier conversation (auto-compacted for length):\n{summary}"),
134            ));
135        }
136        if let Some(name) = self.forced_skill.take()
137            && let Some(skill) = self.skills.iter().find(|s| s.name == name)
138        {
139            let body = std::fs::read_to_string(skill.dir.join("SKILL.md"))
140                .map(|md| crate::skills::skill_body(&md).to_string())
141                .unwrap_or_default();
142            history.push(ChatMessage::text(
143                "system",
144                format!("The user invoked the skill '{name}'. Follow these instructions:\n{body}"),
145            ));
146        }
147        let vision = self.current_model_supports_images();
148        // Dedup state for replayed tool results: (tool, arguments) → latest
149        // full result. A row whose result is byte-identical to the latest
150        // one for the same tool+arguments (e.g. a re-read of an unchanged
151        // file) is replayed as a one-line note instead of the full copy, so
152        // duplicates don't re-enter the context on every request. The live
153        // tool loop applies the same rule with the same state (seeded from
154        // this history), keeping the prompt-cache prefix continuous.
155        let mut seen_results: std::collections::HashMap<(String, String), String> =
156            std::collections::HashMap::new();
157        for m in self.effective_messages() {
158            // Replay past tool calls as real assistant/tool message pairs so
159            // the model remembers what it already tried (and got back) in
160            // prior turns — dropping these caused it to repeat the same
161            // mistakes on file-writing tools with no memory of the failure.
162            // Skip every row that must never reach the model (shared with
163            // compaction, so a digest can't leak the same rows later):
164            // background-job scratch, UI-only prompts, transport failures,
165            // per-persona swarm round replies, and gate replies (whose
166            // survey/plan sections are excluded, so a bare "the second
167            // option" or "drop Q2" must not reach the model without context).
168            if Self::excluded_from_model_history(m) {
169                continue;
170            }
171            if m.role == "tool_call" {
172                if let Some((call, result)) = parse_tool_call_row(&m.content) {
173                    history.push(ChatMessage {
174                        role: "assistant".to_string(),
175                        content: String::new(),
176                        tool_calls: Some(vec![call.clone()]),
177                        tool_call_id: None,
178                        images: Vec::new(),
179                    });
180                    let key = (call.name.clone(), call.arguments.clone());
181                    let content = match seen_results.get(&key) {
182                        Some(prev) if *prev == result => {
183                            crate::tools::tool_result_unchanged_note(&call.name, &call.arguments)
184                        }
185                        _ => {
186                            seen_results.insert(key, result.clone());
187                            result
188                        }
189                    };
190                    history.push(ChatMessage {
191                        role: "tool".to_string(),
192                        content,
193                        tool_calls: None,
194                        tool_call_id: Some(call.id),
195                        images: Vec::new(),
196                    });
197                }
198                continue;
199            }
200            let mut cm = ChatMessage::text(m.role.clone(), m.content.clone());
201            if m.role == "user" && vision {
202                let images_dir = self.space.files_dir(&self.active_space.name);
203                let mut images = Vec::new();
204                let mut rest = m.content.as_str();
205                while let Some(start) = rest.find("![") {
206                    if let Some(end) = rest[start..].find(')') {
207                        let inner = &rest[start + 2..start + end];
208                        if let Some((_alt, file)) = inner.split_once("](") {
209                            let path = images_dir.join(file);
210                            if let Ok(bytes) = std::fs::read(&path) {
211                                images.push(crate::app::transcribe::png_bytes_data_url(&bytes));
212                            }
213                        }
214                        rest = &rest[start + end + 1..];
215                    } else {
216                        break;
217                    }
218                }
219                cm.images = images;
220            }
221            history.push(cm);
222        }
223        history
224    }
225
226    /// Build history and fire one independently-routed streaming request.
227    pub fn start_stream(&mut self) -> Result<()> {
228        let Some(model) = self.current_model.clone() else {
229            return Ok(());
230        };
231        let Some((provider, raw_model)) = self.resolve_model_backend(&model) else {
232            self.push_status(format!(
233                "model backend unavailable: {model} — pick another with /model"
234            ));
235            return Ok(());
236        };
237        let history = self.build_history();
238        // Catalog capabilities can change underneath a persisted preference.
239        // Keep the request, in-memory badge, and database in sync rather than
240        // silently omitting a stale value while the picker still shows it.
241        let stored_effort = self.reasoning.get(&model).cloned();
242        let (reasoning_effort, reasoning_warning) = match stored_effort {
243            Some(effort) if self.effort_accepted(&model, &effort) => (Some(effort), None),
244            Some(effort) => {
245                self.db.set_reasoning(&model, None)?;
246                self.reasoning.remove(&model);
247                (
248                    None,
249                    Some(format!("cleared unsupported reasoning {effort}: {model}")),
250                )
251            }
252            None => (None, None),
253        };
254        let params = ChatParams {
255            reasoning_effort,
256            temperature: self.settings.temperature,
257            top_p: self.settings.top_p,
258            max_tokens: self.settings.max_tokens,
259        };
260        let tools = self.toolbox.defs();
261        let (rx, abort) = provider.stream_chat(
262            raw_model.clone(),
263            history,
264            params,
265            tools,
266            self.toolbox.clone(),
267            crate::provider::openrouter::MAX_TOOL_ITERS,
268        );
269        let Some(session) = self.session.clone() else {
270            return Ok(());
271        };
272        let backend = provider.backend_tag();
273        let (thinking_idx, spinner_color) = pick_flavor();
274        let task_id = self.next_chat_task_id;
275        self.next_chat_task_id = self.next_chat_task_id.wrapping_add(1);
276        let tx = self.chat_event_tx.clone();
277        tokio::spawn(async move {
278            let mut rx = rx;
279            while let Some(event) = rx.recv().await {
280                if tx.send(super::ChatEvent { task_id, event }).is_err() {
281                    break;
282                }
283            }
284            let _ = tx.send(super::ChatEvent {
285                task_id,
286                event: StreamEvent::Done,
287            });
288        });
289        self.chat_tasks.insert(
290            task_id,
291            super::ChatTask {
292                id: task_id,
293                session_id: session.id,
294                session_title: session.title,
295                space_id: self.active_space.id.clone(),
296                model,
297                model_id: raw_model,
298                backend,
299                incognito: self.incognito,
300                buffer: String::new(),
301                thinking: String::new(),
302                tool_status: None,
303                usage: None,
304                usage_row_id: None,
305                started: std::time::Instant::now(),
306                thinking_idx,
307                spinner_color,
308                abort,
309            },
310        );
311        self.spinner_frame = 0;
312        self.thinking_idx = thinking_idx;
313        self.spinner_color = spinner_color;
314        self.push_status(reasoning_warning.unwrap_or_default());
315        // New content is about to arrive below the conversation: the view
316        // resets its scroll/pinning baseline (AppEvent::ViewportReset).
317        self.push_viewport_reset();
318        Ok(())
319    }
320
321    /// Compatibility entry point for tests and synchronous callers. Runtime
322    /// events use `on_chat_event`, which always includes the task id.
323    #[cfg(test)]
324    pub fn on_stream_event(&mut self, ev: StreamEvent) -> Result<()> {
325        let task_id = self
326            .active_chat_task()
327            .map(|task| task.id)
328            .or_else(|| self.chat_tasks.keys().next().copied());
329        if let Some(task_id) = task_id {
330            return self.on_chat_event(task_id, ev);
331        }
332        if let StreamEvent::ToolCall {
333            name,
334            arguments,
335            result,
336        } = ev
337        {
338            let content = serde_json::json!({
339                "name": name,
340                "arguments": arguments,
341                "result": result,
342            })
343            .to_string();
344            if let Some(session) = self.session.as_ref()
345                && !self.incognito
346            {
347                let _ = self.db.add_tool_call_message(&session.id, &content);
348            }
349            self.messages.push(Message {
350                role: "tool_call".to_string(),
351                content,
352                model: None,
353                reasoning: None,
354                tokens: None,
355                secs: None,
356                cost: None,
357                phrase: None,
358                persona: None,
359                created_at: None,
360            });
361        }
362        Ok(())
363    }
364
365    #[allow(clippy::too_many_lines)] // one match per stream event; arms are short
366    pub fn on_chat_event(&mut self, task_id: super::ChatTaskId, ev: StreamEvent) -> Result<()> {
367        match ev {
368            StreamEvent::Token(t) => {
369                if let Some(task) = self.chat_tasks.get_mut(&task_id) {
370                    task.buffer.push_str(&t);
371                }
372            }
373            StreamEvent::Reasoning(t) => {
374                if let Some(task) = self.chat_tasks.get_mut(&task_id) {
375                    task.thinking.push_str(&t);
376                }
377            }
378            StreamEvent::Usage(u) => {
379                // One API request finished: surface its cache hit rate next
380                // to the context window and log the request (tokens, cache,
381                // cost) for the /usage panel. Incognito streams are never
382                // persisted, matching the message-storage convention.
383                //
384                // OpenCode Zen splits accounting across two events: the
385                // finish chunk may carry real usage, and a trailing chunk
386                // carries only the provider-reported `cost`. Merge instead
387                // of logging two rows.
388                if u.prompt_tokens + u.completion_tokens > 0 {
389                    self.last_cache_rate = u.cache_hit_rate();
390                }
391                let Some(task) = self.chat_tasks.get_mut(&task_id) else {
392                    return Ok(());
393                };
394                let merged = match task.usage {
395                    Some(prev) if u.prompt_tokens + u.completion_tokens > 0 => {
396                        // Real accounting (arrives after a cost-only chunk).
397                        Usage {
398                            cost: u.cost.or(prev.cost),
399                            ..u
400                        }
401                    }
402                    Some(prev) => Usage {
403                        cost: u.cost.or(prev.cost),
404                        ..prev
405                    },
406                    None => u,
407                };
408                task.usage = Some(merged);
409                let session_id = task.session_id.clone();
410                let space_id = task.space_id.clone();
411                let model_id = task.model_id.clone();
412                let backend = task.backend;
413                let incognito = task.incognito;
414                let row_id = task.usage_row_id;
415                if !incognito
416                    && (merged.prompt_tokens > 0
417                        || merged.completion_tokens > 0
418                        || merged.cost.is_some())
419                {
420                    // Provider-reported cost wins; otherwise estimate from
421                    // cache-aware catalog rates (None when unknown).
422                    // Non-OpenRouter backends price via their OpenRouter
423                    // catalog twin (see `Db::model_price`).
424                    let cost_is_provider = merged.cost.is_some();
425                    let cost = merged.cost.or_else(|| {
426                        self.db.request_cost(
427                            &model_id,
428                            merged.prompt_tokens,
429                            merged.completion_tokens,
430                            merged.cache_read_tokens,
431                            merged.cache_creation_tokens,
432                        )
433                    });
434                    if let Some(id) = row_id {
435                        let _ = self.db.update_usage(
436                            id,
437                            merged.prompt_tokens,
438                            merged.completion_tokens,
439                            merged.cache_read_tokens,
440                            merged.cache_creation_tokens,
441                            cost,
442                            cost_is_provider,
443                        );
444                    } else {
445                        let id = self.db.log_usage(
446                            backend.name(),
447                            &model_id,
448                            merged.prompt_tokens,
449                            merged.completion_tokens,
450                            merged.cache_read_tokens,
451                            merged.cache_creation_tokens,
452                            cost,
453                            cost_is_provider,
454                            Some(&session_id),
455                            Some(&space_id),
456                        );
457                        if let Ok(id) = id
458                            && let Some(task) = self.chat_tasks.get_mut(&task_id)
459                        {
460                            task.usage_row_id = Some(id);
461                        }
462                    }
463                }
464            }
465            StreamEvent::Status(s) => {
466                if let Some(task) = self.chat_tasks.get_mut(&task_id) {
467                    task.tool_status = Some(s);
468                }
469            }
470            StreamEvent::ToolCall {
471                name,
472                arguments,
473                result,
474            } => {
475                if ((name == "skills")
476                    || (name == "skill_admin")
477                    || (name == "install_skill" && result.starts_with("installed"))
478                    || (name == "create_skill"
479                        && (result.starts_with("created") || result.starts_with("updated"))))
480                    && (result.starts_with("installed")
481                        || result.starts_with("created")
482                        || result.starts_with("updated"))
483                {
484                    self.reload_skills();
485                }
486                let Some(task) = self.chat_tasks.get(&task_id) else {
487                    return Ok(());
488                };
489                let content =
490                    serde_json::json!({ "name": name, "arguments": arguments, "result": result })
491                        .to_string();
492                let target = task.session_id.clone();
493                let incognito = task.incognito;
494                let in_active_space = task.space_id == self.active_space.id;
495                let viewing = self
496                    .session
497                    .as_ref()
498                    .is_some_and(|session| session.id == target);
499                if !incognito {
500                    let _ = self.db.add_tool_call_message(&target, &content);
501                }
502                if viewing {
503                    self.messages.push(Message {
504                        role: "tool_call".to_string(),
505                        content,
506                        model: None,
507                        reasoning: None,
508                        tokens: None,
509                        secs: None,
510                        cost: None,
511                        phrase: None,
512                        persona: None,
513                        created_at: None,
514                    });
515                }
516                // Generated images land on disk but aren't indexed until
517                // rescan_files picks them up. Without this, they'd be missing
518                // from files_cache and never OCR'd for descriptive naming.
519                let media_generated = matches!(
520                    name.as_str(),
521                    "generate_image" | "generate_video" | "video_transform"
522                ) || matches!(
523                    name.as_str(),
524                    "edit_video" | "extract_frame" | "stitch_videos"
525                ) || (name == "media"
526                    && serde_json::from_str::<serde_json::Value>(&arguments)
527                        .ok()
528                        .and_then(|v| v.get("action").and_then(|a| a.as_str()).map(str::to_string))
529                        .is_some_and(|a| {
530                            matches!(
531                                a.as_str(),
532                                "generate_image"
533                                    | "generate_video"
534                                    | "edit"
535                                    | "extract_frame"
536                                    | "stitch"
537                            )
538                        }));
539                if in_active_space && media_generated {
540                    self.rescan_files();
541                }
542            }
543            StreamEvent::Done => self.finish_chat_task(task_id, None)?,
544            StreamEvent::Error(e) => {
545                self.finish_chat_task(task_id, Some(e))?;
546            }
547        }
548        Ok(())
549    }
550
551    /// Cancel one in-flight chat task by id (kills any in-flight request and
552    /// tool loop) and keep whatever text already arrived.
553    pub fn cancel_chat_task(&mut self, task_id: super::ChatTaskId) -> Result<()> {
554        if let Some(task) = self.chat_tasks.remove(&task_id) {
555            task.abort.abort();
556            self.finish_chat_task_state(task, None, true)?;
557        }
558        Ok(())
559    }
560
561    /// Esc while a response streams: abort the background chat task (kills any
562    /// in-flight request and tool loop) and keep whatever text already arrived.
563    pub fn stop_stream(&mut self) -> Result<()> {
564        let Some(task_id) = self.active_chat_task().map(|task| task.id) else {
565            return Ok(());
566        };
567        self.cancel_chat_task(task_id)
568    }
569
570    pub fn discard_chat_task(&mut self, session_id: &str) {
571        if let Some(task) = self
572            .chat_tasks
573            .iter()
574            .find(|(_, task)| task.session_id == session_id)
575            .map(|(id, _)| *id)
576            .and_then(|id| self.chat_tasks.remove(&id))
577        {
578            task.abort.abort();
579        }
580    }
581
582    fn finish_chat_task(
583        &mut self,
584        task_id: super::ChatTaskId,
585        error: Option<String>,
586    ) -> Result<()> {
587        let Some(task) = self.chat_tasks.remove(&task_id) else {
588            return Ok(());
589        };
590        self.finish_chat_task_state(task, error, false)
591    }
592
593    // Long by design (long state-transition fn).
594    #[allow(clippy::too_many_lines)]
595    fn finish_chat_task_state(
596        &mut self,
597        task: super::ChatTask,
598        error: Option<String>,
599        stopped: bool,
600    ) -> Result<()> {
601        let mut reasoning = task.thinking;
602        let (buf, inline) = split_inline_reasoning(&task.buffer);
603        if let Some(inline) = inline {
604            if !reasoning.is_empty() {
605                reasoning.push('\n');
606            }
607            reasoning.push_str(&inline);
608        }
609        if buf.is_empty() && error.is_none() {
610            self.push_status(if stopped {
611                "response stopped".to_string()
612            } else {
613                "response finished without text".to_string()
614            });
615            return Ok(());
616        }
617        // Some reasoning models (routed without the separate `reasoning` delta
618        // field) inline their thinking as `<think>...</think>` in `content`
619        // itself. Pull that out so the stored/displayed/copied message is just
620        // the actual answer, not the thinking — same treatment as the explicit
621        // reasoning channel above.
622        let viewing = self
623            .session
624            .as_ref()
625            .is_some_and(|session| session.id == task.session_id);
626        let model = Some(task.model.clone());
627        // Prefer the provider's exact usage; fall back to a ~4-chars/token estimate.
628        let usage = task.usage;
629        let tokens = Some(match usage {
630            Some(u) => u.completion_tokens as i64,
631            None => buf.chars().count().div_ceil(4) as i64,
632        });
633        if viewing && let Some(u) = usage {
634            // Some providers omit total; derive it from prompt + completion.
635            let total = if u.total_tokens > 0 {
636                u.total_tokens
637            } else {
638                u.prompt_tokens + u.completion_tokens
639            };
640            self.context_total = Some(total);
641        }
642        let secs = Some(task.started.elapsed().as_secs_f64());
643        // Prefer the provider's exact charge; otherwise estimate from the
644        // cache-aware catalog. The same value was logged by the Usage event.
645        let cost = usage.and_then(|u| {
646            u.cost.or_else(|| {
647                self.db.request_cost(
648                    &task.model_id,
649                    u.prompt_tokens,
650                    u.completion_tokens,
651                    u.cache_read_tokens,
652                    u.cache_creation_tokens,
653                )
654            })
655        });
656        let reasoning = (!reasoning.is_empty()).then_some(reasoning);
657        let phrase = Some(THINKING[task.thinking_idx].1.to_string());
658        let created_at = Some(chrono::Utc::now().to_rfc3339());
659
660        if !task.incognito && !buf.is_empty() {
661            self.db.add_assistant_message(
662                &task.session_id,
663                &buf,
664                model.as_deref(),
665                reasoning.as_deref(),
666                tokens,
667                secs,
668                cost,
669                phrase.as_deref(),
670            )?;
671        }
672        if viewing && !buf.is_empty() {
673            self.messages.push(Message {
674                role: "assistant".to_string(),
675                content: buf,
676                model,
677                reasoning,
678                tokens,
679                secs,
680                cost,
681                phrase,
682                persona: None,
683                created_at,
684            });
685            // These jobs still use active-session state, so only launch them
686            // when the task's origin is the session currently being viewed.
687            if !task.incognito {
688                self.maybe_generate_title();
689                self.maybe_extract_memory();
690                self.maybe_compact();
691            }
692        }
693        if let Some(error) = error {
694            if !task.incognito {
695                self.db.add_error_message(&task.session_id, &error)?;
696            }
697            let msg = format!("stream error: {error}");
698            if viewing {
699                self.messages.push(Message {
700                    role: "error".to_string(),
701                    content: error.clone(),
702                    model: None,
703                    reasoning: None,
704                    tokens: None,
705                    secs: None,
706                    cost: None,
707                    phrase: None,
708                    persona: None,
709                    created_at: None,
710                });
711            } else if !task.incognito {
712                self.unread.insert(task.session_id.clone());
713            }
714            self.push_status(if viewing {
715                msg.clone()
716            } else {
717                format!("stream error in {}: {error}", task.session_title)
718            });
719            if !viewing && !task.incognito {
720                super::send_system_notification(
721                    &format!("Chat failed: {}", task.session_title),
722                    &msg,
723                );
724            }
725            if !viewing && !task.incognito {
726                self.notifications.push_back(super::ChatNotification {
727                    session_id: task.session_id,
728                    title: task.session_title,
729                    text: msg,
730                    success: false,
731                });
732            }
733        } else if stopped {
734            self.push_status("response stopped".to_string());
735        } else {
736            if !viewing && !task.incognito {
737                self.unread.insert(task.session_id.clone());
738                self.push_status(format!("✓ response ready in: {}", task.session_title));
739                super::send_system_notification(
740                    &format!("Chat ready: {}", task.session_title),
741                    "response complete",
742                );
743            } else if viewing {
744                self.push_status("response complete".to_string());
745            }
746            if !viewing && !task.incognito {
747                self.notifications.push_back(super::ChatNotification {
748                    session_id: task.session_id,
749                    title: task.session_title,
750                    text: "response complete".to_string(),
751                    success: true,
752                });
753            }
754        }
755        Ok(())
756    }
757
758    /// After the first exchange of a session, ask the model for a short topic and
759    /// slug in the background. Runs once per session (guarded by `slug.is_none()`).
760    pub fn maybe_generate_title(&mut self) {
761        let Some(session) = self.session.as_ref() else {
762            return;
763        };
764        let Some((provider, raw_model)) = self
765            .current_model
766            .as_deref()
767            .and_then(|model| self.resolve_model_backend(model))
768            .or_else(|| self.resolve_utility_model_backend(&self.memory_model))
769        else {
770            return;
771        };
772        if session.slug.is_some() {
773            return; // already named
774        }
775        // Build a compact transcript of the conversation so far.
776        let convo: String = self
777            .messages
778            .iter()
779            .filter(|m| m.role != "tool_call")
780            .map(|m| {
781                format!(
782                    "{}: {}",
783                    m.role,
784                    m.content.chars().take(500).collect::<String>()
785                )
786            })
787            .collect::<Vec<_>>()
788            .join("\n");
789        let session_id = session.id.clone();
790        let (tx, rx) = mpsc::unbounded_channel();
791        self.title_rx = Some(rx);
792        tokio::spawn(async move {
793            let prompt = format!(
794                "Summarise this conversation as a session name. Reply with ONLY a JSON object, \
795                 no markdown, of the form {{\"topic\": \"<3-5 word title>\", \"id\": \"<short-kebab-slug>\"}}.\n\n{convo}"
796            );
797            let msgs = vec![ChatMessage::text("user", prompt)];
798            if let Ok(text) = provider.complete(&raw_model, msgs).await
799                && let Some((topic, slug)) = parse_topic(&text)
800            {
801                let _ = tx.send((session_id, topic, slug));
802            }
803        });
804    }
805
806    /// Apply a generated topic/slug to the matching session (in memory + db).
807    pub fn on_title_result(&mut self, result: Option<(String, String, String)>) {
808        self.title_rx = None;
809        let Some((id, topic, slug)) = result else {
810            return;
811        };
812        let _ = self.db.set_session_title(&id, &topic, Some(&slug));
813        if let Some(s) = self.session.as_mut().filter(|s| s.id == id) {
814            s.title.clone_from(&topic);
815            s.slug = Some(slug.clone());
816        }
817        if let Some(s) = self.sessions_cache.iter_mut().find(|s| s.id == id) {
818            s.title = topic;
819            s.slug = Some(slug);
820        }
821    }
822
823    /// Instructions + memory for the active space, combined into one system
824    /// message. `None` if the space has neither (today's no-system-prompt path).
825    /// The full system prompt: the app's own base prompt (identity/formatting
826    /// rules, `$EDITOR`-editable) first, then space instructions, skills, and
827    /// memory layered on top. Unlike those three, the base prompt is never
828    /// empty — it's the app speaking, not per-space configuration.
829    pub fn system_prompt(&self) -> String {
830        let mut parts: Vec<String> = vec![self.resolved_base_system_prompt()];
831        if !self.incognito {
832            let instructions =
833                std::fs::read_to_string(self.space.instructions_path(&self.active_space.name))
834                    .ok()
835                    .map(|s| s.trim().to_string())
836                    .filter(|s| !s.is_empty());
837            if let Some(i) = instructions {
838                parts.push(i);
839            }
840            if let Some(files) = self.files_section() {
841                parts.push(files);
842            }
843            if let Some(apps) = self.apps_section() {
844                parts.push(apps);
845            }
846            if let Some(scripts) = self.scripts_section() {
847                parts.push(scripts);
848            }
849            let memory = self.read_memory();
850            if !memory.trim().is_empty() {
851                parts.push(format!("## Memory\n{memory}"));
852            }
853        }
854        if let Some(skills) = self.skills_section() {
855            parts.push(skills);
856        }
857        if self.web_mode {
858            let today = Utc::now().format("%Y-%m-%d").to_string();
859            parts.push(web_mode_clause(&today));
860        }
861        if self.is_research_session() {
862            parts.push(
863                "This session came from /research — prefer research_lookup with scope=session_sources over search with mode=web for \
864                 follow-ups; only use web search on a miss."
865                    .to_string(),
866            );
867        }
868        parts.join("\n\n")
869    }
870
871    /// `o` in the history pane: open the `[n]` citation under the current
872    /// text selection (via the `open` crate), resolved against the Sources
873    /// list of the message the selection belongs to. Every miss surfaces as
874    /// a status message rather than doing nothing silently.
875    /// `owner` is the message index at the selection start, computed by the
876    /// view layer from its `HistorySel` state.
877    /// Ctrl+O: navigate to the session linked in a `session_link` message
878    /// under the text selection. Expects the message content's first line to
879    /// be the target session id.
880    pub fn open_session_link(&mut self, owner: Option<usize>) {
881        let Some(msg) = owner.and_then(|i| self.messages.get(i)) else {
882            self.push_status(
883                "select text on a session link message, then press Ctrl+O".to_string(),
884            );
885            return;
886        };
887        if msg.role != "session_link" {
888            self.push_status(
889                "select text on a session link message, then press Ctrl+O".to_string(),
890            );
891            return;
892        }
893        let Some(target) = msg
894            .content
895            .split_once('\n')
896            .map(|(s, _)| s.trim().to_string())
897        else {
898            self.push_status("malformed session link".to_string());
899            return;
900        };
901        if let Err(e) = self.switch_to_session_by_id(&target) {
902            self.push_status(format!("session switch failed: {e}"));
903        }
904    }
905
906    /// Pin or discard the `[n]` source under the current history selection
907    /// (same selection→citation resolution as `open_citation_under_selection`).
908    /// Flags are keyed by the message's normalized URL, session-scoped.
909    /// `selected`/`owner` come from the view's `HistorySel` state.
910    pub fn flag_source_under_selection(
911        &mut self,
912        flag: Option<&str>,
913        selected: Option<String>,
914        owner: Option<usize>,
915    ) {
916        let Some(selected) = selected else {
917            self.push_status("select a [n] citation, then press x".to_string());
918            return;
919        };
920        let Some(n) = crate::citations::citation_number_in(&selected) else {
921            self.push_status("no [n] citation in the current selection".to_string());
922            return;
923        };
924        let Some(msg) = owner.and_then(|i| self.messages.get(i)) else {
925            self.push_status("no [n] citation in the current selection".to_string());
926            return;
927        };
928        let citations = crate::citations::parse_citations(&msg.content);
929        let Some((_, url)) = citations.iter().find(|(num, _)| *num == n) else {
930            self.push_status(format!("no source [{n}] in this message"));
931            return;
932        };
933        let Some(session) = &self.session else {
934            self.push_status("no active session".to_string());
935            return;
936        };
937        let url_norm = crate::tools::normalize_url(url);
938        let verb = match flag {
939            Some("discarded") => "discarded",
940            Some(_) => "pinned",
941            None => "cleared",
942        };
943        match self.db.set_source_flag(&session.id, &url_norm, flag) {
944            Ok(()) => {
945                self.push_status(format!("{verb} [{n}]: {url}"));
946                self.refresh_toolbox();
947            }
948            Err(e) => self.push_status(format!("flag failed: {e}")),
949        }
950    }
951
952    /// `/web`: flip web answer mode for the active (or about-to-be-created)
953    /// session. Persisted immediately if a session already exists; otherwise
954    /// applied to the session created by the next message.
955    pub fn toggle_web_mode(&mut self) {
956        self.web_mode = !self.web_mode;
957        if let Some(session) = self.session.as_mut() {
958            session.web_mode = self.web_mode;
959            let _ = self.db.set_session_web_mode(&session.id, self.web_mode);
960        }
961        self.push_status(if self.web_mode {
962            "🌐 web mode on".to_string()
963        } else {
964            "web mode off".to_string()
965        });
966    }
967
968    pub fn toggle_incognito(&mut self) -> Result<()> {
969        if self.is_streaming() {
970            self.stop_stream()?;
971        }
972        self.session = None;
973        self.messages.clear();
974        self.context_total = None;
975        self.push_composer_clear();
976        self.push_viewport_reset();
977        self.cleanup_incognito_images();
978        self.incognito = !self.incognito;
979        self.push_status(if self.incognito {
980            "incognito mode — nothing persists, no apps".to_string()
981        } else {
982            "incognito mode off".to_string()
983        });
984        Ok(())
985    }
986
987    /// `base_system_prompt` (raw, as read from `system_prompt.md`) with the
988    /// `{{verbosity}}` placeholder swapped for the level the user picked.
989    pub fn resolved_base_system_prompt(&self) -> String {
990        let now = Utc::now().format("%Y-%m-%d %H:%M UTC, %A").to_string();
991        self.base_system_prompt
992            .replace("{{verbosity}}", verbosity_clause(&self.verbosity))
993            .replace("{{datetime}}", &now)
994    }
995
996    /// Re-read `system_prompt.md` after a Ctrl+E hand-edit.
997    pub fn reload_base_system_prompt(&mut self) {
998        if let Ok(text) = crate::config::load_system_prompt() {
999            self.base_system_prompt = text;
1000            self.push_status("system prompt reloaded".to_string());
1001        }
1002    }
1003
1004    /// Names + descriptions of installed skills and how to invoke one — full
1005    /// bodies stay off the wire until the model calls the `skill` tool.
1006    fn skills_section(&self) -> Option<String> {
1007        if self.skills.is_empty() {
1008            return None;
1009        }
1010        let mut s = "## Skills\nYou have skills available. To use one, call the `skills` tool \
1011                     with action=load and its name; the full instructions will be returned.\n"
1012            .to_string();
1013        for skill in &self.skills {
1014            let _ = writeln!(s, "- {}: {}", skill.name, skill.description);
1015        }
1016        Some(s.trim_end().to_string())
1017    }
1018
1019    /// Scripts the model has written (or the user has placed) in the space,
1020    /// listed so the model reuses them instead of rewriting from scratch.
1021    fn scripts_section(&self) -> Option<String> {
1022        if self.scripts_cache.is_empty() {
1023            return None;
1024        }
1025        let mut s = "## Scripts\nThe user has reusable scripts in this space. \
1026                      Call `scripts(action=read, path=...)` to see one, \
1027                      `scripts(action=edit, path=..., edits=...)` to modify, or \
1028                      `scripts(action=run, space=true, path=...)` to execute. \
1029                      The `path` parameter is relative to the scripts dir — do NOT prefix `scripts/`.\n"
1030            .to_string();
1031        for script in &self.scripts_cache {
1032            let _ = writeln!(s, "- {} ({})", script.name, human_size(script.size));
1033        }
1034        Some(s.trim_end().to_string())
1035    }
1036
1037    /// Names/types/sizes of the space's imported files — content stays off the
1038    /// wire until the model calls `files` with the appropriate action.
1039    fn files_section(&self) -> Option<String> {
1040        if self.files_cache.is_empty() {
1041            return None;
1042        }
1043        let mut s = "## Files\nThe user has imported these files into this space. Do not guess \
1044                     their contents: call `files(action=search, query=...)` to find relevant passages, or \
1045                     `files(action=read, name=...)` to read one (200 lines per call, use offset to page). \
1046                     Need several at once? Wrap them in one `batch` call.\n"
1047            .to_string();
1048        for f in &self.files_cache {
1049            let kind = std::path::Path::new(&f.name)
1050                .extension()
1051                .and_then(|e| e.to_str())
1052                .unwrap_or("file")
1053                .to_lowercase();
1054            let _ = writeln!(
1055                s,
1056                "- {} ({kind}, {}, {})",
1057                f.name,
1058                human_size(f.size.unsigned_abs()),
1059                f.status
1060            );
1061        }
1062        Some(s.trim_end().to_string())
1063    }
1064
1065    /// How to build/edit locally served web apps, plus the space's existing
1066    /// apps. Present whenever the app server is running. Hidden in incognito.
1067    fn apps_section(&self) -> Option<String> {
1068        if self.incognito {
1069            return None;
1070        }
1071        self.app_server.as_ref()?;
1072        let mut s = "## Apps\nYou can build apps served locally. \
1073                     ALWAYS use the KV store for persistence (not LocalStorage), the upload endpoint for file \
1074                     uploads, and the app tool to bring user data into the app.\n\n\
1075                     **CRITICAL: URLs are UUID-based.** The UUID comes from `app(action=write)`'s result \
1076                     (\"live at http://...\"). Copy it from there — never invent or guess a UUID. \
1077                     If you don't have the result, call `app(action=read)` or `app(action=search)` on the app to \
1078                     rediscover it.\n\n"
1079            .to_string();
1080
1081        s.push_str("### Tools\n");
1082        s.push_str("- `batch` — wrap several app operations into one round-trip (results come back labeled).\n");
1083        s.push_str("- `app(action=write, app, path, content)` — create/replace a file. App can be a name or UUID; a new app gets a UUID.\n");
1084        s.push_str("- `app(action=read, app, path)` / `app(action=patch, app, path, edits)` — read and edit by hashline.\n");
1085        s.push_str("- `app(action=diff, app, path, content)` — preview a complete-file change without writing it.\n");
1086        s.push_str("- `app(action=search, app, pattern)` — search non-ignored app files; respects `.gitignore`.\n");
1087        s.push_str(
1088            "- `scripts(action=install, app=..., packages=[...])` — npm-install into an app.\n",
1089        );
1090        s.push_str("- `app(action=list)` — list pasted conversation images.\n");
1091        s.push_str("- `app(action=copy_images, image_ids, app)` — copy images into `_images/` for `<img src=\"...\">`.\n");
1092        s.push_str("- `app(action=copy_file, file_name, app)` — copy a space file's text into the app's KV store.\n\n");
1093
1094        s.push_str("### KV Store (persistent key-value per app)\n");
1095        s.push_str("Each app has a SQLite-backed KV store. Call these from frontend JS:\n");
1096        s.push_str("- `PUT <app_url>/_api/kv/<key>` — upsert a value (body = raw text)\n");
1097        s.push_str("- `GET <app_url>/_api/kv/<key>` — read a value\n");
1098        s.push_str("- `DELETE <app_url>/_api/kv/<key>` — delete a value\n");
1099        s.push_str("- `GET <app_url>/_api/kv` — list all keys (returns JSON array)\n\n");
1100
1101        s.push_str("### File Upload\n");
1102        s.push_str("- `POST <app_url>/_api/upload` with `multipart/form-data` — upload a file. Returns `{\"name\", \"url\"}`. Files persist and are served via GET.\n\n");
1103
1104        s.push_str("### Using User Images\n");
1105        s.push_str("1. `app(action=list)` to see conversation images.\n");
1106        s.push_str("2. `app(action=copy_images, image_ids, app)` to copy them into `_images/`.\n");
1107        s.push_str("3. Use returned URLs in `<img src=\"...\">` tags.\n\n");
1108
1109        s.push_str("### Using Space Files\n");
1110        s.push_str("- `app(action=copy_file, file_name, app)` copies file text into KV under `_file:<name>`. Read it via `GET <app_url>/_api/kv/_file:<name>`.\n\n");
1111
1112        let apps = self.list_apps();
1113        if apps.is_empty() {
1114            s.push_str("No apps exist in this space yet.");
1115        } else {
1116            s.push_str("Existing apps:\n");
1117            for a in &apps {
1118                if let Some(uuid) = self
1119                    .app_server
1120                    .as_ref()
1121                    .and_then(|s| s.registry().resolve(&self.active_space.name, a))
1122                {
1123                    let _ = writeln!(s, "- {a} (uuid {uuid})");
1124                } else {
1125                    let _ = writeln!(s, "- {a}");
1126                }
1127            }
1128        }
1129        Some(s.trim_end().to_string())
1130    }
1131
1132    /// A system message with space-file images inline, for vision models.
1133    /// Returns None when there are no image-type space files.
1134    fn space_images_message(&self) -> Option<ChatMessage> {
1135        let files_dir = self.space.files_dir(&self.active_space.name);
1136        let mut urls: Vec<String> = Vec::new();
1137        let mut names: Vec<&str> = Vec::new();
1138        for f in &self.files_cache {
1139            let ext = std::path::Path::new(&f.name)
1140                .extension()
1141                .and_then(|e| e.to_str())
1142                .unwrap_or("");
1143            if !crate::extract::is_image_ext(ext) {
1144                continue;
1145            }
1146            let path = files_dir.join(&f.name);
1147            if let Ok(bytes) = std::fs::read(&path) {
1148                use base64::Engine;
1149                let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
1150                let mime = match ext {
1151                    "jpg" | "jpeg" => "image/jpeg",
1152                    "gif" => "image/gif",
1153                    "webp" => "image/webp",
1154                    "bmp" => "image/bmp",
1155                    _ => "image/png",
1156                };
1157                urls.push(format!("data:{mime};base64,{b64}"));
1158            }
1159            names.push(f.name.as_str());
1160        }
1161        if urls.is_empty() {
1162            return None;
1163        }
1164        let text = format!(
1165            "The user has these image files in this space which you can see below: {}",
1166            names.join(", ")
1167        );
1168        Some(ChatMessage {
1169            role: "system".to_string(),
1170            content: text,
1171            tool_calls: None,
1172            tool_call_id: None,
1173            images: urls,
1174        })
1175    }
1176
1177    /// Names of the active space's existing apps (directory listing).
1178    pub fn list_apps(&self) -> Vec<String> {
1179        let dir = self.space.apps_dir(&self.active_space.name);
1180        let Ok(rd) = std::fs::read_dir(dir) else {
1181            return Vec::new();
1182        };
1183        let mut apps: Vec<String> = rd
1184            .filter_map(std::result::Result::ok)
1185            .filter(|e| e.path().is_dir())
1186            .filter_map(|e| e.file_name().into_string().ok())
1187            .collect();
1188        apps.sort();
1189        apps
1190    }
1191}
1192
1193/// Pick a thinking-phrase index and spinner colour pseudo-randomly (seeded from
1194/// the clock; no rng dep).
1195/// Each fenced code block in `md` as `(language, code)`.
1196pub fn code_blocks(md: &str) -> Vec<(Option<String>, String)> {
1197    let mut out = Vec::new();
1198    let mut inside = false;
1199    let mut lang: Option<String> = None;
1200    let mut buf = String::new();
1201    for line in md.lines() {
1202        let trimmed = line.trim_start();
1203        if trimmed.starts_with("```") {
1204            if inside {
1205                out.push((lang.take(), std::mem::take(&mut buf)));
1206            } else {
1207                let l = trimmed.trim_start_matches('`').trim();
1208                lang = (!l.is_empty()).then(|| l.to_string());
1209            }
1210            inside = !inside;
1211            continue;
1212        }
1213        if inside {
1214            buf.push_str(line);
1215            buf.push('\n');
1216        }
1217    }
1218    if inside && !buf.is_empty() {
1219        out.push((lang.take(), buf)); // unterminated (e.g. mid-stream)
1220    }
1221    out
1222}
1223
1224pub fn pick_greeting() -> &'static str {
1225    let n = std::time::SystemTime::now()
1226        .duration_since(std::time::UNIX_EPOCH)
1227        .map_or(0, |d| d.subsec_nanos() as usize);
1228    super::GREETINGS[n % super::GREETINGS.len()]
1229}
1230
1231pub fn pick_flavor() -> (usize, SpinnerColor) {
1232    let n = std::time::SystemTime::now()
1233        .duration_since(std::time::UNIX_EPOCH)
1234        .map_or(0, |d| d.subsec_nanos() as usize);
1235    (n % THINKING.len(), SPINNER_COLORS[n % SPINNER_COLORS.len()])
1236}
1237
1238/// Short session title from the first user message.
1239/// The instruction block appended to the system prompt when web mode is on:
1240/// forces search-first, inline `[n]` citations, and a trailing Sources list.
1241/// `today` keeps the model from hedging with stale training-data dates.
1242pub fn web_mode_clause(today: &str) -> String {
1243    format!(
1244        "Web answer mode is ON for this session. Today's date is {today}. Before answering, you \
1245         MUST call search with mode=web and a focused query (and fetch_url on the most promising results) — \
1246         never answer from memory alone. You may search more than once with refined queries if the \
1247         first results are insufficient. Each search result is numbered [1], [2], ... with title, \
1248         URL, and snippet. Cite every claim inline immediately as [n]; do not bunch citations at \
1249         the end of a paragraph. End your reply with a line starting exactly with 'Sources:' \
1250         followed by every citation you used, one per line, as [n] title — url. Do not fabricate \
1251         sources; if a claim is not backed by a search/fetched source, do not cite it."
1252    )
1253}
1254
1255pub fn title_from(text: &str) -> String {
1256    let t: String = text.chars().take(40).collect();
1257    if t.trim().is_empty() {
1258        "new chat".to_string()
1259    } else {
1260        t
1261    }
1262}
1263
1264/// Recover a `(ToolCall, result)` pair from a stored `tool_call` row's JSON
1265/// content (`{"name","arguments","result"}`), for replaying past tool use
1266/// back into the request history. `id` is synthesized — it only needs to
1267/// match between the assistant/tool pair built at the same call site.
1268fn parse_tool_call_row(content: &str) -> Option<(ToolCall, String)> {
1269    let v: serde_json::Value = serde_json::from_str(content).ok()?;
1270    let name = v.get("name")?.as_str()?.to_string();
1271    let arguments = v.get("arguments")?.as_str()?.to_string();
1272    let result = v.get("result")?.as_str()?.to_string();
1273    Some((
1274        ToolCall {
1275            id: "call_0".to_string(),
1276            name,
1277            arguments,
1278        },
1279        result,
1280    ))
1281}
1282
1283/// Compact byte counts: 940 B, 1.2 KB, 3.4 MB.
1284pub fn human_size(bytes: u64) -> String {
1285    match bytes {
1286        b if b < 1024 => format!("{b} B"),
1287        b if b < 1024 * 1024 => format!("{:.1} KB", b as f64 / 1024.0),
1288        b => format!("{:.1} MB", b as f64 / (1024.0 * 1024.0)),
1289    }
1290}
1291
1292/// Strip `<think>...</think>` blocks out of `text`, returning the cleaned
1293/// content and the extracted reasoning (blocks joined by newlines), if any. An
1294/// unterminated tag (e.g. a truncated stream) treats the remainder as
1295/// reasoning rather than leaking a dangling tag into the answer.
1296pub fn split_inline_reasoning(text: &str) -> (String, Option<String>) {
1297    const OPEN: &str = "<think>";
1298    const CLOSE: &str = "</think>";
1299    let mut content = String::with_capacity(text.len());
1300    let mut reasoning = String::new();
1301    let mut rest = text;
1302    loop {
1303        let Some(start) = rest.find(OPEN) else {
1304            content.push_str(rest);
1305            break;
1306        };
1307        content.push_str(&rest[..start]);
1308        let after_open = &rest[start + OPEN.len()..];
1309        let (block, remainder) = match after_open.find(CLOSE) {
1310            Some(end) => (&after_open[..end], &after_open[end + CLOSE.len()..]),
1311            None => (after_open, ""),
1312        };
1313        if !reasoning.is_empty() {
1314            reasoning.push('\n');
1315        }
1316        reasoning.push_str(block.trim());
1317        rest = remainder;
1318    }
1319    let content = content.trim().to_string();
1320    (content, (!reasoning.is_empty()).then_some(reasoning))
1321}