Skip to main content

whycodes_session/
title.rs

1//! Session display titles — defaults, heuristics, and sanitization.
2//!
3//! Cheap placeholder at create time,
4//! then a short human-readable title once the conversation has content.
5//! WhyCodes goes further with an instant offline heuristic before any LLM call.
6
7use std::path::Path;
8
9/// Where the current session title came from.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum TitleSource {
13    /// `{project}-{ab}` placeholder assigned at creation.
14    #[default]
15    Default,
16    /// Offline title derived from the first user message.
17    Heuristic,
18    /// Title produced by a small/fast model.
19    Generated,
20    /// User set the name (`/rename`, CLI rename, `--title`).
21    Manual,
22}
23
24impl TitleSource {
25    /// May replace with a first-message heuristic.
26    pub fn allows_heuristic(self) -> bool {
27        matches!(self, Self::Default)
28    }
29
30    /// May upgrade via a small-model title call.
31    pub fn allows_llm(self) -> bool {
32        matches!(self, Self::Default | Self::Heuristic)
33    }
34}
35
36/// Default display name: project basename + two hex chars from the session id.
37///
38/// Example: `whycodes-a3`. Short and scannable
39/// in a list of live sessions without waiting for an LLM.
40pub fn default_title(project_path: &Path, session_id: &str) -> String {
41    let base = project_basename(project_path);
42    let suffix = short_id_suffix(session_id);
43    format!("{base}-{suffix}")
44}
45
46fn project_basename(project_path: &Path) -> String {
47    project_path
48        .file_name()
49        .and_then(|s| s.to_str())
50        .map(|s| s.trim())
51        .filter(|s| !s.is_empty() && *s != "/" && *s != ".")
52        .unwrap_or("session")
53        .chars()
54        .map(|c| {
55            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
56                c
57            } else {
58                '-'
59            }
60        })
61        .collect::<String>()
62        .trim_matches('-')
63        .chars()
64        .take(32)
65        .collect::<String>()
66        .pipe(|s| if s.is_empty() { "session".into() } else { s })
67}
68
69/// Tiny helper so we can keep the basename pipeline readable.
70trait Pipe: Sized {
71    fn pipe<F, R>(self, f: F) -> R
72    where
73        F: FnOnce(Self) -> R;
74}
75
76impl<T> Pipe for T {
77    fn pipe<F, R>(self, f: F) -> R
78    where
79        F: FnOnce(Self) -> R,
80    {
81        f(self)
82    }
83}
84
85fn short_id_suffix(session_id: &str) -> String {
86    let hex: String = session_id
87        .chars()
88        .filter(|c| c.is_ascii_hexdigit())
89        .take(2)
90        .collect::<String>()
91        .to_ascii_lowercase();
92    if hex.len() == 2 { hex } else { "00".into() }
93}
94
95/// True when `title` still looks like an auto placeholder for this project.
96pub fn looks_like_default_title(title: &str, project_path: &Path) -> bool {
97    let t = title.trim();
98    if t.starts_with("New session") {
99        return true;
100    }
101    let base = project_basename(project_path);
102    // `{base}-xx` where xx is two hex digits
103    if let Some(rest) = t.strip_prefix(&format!("{base}-"))
104        && rest.len() == 2
105        && rest.chars().all(|c| c.is_ascii_hexdigit())
106    {
107        return true;
108    }
109    false
110}
111
112/// Instant offline title from the first user message.
113///
114/// Strips common filler, keeps 3–8 content words, caps length. Good enough for
115/// the session picker before (or without) an LLM refine pass.
116pub fn heuristic_title(text: &str) -> String {
117    let raw = first_meaningful_line(text);
118    let stripped = strip_leading_filler(raw);
119    let words: Vec<&str> = stripped
120        .split_whitespace()
121        .filter(|w| !w.is_empty())
122        .take(8)
123        .collect();
124    if words.is_empty() {
125        return String::new();
126    }
127    // Prefer at least 3 words when available; allow fewer for short prompts.
128    let take = words.len().clamp(1, 8);
129    let joined = words[..take].join(" ");
130    sanitize_title(&joined)
131}
132
133fn first_meaningful_line(text: &str) -> &str {
134    for line in text.lines() {
135        let t = line.trim();
136        if t.is_empty() {
137            continue;
138        }
139        // Skip pure @path lines from expansions when a real prompt follows.
140        if t.starts_with('@') && !t.contains(' ') && text.lines().count() > 1 {
141            continue;
142        }
143        return t;
144    }
145    text.trim()
146}
147
148fn strip_leading_filler(s: &str) -> &str {
149    const FILLERS: &[&str] = &[
150        "please ",
151        "pls ",
152        "can you ",
153        "could you ",
154        "would you ",
155        "help me ",
156        "i need you to ",
157        "i want you to ",
158        "hey ",
159        "hi ",
160        "hello ",
161    ];
162    let mut lower = s.to_ascii_lowercase();
163    let mut offset = 0usize;
164    let bytes = s.as_bytes();
165    loop {
166        let mut hit = false;
167        for f in FILLERS {
168            if lower.starts_with(f) {
169                offset += f.len();
170                lower = lower[f.len()..].to_string();
171                hit = true;
172                break;
173            }
174        }
175        if !hit {
176            break;
177        }
178    }
179    // Map byte offset back safely (fillers are ASCII).
180    let start = offset.min(bytes.len());
181    s.get(start..).unwrap_or(s).trim()
182}
183
184/// Normalize a model- or heuristic-produced title for storage/display.
185pub fn sanitize_title(raw: &str) -> String {
186    let mut s = raw.trim().to_string();
187    // Strip wrapping quotes the model often adds.
188    if (s.starts_with('"') && s.ends_with('"'))
189        || (s.starts_with('\'') && s.ends_with('\''))
190        || (s.starts_with('“') && s.ends_with('”'))
191    {
192        s = s[1..s.len() - 1].trim().to_string();
193    }
194    // Drop a trailing period/exclamation (keep `?` rare titles intact? drop all).
195    while s.ends_with(['.', '!', ':', ';', ',']) {
196        s.pop();
197        s = s.trim_end().to_string();
198    }
199    // Collapse whitespace / strip control chars.
200    let cleaned: String = s
201        .chars()
202        .map(|c| if c.is_control() { ' ' } else { c })
203        .collect::<String>()
204        .split_whitespace()
205        .collect::<Vec<_>>()
206        .join(" ");
207    // Hard cap — session pickers are narrow.
208    const MAX_CHARS: usize = 64;
209    let n = cleaned.chars().count();
210    if n <= MAX_CHARS {
211        cleaned
212    } else {
213        let mut out: String = cleaned.chars().take(MAX_CHARS.saturating_sub(1)).collect();
214        out.push('…');
215        out
216    }
217}
218
219/// Infer source when loading a row that predates `title_source` persistence.
220pub fn infer_source_from_title(title: &str, project_path: &Path) -> TitleSource {
221    if looks_like_default_title(title, project_path) {
222        TitleSource::Default
223    } else {
224        // Already has a real name — do not thrash it on resume.
225        TitleSource::Generated
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use std::path::PathBuf;
233
234    #[test]
235    fn default_title_uses_basename_and_id() {
236        let t = default_title(&PathBuf::from("/home/u/whycodes"), "a3f1c2d4-…");
237        assert_eq!(t, "whycodes-a3");
238    }
239
240    #[test]
241    fn heuristic_strips_filler_and_limits_words() {
242        let t = heuristic_title("Please can you fix the stripe webhook timeout in payments.rs");
243        assert!(t.to_ascii_lowercase().contains("stripe"));
244        assert!(!t.to_ascii_lowercase().starts_with("please"));
245        assert!(t.split_whitespace().count() <= 8);
246    }
247
248    #[test]
249    fn sanitize_strips_quotes() {
250        assert_eq!(sanitize_title("  \"Auth refactor\"  "), "Auth refactor");
251    }
252
253    #[test]
254    fn looks_like_default() {
255        let p = PathBuf::from("/tmp/my-app");
256        assert!(looks_like_default_title("my-app-3f", &p));
257        assert!(looks_like_default_title("New session - 2026-01-01", &p));
258        assert!(!looks_like_default_title("Fix webhook timeout", &p));
259    }
260
261    #[test]
262    fn infer_source_from_title_classifies_default_vs_named() {
263        let p = PathBuf::from("/tmp/my-app");
264        assert_eq!(
265            infer_source_from_title("my-app-3f", &p),
266            TitleSource::Default
267        );
268        assert_eq!(
269            infer_source_from_title("Fix webhook timeout", &p),
270            TitleSource::Generated
271        );
272    }
273
274    #[test]
275    fn source_gates() {
276        assert!(TitleSource::Default.allows_heuristic());
277        assert!(TitleSource::Default.allows_llm());
278        assert!(!TitleSource::Heuristic.allows_heuristic());
279        assert!(TitleSource::Heuristic.allows_llm());
280        assert!(!TitleSource::Manual.allows_llm());
281        assert!(!TitleSource::Generated.allows_llm());
282    }
283
284    #[test]
285    fn defaults_handle_unusual_paths_and_ids() {
286        assert_eq!(default_title(Path::new("/"), "x"), "session-00");
287        assert_eq!(default_title(Path::new("/tmp/!!!"), "AB"), "session-ab");
288        assert_eq!(
289            default_title(Path::new("/tmp/a name"), "--fZ3"),
290            "a-name-f3"
291        );
292        let long = "a".repeat(40);
293        assert_eq!(default_title(Path::new(&long), "12").len(), 35);
294        assert_eq!(default_title(Path::new("."), "zz"), "session-00");
295        assert_eq!(project_basename(Path::new("")), "session");
296        assert_eq!(short_id_suffix("zz"), "00");
297    }
298
299    #[test]
300    fn heuristic_handles_empty_paths_and_multiline_prompts() {
301        assert_eq!(heuristic_title(" \n\t"), "");
302        assert_eq!(
303            heuristic_title("@src/lib.rs\nPlease fix login now"),
304            "fix login now"
305        );
306        assert_eq!(heuristic_title("@only.rs"), "@only.rs");
307        assert_eq!(heuristic_title("Hello help me repair it"), "repair it");
308    }
309
310    #[test]
311    fn sanitize_handles_quote_styles_punctuation_controls_and_caps() {
312        assert_eq!(sanitize_title("'Single title'"), "Single title");
313        assert_eq!(sanitize_title("Title!!!  "), "Title");
314        assert_eq!(sanitize_title("\"Double title\""), "Double title");
315        assert_eq!(sanitize_title("a\n\tb"), "a b");
316        let long = "é".repeat(70);
317        let cleaned = sanitize_title(&long);
318        assert_eq!(cleaned.chars().count(), 64);
319        assert!(cleaned.ends_with('…'));
320    }
321
322    #[test]
323    fn default_detection_and_source_inference_cover_false_shapes() {
324        let p = PathBuf::from("/tmp/my-app");
325        assert!(!looks_like_default_title("other-3f", &p));
326        assert!(!looks_like_default_title("my-app-z9", &p));
327        assert!(!looks_like_default_title("my-app-123", &p));
328        assert_eq!(
329            infer_source_from_title("my-app-af", &p),
330            TitleSource::Default
331        );
332        assert_eq!(
333            infer_source_from_title("Useful title", &p),
334            TitleSource::Generated
335        );
336    }
337}