Skip to main content

nexus_core/app/
sessions.rs

1use anyhow::Result;
2
3use super::App;
4use crate::db::Session;
5
6impl App {
7    pub fn activate_notification(&mut self, index: usize) -> Result<()> {
8        let Some(notification) = self.notifications.remove(index) else {
9            return Ok(());
10        };
11        self.switch_to_session_by_id(&notification.session_id)
12    }
13
14    // --- commands ---
15
16    /// Clear back to a blank conversation. Doesn't touch the db — a session
17    /// row is only created lazily on the first message actually sent (same as
18    /// the very first message of the app), so `/new` without typing anything
19    /// doesn't leave an empty "new chat" behind in the session list.
20    pub fn new_session(&mut self) {
21        self.session = None;
22        self.messages.clear();
23        self.refresh_memory_snapshot();
24        // A selection points into the old session's wrapped lines — a stale
25        // one would mis-highlight the new chat and resolve links against the
26        // wrong messages; the view clears it on `ViewportReset`.
27        self.context_total = None;
28        self.push_viewport_reset();
29        self.cleanup_incognito_images();
30        self.push_status("new chat — send a message to start it".to_string());
31    }
32
33    /// Switch to a session by its id. Used by session-link navigation (Ctrl+O),
34    /// notification clicks, the `ResolveSession` command, and the session
35    /// picker's confirm (via the view layer).
36    pub fn switch_to_session_by_id(&mut self, id: &str) -> Result<()> {
37        let Some(s) = self
38            .db
39            .get_session(id)?
40            .or_else(|| self.sessions_cache.iter().find(|s| s.id == id).cloned())
41        else {
42            self.push_status(format!("session not found: {id}"));
43            return Ok(());
44        };
45        self.messages = self.db.load_messages(&s.id)?;
46        self.unread.remove(&s.id);
47        self.notifications.retain(|n| n.session_id != s.id);
48        self.push_status(format!("switched to: {}", s.title));
49        self.current_model = Some(s.model.clone());
50        self.web_mode = s.web_mode;
51        self.session = Some(s);
52        self.refresh_memory_snapshot();
53        self.backfill_compaction_row();
54        self.restore_survey_gate_prompt();
55        self.refresh_toolbox();
56        self.context_total = None;
57        // Selection + scroll point into the previous session's lines; the
58        // view resets them on `ViewportReset`.
59        self.push_viewport_reset();
60        self.cleanup_incognito_images();
61        // Opening an old session should be enough to trigger auto-compaction
62        // once its catalog/context window is available. `on_models_result`
63        // repeats this check when the model fetch races the session switch.
64        self.maybe_compact();
65        Ok(())
66    }
67}
68
69/// Best fuzzy score of `needle` against a session's title, slug, and uuid.
70pub fn session_score(s: &Session, needle: &str) -> Option<i32> {
71    use crate::app::fuzzy_score;
72    let mut best = fuzzy_score(&s.title, needle);
73    let upd = |best: &mut Option<i32>, cand: Option<i32>| {
74        if let Some(c) = cand {
75            *best = Some(best.map_or(c, |b| b.max(c)));
76        }
77    };
78    if let Some(slug) = &s.slug {
79        upd(&mut best, fuzzy_score(slug, needle).map(|v| v + 2));
80    }
81    upd(&mut best, fuzzy_score(&s.id, needle));
82    best
83}
84
85/// Parse the model's topic reply into `(topic, slug)`. Tolerates surrounding prose
86/// or code fences by extracting the first `{...}` and reading the two fields.
87pub fn parse_topic(text: &str) -> Option<(String, String)> {
88    let start = text.find('{')?;
89    let end = text.rfind('}')?;
90    let json = text.get(start..=end)?;
91    let v: serde_json::Value = serde_json::from_str(json).ok()?;
92    let topic = v.get("topic").and_then(|t| t.as_str())?.trim();
93    if topic.is_empty() {
94        return None;
95    }
96    let raw_slug = v.get("id").and_then(|s| s.as_str()).unwrap_or(topic);
97    Some((topic.to_string(), slugify(raw_slug)))
98}
99
100/// Normalise to a short kebab-case slug: lowercase, `[a-z0-9-]`, max 5 words.
101pub fn slugify(s: &str) -> String {
102    let slug = s
103        .to_lowercase()
104        .split(|c: char| !c.is_ascii_alphanumeric())
105        .filter(|w| !w.is_empty())
106        .take(5)
107        .collect::<Vec<_>>()
108        .join("-");
109    if slug.is_empty() {
110        "chat".to_string()
111    } else {
112        slug
113    }
114}