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