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        // A selection points into the old session's wrapped lines — a stale
24        // one would mis-highlight the new chat and resolve links against the
25        // wrong messages; the view clears it on `ViewportReset`.
26        self.context_total = None;
27        self.push_viewport_reset();
28        self.cleanup_incognito_images();
29        self.push_status("new chat — send a message to start it".to_string());
30    }
31
32    /// Switch to a session by its id. Used by session-link navigation (Ctrl+O),
33    /// notification clicks, the `ResolveSession` command, and the session
34    /// picker's confirm (via the view layer).
35    pub fn switch_to_session_by_id(&mut self, id: &str) -> Result<()> {
36        let Some(s) = self
37            .db
38            .get_session(id)?
39            .or_else(|| self.sessions_cache.iter().find(|s| s.id == id).cloned())
40        else {
41            self.push_status(format!("session not found: {id}"));
42            return Ok(());
43        };
44        self.messages = self.db.load_messages(&s.id)?;
45        self.unread.remove(&s.id);
46        self.notifications.retain(|n| n.session_id != s.id);
47        self.push_status(format!("switched to: {}", s.title));
48        self.current_model = Some(s.model.clone());
49        self.web_mode = s.web_mode;
50        self.session = Some(s);
51        self.backfill_compaction_row();
52        self.restore_survey_gate_prompt();
53        self.refresh_toolbox();
54        self.context_total = None;
55        // Selection + scroll point into the previous session's lines; the
56        // view resets them on `ViewportReset`.
57        self.push_viewport_reset();
58        self.cleanup_incognito_images();
59        Ok(())
60    }
61}
62
63/// Best fuzzy score of `needle` against a session's title, slug, and uuid.
64pub fn session_score(s: &Session, needle: &str) -> Option<i32> {
65    use crate::app::fuzzy_score;
66    let mut best = fuzzy_score(&s.title, needle);
67    let upd = |best: &mut Option<i32>, cand: Option<i32>| {
68        if let Some(c) = cand {
69            *best = Some(best.map_or(c, |b| b.max(c)));
70        }
71    };
72    if let Some(slug) = &s.slug {
73        upd(&mut best, fuzzy_score(slug, needle).map(|v| v + 2));
74    }
75    upd(&mut best, fuzzy_score(&s.id, needle));
76    best
77}
78
79/// Parse the model's topic reply into `(topic, slug)`. Tolerates surrounding prose
80/// or code fences by extracting the first `{...}` and reading the two fields.
81pub fn parse_topic(text: &str) -> Option<(String, String)> {
82    let start = text.find('{')?;
83    let end = text.rfind('}')?;
84    let json = text.get(start..=end)?;
85    let v: serde_json::Value = serde_json::from_str(json).ok()?;
86    let topic = v.get("topic").and_then(|t| t.as_str())?.trim();
87    if topic.is_empty() {
88        return None;
89    }
90    let raw_slug = v.get("id").and_then(|s| s.as_str()).unwrap_or(topic);
91    Some((topic.to_string(), slugify(raw_slug)))
92}
93
94/// Normalise to a short kebab-case slug: lowercase, `[a-z0-9-]`, max 5 words.
95pub fn slugify(s: &str) -> String {
96    let slug = s
97        .to_lowercase()
98        .split(|c: char| !c.is_ascii_alphanumeric())
99        .filter(|w| !w.is_empty())
100        .take(5)
101        .collect::<Vec<_>>()
102        .join("-");
103    if slug.is_empty() {
104        "chat".to_string()
105    } else {
106        slug
107    }
108}