Skip to main content

nexus_core/app/
mod.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 std::collections::{HashMap, HashSet, VecDeque};
11
12use anyhow::Result;
13use chrono::Utc;
14use tokio::sync::mpsc;
15
16use crate::config;
17use crate::db::{DEFAULT_SPACE, Db, Message, Session, Space as SpaceRow};
18use crate::provider::openrouter::OpenRouter;
19use crate::provider::{BackendTag, Model, StreamEvent, Usage};
20use crate::space::Space;
21
22mod apps;
23mod backends;
24mod chat;
25mod commands;
26mod compaction;
27pub mod export;
28mod files;
29pub mod headless;
30mod images;
31mod memory;
32mod models;
33mod research;
34mod scripts;
35mod sessions;
36mod settings;
37mod skills_popup;
38mod snapshot;
39mod spaces;
40mod swarm;
41#[cfg(test)]
42mod tests;
43mod transcribe;
44pub mod usage;
45mod watches;
46pub use backends::{Backends, composite_id};
47pub use chat::{code_blocks, human_size, pick_greeting};
48pub use commands::{AppCommand, COMMANDS, Command, Match, command_score, fuzzy_score};
49pub use files::OcrUpdate;
50pub use research::{PlanQuestion, ResearchUpdate};
51pub use sessions::session_score;
52pub use snapshot::{CoreSnapshot, ModelSnapshot, SessionSnapshot, SettingsSnapshot, TaskSnapshot};
53pub use swarm::{SwarmUpdate, parse_persona_editor};
54
55#[cfg(test)]
56use chat::split_inline_reasoning;
57#[cfg(test)]
58use memory::parse_memory_ops;
59use sessions::parse_topic;
60
61/// Nudge a bounded selection index by `delta`, hard-clamping to `[0, len-1]`
62/// (or `0` if `len` is `0`). Shared by the picker/list selection-movement
63/// methods that clamp at the ends rather than wrapping around.
64pub fn clamp_cursor(current: usize, len: usize, delta: i32) -> usize {
65    if len == 0 {
66        return 0;
67    }
68    (current as i32 + delta).clamp(0, len as i32 - 1) as usize
69}
70
71/// Filter `items` down to those `score_fn` returns `Some` for, sorted
72/// descending by score (best match first, stable on ties). Shared by the
73/// space and session pickers' fuzzy filters.
74pub fn fuzzy_filter_sorted<T>(items: &[T], score_fn: impl Fn(&T) -> Option<i32>) -> Vec<&T> {
75    let mut scored: Vec<(i32, &T)> = items
76        .iter()
77        .filter_map(|item| score_fn(item).map(|sc| (sc, item)))
78        .collect();
79    scored.sort_by_key(|(sc, _)| std::cmp::Reverse(*sc));
80    scored.into_iter().map(|(_, item)| item).collect()
81}
82
83/// Which tab in the `/files` popup is active.
84#[derive(Debug, PartialEq, Eq, Clone, Copy, serde::Serialize, serde::Deserialize)]
85pub enum FilesTab {
86    Files,
87    Images,
88    Scripts,
89}
90
91/// Which modal popover, if any, is open.
92#[derive(Debug, PartialEq, Eq)]
93pub enum Popup {
94    None,
95    Model,
96    Session,
97    Key,
98    Settings,
99    Copy,
100    Space,
101    Context,
102    Skills,
103    Files,
104    Apps,
105    Watch,
106    ResearchLive,
107    Swarm,
108    /// `/usage`: aggregated per-backend/per-model token, cache, and cost stats.
109    Usage,
110    /// `/login`'s provider selector (`OpenRouter` / `OpenCode` Go / `OpenAI` / Codex).
111    Login,
112}
113
114/// Which backend a pasted key in `Popup::Key` is for — set by whichever
115/// `/login` row opened the prompt, since these keys aren't distinguishable
116/// by shape (unlike `OpenRouter`'s `sk-or-` prefix).
117#[derive(PartialEq, Eq, Clone, Copy)]
118pub enum KeyTarget {
119    OpenRouter,
120    OpenAi,
121    OpencodeGo,
122}
123
124/// What the `/swarm` roster popup is doing.
125#[derive(PartialEq, Eq, Clone, Copy)]
126pub enum SwarmPopupMode {
127    Browse,
128    ConfirmDelete,
129}
130
131/// What the apps popup is doing: browsing the space's apps or confirming
132/// removal of the highlighted one.
133#[derive(PartialEq, Eq, Clone, Copy)]
134pub enum AppsMode {
135    Browse,
136    ConfirmDelete,
137    EditFile,
138}
139
140/// What the watch picker is doing: browsing or confirming removal of the
141/// highlighted watch.
142#[derive(Debug, PartialEq, Eq, Clone, Copy)]
143pub enum WatchMode {
144    Browse,
145    ConfirmDelete,
146}
147
148/// What the skills popup is doing: browsing, typing a GitHub `owner/repo/path`
149/// to install, or confirming removal of the highlighted skill.
150#[derive(PartialEq, Eq, Clone, Copy)]
151pub enum SkillsMode {
152    Browse,
153    Install,
154    ConfirmRemove,
155}
156
157/// What the files popup is doing: browsing the fileset, typing a path to
158/// import, renaming the highlighted file, or confirming its removal.
159#[derive(PartialEq, Eq, Clone, Copy)]
160pub enum FilesMode {
161    Browse,
162    Add,
163    Rename,
164    ConfirmDelete,
165    Pick,
166}
167
168/// What the images popup is doing: browsing or confirming removal.
169#[derive(PartialEq, Eq, Clone, Copy)]
170pub enum ImagesMode {
171    Browse,
172    ConfirmDelete,
173}
174
175/// What the scripts popup is doing: browsing, creating, renaming, or
176/// confirming removal of the highlighted script.
177#[derive(PartialEq, Eq, Clone, Copy)]
178pub enum ScriptsMode {
179    Browse,
180    Create,
181    Rename,
182    ConfirmDelete,
183}
184
185/// What the space picker is doing: browsing, naming a new space, renaming the
186/// highlighted one, or confirming a delete.
187#[derive(PartialEq, Eq, Clone, Copy)]
188pub enum SpaceMode {
189    Browse,
190    Create,
191    Rename,
192    ConfirmDelete,
193}
194
195/// One entry in the `/copy` menu: what to show and the text it puts on the clipboard.
196pub struct CopyOption {
197    pub label: String,
198    pub text: String,
199}
200
201/// Token estimate breakdown shown in the context popup (Ctrl+I).
202pub struct ContextBreakdown {
203    pub system_tokens: u64,
204    pub memory_tokens: u64,
205    pub skills_tokens: u64,
206    pub conversation_tokens: u64,
207    pub limit: Option<u64>,
208    /// Whether the session has ever been auto-compacted.
209    pub compacted: bool,
210}
211
212/// What the session picker is doing: browsing, renaming the highlighted row, or
213/// confirming a delete.
214#[derive(PartialEq, Eq, Clone, Copy)]
215pub enum SessionMode {
216    Browse,
217    Rename,
218    ConfirmDelete,
219}
220
221/// Which pane an in-progress mouse press is driving.
222#[derive(PartialEq, Eq, Clone, Copy)]
223pub enum MouseTarget {
224    None,
225    Input,
226    History,
227}
228
229/// The two columns of the model picker.
230#[derive(PartialEq, Eq, Clone, Copy)]
231pub enum ModelPanel {
232    Favorites,
233    Available,
234}
235
236/// What a confirmed model picker selection is for: the active session's model,
237/// or the background memory-extraction model.
238#[derive(PartialEq, Eq, Clone, Copy, Default)]
239pub enum ModelPickTarget {
240    #[default]
241    Session,
242    Memory,
243    Transcriber,
244    Ocr,
245    /// Picking the model for one row of the active session's `/swarm` roster.
246    SwarmPersona(usize),
247    /// Model used for AI image generation.
248    ImageGen,
249    /// Model used for AI video generation.
250    VideoGen,
251}
252
253/// Editable rows in the nerd-config popup.
254#[derive(PartialEq, Eq, Clone, Copy)]
255pub enum SettingsField {
256    ShowStats,
257    ShowReasoning,
258    HideHints,
259    Temperature,
260    TopP,
261    MaxTokens,
262    MemoryModel,
263    CompactThreshold,
264    SearxngUrl,
265    Verbosity,
266    LangsearchKey,
267    SearchProvider,
268    TranscriberModel,
269    OcrModel,
270    OcrEngine,
271    EmbeddingModel,
272    BlockedDomains,
273    ImageGenModel,
274    VideoGenModel,
275}
276
277impl SettingsField {
278    pub const ALL: [Self; 19] = [
279        Self::ShowStats,
280        Self::ShowReasoning,
281        Self::HideHints,
282        Self::Temperature,
283        Self::TopP,
284        Self::MaxTokens,
285        Self::MemoryModel,
286        Self::CompactThreshold,
287        Self::SearxngUrl,
288        Self::Verbosity,
289        Self::LangsearchKey,
290        Self::SearchProvider,
291        Self::TranscriberModel,
292        Self::OcrModel,
293        Self::OcrEngine,
294        Self::EmbeddingModel,
295        Self::BlockedDomains,
296        Self::ImageGenModel,
297        Self::VideoGenModel,
298    ];
299
300    pub const fn label(self) -> &'static str {
301        match self {
302            Self::ShowStats => "show stats (model · TPS footer)",
303            Self::ShowReasoning => "expand reasoning (Ctrl+R)",
304            Self::HideHints => "hide hints (keybind labels)",
305            Self::Temperature => "temperature",
306            Self::TopP => "top_p",
307            Self::MaxTokens => "max_tokens",
308            Self::MemoryModel => "memory model (Enter to pick, Backspace clears)",
309            Self::CompactThreshold => "auto-compact at (% of context, 0 disables)",
310            Self::SearxngUrl => "web search URL (SearXNG instance, blank disables)",
311            Self::Verbosity => "answer length (Space cycles normal/concise/caveman)",
312            Self::LangsearchKey => "LangSearch API key (langsearch.com/dashboard, free)",
313            Self::SearchProvider => {
314                "search provider (Space cycles auto/langsearch/searxng/duckduckgo)"
315            }
316            Self::TranscriberModel => "image model (Enter to pick, Backspace clears)",
317            Self::OcrModel => "OCR model (Enter to pick, Backspace clears)",
318            Self::OcrEngine => {
319                "OCR engine (Space cycles auto/tesseract/vlm/local; local pulls via ollama)"
320            }
321            Self::EmbeddingModel => "embedding model (file search, blank disables)",
322            Self::BlockedDomains => "blocked domains (comma-separated, always excluded; per-space)",
323            Self::ImageGenModel => {
324                "image gen model (Enter to pick, Backspace clears; blank = disabled)"
325            }
326            Self::VideoGenModel => {
327                "video gen model (Enter to pick, Backspace clears; blank = disabled)"
328            }
329        }
330    }
331}
332
333/// A functional group of settings fields, shown as a collapsible section in
334/// the nerd-config popup — grouped by what part of the pipeline the fields
335/// configure (chat display, sampling, memory, research, web search, or
336/// voice/vision input), not by widget type.
337pub struct SettingsGroup {
338    pub name: &'static str,
339    pub fields: &'static [SettingsField],
340}
341
342pub const SETTINGS_GROUPS: &[SettingsGroup] = &[
343    SettingsGroup {
344        name: "Interface",
345        fields: &[
346            SettingsField::ShowStats,
347            SettingsField::ShowReasoning,
348            SettingsField::HideHints,
349            SettingsField::Verbosity,
350        ],
351    },
352    SettingsGroup {
353        name: "Generation",
354        fields: &[
355            SettingsField::Temperature,
356            SettingsField::TopP,
357            SettingsField::MaxTokens,
358        ],
359    },
360    SettingsGroup {
361        name: "Memory & Context",
362        fields: &[
363            SettingsField::MemoryModel,
364            SettingsField::CompactThreshold,
365            SettingsField::EmbeddingModel,
366        ],
367    },
368    SettingsGroup {
369        name: "Web Search",
370        fields: &[
371            SettingsField::SearchProvider,
372            SettingsField::SearxngUrl,
373            SettingsField::LangsearchKey,
374            SettingsField::BlockedDomains,
375        ],
376    },
377    SettingsGroup {
378        name: "Voice & Vision",
379        fields: &[
380            SettingsField::TranscriberModel,
381            SettingsField::OcrModel,
382            SettingsField::OcrEngine,
383        ],
384    },
385    SettingsGroup {
386        name: "Image Generation",
387        fields: &[SettingsField::ImageGenModel],
388    },
389    SettingsGroup {
390        name: "Video Generation",
391        fields: &[SettingsField::VideoGenModel],
392    },
393];
394
395/// One visible row in the settings popup: a collapsible group header, or a
396/// field nested under one (only present when its group isn't collapsed).
397#[derive(Clone, Copy, PartialEq, Eq)]
398pub enum SettingsRow {
399    Group(usize),
400    Field(SettingsField),
401}
402
403pub const VERBOSITY_LEVELS: [&str; 3] = ["normal", "concise", "caveman"];
404pub const OCR_ENGINES: [&str; 4] = ["auto", "tesseract", "vlm", "local"];
405pub const SEARCH_PROVIDERS: [&str; 4] = ["auto", "langsearch", "searxng", "duckduckgo"];
406
407/// Nerd config: footer toggles + core sampling parameters.
408#[derive(Debug, Clone)]
409// A settings struct is inherently many booleans; grouped by feature in the popup.
410#[allow(clippy::struct_excessive_bools)]
411pub struct Settings {
412    /// Show the per-message model · TPS footer.
413    pub show_stats: bool,
414    /// Expand stored reasoning traces (vs. a collapsed one-liner).
415    pub show_reasoning: bool,
416    /// Hide keybind hints (input hint, popup titles, "Ctrl+R to expand", …).
417    pub hide_hints: bool,
418    pub temperature: Option<f32>,
419    pub top_p: Option<f32>,
420    pub max_tokens: Option<u32>,
421    /// Auto-compact once context usage crosses this percent of the model's
422    /// context window. 0 disables auto-compaction.
423    pub compact_threshold: u8,
424}
425
426impl Default for Settings {
427    fn default() -> Self {
428        Self {
429            show_stats: false,
430            show_reasoning: false,
431            hide_hints: false,
432            temperature: None,
433            top_p: None,
434            max_tokens: None,
435            compact_threshold: 60,
436        }
437    }
438}
439
440/// The three answer-length presets, cycled by the settings popup's
441/// `verbosity` field. "concise" is the default — terse but not telegraphic;
442/// "caveman" is deliberately more aggressive (dropped articles/grammar),
443/// matching the community caveman-prompt technique, for users who want it.
444fn verbosity_clause(level: &str) -> &'static str {
445    match level {
446        "normal" => {
447            "Answer at whatever length the question deserves — don't optimize for brevity over completeness."
448        }
449        "caveman" => {
450            "Talk caveman-terse. Drop articles (a/an/the) and filler words. Short fragments over full sentences. No politeness, no hedging, no restating the question. Symbols over words where clear (→, =, vs). Full explanations ONLY when explicitly asked — otherwise state the fact/answer and stop. Preserve numbers, code, names, and technical terms exactly."
451        }
452        _ => {
453            "Answer style: default to short. Say the answer, then stop — don't restate the question, don't add a summary, don't hedge (\"it's worth noting...\", \"generally speaking...\"). No preamble (\"Great question!\", \"I'd be happy to help with that\"), no postamble (\"Let me know if you have questions!\"). One clear sentence beats three vague ones.\nThis is a floor, not a ceiling: if the user asks to explain, teach, or go deep, or the topic genuinely needs multiple steps to be correct (debugging, multi-part instructions, tradeoffs), give it the room it needs. Brevity for its own sake that omits a needed step is wrong, not concise.\nKeep full grammar — this isn't telegraphic shorthand. Drop filler, not clarity."
454        }
455    }
456}
457
458const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
459
460/// Greeting lines for the start screen; one is picked at random per launch.
461const GREETINGS: [&str; 8] = [
462    "What are we building today?",
463    "Ask me anything.",
464    "Fresh session, fresh ideas.",
465    "The terminal is your canvas.",
466    "Ready when you are.",
467    "Type / to see commands.",
468    "Let's get to work.",
469    "How can I help?",
470];
471
472/// Dumb little "thinking" verbs, Claude-Code style: (present, past). One pair is
473/// picked per request — "⠹ Vibing" while streaming, "Vibed for 3s" once done.
474const THINKING: [(&str, &str); 16] = [
475    ("Thinking", "Thought"),
476    ("Pondering", "Pondered"),
477    ("Noodling", "Noodled"),
478    ("Ruminating", "Ruminated"),
479    ("Cogitating", "Cogitated"),
480    ("Marinating", "Marinated"),
481    ("Percolating", "Percolated"),
482    ("Mulling", "Mulled"),
483    ("Conjuring", "Conjured"),
484    ("Vibing", "Vibed"),
485    ("Scheming", "Schemed"),
486    ("Wrangling tokens", "Wrangled tokens"),
487    ("Brewing", "Brewed"),
488    ("Musing", "Mused"),
489    ("Galaxy-braining", "Galaxy-brained"),
490    ("Doing the thing", "Did the thing"),
491];
492
493/// Spinner colour for an in-flight response. Core names the palette
494/// abstractly (the TUI maps it to terminal colors) so this type can cross
495/// the Phase 4 API boundary unchanged.
496#[derive(Clone, Copy, PartialEq, Eq)]
497pub enum SpinnerColor {
498    Green,
499    Cyan,
500    Magenta,
501}
502
503/// Palette the spinner colour is randomly drawn from each request.
504const SPINNER_COLORS: [SpinnerColor; 3] = [
505    SpinnerColor::Green,
506    SpinnerColor::Cyan,
507    SpinnerColor::Magenta,
508];
509
510type ModelsResult = std::result::Result<Vec<Model>, String>;
511
512/// One memory-extraction op, as emitted by the memory model.
513#[derive(Clone)]
514pub enum MemoryOp {
515    Add(String),
516    Update(usize, String),
517    Delete(usize),
518}
519
520/// One row in the `/image` popup: name, size in bytes, modified rfc3339.
521#[derive(Clone)]
522pub struct ImageMeta {
523    pub name: String,
524    pub size: u64,
525    pub modified: String,
526}
527
528/// One row in the `/script` popup: name, size in bytes, modified rfc3339.
529#[derive(Clone)]
530pub struct ScriptMeta {
531    pub name: String,
532    pub size: u64,
533    pub modified: String,
534}
535
536/// Maximum number of concurrent interactive chat responses.
537pub const MAX_CHAT_TASKS: usize = 10;
538
539pub type ChatTaskId = u64;
540
541/// Identity of a parked survey/plan gate, as surfaced on the event stream.
542#[derive(Clone)]
543pub struct GateState {
544    /// The session the reply must come from.
545    pub session_id: String,
546    /// Which phase is waiting (drives the prompt shown).
547    pub phase: SurveyPhase,
548}
549
550/// One event routed from a provider stream to its originating chat task.
551pub struct ChatEvent {
552    pub task_id: ChatTaskId,
553    pub event: StreamEvent,
554}
555
556/// State owned by one in-flight chat response. Provider/toolbox values stay in
557/// the spawned task; this state is the UI/database-facing projection.
558pub struct ChatTask {
559    pub id: ChatTaskId,
560    pub session_id: String,
561    pub session_title: String,
562    pub space_id: String,
563    pub model: String,
564    /// Raw wire model id (backend prefix stripped) — the key for pricing.
565    pub model_id: String,
566    /// Which backend serves this task's requests.
567    pub backend: BackendTag,
568    pub incognito: bool,
569    pub buffer: String,
570    pub thinking: String,
571    pub tool_status: Option<String>,
572    pub usage: Option<Usage>,
573    /// Row id of this task's `usage_log` row (one per request); the trailing
574    /// `OpenCode` Zen cost event updates it rather than inserting a duplicate.
575    pub usage_row_id: Option<i64>,
576    pub started: std::time::Instant,
577    pub thinking_idx: usize,
578    pub spinner_color: SpinnerColor,
579    pub abort: tokio::task::AbortHandle,
580}
581
582/// A completed chat task waiting for the user to open its session.
583pub struct ChatNotification {
584    pub session_id: String,
585    pub title: String,
586    pub text: String,
587    pub success: bool,
588}
589
590/// A background event surfaced to the event loop. `None` means that source's
591/// channel closed (task ended).
592/// One file's embedding result: (space id, file id, (seq, vector) pairs or error).
593pub type EmbedMsg = (
594    String,
595    String,
596    std::result::Result<Vec<(i64, Vec<f32>)>, String>,
597);
598
599/// A background research pipeline update: (session id, space id, space name,
600/// stage update or final result).
601pub type ResearchMsg = (String, String, String, research::ResearchUpdate);
602
603/// A parked conversation awaiting a chat reply: which session the reply
604/// must come from, the channel to send it on, and which phase is waiting.
605/// Generic — the research survey/plan-approval gates ride it, and any other
606/// mode (swarm, watch setup, plain chat) can arm it the same way. Armed by
607/// the owning job's update handler on each pending section, cleared when
608/// the user replies or the job ends.
609pub struct SurveyGate {
610    pub session_id: String,
611    pub reply_tx: mpsc::UnboundedSender<String>,
612    pub phase: SurveyPhase,
613    /// The actionable transcript row for this gate. Keeping it with the gate
614    /// lets an incognito prompt be restored after the user switches away and
615    /// back without writing private content to the database.
616    pub prompt_role: String,
617    pub prompt_content: String,
618}
619
620/// What a parked survey gate is waiting for — drives the status line and
621/// which phase's reply is routed. Mode-agnostic: `Clarify` is any
622/// clarifying-question round, `Approve` any presented-artifact approval.
623#[derive(Clone)]
624pub enum SurveyPhase {
625    /// A clarifying-question round (1-based).
626    Clarify { round: u8 },
627    /// Approval of a presented artifact; `rework` is true on a
628    /// re-presentation after the user's edits were folded in.
629    Approve { rework: bool },
630}
631
632#[derive(Clone)]
633pub enum LoginMsg {
634    Status(String),
635    Done(Result<crate::config::CodexCredentials, String>),
636}
637
638/// External-editor request queued by app/input code. The event loop owns the
639/// terminal suspend/resume and applies structured edits after `$EDITOR` exits.
640pub enum PendingEditor {
641    AppFile(std::path::PathBuf),
642    Persona(std::path::PathBuf),
643    ScriptFile(std::path::PathBuf),
644}
645
646/// Ensures a child stream spawned by `OpenRouter::stream_chat` is cancelled
647/// when its parent research/swarm task is aborted or otherwise dropped.
648pub struct AbortOnDrop(pub tokio::task::AbortHandle);
649
650impl Drop for AbortOnDrop {
651    fn drop(&mut self) {
652        self.0.abort();
653    }
654}
655
656#[derive(Clone)]
657pub enum AppEvent {
658    /// A one-line status update from a domain path (the 2e step converts the
659    /// `status` field writes into these; the field still exists until then).
660    Status(String),
661    /// The composer should be replaced with this text (e.g. a send-failure
662    /// path restoring the user's message). The view layer applies it to its
663    /// `TextArea`.
664    ComposerSet(String),
665    /// The composer should be cleared.
666    ComposerClear,
667    /// The view should reset its viewport state (scroll, selection, pinning
668    /// baseline) — pushed wherever domain code switches sessions, starts a
669    /// stream, or otherwise invalidates the rendered conversation.
670    ViewportReset,
671    /// The wrapped-history render cache must be rebuilt (in-place message
672    /// edits would otherwise leave stale wrapped content).
673    HistoryInvalidated,
674    /// A domain path fell back to "no backend configured" and wants the
675    /// login selector shown (e.g. a swarm turn with no resolvable model).
676    OpenLoginPopup,
677    /// A survey/plan gate armed (`Some`) or cleared (`None`). `GateState`
678    /// carries the session id the reply must come from, so a gate in another
679    /// session can never swallow typing — the consumer compares the payload
680    /// against the viewed session.
681    Gate(Option<GateState>),
682    Stream(Option<(ChatTaskId, StreamEvent)>),
683    Models(Option<ModelsResult>),
684    /// A generated session topic: (session id, title, slug).
685    Title(Option<(String, String, String)>),
686    /// Extracted memory ops for a space, tagged with the space name so a
687    /// meanwhile space-switch can discard stale results.
688    Memory(Option<(String, Vec<MemoryOp>)>),
689    /// A compaction digest: (session id, digest, messages covered, pre-compaction %).
690    Compact(Option<(String, String, i64, u64)>),
691    /// Result of `/skills` install: skill name on success, error message on failure.
692    SkillInstall(Option<Result<String, String>>),
693
694    /// A per-page progress or final OCR result for one scanned PDF, or `None`
695    /// when the batch's channel closed.
696    Ocr(Option<(String, String, files::OcrUpdate)>),
697    /// One file's chunk-embedding job finished (or the channel closed).
698    Embed(Option<EmbedMsg>),
699    /// A local-OCR-model pull finished: model name or error.
700    OcrPull(Option<Result<String, String>>),
701    /// A deep-research pipeline update, or `None` when its channel closed.
702    Research(Option<ResearchMsg>),
703    /// `/research` with no topic: a distilled topic from recent chat, or an error.
704    ResearchTopic(Option<Result<String, String>>),
705    /// Startup update check: newest published version, or `None` when the
706    /// check failed (offline, index hiccup) — silently ignored.
707    UpdateCheck(Option<String>),
708    /// `OpenAI` Codex subscription login status or final result.
709    Login(Option<LoginMsg>),
710    /// A `/swarm` turn update, or `None` when its channel closed.
711    Swarm(Option<swarm::SwarmMsg>),
712}
713
714// Channel/state fields share *_rx/*_tx postfixes by design — the postfix is the meaning.
715// App state is inherently many booleans (modes, toggles, dirty flags).
716#[allow(clippy::struct_field_names, clippy::struct_excessive_bools)]
717pub struct App {
718    pub db: Db,
719    pub space: Space,
720    /// Every backend currently logged into. `/model` merges all of their
721    /// catalogs into one list; picking a model resolves back to the right
722    /// one here.
723    pub backends: Backends,
724    /// Every credential configured on disk, kept in sync with `backends`.
725    pub saved: crate::config::SavedCreds,
726
727    /// The space the current/next session belongs to.
728    pub active_space: SpaceRow,
729    /// Model used for background memory extraction (empty = disabled).
730    pub memory_model: String,
731    /// Model used for image transcription (empty = disabled).
732    pub transcriber_model: String,
733    /// Vision model for scanned-PDF OCR (empty = tesseract only).
734    pub ocr_model: String,
735    /// OCR engine choice: "auto" (vlm when `ocr_model` set), "tesseract",
736    /// "vlm", or "local" (Ollama on 127.0.0.1:11434, set up by cycling to it in /config).
737    pub ocr_engine: String,
738    /// Ollama model name for the "local" OCR engine.
739    pub local_ocr_model: String,
740    /// Embedding model for semantic file search (empty = keyword FTS only).
741    pub embedding_model: String,
742    /// Model used for AI image generation (empty = disabled).
743    pub image_gen_model: String,
744    /// Model used for AI video generation (empty = disabled).
745    pub video_gen_model: String,
746    /// Base URL of a `SearXNG` instance for the web-search tool, or empty to
747    /// disable it. Configured in-app (Ctrl+O settings), not a config file.
748    pub searxng_url: String,
749    /// `LangSearch` API key (free tier), or empty to disable it.
750    pub langsearch_key: String,
751    /// Which web-search backend to prefer: "auto"/"langsearch"/"searxng"/"duckduckgo".
752    pub search_provider: String,
753    /// Raw contents of `system_prompt.md` (with an unresolved `{{verbosity}}`
754    /// placeholder) — the app's own base system prompt, `$EDITOR`-editable.
755    pub base_system_prompt: String,
756    /// Answer-length preference woven into the system prompt: "normal",
757    /// "concise" (default), or "caveman".
758    pub verbosity: String,
759    pub memory_rx: Option<mpsc::UnboundedReceiver<(String, Vec<MemoryOp>)>>,
760    /// Background compaction result: (session id, digest, messages-covered, pre-compaction %).
761    pub compact_rx: Option<mpsc::UnboundedReceiver<(String, String, i64, u64)>>,
762
763    /// Installed skills (name/description only — bodies are read from disk on
764    /// invocation, so this list is cheap and reloaded whenever it changes).
765    pub skills: Vec<crate::skills::Skill>,
766    /// A skill armed by `/<skill-name>`, injected into the next message only.
767    pub forced_skill: Option<String>,
768    /// `/web` answer mode for the active session (or the next one created).
769    pub web_mode: bool,
770    pub incognito: bool,
771    /// Temp directory for incognito image files, cleaned up on toggle.
772    pub incognito_img_dir: Option<std::path::PathBuf>,
773    /// A parked conversation's chat-reply gate (clarifying questions or an
774    /// approval) — armed only while a reply is actually pending, so a gate
775    /// in another session can never swallow typing.
776    pub survey_gate: Option<SurveyGate>,
777    /// Sender half of the reply channel into a parked gate. Created at the
778    /// owning job's start; the gate itself arms/disarms as pending-section
779    /// updates arrive.
780    pub survey_reply_tx: Option<mpsc::UnboundedSender<String>>,
781    /// Every `/steer` queued during the current job, as `(queue position,
782    /// text)` — position 1-based, assigned in queue order. Entries are
783    /// dropped once the pipeline acknowledges them (`research_steer_acked`),
784    /// and the whole log is cleared when the job stops or its channel
785    /// closes, so retained steer text stays bounded per job.
786    pub research_steer_log: Vec<(usize, String)>,
787    /// Steer positions (`steer #N`) the pipeline has drained and persisted —
788    /// parsed from `Stage` updates in `on_research_done`, so the live popup
789    /// knows what's picked up even when opened from another session, and the
790    /// retained log can drop acknowledged entries.
791    pub research_steer_acked: std::collections::HashSet<usize>,
792    /// The running job's stage rows (`label: detail` content strings), kept
793    /// in sync by `mirror_stage` regardless of which session is viewed — the
794    /// live popup renders from here instead of re-reading the db per frame.
795    pub research_stage_rows: Vec<String>,
796    /// Incognito mode captured when the job started: artifact persistence
797    /// (plan files, and the plan message itself, which folds in survey
798    /// replies) is decided by this, never by toggling `incognito` mid-job.
799    pub research_incognito: bool,
800    /// Queues `/steer` instructions into the currently running research job's
801    /// round-boundary check. `None` when no research job is running.
802    pub research_steer_tx: Option<mpsc::UnboundedSender<String>>,
803    /// In-progress `/research` (no args) topic distillation from recent chat.
804    pub research_topic_rx: Option<mpsc::UnboundedReceiver<Result<String, String>>>,
805    /// Composer buffer for the live research-activity view's steer input.
806    pub research_live_input: String,
807    pub toolbox: std::sync::Arc<dyn crate::tools::ToolExecutor>,
808    /// Local static server for model-created apps (None if it failed to bind).
809    pub app_server: Option<crate::appserver::AppServer>,
810    pub skills_rx: Option<mpsc::UnboundedReceiver<Result<String, String>>>,
811    /// Background OCR updates: (`space_id`, file name, progress or final result).
812    pub ocr_rx: Option<mpsc::UnboundedReceiver<(String, String, files::OcrUpdate)>>,
813    /// One in-flight chunk-embedding job: (space id, file id, vectors or error).
814    pub embed_rx: Option<mpsc::UnboundedReceiver<EmbedMsg>>,
815    /// A running local-OCR-model pull: model name on success, error text on failure.
816    pub ocr_pull_rx: Option<mpsc::UnboundedReceiver<Result<String, String>>>,
817    /// A running `/research` job's channel and cancellation handle.
818    pub research_rx: Option<mpsc::UnboundedReceiver<ResearchMsg>>,
819    pub research_abort: Option<tokio::task::AbortHandle>,
820    pub login_rx: Option<mpsc::UnboundedReceiver<LoginMsg>>,
821    /// Startup update check result (newest published version, or `None` on failure).
822    pub update_rx: Option<mpsc::UnboundedReceiver<Option<String>>>,
823    /// A running `/swarm` discussion's channel, cancellation handle, and
824    /// origin session id (used for targeting the correct progress row).
825    pub swarm_rx: Option<mpsc::UnboundedReceiver<swarm::SwarmMsg>>,
826    pub swarm_abort: Option<tokio::task::AbortHandle>,
827    pub swarm_session: Option<String>,
828    /// The active session's `/swarm` roster, cached for the popup (kept in
829    /// sync by `on_swarm_update` while a turn runs; the view owns the cursor).
830    pub swarm_cache: Vec<crate::db::Persona>,
831    /// (session id, topic) of the `/research` job currently running, if any —
832    /// cleared when its channel closes.
833    pub research_running: Option<(String, String)>,
834
835    /// The active space's imported files (refreshed by `rescan_files`).
836    pub files_cache: Vec<crate::db::FileRow>,
837    /// The space's apps (`/apps` popup): names, cursor, and mode.
838    pub apps_cache: Vec<String>,
839    /// The space's images (`/image` popup): cache and cursor.
840    pub images_cache: Vec<ImageMeta>,
841
842    /// The space's scripts (`/script` popup): cache, cursor, and edit buffer.
843    pub scripts_cache: Vec<ScriptMeta>,
844    /// The space's standing research watches (`/watch` picker): cache + cursor.
845    pub watches_cache: Vec<crate::db::Watch>,
846    /// Time window the `/usage` dashboard aggregates (`24h/7d/30d/all`) — a
847    /// persisted preference, applied by `apply_setting` on load.
848    pub usage_range: crate::db::UsageRange,
849
850    /// Live model catalog (fetched on demand, never hardcoded).
851    pub models: Vec<Model>,
852    pub current_model: Option<String>,
853    pub models_rx: Option<mpsc::UnboundedReceiver<ModelsResult>>,
854    /// Model ids marked favorite, and when each model was last used (rfc3339).
855    pub favorites: HashSet<String>,
856    pub last_used: HashMap<String, String>,
857    /// Per-model reasoning effort (wire string from `ReasoningEffort::as_str`,
858    /// e.g. "minimal" / "low" / "high" / "xhigh" / "max" / "none").
859    pub reasoning: HashMap<String, String>,
860
861    pub session: Option<Session>,
862    pub messages: Vec<Message>,
863
864    /// Central event channel for all in-flight chat tasks.
865    pub chat_event_tx: mpsc::UnboundedSender<ChatEvent>,
866    pub chat_event_rx: mpsc::UnboundedReceiver<ChatEvent>,
867    pub chat_tasks: HashMap<ChatTaskId, ChatTask>,
868    pub next_chat_task_id: ChatTaskId,
869    /// Completed task notifications, kept independently of the one-line status.
870    pub notifications: VecDeque<ChatNotification>,
871    /// Queued `Status`/`Gate` events, drained by `next_event` before the
872    /// channel sources. The TUI/CLI/host consume the same stream either way.
873    pub(crate) pending_events: VecDeque<AppEvent>,
874    /// Sessions holding a response that finished while the user was elsewhere.
875    pub unread: std::collections::HashSet<String>,
876    /// Exact conversation token total from the last completed response.
877    pub context_total: Option<u64>,
878    /// Cache hit rate of the most recent completed request (0..=1), shown
879    /// next to the context window. Transient — not persisted here; the
880    /// per-request numbers live in `usage_log`.
881    pub last_cache_rate: Option<f64>,
882
883    pub settings: Settings,
884    /// What a confirmed model-picker selection is currently for (the active
885    /// session's model, a feature model from `/config`, or a swarm persona).
886    pub model_pick_target: ModelPickTarget,
887    /// Animated "thinking" indicator shown while a response streams.
888    pub spinner_frame: usize,
889    pub thinking_idx: usize,
890    pub spinner_color: SpinnerColor,
891
892    pub sessions_cache: Vec<Session>,
893    /// Background topic-generation result channel.
894    pub(crate) title_rx: Option<mpsc::UnboundedReceiver<(String, String, String)>>,
895}
896
897impl App {
898    // Long by design (app bootstrap).
899    #[allow(clippy::too_many_lines)]
900    pub fn new(db: Db, key: Option<&str>, space: Space) -> Self {
901        let provider = key.map(|k| OpenRouter::from_key_auto(k.to_string()));
902        // A single bootstrap key (test convenience / a fresh single-backend
903        // config): guess its flavor and seed both `backends` and `saved`
904        // with it. Real app startup (main.rs) overwrites `saved` with the
905        // authoritative on-disk creds right after construction and rebuilds
906        // `backends` from that instead.
907        let mut backends = Backends::default();
908        let mut saved = crate::config::SavedCreds::default();
909        if let (Some(k), Some(p)) = (&key, &provider) {
910            let tag = p.backend_tag();
911            backends.set(tag, p.clone());
912            match tag {
913                crate::provider::BackendTag::OpenRouter => {
914                    saved.openrouter_key = Some((*k).to_string());
915                }
916                crate::provider::BackendTag::OpenAi => saved.openai_key = Some((*k).to_string()),
917                crate::provider::BackendTag::OpencodeGo => {
918                    saved.opencode_key = Some((*k).to_string());
919                }
920                // No full CodexCredentials from a bare key — fine for the
921                // bootstrap/test path, main.rs always has the real ones.
922                crate::provider::BackendTag::Codex => {}
923            }
924        }
925        let status = if key.is_some() {
926            "loading models…  (/model to pick, /help for commands)".to_string()
927        } else {
928            "no API key — set it with /key (or $OPENROUTER_API_KEY/$OPENAI_API_KEY)".to_string()
929        };
930        // Fall back to a fresh in-memory default row if the db lookup somehow
931        // fails — the space name still resolves to real files on disk.
932        let active_space = db
933            .default_space_id()
934            .ok()
935            .and_then(|id| {
936                db.list_spaces()
937                    .ok()
938                    .and_then(|s| s.into_iter().find(|s| s.id == id))
939            })
940            .unwrap_or_else(|| SpaceRow {
941                id: String::new(),
942                name: DEFAULT_SPACE.to_string(),
943                created_at: Utc::now().to_rfc3339(),
944            });
945        let _ = space.ensure_space_dir(&active_space.name);
946        let default_model_id = |f: fn(&OpenRouter) -> &'static str, fallback: &'static str| {
947            provider.as_ref().map_or_else(
948                || fallback.to_string(),
949                |p| {
950                    let model = f(p);
951                    if model.is_empty() {
952                        String::new()
953                    } else {
954                        format!("{}{}", p.backend_tag().key_prefix(), model)
955                    }
956                },
957            )
958        };
959        let utility_model = default_model_id(
960            OpenRouter::default_utility_model,
961            "google/gemini-2.5-flash-lite",
962        );
963        let embedding_model = default_model_id(
964            OpenRouter::default_embedding_model,
965            "openai/text-embedding-3-small",
966        );
967        let image_gen_model =
968            default_model_id(OpenRouter::default_image_gen_model, "openai/gpt-image-2");
969        let video_gen_model =
970            default_model_id(OpenRouter::default_video_gen_model, "google/veo-3.1");
971        let skills_dir = crate::skills::skills_dir(&space.root);
972        let skills = crate::skills::load_skills(&skills_dir);
973        let (chat_event_tx, chat_event_rx) = mpsc::unbounded_channel();
974        // Built with search disabled; `load_settings()` below reads the
975        // persisted config (if any) and rebuilds this via `refresh_toolbox`.
976        let toolbox = std::sync::Arc::new(crate::tools::ToolBox::new(
977            skills_dir,
978            None,
979            None,
980            "auto".to_string(),
981            Vec::new(),
982            Some(space.db_path()),
983            Some(crate::tools::FilesCtx {
984                db_path: space.db_path(),
985                space_id: active_space.id.clone(),
986                embedder: (!embedding_model.is_empty())
987                    .then(|| backends.resolve(&embedding_model))
988                    .flatten(),
989            }),
990            // No apps ctx yet — the app server starts after construction;
991            // main() calls refresh_toolbox() once it's up.
992            None,
993        ));
994        let mut app = Self {
995            db,
996            space,
997            backends,
998            saved,
999            skills,
1000            searxng_url: String::new(),
1001            langsearch_key: String::new(),
1002            search_provider: "auto".to_string(),
1003            forced_skill: None,
1004            web_mode: false,
1005            incognito: false,
1006            incognito_img_dir: None,
1007            survey_gate: None,
1008            survey_reply_tx: None,
1009            research_steer_log: Vec::new(),
1010            research_steer_acked: std::collections::HashSet::new(),
1011            research_stage_rows: Vec::new(),
1012            research_incognito: false,
1013            research_steer_tx: None,
1014            research_topic_rx: None,
1015            research_live_input: String::new(),
1016            toolbox,
1017            app_server: None,
1018            skills_rx: None,
1019            ocr_rx: None,
1020            embed_rx: None,
1021            ocr_pull_rx: None,
1022            research_rx: None,
1023            research_abort: None,
1024            login_rx: None,
1025            update_rx: None,
1026            swarm_rx: None,
1027            swarm_abort: None,
1028            swarm_session: None,
1029            swarm_cache: Vec::new(),
1030            research_running: None,
1031            files_cache: Vec::new(),
1032            apps_cache: Vec::new(),
1033            watches_cache: Vec::new(),
1034            images_cache: Vec::new(),
1035            scripts_cache: Vec::new(),
1036            usage_range: crate::db::UsageRange::default(),
1037            active_space,
1038            memory_model: utility_model.clone(),
1039            transcriber_model: utility_model.clone(),
1040            ocr_model: utility_model,
1041            ocr_engine: "auto".to_string(),
1042            local_ocr_model: "glm-ocr".to_string(),
1043            embedding_model,
1044            image_gen_model,
1045            video_gen_model,
1046            base_system_prompt: config::load_system_prompt().unwrap_or_default(),
1047            verbosity: "concise".to_string(),
1048            memory_rx: None,
1049            compact_rx: None,
1050            models: Vec::new(),
1051            current_model: None,
1052            models_rx: None,
1053            favorites: HashSet::new(),
1054            last_used: HashMap::new(),
1055            reasoning: HashMap::new(),
1056            session: None,
1057            messages: Vec::new(),
1058            chat_event_tx,
1059            chat_event_rx,
1060            chat_tasks: HashMap::new(),
1061            next_chat_task_id: 0,
1062            notifications: VecDeque::new(),
1063            pending_events: VecDeque::new(),
1064            unread: std::collections::HashSet::new(),
1065            context_total: None,
1066            last_cache_rate: None,
1067            settings: Settings::default(),
1068            model_pick_target: ModelPickTarget::Session,
1069            spinner_frame: 0,
1070            thinking_idx: 0,
1071            spinner_color: SpinnerColor::Green,
1072            sessions_cache: Vec::new(),
1073            title_rx: None,
1074        };
1075        app.pending_events.push_back(AppEvent::Status(status));
1076        app.load_prefs();
1077        app.load_settings();
1078        app
1079    }
1080
1081    /// Load favorites + last-used timestamps from the db (best effort), and
1082    /// default the active model to the most-recently-used one so a new session
1083    /// needs no re-selection.
1084    fn load_prefs(&mut self) {
1085        if let Ok(prefs) = self.db.load_model_prefs() {
1086            for p in prefs {
1087                if p.favorite {
1088                    self.favorites.insert(p.id.clone());
1089                }
1090                if let Some(t) = p.last_used {
1091                    self.last_used.insert(p.id.clone(), t);
1092                }
1093                if let Some(r) = p.reasoning {
1094                    self.reasoning.insert(p.id, r);
1095                }
1096            }
1097        }
1098        if let Some((id, _)) = self.last_used.iter().max_by(|a, b| a.1.cmp(b.1)) {
1099            let id = id.clone();
1100            if self.backends.any() {
1101                self.push_status(format!("model: {id} — type a message, /model to change"));
1102            }
1103            self.current_model = Some(id);
1104        }
1105    }
1106
1107    /// Load persisted nerd-config settings from the db (best effort).
1108    fn load_settings(&mut self) {
1109        let Ok(kv) = self.db.load_settings() else {
1110            return;
1111        };
1112        for (k, v) in kv {
1113            self.apply_setting(&k, &v);
1114        }
1115        self.refresh_toolbox();
1116    }
1117
1118    /// Apply one persisted setting key to live state. Shared by
1119    /// `load_settings` and the `SetSetting` command.
1120    fn apply_setting(&mut self, k: &str, v: &str) {
1121        match k {
1122            "show_stats" => self.settings.show_stats = v == "1",
1123            "show_reasoning" => self.settings.show_reasoning = v == "1",
1124            "hide_hints" => self.settings.hide_hints = v == "1",
1125            "usage_range" => self.usage_range = crate::db::UsageRange::from_key(v),
1126            "temperature" => self.settings.temperature = v.parse().ok(),
1127            "top_p" => self.settings.top_p = v.parse().ok(),
1128            "max_tokens" => self.settings.max_tokens = v.parse().ok(),
1129            "memory_model" => self.memory_model = v.to_string(),
1130            "transcriber_model" => self.transcriber_model = v.to_string(),
1131            "ocr_model" => self.ocr_model = v.to_string(),
1132            "ocr_engine" if OCR_ENGINES.contains(&v) => self.ocr_engine = v.to_string(),
1133            "local_ocr_model" => self.local_ocr_model = v.to_string(),
1134            "embedding_model" => self.embedding_model = v.to_string(),
1135            // Migrate the old defaults: flux-dev is no longer in
1136            // OpenRouter's image catalog, and Veo Lite is the lower
1137            // quality tier.
1138            "image_gen_model" => {
1139                self.image_gen_model = match v {
1140                    "black-forest-labs/flux-dev" => "openai/gpt-image-2".to_string(),
1141                    _ => v.to_string(),
1142                }
1143            }
1144            "video_gen_model" => {
1145                self.video_gen_model = match v {
1146                    "google/veo-3.1-lite" => "google/veo-3.1".to_string(),
1147                    _ => v.to_string(),
1148                }
1149            }
1150            "compact_threshold" => {
1151                if let Ok(t) = v.parse() {
1152                    self.settings.compact_threshold = t;
1153                }
1154            }
1155            "searxng_url" => self.searxng_url = v.to_string(),
1156            "verbosity" if VERBOSITY_LEVELS.contains(&v) => self.verbosity = v.to_string(),
1157            "langsearch_key" => self.langsearch_key = v.to_string(),
1158            "search_provider" if SEARCH_PROVIDERS.contains(&v) => {
1159                self.search_provider = v.to_string();
1160            }
1161            _ => {}
1162        }
1163    }
1164
1165    /// Rebuild the toolbox from the current `searxng_url`, so a settings
1166    /// change takes effect immediately (no restart). Web search tries the
1167    /// configured backends first and has keyless HTML fallbacks.
1168    pub fn refresh_toolbox(&mut self) {
1169        let url =
1170            (!self.searxng_url.trim().is_empty()).then(|| self.searxng_url.trim().to_string());
1171        let key = (!self.langsearch_key.trim().is_empty())
1172            .then(|| self.langsearch_key.trim().to_string());
1173        // The toolbox sits behind the `ToolExecutor` seam now, so the skills
1174        // dir comes from the space layout instead of off the old toolbox.
1175        let skills_dir = crate::skills::skills_dir(&self.space.root);
1176        crate::skills::install_builtin(&skills_dir);
1177        let mut toolbox = crate::tools::ToolBox::new(
1178            skills_dir.clone(),
1179            url,
1180            key,
1181            self.search_provider.clone(),
1182            self.blocked_domains(),
1183            Some(self.space.db_path()),
1184            Some(crate::tools::FilesCtx {
1185                db_path: self.space.db_path(),
1186                space_id: self.active_space.id.clone(),
1187                embedder: (!self.embedding_model.trim().is_empty())
1188                    .then(|| self.backends.resolve(self.embedding_model.trim()))
1189                    .flatten(),
1190            }),
1191            // App tools only exist while the server runs — an app(action=write) whose
1192            // link can never load is worse than no tool. Disabled in incognito.
1193            self.app_server
1194                .as_ref()
1195                .filter(|_| !self.incognito)
1196                .map(|s| crate::tools::AppsCtx {
1197                    dir: self.space.apps_dir(&self.active_space.name),
1198                    server_port: s.port(),
1199                    public_base: s.public_base().map(str::to_string),
1200                    registry: s.registry().clone(),
1201                    space_name: self.active_space.name.clone(),
1202                    space_id: self.active_space.id.clone(),
1203                    space_db_path: self.space.db_path(),
1204                    files_dir: self.space.files_dir(&self.active_space.name),
1205                    session_id: self
1206                        .session
1207                        .as_ref()
1208                        .map(|s| s.id.clone())
1209                        .unwrap_or_default(),
1210                }),
1211        );
1212        if self.is_research_session()
1213            && let Some(session_id) = self.session.as_ref().map(|s| s.id.clone())
1214        {
1215            toolbox = toolbox.with_research_session(session_id);
1216        }
1217        toolbox.image_gen_backend = (!self.image_gen_model.trim().is_empty())
1218            .then(|| self.backends.resolve(self.image_gen_model.trim()))
1219            .flatten();
1220        toolbox.video_gen_backend = (!self.video_gen_model.trim().is_empty())
1221            .then(|| self.backends.resolve(self.video_gen_model.trim()))
1222            .flatten();
1223        toolbox.space_files_dir = self.space.files_dir(&self.active_space.name);
1224        toolbox.space_apps_dir = self.space.apps_dir(&self.active_space.name);
1225        toolbox.space_scripts_dir = self.space.scripts_dir(&self.active_space.name);
1226        toolbox.supports_images = self.current_model_supports_images();
1227        toolbox.session_id = self
1228            .session
1229            .as_ref()
1230            .map(|s| s.id.clone())
1231            .unwrap_or_default();
1232        self.toolbox = std::sync::Arc::new(toolbox);
1233        self.reload_skills();
1234    }
1235
1236    /// Whether the active session is a research session.
1237    pub fn is_research_session(&self) -> bool {
1238        self.session.as_ref().is_some_and(|s| s.kind == "research")
1239    }
1240
1241    /// The active space's always-excluded search domains, from its
1242    /// `blocked_domains.txt` (comma-separated; missing file = none).
1243    pub fn blocked_domains(&self) -> Vec<String> {
1244        std::fs::read_to_string(self.space.blocked_domains_path(&self.active_space.name))
1245            .unwrap_or_default()
1246            .split(',')
1247            .map(|d| d.trim().to_string())
1248            .filter(|d| !d.is_empty())
1249            .collect()
1250    }
1251
1252    /// Kick off the initial model fetch if a key is already present. Call once
1253    /// after construction, from within the tokio runtime.
1254    pub fn init(&mut self) {
1255        if self.backends.any() {
1256            self.fetch_models();
1257        }
1258        self.rescan_files();
1259    }
1260
1261    /// Kick off the startup update check: once per day, compare the newest
1262    /// published version against this build and auto-install it when a
1263    /// newer release exists. Best-effort — offline or a failed fetch is
1264    /// silent. Spawned before the event loop starts; the result arrives as
1265    /// `AppEvent::UpdateCheck`.
1266    pub fn spawn_update_check(&mut self) {
1267        if !self.db.update_check_due() {
1268            return;
1269        }
1270        let (tx, rx) = mpsc::unbounded_channel();
1271        self.update_rx = Some(rx);
1272        tokio::spawn(async move {
1273            let latest = crate::update::latest_version().await;
1274            let _ = tx.send(latest);
1275        });
1276    }
1277
1278    /// Handle the startup update check result: when a newer version is
1279    /// published, auto-install it via `cargo` in a detached background
1280    /// process when the environment allows (dev builds, missing cargo, and
1281    /// opted-out installs fall back to a plain notice). `None` (failed
1282    /// check) and same/older versions are silent — the check is a
1283    /// courtesy, not a nag.
1284    pub fn on_update_check(&mut self, latest: Option<String>) {
1285        let Some(latest) = latest else {
1286            return;
1287        };
1288        if !crate::update::version_gt(&latest, crate::update::CURRENT) {
1289            return;
1290        }
1291        let notice = |app: &mut Self| {
1292            app.push_status(format!(
1293                "update available: v{latest} (you have {}) — run `cargo install nexus-chat`",
1294                crate::update::CURRENT
1295            ));
1296            app.notifications.push_back(ChatNotification {
1297                session_id: String::new(),
1298                title: "update available".to_string(),
1299                text: format!("v{latest} is out — you have {}", crate::update::CURRENT),
1300                success: true,
1301            });
1302        };
1303        match crate::update::try_start_auto_update(&self.space.root, &latest) {
1304            crate::update::AutoUpdateOutcome::Started => {
1305                self.push_status(format!(
1306                    "auto-updating to v{latest} in the background — restart nexus to apply"
1307                ));
1308                self.notifications.push_back(ChatNotification {
1309                    session_id: String::new(),
1310                    title: format!("updating to v{latest}"),
1311                    text: "installing via `cargo install` in the background — the new \
1312                           version is live on your next launch"
1313                        .to_string(),
1314                    success: true,
1315                });
1316            }
1317            crate::update::AutoUpdateOutcome::InFlight => {
1318                // A previous launch already started the install; the next
1319                // launch picks it up. Nothing to do.
1320            }
1321            crate::update::AutoUpdateOutcome::Unavailable => notice(self),
1322        }
1323    }
1324
1325    pub fn is_streaming(&self) -> bool {
1326        !self.chat_tasks.is_empty()
1327    }
1328
1329    pub fn chat_task_count(&self) -> usize {
1330        self.chat_tasks.len()
1331    }
1332
1333    pub fn chat_task_for_session(&self, session_id: &str) -> Option<&ChatTask> {
1334        self.chat_tasks
1335            .values()
1336            .find(|task| task.session_id == session_id)
1337    }
1338
1339    pub fn active_chat_task(&self) -> Option<&ChatTask> {
1340        self.session
1341            .as_ref()
1342            .and_then(|session| self.chat_task_for_session(&session.id))
1343    }
1344
1345    pub fn active_streaming_text(&self) -> Option<&str> {
1346        self.active_chat_task().map(|task| task.buffer.as_str())
1347    }
1348
1349    /// True when the in-flight stream belongs to the active session (every
1350    /// stream carries its origin `session_id`, so this is exact).
1351    pub fn viewing_stream(&self) -> bool {
1352        self.active_chat_task().is_some()
1353    }
1354
1355    /// The empty start screen (banner + greeting + clock) shows when there's no
1356    /// conversation yet — a stream running in another session doesn't hide it.
1357    pub fn is_welcome(&self) -> bool {
1358        self.messages.is_empty() && !self.viewing_stream()
1359    }
1360
1361    /// Advance the spinner one frame (called on the animation tick).
1362    pub const fn tick_spinner(&mut self) {
1363        self.spinner_frame = self.spinner_frame.wrapping_add(1);
1364    }
1365
1366    /// Current spinner glyph.
1367    pub const fn spinner_char(&self) -> &'static str {
1368        SPINNER[self.spinner_frame % SPINNER.len()]
1369    }
1370
1371    /// Randomly-chosen spinner colour for the current response.
1372    pub fn spinner_color(&self) -> SpinnerColor {
1373        self.active_chat_task()
1374            .map_or(self.spinner_color, |task| task.spinner_color)
1375    }
1376
1377    /// Present-tense phrase for the in-progress response ("Vibing").
1378    pub fn thinking_phrase(&self) -> &'static str {
1379        self.active_chat_task()
1380            .map_or(THINKING[self.thinking_idx].0, |task| {
1381                THINKING[task.thinking_idx].0
1382            })
1383    }
1384
1385    /// Reasoning tokens accumulated so far this stream, if any.
1386    pub fn thinking_text(&self) -> Option<&str> {
1387        self.active_chat_task()
1388            .and_then(|task| (!task.thinking.is_empty()).then_some(task.thinking.as_str()))
1389    }
1390
1391    // --- async event sources (drained by the event loop) ---
1392
1393    /// Queue a one-line status update as `AppEvent::Status`. The 2e view
1394    /// layer keeps its own `status` field, fed by these events; headless
1395    /// consumers track them locally.
1396    pub fn push_status(&mut self, s: impl Into<String>) {
1397        self.pending_events.push_back(AppEvent::Status(s.into()));
1398    }
1399
1400    /// Ask the view to replace its composer contents (a send-failure path
1401    /// restoring the user's message, a gate reply rolled back, …).
1402    pub fn push_composer_set(&mut self, text: impl Into<String>) {
1403        self.pending_events
1404            .push_back(AppEvent::ComposerSet(text.into()));
1405    }
1406
1407    /// Ask the view to clear its composer.
1408    pub fn push_composer_clear(&mut self) {
1409        self.pending_events.push_back(AppEvent::ComposerClear);
1410    }
1411
1412    /// Ask the view to reset its viewport state (scroll, selection, pinning
1413    /// baseline) — pushed wherever domain code switches sessions, starts a
1414    /// stream, or otherwise invalidates the rendered conversation.
1415    pub fn push_viewport_reset(&mut self) {
1416        self.pending_events.push_back(AppEvent::ViewportReset);
1417    }
1418
1419    /// Ask the view to rebuild its wrapped-history render cache (in-place
1420    /// message edits would otherwise leave stale wrapped content).
1421    pub fn push_history_invalidated(&mut self) {
1422        self.pending_events.push_back(AppEvent::HistoryInvalidated);
1423    }
1424
1425    /// Pop one locally-queued event (status lines, gate arming, composer
1426    /// feedback). The view drains these before every draw so status changes
1427    /// land on the same frame as the action that caused them; `next_event`
1428    /// drains any remainder before blocking on the channel sources.
1429    pub fn pop_pending_event(&mut self) -> Option<AppEvent> {
1430        self.pending_events.pop_front()
1431    }
1432
1433    /// Test helper: drain the pending event queue in one pass, returning
1434    /// `(composer sets in order, last status line)` — the 2e status field and
1435    /// the composer live in the view, so tests assert on the events.
1436    #[cfg(feature = "test-helpers")]
1437    pub fn drain_ui_events(&mut self) -> (Vec<String>, String) {
1438        let mut sets = Vec::new();
1439        let mut last = String::new();
1440        while let Some(ev) = self.pop_pending_event() {
1441            match ev {
1442                AppEvent::ComposerSet(s) => sets.push(s),
1443                AppEvent::Status(s) => last = s,
1444                _ => {}
1445            }
1446        }
1447        (sets, last)
1448    }
1449
1450    /// Test helper: the last queued `Status` event, draining the queue.
1451    #[cfg(feature = "test-helpers")]
1452    pub fn last_status(&mut self) -> String {
1453        self.drain_ui_events().1
1454    }
1455
1456    /// Test helper: every queued `ComposerSet` payload, in order, draining
1457    /// the queue (asserts composer-restore paths without a TextArea).
1458    #[cfg(feature = "test-helpers")]
1459    pub fn drain_composer_sets(&mut self) -> Vec<String> {
1460        self.drain_ui_events().0
1461    }
1462
1463    /// Next background event from either the streaming task or a model fetch.
1464    /// Pends on an idle source, so it only resolves when something happens.
1465    pub async fn next_event(&mut self) -> AppEvent {
1466        // Locally-queued events (status lines, gate arming) drain first.
1467        if let Some(ev) = self.pending_events.pop_front() {
1468            return ev;
1469        }
1470        tokio::select! {
1471            ev = self.chat_event_rx.recv() => {
1472                AppEvent::Stream(ev.map(|event| (event.task_id, event.event)))
1473            },
1474            res = async {
1475                match self.models_rx.as_mut() {
1476                    Some(rx) => rx.recv().await,
1477                    None => std::future::pending().await,
1478                }
1479            } => AppEvent::Models(res),
1480            t = async {
1481                match self.title_rx.as_mut() {
1482                    Some(rx) => rx.recv().await,
1483                    None => std::future::pending().await,
1484                }
1485            } => AppEvent::Title(t),
1486            m = async {
1487                match self.memory_rx.as_mut() {
1488                    Some(rx) => rx.recv().await,
1489                    None => std::future::pending().await,
1490                }
1491            } => AppEvent::Memory(m),
1492            c = async {
1493                match self.compact_rx.as_mut() {
1494                    Some(rx) => rx.recv().await,
1495                    None => std::future::pending().await,
1496                }
1497            } => AppEvent::Compact(c),
1498            r = async {
1499                match self.skills_rx.as_mut() {
1500                    Some(rx) => rx.recv().await,
1501                    None => std::future::pending().await,
1502                }
1503            } => AppEvent::SkillInstall(r),
1504            r = async {
1505                match self.ocr_rx.as_mut() {
1506                    Some(rx) => rx.recv().await,
1507                    None => std::future::pending().await,
1508                }
1509            } => AppEvent::Ocr(r),
1510            r = async {
1511                match self.embed_rx.as_mut() {
1512                    Some(rx) => rx.recv().await,
1513                    None => std::future::pending().await,
1514                }
1515            } => AppEvent::Embed(r),
1516            r = async {
1517                match self.ocr_pull_rx.as_mut() {
1518                    Some(rx) => rx.recv().await,
1519                    None => std::future::pending().await,
1520                }
1521            } => AppEvent::OcrPull(r),
1522            r = async {
1523                match self.research_rx.as_mut() {
1524                    Some(rx) => rx.recv().await,
1525                    None => std::future::pending().await,
1526                }
1527            } => AppEvent::Research(r),
1528            r = async {
1529                match self.research_topic_rx.as_mut() {
1530                    Some(rx) => rx.recv().await,
1531                    None => std::future::pending().await,
1532                }
1533            } => AppEvent::ResearchTopic(r),
1534            r = async {
1535                match self.login_rx.as_mut() {
1536                    Some(rx) => rx.recv().await,
1537                    None => std::future::pending().await,
1538                }
1539            } => AppEvent::Login(r),
1540            r = async {
1541                match self.update_rx.as_mut() {
1542                    Some(rx) => rx.recv().await,
1543                    None => std::future::pending().await,
1544                }
1545            } => AppEvent::UpdateCheck(r.flatten()),
1546            r = async {
1547                match self.swarm_rx.as_mut() {
1548                    Some(rx) => rx.recv().await,
1549                    None => std::future::pending().await,
1550                }
1551            } => AppEvent::Swarm(r),
1552        }
1553    }
1554
1555    pub fn on_models_result(&mut self, result: Option<ModelsResult>) {
1556        self.models_rx = None;
1557        let result = match result {
1558            Some(r) => r,
1559            None => Err("model fetch cancelled".to_string()),
1560        };
1561        match result {
1562            Ok(models) => {
1563                let n = models.len();
1564                // Keep per-model catalog pricing current (OpenRouter only;
1565                // other backends report none). Costs are computed against
1566                // this table when usage is logged. Batched into a single
1567                // transaction — hundreds of per-model writes on the UI task
1568                // would stall the interface after every catalog fetch.
1569                let prices: Vec<_> = models
1570                    .iter()
1571                    .filter_map(|m| {
1572                        m.pricing
1573                            .map(|price| (m.id.clone(), m.backend.name().to_string(), price))
1574                    })
1575                    .collect();
1576                let _ = self.db.upsert_model_prices(&prices);
1577                // Fresh catalog in hand — recompute every logged request's
1578                // cost against it (fills rows logged before pricing existed
1579                // and heals any legacy per-token-shaped catalog).
1580                let _ = self.db.backfill_usage_costs();
1581                self.models = models;
1582                self.push_status(format!("loaded {n} models"));
1583                // The 2e view layer opens the model picker here: it owns the
1584                // `popup` state, and checks `models`/`current_model` after
1585                // this handler runs.
1586            }
1587            Err(e) => self.push_status(format!("model fetch failed: {e}")),
1588        }
1589    }
1590
1591    // --- input handling ---
1592    // --- nerd config (settings popup) ---
1593
1594    /// Whether scanned PDFs should OCR through the `OpenRouter` vision model:
1595    /// explicit "vlm", or "auto" with an OCR model configured. ("local" and
1596    /// "tesseract" route elsewhere.)
1597    pub fn vlm_ocr_enabled(&self) -> bool {
1598        !self.ocr_model.trim().is_empty() && matches!(self.ocr_engine.as_str(), "vlm" | "auto")
1599    }
1600}
1601
1602impl App {
1603    /// Abort every interactive chat task before the TUI exits. Chat streams are
1604    /// intentionally not persisted or resumed across process restarts.
1605    pub fn cancel_chat_tasks(&mut self) {
1606        for task in self.chat_tasks.values() {
1607            task.abort.abort();
1608        }
1609        self.chat_tasks.clear();
1610    }
1611}
1612
1613/// Best-effort transient Linux desktop notification. The TUI notification
1614/// remains the actionable one because `notify-send` cannot route a click back
1615/// into this running process without a long-lived D-Bus action listener.
1616pub fn send_system_notification(title: &str, body: &str) {
1617    #[cfg(all(target_os = "linux", not(test)))]
1618    {
1619        let _ = std::process::Command::new("notify-send")
1620            .args([
1621                "--app-name=nexus-chat",
1622                "--urgency=normal",
1623                "--expire-time=5000",
1624                "--hint=int:transient:1",
1625                title,
1626                body,
1627            ])
1628            .spawn();
1629    }
1630    #[cfg(any(not(target_os = "linux"), test))]
1631    let _ = (title, body);
1632}
1633
1634// Long by design (tool-summary table).
1635#[allow(clippy::too_many_lines)]
1636/// The one-line transcript summary for a tool-call block: the tool's name
1637/// plus the argument (and result shape) a reader actually cares about.
1638pub fn tool_call_summary(name: &str, args: &str, result: &str) -> String {
1639    let v: serde_json::Value = serde_json::from_str(args).unwrap_or_default();
1640    let f = |k: &str| {
1641        v.get(k)
1642            .and_then(|x| x.as_str())
1643            .unwrap_or_default()
1644            .to_string()
1645    };
1646    let f_or = |first: &str, second: &str| {
1647        let value = f(first);
1648        if value.is_empty() { f(second) } else { value }
1649    };
1650    match name {
1651        "skills" => {
1652            let target = if f("action") == "install" {
1653                f("source")
1654            } else {
1655                f("name")
1656            };
1657            format!("skills/{} {}", f("action"), target)
1658        }
1659        "scripts" => {
1660            let target = match f("action").as_str() {
1661                "install" => {
1662                    let n = v
1663                        .get("packages")
1664                        .and_then(|a| a.as_array())
1665                        .map_or(0, Vec::len);
1666                    format!("{n} packages")
1667                }
1668                "python" => f("name"),
1669                _ => f("path"),
1670            };
1671            format!("scripts/{} {}", f("action"), target)
1672        }
1673        "app" => format!("app/{} {}", f("action"), f("app")),
1674        "media" => {
1675            let target = [f("video_id"), f("name"), f("image_id")]
1676                .into_iter()
1677                .find(|t| !t.is_empty())
1678                .unwrap_or_else(|| f("prompt").chars().take(30).collect());
1679            format!("media/{} {}", f("action"), target)
1680        }
1681        "skill" => format!("skill {}", f("name")),
1682        "skill_admin" => format!("skill_admin {} → {}", f("action"), first_line(result)),
1683        "search" => {
1684            let failed = result.starts_with("no results") || result.contains("failed");
1685            let hits = if failed { "no hits" } else { "hits" };
1686            format!("search/{} \"{}\" → {hits}", f("mode"), f("query"))
1687        }
1688        "research_lookup" => format!("research_lookup/{} \"{}\"", f("scope"), f("query")),
1689        "batch" => {
1690            let calls = v.get("calls").and_then(|c| c.as_array());
1691            let n = calls.map_or(0, Vec::len);
1692            let tools: Vec<&str> = calls
1693                .map(|arr| {
1694                    arr.iter()
1695                        .filter_map(|item| item.get("tool").and_then(|t| t.as_str()))
1696                        .collect()
1697                })
1698                .unwrap_or_default();
1699            format!("batch [{n} ops: {}]", tools.join(", "))
1700        }
1701        "fetch_url" => format!("fetch_url {} → {}", f("url"), first_line(result)),
1702        "files" => format!(
1703            "files/{} {} → {}",
1704            f("action"),
1705            f_or("name", "query"),
1706            first_line(result)
1707        ),
1708        "app_inspect" => format!(
1709            "app_inspect/{} {}/{}",
1710            f("action"),
1711            f("app"),
1712            f_or("path", "pattern")
1713        ),
1714        "app_modify" => format!("app_modify/{} {}/{}", f("action"), f("app"), f("path")),
1715        "app_assets" => format!("app_assets/{} {}", f("action"), f("app")),
1716        "script_files" => format!("script_files/{} {}", f("action"), f("path")),
1717        "video_transform" => format!("video_transform/{} {}", f("action"), f("video_id")),
1718        "video_references" => format!("video_references/{} {}", f("action"), f("name")),
1719        "install_skill" => format!("install_skill {} → {}", f("source"), first_line(result)),
1720        "create_skill" => format!("create_skill {} → {}", f("name"), first_line(result)),
1721        "run_script" => {
1722            let path = f_or("path", "script");
1723            if v.get("space")
1724                .and_then(serde_json::Value::as_bool)
1725                .unwrap_or(false)
1726            {
1727                format!("run_script space/{path}")
1728            } else {
1729                format!("run_script {}/{}", f("skill"), path)
1730            }
1731        }
1732        "run_python" => format!("run_python ({} lines)", f("code").lines().count().max(1)),
1733        "grep_app" => {
1734            let hits = if result.starts_with("no matches") {
1735                "no hits".to_string()
1736            } else {
1737                format!(
1738                    "{} files",
1739                    result.lines().filter(|line| !line.starts_with('…')).count()
1740                )
1741            };
1742            format!("grep_app {} \"{}\" → {hits}", f("app"), f("pattern"))
1743        }
1744        "install_packages" => {
1745            let pkgs = v
1746                .get("packages")
1747                .and_then(|a| a.as_array())
1748                .map(|a| {
1749                    a.iter()
1750                        .filter_map(|x| x.as_str())
1751                        .collect::<Vec<_>>()
1752                        .join(" ")
1753                })
1754                .unwrap_or_default();
1755            let target = [f("skill"), f("app")]
1756                .into_iter()
1757                .find(|t| !t.is_empty())
1758                .unwrap_or_default();
1759            format!("install_packages {pkgs} → {target}")
1760        }
1761        "web_search" | "search_files" => {
1762            let failed = result.starts_with("no results")
1763                || result.starts_with("no matches")
1764                || result.contains("failed");
1765            let hits = if failed {
1766                "no hits".to_string()
1767            } else {
1768                format!("{} hits", result.lines().count())
1769            };
1770            format!("{name} \"{}\" → {hits}", f("query"))
1771        }
1772        "read_file" => format!("read_file {} → {}", f("name"), first_line(result)),
1773        "read_app_file" => format!("read_app_file {}/{}", f("app"), f("path")),
1774        "diff_app" => format!("diff_app {}/{}", f("app"), f("path")),
1775        "write_file" => {
1776            format!(
1777                "write_file {}/{} ({} bytes)",
1778                f("app"),
1779                f("path"),
1780                f("content").len()
1781            )
1782        }
1783        "edit_file" => format!("edit_file {}/{}", f("app"), f("path")),
1784        "generate_video" => format!("generate_video \"{}\"", f("prompt")),
1785        "edit_video" => format!("edit_video {} {}", f("video_id"), f("lighting")),
1786        "extract_frame" => format!("extract_frame {} @ {:.1}s", f("video_id"), f("time_sec")),
1787        "stitch_videos" => format!("stitch_videos {}", f("video_ids")),
1788        "save_reference" => format!("save_reference {}", f("name")),
1789        "list_references" => "list_references".to_string(),
1790        "delete_reference" => format!("delete_reference {}", f("name")),
1791        "generate_image" => format!("generate_image \"{}\"", f("prompt")),
1792        _ => {
1793            let mut a: String = args.chars().take(60).collect();
1794            if args.chars().count() > 60 {
1795                a.push('…');
1796            }
1797            format!("{name} {a}")
1798        }
1799    }
1800}
1801
1802/// First line of a tool result, e.g. `report.pdf (lines 1-200 of 831):`.
1803fn first_line(result: &str) -> String {
1804    result
1805        .lines()
1806        .next()
1807        .unwrap_or("")
1808        .trim_end_matches(':')
1809        .to_string()
1810}