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    /// Most recent `usage_log` row for this task; a trailing `OpenCode` Zen
574    /// 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    /// Memory contents captured when the active session/cache epoch began.
732    /// Background extraction may update the space file without changing this
733    /// snapshot during the active run.
734    pub(crate) memory_snapshot: String,
735    /// App-local prompt-cache epoch. Changes that alter the serialized prefix
736    /// advance it so a provider gets a fresh cache lane.
737    pub(crate) cache_epoch: u64,
738    /// Timestamp captured for the current prompt-cache epoch. Keeping the
739    /// `{{datetime}}` expansion stable prevents a clock tick from invalidating
740    /// the entire serialized prefix on every request.
741    pub(crate) prompt_datetime: String,
742    /// Model used for image transcription (empty = disabled).
743    pub transcriber_model: String,
744    /// Vision model for scanned-PDF OCR (empty = tesseract only).
745    pub ocr_model: String,
746    /// OCR engine choice: "auto" (vlm when `ocr_model` set), "tesseract",
747    /// "vlm", or "local" (Ollama on 127.0.0.1:11434, set up by cycling to it in /config).
748    pub ocr_engine: String,
749    /// Ollama model name for the "local" OCR engine.
750    pub local_ocr_model: String,
751    /// Embedding model for semantic file search (empty = keyword FTS only).
752    pub embedding_model: String,
753    /// Model used for AI image generation (empty = disabled).
754    pub image_gen_model: String,
755    /// Model used for AI video generation (empty = disabled).
756    pub video_gen_model: String,
757    /// Base URL of a `SearXNG` instance for the web-search tool, or empty to
758    /// disable it. Configured in-app (Ctrl+O settings), not a config file.
759    pub searxng_url: String,
760    /// `LangSearch` API key (free tier), or empty to disable it.
761    pub langsearch_key: String,
762    /// Which web-search backend to prefer: "auto"/"langsearch"/"searxng"/"duckduckgo".
763    pub search_provider: String,
764    /// Raw contents of `system_prompt.md` (with an unresolved `{{verbosity}}`
765    /// placeholder) — the app's own base system prompt, `$EDITOR`-editable.
766    pub base_system_prompt: String,
767    /// Answer-length preference woven into the system prompt: "normal",
768    /// "concise" (default), or "caveman".
769    pub verbosity: String,
770    pub memory_rx: Option<mpsc::UnboundedReceiver<(String, Vec<MemoryOp>)>>,
771    /// Background compaction result: (session id, digest, messages-covered, pre-compaction %).
772    pub compact_rx: Option<mpsc::UnboundedReceiver<(String, String, i64, u64)>>,
773    /// Session currently being compacted. Kept separately from `compact_rx` so
774    /// the TUI can mark the right row while the job is still in flight.
775    pub compacting_session_id: Option<String>,
776
777    /// Discovered skills (name/description only — bodies are read from disk on
778    /// invocation, so this list is cheap and reloaded whenever it changes).
779    pub skills: Vec<crate::skills::Skill>,
780    /// A skill armed by `/<skill-name>`, injected into the next message only.
781    pub forced_skill: Option<String>,
782    /// `/web` answer mode for the active session (or the next one created).
783    pub web_mode: bool,
784    pub incognito: bool,
785    /// Temp directory for incognito image files, cleaned up on toggle.
786    pub incognito_img_dir: Option<std::path::PathBuf>,
787    /// A parked conversation's chat-reply gate (clarifying questions or an
788    /// approval) — armed only while a reply is actually pending, so a gate
789    /// in another session can never swallow typing.
790    pub survey_gate: Option<SurveyGate>,
791    /// Sender half of the reply channel into a parked gate. Created at the
792    /// owning job's start; the gate itself arms/disarms as pending-section
793    /// updates arrive.
794    pub survey_reply_tx: Option<mpsc::UnboundedSender<String>>,
795    /// Every `/steer` queued during the current job, as `(queue position,
796    /// text)` — position 1-based, assigned in queue order. Entries are
797    /// dropped once the pipeline acknowledges them (`research_steer_acked`),
798    /// and the whole log is cleared when the job stops or its channel
799    /// closes, so retained steer text stays bounded per job.
800    pub research_steer_log: Vec<(usize, String)>,
801    /// Steer positions (`steer #N`) the pipeline has drained and persisted —
802    /// parsed from `Stage` updates in `on_research_done`, so the live popup
803    /// knows what's picked up even when opened from another session, and the
804    /// retained log can drop acknowledged entries.
805    pub research_steer_acked: std::collections::HashSet<usize>,
806    /// The running job's stage rows (`label: detail` content strings), kept
807    /// in sync by `mirror_stage` regardless of which session is viewed — the
808    /// live popup renders from here instead of re-reading the db per frame.
809    pub research_stage_rows: Vec<String>,
810    /// Incognito mode captured when the job started: artifact persistence
811    /// (plan files, and the plan message itself, which folds in survey
812    /// replies) is decided by this, never by toggling `incognito` mid-job.
813    pub research_incognito: bool,
814    /// Queues `/steer` instructions into the currently running research job's
815    /// round-boundary check. `None` when no research job is running.
816    pub research_steer_tx: Option<mpsc::UnboundedSender<String>>,
817    /// In-progress `/research` (no args) topic distillation from recent chat.
818    pub research_topic_rx: Option<mpsc::UnboundedReceiver<Result<String, String>>>,
819    /// Composer buffer for the live research-activity view's steer input.
820    pub research_live_input: String,
821    pub toolbox: std::sync::Arc<dyn crate::tools::ToolExecutor>,
822    /// Local static server for model-created apps (None if it failed to bind).
823    pub app_server: Option<crate::appserver::AppServer>,
824    pub skills_rx: Option<mpsc::UnboundedReceiver<Result<String, String>>>,
825    /// Background OCR updates: (`space_id`, file name, progress or final result).
826    pub ocr_rx: Option<mpsc::UnboundedReceiver<(String, String, files::OcrUpdate)>>,
827    /// One in-flight chunk-embedding job: (space id, file id, vectors or error).
828    pub embed_rx: Option<mpsc::UnboundedReceiver<EmbedMsg>>,
829    /// A running local-OCR-model pull: model name on success, error text on failure.
830    pub ocr_pull_rx: Option<mpsc::UnboundedReceiver<Result<String, String>>>,
831    /// A running `/research` job's channel and cancellation handle.
832    pub research_rx: Option<mpsc::UnboundedReceiver<ResearchMsg>>,
833    pub research_abort: Option<tokio::task::AbortHandle>,
834    pub login_rx: Option<mpsc::UnboundedReceiver<LoginMsg>>,
835    /// Startup update check result (newest published version, or `None` on failure).
836    pub update_rx: Option<mpsc::UnboundedReceiver<Option<String>>>,
837    /// A running `/swarm` discussion's channel, cancellation handle, and
838    /// origin session id (used for targeting the correct progress row).
839    pub swarm_rx: Option<mpsc::UnboundedReceiver<swarm::SwarmMsg>>,
840    pub swarm_abort: Option<tokio::task::AbortHandle>,
841    pub swarm_session: Option<String>,
842    /// The active session's `/swarm` roster, cached for the popup (kept in
843    /// sync by `on_swarm_update` while a turn runs; the view owns the cursor).
844    pub swarm_cache: Vec<crate::db::Persona>,
845    /// (session id, topic) of the `/research` job currently running, if any —
846    /// cleared when its channel closes.
847    pub research_running: Option<(String, String)>,
848
849    /// The active space's imported files (refreshed by `rescan_files`).
850    pub files_cache: Vec<crate::db::FileRow>,
851    /// The space's apps (`/apps` popup): names, cursor, and mode.
852    pub apps_cache: Vec<String>,
853    /// The space's images (`/image` popup): cache and cursor.
854    pub images_cache: Vec<ImageMeta>,
855
856    /// The space's scripts (`/script` popup): cache, cursor, and edit buffer.
857    pub scripts_cache: Vec<ScriptMeta>,
858    /// The space's standing research watches (`/watch` picker): cache + cursor.
859    pub watches_cache: Vec<crate::db::Watch>,
860    /// Time window the `/usage` dashboard aggregates (`24h/7d/30d/all`) — a
861    /// persisted preference, applied by `apply_setting` on load.
862    pub usage_range: crate::db::UsageRange,
863
864    /// Live model catalog (fetched on demand, never hardcoded).
865    pub models: Vec<Model>,
866    pub current_model: Option<String>,
867    pub models_rx: Option<mpsc::UnboundedReceiver<ModelsResult>>,
868    /// Model ids marked favorite, and when each model was last used (rfc3339).
869    pub favorites: HashSet<String>,
870    pub last_used: HashMap<String, String>,
871    /// Per-model reasoning effort (wire string from `ReasoningEffort::as_str`,
872    /// e.g. "minimal" / "low" / "high" / "xhigh" / "max" / "none").
873    pub reasoning: HashMap<String, String>,
874
875    pub session: Option<Session>,
876    pub messages: Vec<Message>,
877
878    /// Central event channel for all in-flight chat tasks.
879    pub chat_event_tx: mpsc::UnboundedSender<ChatEvent>,
880    pub chat_event_rx: mpsc::UnboundedReceiver<ChatEvent>,
881    pub chat_tasks: HashMap<ChatTaskId, ChatTask>,
882    pub next_chat_task_id: ChatTaskId,
883    /// Completed task notifications, kept independently of the one-line status.
884    pub notifications: VecDeque<ChatNotification>,
885    /// Queued `Status`/`Gate` events, drained by `next_event` before the
886    /// channel sources. The TUI/CLI/host consume the same stream either way.
887    pub(crate) pending_events: VecDeque<AppEvent>,
888    /// Sessions holding a response that finished while the user was elsewhere.
889    pub unread: std::collections::HashSet<String>,
890    /// Exact conversation token total from the last completed response.
891    pub context_total: Option<u64>,
892    /// Cache hit rate of the most recent completed request (0..=1), shown
893    /// next to the context window. Transient — not persisted here; the
894    /// per-request numbers live in `usage_log`.
895    pub last_cache_rate: Option<f64>,
896
897    pub settings: Settings,
898    /// What a confirmed model-picker selection is currently for (the active
899    /// session's model, a feature model from `/config`, or a swarm persona).
900    pub model_pick_target: ModelPickTarget,
901    /// Animated "thinking" indicator shown while a response streams.
902    pub spinner_frame: usize,
903    pub thinking_idx: usize,
904    pub spinner_color: SpinnerColor,
905
906    pub sessions_cache: Vec<Session>,
907    /// Background topic-generation result channel.
908    pub(crate) title_rx: Option<mpsc::UnboundedReceiver<(String, String, String)>>,
909}
910
911impl App {
912    // Long by design (app bootstrap).
913    #[allow(clippy::too_many_lines)]
914    pub fn new(db: Db, key: Option<&str>, space: Space) -> Self {
915        let provider = key.map(|k| OpenRouter::from_key_auto(k.to_string()));
916        // A single bootstrap key (test convenience / a fresh single-backend
917        // config): guess its flavor and seed both `backends` and `saved`
918        // with it. Real app startup (main.rs) overwrites `saved` with the
919        // authoritative on-disk creds right after construction and rebuilds
920        // `backends` from that instead.
921        let mut backends = Backends::default();
922        let mut saved = crate::config::SavedCreds::default();
923        if let (Some(k), Some(p)) = (&key, &provider) {
924            let tag = p.backend_tag();
925            backends.set(tag, p.clone());
926            match tag {
927                crate::provider::BackendTag::OpenRouter => {
928                    saved.openrouter_key = Some((*k).to_string());
929                }
930                crate::provider::BackendTag::OpenAi => saved.openai_key = Some((*k).to_string()),
931                crate::provider::BackendTag::OpencodeGo => {
932                    saved.opencode_key = Some((*k).to_string());
933                }
934                // No full CodexCredentials from a bare key — fine for the
935                // bootstrap/test path, main.rs always has the real ones.
936                crate::provider::BackendTag::Codex => {}
937            }
938        }
939        let status = if key.is_some() {
940            "loading models…  (/model to pick, /help for commands)".to_string()
941        } else {
942            "no API key — set it with /key (or $OPENROUTER_API_KEY/$OPENAI_API_KEY)".to_string()
943        };
944        // Fall back to a fresh in-memory default row if the db lookup somehow
945        // fails — the space name still resolves to real files on disk.
946        let active_space = db
947            .default_space_id()
948            .ok()
949            .and_then(|id| {
950                db.list_spaces()
951                    .ok()
952                    .and_then(|s| s.into_iter().find(|s| s.id == id))
953            })
954            .unwrap_or_else(|| SpaceRow {
955                id: String::new(),
956                name: DEFAULT_SPACE.to_string(),
957                created_at: Utc::now().to_rfc3339(),
958            });
959        let _ = space.ensure_space_dir(&active_space.name);
960        let default_model_id = |f: fn(&OpenRouter) -> &'static str, fallback: &'static str| {
961            provider.as_ref().map_or_else(
962                || fallback.to_string(),
963                |p| {
964                    let model = f(p);
965                    if model.is_empty() {
966                        String::new()
967                    } else {
968                        format!("{}{}", p.backend_tag().key_prefix(), model)
969                    }
970                },
971            )
972        };
973        let utility_model = default_model_id(
974            OpenRouter::default_utility_model,
975            "google/gemini-2.5-flash-lite",
976        );
977        let embedding_model = default_model_id(
978            OpenRouter::default_embedding_model,
979            "openai/text-embedding-3-small",
980        );
981        let image_gen_model =
982            default_model_id(OpenRouter::default_image_gen_model, "openai/gpt-image-2");
983        let video_gen_model =
984            default_model_id(OpenRouter::default_video_gen_model, "google/veo-3.1");
985        let skills_dir = crate::skills::skills_dir(&space.root);
986        let skill_dirs = crate::skills::app_skill_roots(&space.root);
987        let skills = crate::skills::load_skills_from_dirs(&skill_dirs);
988        let (chat_event_tx, chat_event_rx) = mpsc::unbounded_channel();
989        // Built with search disabled; `load_settings()` below reads the
990        // persisted config (if any) and rebuilds this via `refresh_toolbox`.
991        let toolbox = std::sync::Arc::new(
992            crate::tools::ToolBox::new(
993                skills_dir,
994                None,
995                None,
996                "auto".to_string(),
997                Vec::new(),
998                Some(space.db_path()),
999                Some(crate::tools::FilesCtx {
1000                    db_path: space.db_path(),
1001                    space_id: active_space.id.clone(),
1002                    embedder: (!embedding_model.is_empty())
1003                        .then(|| backends.resolve(&embedding_model))
1004                        .flatten(),
1005                }),
1006                // No apps ctx yet — the app server starts after construction;
1007                // main() calls refresh_toolbox() once it's up.
1008                None,
1009            )
1010            .with_skill_dirs(skill_dirs),
1011        );
1012        let mut app = Self {
1013            db,
1014            space,
1015            backends,
1016            saved,
1017            skills,
1018            searxng_url: String::new(),
1019            langsearch_key: String::new(),
1020            search_provider: "auto".to_string(),
1021            forced_skill: None,
1022            web_mode: false,
1023            incognito: false,
1024            incognito_img_dir: None,
1025            survey_gate: None,
1026            survey_reply_tx: None,
1027            research_steer_log: Vec::new(),
1028            research_steer_acked: std::collections::HashSet::new(),
1029            research_stage_rows: Vec::new(),
1030            research_incognito: false,
1031            research_steer_tx: None,
1032            research_topic_rx: None,
1033            research_live_input: String::new(),
1034            toolbox,
1035            app_server: None,
1036            skills_rx: None,
1037            ocr_rx: None,
1038            embed_rx: None,
1039            ocr_pull_rx: None,
1040            research_rx: None,
1041            research_abort: None,
1042            login_rx: None,
1043            update_rx: None,
1044            swarm_rx: None,
1045            swarm_abort: None,
1046            swarm_session: None,
1047            swarm_cache: Vec::new(),
1048            research_running: None,
1049            files_cache: Vec::new(),
1050            apps_cache: Vec::new(),
1051            watches_cache: Vec::new(),
1052            images_cache: Vec::new(),
1053            scripts_cache: Vec::new(),
1054            usage_range: crate::db::UsageRange::default(),
1055            active_space,
1056            memory_model: utility_model.clone(),
1057            memory_snapshot: String::new(),
1058            cache_epoch: 0,
1059            prompt_datetime: Utc::now().format("%Y-%m-%d %H:%M UTC, %A").to_string(),
1060            transcriber_model: utility_model.clone(),
1061            ocr_model: utility_model,
1062            ocr_engine: "auto".to_string(),
1063            local_ocr_model: "glm-ocr".to_string(),
1064            embedding_model,
1065            image_gen_model,
1066            video_gen_model,
1067            base_system_prompt: config::load_system_prompt().unwrap_or_default(),
1068            verbosity: "concise".to_string(),
1069            memory_rx: None,
1070            compact_rx: None,
1071            compacting_session_id: None,
1072            models: Vec::new(),
1073            current_model: None,
1074            models_rx: None,
1075            favorites: HashSet::new(),
1076            last_used: HashMap::new(),
1077            reasoning: HashMap::new(),
1078            session: None,
1079            messages: Vec::new(),
1080            chat_event_tx,
1081            chat_event_rx,
1082            chat_tasks: HashMap::new(),
1083            next_chat_task_id: 0,
1084            notifications: VecDeque::new(),
1085            pending_events: VecDeque::new(),
1086            unread: std::collections::HashSet::new(),
1087            context_total: None,
1088            last_cache_rate: None,
1089            settings: Settings::default(),
1090            model_pick_target: ModelPickTarget::Session,
1091            spinner_frame: 0,
1092            thinking_idx: 0,
1093            spinner_color: SpinnerColor::Green,
1094            sessions_cache: Vec::new(),
1095            title_rx: None,
1096        };
1097        // Capture the first session snapshot before any request can be built.
1098        // Subsequent session switches/new chats refresh it explicitly.
1099        app.memory_snapshot = app.read_memory();
1100        app.pending_events.push_back(AppEvent::Status(status));
1101        app.load_prefs();
1102        app.load_settings();
1103        app
1104    }
1105
1106    /// Load favorites + last-used timestamps from the db (best effort), and
1107    /// default the active model to the most-recently-used one so a new session
1108    /// needs no re-selection.
1109    fn load_prefs(&mut self) {
1110        if let Ok(prefs) = self.db.load_model_prefs() {
1111            for p in prefs {
1112                if p.favorite {
1113                    self.favorites.insert(p.id.clone());
1114                }
1115                if let Some(t) = p.last_used {
1116                    self.last_used.insert(p.id.clone(), t);
1117                }
1118                if let Some(r) = p.reasoning {
1119                    self.reasoning.insert(p.id, r);
1120                }
1121            }
1122        }
1123        if let Some((id, _)) = self.last_used.iter().max_by(|a, b| a.1.cmp(b.1)) {
1124            let id = id.clone();
1125            if self.backends.any() {
1126                self.push_status(format!("model: {id} — type a message, /model to change"));
1127            }
1128            self.current_model = Some(id);
1129        }
1130    }
1131
1132    /// Load persisted nerd-config settings from the db (best effort).
1133    fn load_settings(&mut self) {
1134        let Ok(kv) = self.db.load_settings() else {
1135            return;
1136        };
1137        for (k, v) in kv {
1138            self.apply_setting(&k, &v);
1139        }
1140        self.refresh_toolbox();
1141    }
1142
1143    /// Apply one persisted setting key to live state. Shared by
1144    /// `load_settings` and the `SetSetting` command.
1145    fn apply_setting(&mut self, k: &str, v: &str) {
1146        match k {
1147            "show_stats" => self.settings.show_stats = v == "1",
1148            "show_reasoning" => self.settings.show_reasoning = v == "1",
1149            "hide_hints" => self.settings.hide_hints = v == "1",
1150            "usage_range" => self.usage_range = crate::db::UsageRange::from_key(v),
1151            "temperature" => self.settings.temperature = v.parse().ok(),
1152            "top_p" => self.settings.top_p = v.parse().ok(),
1153            "max_tokens" => self.settings.max_tokens = v.parse().ok(),
1154            "memory_model" => self.memory_model = v.to_string(),
1155            "transcriber_model" => self.transcriber_model = v.to_string(),
1156            "ocr_model" => self.ocr_model = v.to_string(),
1157            "ocr_engine" if OCR_ENGINES.contains(&v) => self.ocr_engine = v.to_string(),
1158            "local_ocr_model" => self.local_ocr_model = v.to_string(),
1159            "embedding_model" => self.embedding_model = v.to_string(),
1160            // Migrate the old defaults: flux-dev is no longer in
1161            // OpenRouter's image catalog, and Veo Lite is the lower
1162            // quality tier.
1163            "image_gen_model" => {
1164                self.image_gen_model = match v {
1165                    "black-forest-labs/flux-dev" => "openai/gpt-image-2".to_string(),
1166                    _ => v.to_string(),
1167                }
1168            }
1169            "video_gen_model" => {
1170                self.video_gen_model = match v {
1171                    "google/veo-3.1-lite" => "google/veo-3.1".to_string(),
1172                    _ => v.to_string(),
1173                }
1174            }
1175            "compact_threshold" => {
1176                if let Ok(t) = v.parse() {
1177                    self.settings.compact_threshold = t;
1178                }
1179            }
1180            "searxng_url" => self.searxng_url = v.to_string(),
1181            "verbosity" if VERBOSITY_LEVELS.contains(&v) => {
1182                if self.verbosity != v {
1183                    self.verbosity = v.to_string();
1184                    self.bump_cache_epoch();
1185                }
1186            }
1187            "langsearch_key" => self.langsearch_key = v.to_string(),
1188            "search_provider" if SEARCH_PROVIDERS.contains(&v) => {
1189                self.search_provider = v.to_string();
1190            }
1191            _ => {}
1192        }
1193    }
1194
1195    /// Rebuild the toolbox from the current `searxng_url`, so a settings
1196    /// change takes effect immediately (no restart). Web search tries the
1197    /// configured backends first and has keyless HTML fallbacks.
1198    pub fn refresh_toolbox(&mut self) {
1199        let url =
1200            (!self.searxng_url.trim().is_empty()).then(|| self.searxng_url.trim().to_string());
1201        let key = (!self.langsearch_key.trim().is_empty())
1202            .then(|| self.langsearch_key.trim().to_string());
1203        // The toolbox sits behind the `ToolExecutor` seam now. It writes only
1204        // to the app-managed root but reads the full Agent Skills search path.
1205        let skills_dir = crate::skills::skills_dir(&self.space.root);
1206        crate::skills::install_builtin(&skills_dir);
1207        let skill_dirs = crate::skills::app_skill_roots(&self.space.root);
1208        let mut toolbox = crate::tools::ToolBox::new(
1209            skills_dir.clone(),
1210            url,
1211            key,
1212            self.search_provider.clone(),
1213            self.blocked_domains(),
1214            Some(self.space.db_path()),
1215            Some(crate::tools::FilesCtx {
1216                db_path: self.space.db_path(),
1217                space_id: self.active_space.id.clone(),
1218                embedder: (!self.embedding_model.trim().is_empty())
1219                    .then(|| self.backends.resolve(self.embedding_model.trim()))
1220                    .flatten(),
1221            }),
1222            // App tools only exist while the server runs — an app(action=write) whose
1223            // link can never load is worse than no tool. Disabled in incognito.
1224            self.app_server
1225                .as_ref()
1226                .filter(|_| !self.incognito)
1227                .map(|s| crate::tools::AppsCtx {
1228                    dir: self.space.apps_dir(&self.active_space.name),
1229                    server_port: s.port(),
1230                    public_base: s.public_base().map(str::to_string),
1231                    registry: s.registry().clone(),
1232                    space_name: self.active_space.name.clone(),
1233                    space_id: self.active_space.id.clone(),
1234                    space_db_path: self.space.db_path(),
1235                    files_dir: self.space.files_dir(&self.active_space.name),
1236                    session_id: self
1237                        .session
1238                        .as_ref()
1239                        .map(|s| s.id.clone())
1240                        .unwrap_or_default(),
1241                }),
1242        )
1243        .with_skill_dirs(skill_dirs);
1244        if self.is_research_session()
1245            && let Some(session_id) = self.session.as_ref().map(|s| s.id.clone())
1246        {
1247            toolbox = toolbox.with_research_session(session_id);
1248        }
1249        toolbox.image_gen_backend = (!self.image_gen_model.trim().is_empty())
1250            .then(|| self.backends.resolve(self.image_gen_model.trim()))
1251            .flatten();
1252        toolbox.video_gen_backend = (!self.video_gen_model.trim().is_empty())
1253            .then(|| self.backends.resolve(self.video_gen_model.trim()))
1254            .flatten();
1255        toolbox.space_files_dir = self.space.files_dir(&self.active_space.name);
1256        toolbox.space_apps_dir = self.space.apps_dir(&self.active_space.name);
1257        toolbox.space_scripts_dir = self.space.scripts_dir(&self.active_space.name);
1258        toolbox.supports_images = self.current_model_supports_images();
1259        toolbox.session_id = self
1260            .session
1261            .as_ref()
1262            .map(|s| s.id.clone())
1263            .unwrap_or_default();
1264        self.toolbox = std::sync::Arc::new(toolbox);
1265        self.reload_skills();
1266    }
1267
1268    /// Whether the active session is a research session.
1269    pub fn is_research_session(&self) -> bool {
1270        self.session.as_ref().is_some_and(|s| s.kind == "research")
1271    }
1272
1273    /// The active space's always-excluded search domains, from its
1274    /// `blocked_domains.txt` (comma-separated; missing file = none).
1275    pub fn blocked_domains(&self) -> Vec<String> {
1276        std::fs::read_to_string(self.space.blocked_domains_path(&self.active_space.name))
1277            .unwrap_or_default()
1278            .split(',')
1279            .map(|d| d.trim().to_string())
1280            .filter(|d| !d.is_empty())
1281            .collect()
1282    }
1283
1284    /// Kick off the initial model fetch if a key is already present. Call once
1285    /// after construction, from within the tokio runtime.
1286    pub fn init(&mut self) {
1287        if self.backends.any() {
1288            self.fetch_models();
1289        }
1290        self.rescan_files();
1291    }
1292
1293    /// Kick off the startup update check: once per day, compare the newest
1294    /// published version against this build and auto-install it when a
1295    /// newer release exists. Best-effort — offline or a failed fetch is
1296    /// silent. Spawned before the event loop starts; the result arrives as
1297    /// `AppEvent::UpdateCheck`.
1298    pub fn spawn_update_check(&mut self) {
1299        if !self.db.update_check_due() {
1300            return;
1301        }
1302        let (tx, rx) = mpsc::unbounded_channel();
1303        self.update_rx = Some(rx);
1304        tokio::spawn(async move {
1305            let latest = crate::update::latest_version().await;
1306            let _ = tx.send(latest);
1307        });
1308    }
1309
1310    /// Handle the startup update check result: when a newer version is
1311    /// published, auto-install it via `cargo` in a detached background
1312    /// process when the environment allows (dev builds, missing cargo, and
1313    /// opted-out installs fall back to a plain notice). `None` (failed
1314    /// check) and same/older versions are silent — the check is a
1315    /// courtesy, not a nag.
1316    pub fn on_update_check(&mut self, latest: Option<String>) {
1317        let Some(latest) = latest else {
1318            return;
1319        };
1320        if !crate::update::version_gt(&latest, crate::update::CURRENT) {
1321            return;
1322        }
1323        let notice = |app: &mut Self| {
1324            app.push_status(format!(
1325                "update available: v{latest} (you have {}) — run `cargo install nexus-chat`",
1326                crate::update::CURRENT
1327            ));
1328            app.notifications.push_back(ChatNotification {
1329                session_id: String::new(),
1330                title: "update available".to_string(),
1331                text: format!("v{latest} is out — you have {}", crate::update::CURRENT),
1332                success: true,
1333            });
1334        };
1335        match crate::update::try_start_auto_update(&self.space.root, &latest) {
1336            crate::update::AutoUpdateOutcome::Started => {
1337                self.push_status(format!(
1338                    "auto-updating to v{latest} in the background — restart nexus to apply"
1339                ));
1340                self.notifications.push_back(ChatNotification {
1341                    session_id: String::new(),
1342                    title: format!("updating to v{latest}"),
1343                    text: "installing via `cargo install` in the background — the new \
1344                           version is live on your next launch"
1345                        .to_string(),
1346                    success: true,
1347                });
1348            }
1349            crate::update::AutoUpdateOutcome::InFlight => {
1350                // A previous launch already started the install; the next
1351                // launch picks it up. Nothing to do.
1352            }
1353            crate::update::AutoUpdateOutcome::Unavailable => notice(self),
1354        }
1355    }
1356
1357    pub fn is_streaming(&self) -> bool {
1358        !self.chat_tasks.is_empty()
1359    }
1360
1361    pub fn chat_task_count(&self) -> usize {
1362        self.chat_tasks.len()
1363    }
1364
1365    pub fn chat_task_for_session(&self, session_id: &str) -> Option<&ChatTask> {
1366        self.chat_tasks
1367            .values()
1368            .find(|task| task.session_id == session_id)
1369    }
1370
1371    pub fn active_chat_task(&self) -> Option<&ChatTask> {
1372        self.session
1373            .as_ref()
1374            .and_then(|session| self.chat_task_for_session(&session.id))
1375    }
1376
1377    pub fn active_streaming_text(&self) -> Option<&str> {
1378        self.active_chat_task().map(|task| task.buffer.as_str())
1379    }
1380
1381    /// True when the in-flight stream belongs to the active session (every
1382    /// stream carries its origin `session_id`, so this is exact).
1383    pub fn viewing_stream(&self) -> bool {
1384        self.active_chat_task().is_some()
1385    }
1386
1387    /// The empty start screen (banner + greeting + clock) shows when there's no
1388    /// conversation yet — a stream running in another session doesn't hide it.
1389    pub fn is_welcome(&self) -> bool {
1390        self.messages.is_empty() && !self.viewing_stream()
1391    }
1392
1393    /// Advance the spinner one frame (called on the animation tick).
1394    pub const fn tick_spinner(&mut self) {
1395        self.spinner_frame = self.spinner_frame.wrapping_add(1);
1396    }
1397
1398    /// Current spinner glyph.
1399    pub const fn spinner_char(&self) -> &'static str {
1400        SPINNER[self.spinner_frame % SPINNER.len()]
1401    }
1402
1403    /// Randomly-chosen spinner colour for the current response.
1404    pub fn spinner_color(&self) -> SpinnerColor {
1405        self.active_chat_task()
1406            .map_or(self.spinner_color, |task| task.spinner_color)
1407    }
1408
1409    /// Present-tense phrase for the in-progress response ("Vibing").
1410    pub fn thinking_phrase(&self) -> &'static str {
1411        self.active_chat_task()
1412            .map_or(THINKING[self.thinking_idx].0, |task| {
1413                THINKING[task.thinking_idx].0
1414            })
1415    }
1416
1417    /// Reasoning tokens accumulated so far this stream, if any.
1418    pub fn thinking_text(&self) -> Option<&str> {
1419        self.active_chat_task()
1420            .and_then(|task| (!task.thinking.is_empty()).then_some(task.thinking.as_str()))
1421    }
1422
1423    // --- async event sources (drained by the event loop) ---
1424
1425    /// Queue a one-line status update as `AppEvent::Status`. The 2e view
1426    /// layer keeps its own `status` field, fed by these events; headless
1427    /// consumers track them locally.
1428    pub fn push_status(&mut self, s: impl Into<String>) {
1429        self.pending_events.push_back(AppEvent::Status(s.into()));
1430    }
1431
1432    /// Ask the view to replace its composer contents (a send-failure path
1433    /// restoring the user's message, a gate reply rolled back, …).
1434    pub fn push_composer_set(&mut self, text: impl Into<String>) {
1435        self.pending_events
1436            .push_back(AppEvent::ComposerSet(text.into()));
1437    }
1438
1439    /// Ask the view to clear its composer.
1440    pub fn push_composer_clear(&mut self) {
1441        self.pending_events.push_back(AppEvent::ComposerClear);
1442    }
1443
1444    /// Ask the view to reset its viewport state (scroll, selection, pinning
1445    /// baseline) — pushed wherever domain code switches sessions, starts a
1446    /// stream, or otherwise invalidates the rendered conversation.
1447    pub fn push_viewport_reset(&mut self) {
1448        self.pending_events.push_back(AppEvent::ViewportReset);
1449    }
1450
1451    /// Ask the view to rebuild its wrapped-history render cache (in-place
1452    /// message edits would otherwise leave stale wrapped content).
1453    pub fn push_history_invalidated(&mut self) {
1454        self.pending_events.push_back(AppEvent::HistoryInvalidated);
1455    }
1456
1457    /// Pop one locally-queued event (status lines, gate arming, composer
1458    /// feedback). The view drains these before every draw so status changes
1459    /// land on the same frame as the action that caused them; `next_event`
1460    /// drains any remainder before blocking on the channel sources.
1461    pub fn pop_pending_event(&mut self) -> Option<AppEvent> {
1462        self.pending_events.pop_front()
1463    }
1464
1465    /// Test helper: drain the pending event queue in one pass, returning
1466    /// `(composer sets in order, last status line)` — the 2e status field and
1467    /// the composer live in the view, so tests assert on the events.
1468    #[cfg(feature = "test-helpers")]
1469    pub fn drain_ui_events(&mut self) -> (Vec<String>, String) {
1470        let mut sets = Vec::new();
1471        let mut last = String::new();
1472        while let Some(ev) = self.pop_pending_event() {
1473            match ev {
1474                AppEvent::ComposerSet(s) => sets.push(s),
1475                AppEvent::Status(s) => last = s,
1476                _ => {}
1477            }
1478        }
1479        (sets, last)
1480    }
1481
1482    /// Test helper: the last queued `Status` event, draining the queue.
1483    #[cfg(feature = "test-helpers")]
1484    pub fn last_status(&mut self) -> String {
1485        self.drain_ui_events().1
1486    }
1487
1488    /// Test helper: every queued `ComposerSet` payload, in order, draining
1489    /// the queue (asserts composer-restore paths without a `TextArea`).
1490    #[cfg(feature = "test-helpers")]
1491    pub fn drain_composer_sets(&mut self) -> Vec<String> {
1492        self.drain_ui_events().0
1493    }
1494
1495    /// Next background event from either the streaming task or a model fetch.
1496    /// Pends on an idle source, so it only resolves when something happens.
1497    pub async fn next_event(&mut self) -> AppEvent {
1498        // Locally-queued events (status lines, gate arming) drain first.
1499        if let Some(ev) = self.pending_events.pop_front() {
1500            return ev;
1501        }
1502        tokio::select! {
1503            ev = self.chat_event_rx.recv() => {
1504                AppEvent::Stream(ev.map(|event| (event.task_id, event.event)))
1505            },
1506            res = async {
1507                match self.models_rx.as_mut() {
1508                    Some(rx) => rx.recv().await,
1509                    None => std::future::pending().await,
1510                }
1511            } => AppEvent::Models(res),
1512            t = async {
1513                match self.title_rx.as_mut() {
1514                    Some(rx) => rx.recv().await,
1515                    None => std::future::pending().await,
1516                }
1517            } => AppEvent::Title(t),
1518            m = async {
1519                match self.memory_rx.as_mut() {
1520                    Some(rx) => rx.recv().await,
1521                    None => std::future::pending().await,
1522                }
1523            } => AppEvent::Memory(m),
1524            c = async {
1525                match self.compact_rx.as_mut() {
1526                    Some(rx) => rx.recv().await,
1527                    None => std::future::pending().await,
1528                }
1529            } => AppEvent::Compact(c),
1530            r = async {
1531                match self.skills_rx.as_mut() {
1532                    Some(rx) => rx.recv().await,
1533                    None => std::future::pending().await,
1534                }
1535            } => AppEvent::SkillInstall(r),
1536            r = async {
1537                match self.ocr_rx.as_mut() {
1538                    Some(rx) => rx.recv().await,
1539                    None => std::future::pending().await,
1540                }
1541            } => AppEvent::Ocr(r),
1542            r = async {
1543                match self.embed_rx.as_mut() {
1544                    Some(rx) => rx.recv().await,
1545                    None => std::future::pending().await,
1546                }
1547            } => AppEvent::Embed(r),
1548            r = async {
1549                match self.ocr_pull_rx.as_mut() {
1550                    Some(rx) => rx.recv().await,
1551                    None => std::future::pending().await,
1552                }
1553            } => AppEvent::OcrPull(r),
1554            r = async {
1555                match self.research_rx.as_mut() {
1556                    Some(rx) => rx.recv().await,
1557                    None => std::future::pending().await,
1558                }
1559            } => AppEvent::Research(r),
1560            r = async {
1561                match self.research_topic_rx.as_mut() {
1562                    Some(rx) => rx.recv().await,
1563                    None => std::future::pending().await,
1564                }
1565            } => AppEvent::ResearchTopic(r),
1566            r = async {
1567                match self.login_rx.as_mut() {
1568                    Some(rx) => rx.recv().await,
1569                    None => std::future::pending().await,
1570                }
1571            } => AppEvent::Login(r),
1572            r = async {
1573                match self.update_rx.as_mut() {
1574                    Some(rx) => rx.recv().await,
1575                    None => std::future::pending().await,
1576                }
1577            } => AppEvent::UpdateCheck(r.flatten()),
1578            r = async {
1579                match self.swarm_rx.as_mut() {
1580                    Some(rx) => rx.recv().await,
1581                    None => std::future::pending().await,
1582                }
1583            } => AppEvent::Swarm(r),
1584        }
1585    }
1586
1587    pub fn on_models_result(&mut self, result: Option<ModelsResult>) {
1588        self.models_rx = None;
1589        let result = match result {
1590            Some(r) => r,
1591            None => Err("model fetch cancelled".to_string()),
1592        };
1593        match result {
1594            Ok(models) => {
1595                let n = models.len();
1596                // Keep per-model catalog pricing current (OpenRouter only;
1597                // other backends report none). Costs are computed against
1598                // this table when usage is logged. Batched into a single
1599                // transaction — hundreds of per-model writes on the UI task
1600                // would stall the interface after every catalog fetch.
1601                let prices: Vec<_> = models
1602                    .iter()
1603                    .filter_map(|m| {
1604                        m.pricing
1605                            .map(|price| (m.id.clone(), m.backend.name().to_string(), price))
1606                    })
1607                    .collect();
1608                let _ = self.db.upsert_model_prices(&prices);
1609                // Fresh catalog in hand — recompute every logged request's
1610                // cost against it (fills rows logged before pricing existed
1611                // and heals any legacy per-token-shaped catalog).
1612                let _ = self.db.backfill_usage_costs();
1613                self.models = models;
1614                self.push_status(format!("loaded {n} models"));
1615                // A session may have been opened before the catalog arrived.
1616                // Re-check it now so old, already-large conversations are
1617                // compacted without requiring another user turn.
1618                self.maybe_compact();
1619                // The 2e view layer opens the model picker here: it owns the
1620                // `popup` state, and checks `models`/`current_model` after
1621                // this handler runs.
1622            }
1623            Err(e) => self.push_status(format!("model fetch failed: {e}")),
1624        }
1625    }
1626
1627    // --- input handling ---
1628    // --- nerd config (settings popup) ---
1629
1630    /// Whether scanned PDFs should OCR through the `OpenRouter` vision model:
1631    /// explicit "vlm", or "auto" with an OCR model configured. ("local" and
1632    /// "tesseract" route elsewhere.)
1633    pub fn vlm_ocr_enabled(&self) -> bool {
1634        !self.ocr_model.trim().is_empty() && matches!(self.ocr_engine.as_str(), "vlm" | "auto")
1635    }
1636}
1637
1638impl App {
1639    /// Abort every interactive chat task before the TUI exits. Chat streams are
1640    /// intentionally not persisted or resumed across process restarts.
1641    pub fn cancel_chat_tasks(&mut self) {
1642        for task in self.chat_tasks.values() {
1643            task.abort.abort();
1644        }
1645        self.chat_tasks.clear();
1646    }
1647}
1648
1649/// Best-effort transient Linux desktop notification. The TUI notification
1650/// remains the actionable one because `notify-send` cannot route a click back
1651/// into this running process without a long-lived D-Bus action listener.
1652pub fn send_system_notification(title: &str, body: &str) {
1653    #[cfg(all(target_os = "linux", not(test)))]
1654    {
1655        let _ = std::process::Command::new("notify-send")
1656            .args([
1657                "--app-name=nexus-chat",
1658                "--urgency=normal",
1659                "--expire-time=5000",
1660                "--hint=int:transient:1",
1661                title,
1662                body,
1663            ])
1664            .spawn();
1665    }
1666    #[cfg(any(not(target_os = "linux"), test))]
1667    let _ = (title, body);
1668}
1669
1670// Long by design (tool-summary table).
1671#[allow(clippy::too_many_lines)]
1672/// The one-line transcript summary for a tool-call block: the tool's name
1673/// plus the argument (and result shape) a reader actually cares about.
1674pub fn tool_call_summary(name: &str, args: &str, result: &str) -> String {
1675    let v: serde_json::Value = serde_json::from_str(args).unwrap_or_default();
1676    let f = |k: &str| {
1677        v.get(k)
1678            .and_then(|x| x.as_str())
1679            .unwrap_or_default()
1680            .to_string()
1681    };
1682    let f_or = |first: &str, second: &str| {
1683        let value = f(first);
1684        if value.is_empty() { f(second) } else { value }
1685    };
1686    match name {
1687        "skills" => {
1688            let target = if f("action") == "install" {
1689                f("source")
1690            } else {
1691                f("name")
1692            };
1693            format!("skills/{} {}", f("action"), target)
1694        }
1695        "scripts" => {
1696            let target = match f("action").as_str() {
1697                "install" => {
1698                    let n = v
1699                        .get("packages")
1700                        .and_then(|a| a.as_array())
1701                        .map_or(0, Vec::len);
1702                    format!("{n} packages")
1703                }
1704                "python" => f("name"),
1705                _ => f("path"),
1706            };
1707            format!("scripts/{} {}", f("action"), target)
1708        }
1709        "app" => format!("app/{} {}", f("action"), f("app")),
1710        "media" => {
1711            let target = [f("video_id"), f("name"), f("image_id")]
1712                .into_iter()
1713                .find(|t| !t.is_empty())
1714                .unwrap_or_else(|| f("prompt").chars().take(30).collect());
1715            format!("media/{} {}", f("action"), target)
1716        }
1717        "skill" => format!("skill {}", f("name")),
1718        "skill_admin" => format!("skill_admin {} → {}", f("action"), first_line(result)),
1719        "search" => {
1720            let failed = result.starts_with("no results") || result.contains("failed");
1721            let hits = if failed { "no hits" } else { "hits" };
1722            format!("search/{} \"{}\" → {hits}", f("mode"), f("query"))
1723        }
1724        "research_lookup" => format!("research_lookup/{} \"{}\"", f("scope"), f("query")),
1725        "batch" => {
1726            let calls = v.get("calls").and_then(|c| c.as_array());
1727            let n = calls.map_or(0, Vec::len);
1728            let tools: Vec<&str> = calls
1729                .map(|arr| {
1730                    arr.iter()
1731                        .filter_map(|item| item.get("tool").and_then(|t| t.as_str()))
1732                        .collect()
1733                })
1734                .unwrap_or_default();
1735            format!("batch [{n} ops: {}]", tools.join(", "))
1736        }
1737        "fetch_url" => format!("fetch_url {} → {}", f("url"), first_line(result)),
1738        "files" => format!(
1739            "files/{} {} → {}",
1740            f("action"),
1741            f_or("name", "query"),
1742            first_line(result)
1743        ),
1744        "app_inspect" => format!(
1745            "app_inspect/{} {}/{}",
1746            f("action"),
1747            f("app"),
1748            f_or("path", "pattern")
1749        ),
1750        "app_modify" => format!("app_modify/{} {}/{}", f("action"), f("app"), f("path")),
1751        "app_assets" => format!("app_assets/{} {}", f("action"), f("app")),
1752        "script_files" => format!("script_files/{} {}", f("action"), f("path")),
1753        "video_transform" => format!("video_transform/{} {}", f("action"), f("video_id")),
1754        "video_references" => format!("video_references/{} {}", f("action"), f("name")),
1755        "install_skill" => format!("install_skill {} → {}", f("source"), first_line(result)),
1756        "create_skill" => format!("create_skill {} → {}", f("name"), first_line(result)),
1757        "run_script" => {
1758            let path = f_or("path", "script");
1759            if v.get("space")
1760                .and_then(serde_json::Value::as_bool)
1761                .unwrap_or(false)
1762            {
1763                format!("run_script space/{path}")
1764            } else {
1765                format!("run_script {}/{}", f("skill"), path)
1766            }
1767        }
1768        "run_python" => format!("run_python ({} lines)", f("code").lines().count().max(1)),
1769        "grep_app" => {
1770            let hits = if result.starts_with("no matches") {
1771                "no hits".to_string()
1772            } else {
1773                format!(
1774                    "{} files",
1775                    result.lines().filter(|line| !line.starts_with('…')).count()
1776                )
1777            };
1778            format!("grep_app {} \"{}\" → {hits}", f("app"), f("pattern"))
1779        }
1780        "install_packages" => {
1781            let pkgs = v
1782                .get("packages")
1783                .and_then(|a| a.as_array())
1784                .map(|a| {
1785                    a.iter()
1786                        .filter_map(|x| x.as_str())
1787                        .collect::<Vec<_>>()
1788                        .join(" ")
1789                })
1790                .unwrap_or_default();
1791            let target = [f("skill"), f("app")]
1792                .into_iter()
1793                .find(|t| !t.is_empty())
1794                .unwrap_or_default();
1795            format!("install_packages {pkgs} → {target}")
1796        }
1797        "web_search" | "search_files" => {
1798            let failed = result.starts_with("no results")
1799                || result.starts_with("no matches")
1800                || result.contains("failed");
1801            let hits = if failed {
1802                "no hits".to_string()
1803            } else {
1804                format!("{} hits", result.lines().count())
1805            };
1806            format!("{name} \"{}\" → {hits}", f("query"))
1807        }
1808        "read_file" => format!("read_file {} → {}", f("name"), first_line(result)),
1809        "read_app_file" => format!("read_app_file {}/{}", f("app"), f("path")),
1810        "diff_app" => format!("diff_app {}/{}", f("app"), f("path")),
1811        "write_file" => {
1812            format!(
1813                "write_file {}/{} ({} bytes)",
1814                f("app"),
1815                f("path"),
1816                f("content").len()
1817            )
1818        }
1819        "edit_file" => format!("edit_file {}/{}", f("app"), f("path")),
1820        "generate_video" => format!("generate_video \"{}\"", f("prompt")),
1821        "edit_video" => format!("edit_video {} {}", f("video_id"), f("lighting")),
1822        "extract_frame" => format!("extract_frame {} @ {:.1}s", f("video_id"), f("time_sec")),
1823        "stitch_videos" => format!("stitch_videos {}", f("video_ids")),
1824        "save_reference" => format!("save_reference {}", f("name")),
1825        "list_references" => "list_references".to_string(),
1826        "delete_reference" => format!("delete_reference {}", f("name")),
1827        "generate_image" => format!("generate_image \"{}\"", f("prompt")),
1828        _ => {
1829            let mut a: String = args.chars().take(60).collect();
1830            if args.chars().count() > 60 {
1831                a.push('…');
1832            }
1833            format!("{name} {a}")
1834        }
1835    }
1836}
1837
1838/// First line of a tool result, e.g. `report.pdf (lines 1-200 of 831):`.
1839fn first_line(result: &str) -> String {
1840    result
1841        .lines()
1842        .next()
1843        .unwrap_or("")
1844        .trim_end_matches(':')
1845        .to_string()
1846}