Skip to main content

tj_core/
title.rs

1//! Human-readable title derivation for auto-opened tasks.
2//!
3//! When the journal auto-opens a task it has only a raw chat chunk to name it
4//! with. Early versions took the first non-empty line verbatim — which, at
5//! session start, is often terminal scrollback (`685] INFO: Mapped {…}`), a
6//! shell prompt (`user@host:~$ …`), or the journal's own resume banner
7//! (`[Task Journal resumed: …]`). Those leak into the task list and the
8//! Claude Code session name.
9//!
10//! `humanize_title` scans for the first line that looks like natural-language
11//! intent and returns it cleaned + truncated. When nothing qualifies it
12//! returns `None` so the caller declines to auto-open rather than label a task
13//! with machine noise.
14
15/// Pick the first natural-language line from `raw` as a task title.
16/// Returns `None` when the input is only logs / banners / shell prompts / JSON.
17pub fn humanize_title(raw: &str) -> Option<String> {
18    intent_line(raw).map(|l| truncate(l, 80))
19}
20
21/// Like [`humanize_title`] but truncated to `max` chars — used for the task
22/// goal, which tolerates a longer sentence than the 80-char title.
23pub fn humanize_goal(raw: &str, max: usize) -> Option<String> {
24    intent_line(raw).map(|l| truncate(l, max))
25}
26
27/// First line of `raw` that reads like a human wrote it on purpose.
28fn intent_line(raw: &str) -> Option<&str> {
29    raw.lines().map(str::trim).find(|l| is_human_intent(l))
30}
31
32fn is_human_intent(line: &str) -> bool {
33    let l = line.trim();
34    if l.chars().count() < 6 {
35        return false;
36    }
37    // Slash command, shell line, markdown heading, JSON/array/tag, log timestamp.
38    if let Some(c) = l.chars().next() {
39        if matches!(c, '/' | '$' | '#' | '{' | '[' | '<' | '|' | '`') {
40            return false;
41        }
42    }
43    if l.starts_with("http://") || l.starts_with("https://") {
44        return false;
45    }
46    if l.contains("Task Journal resumed") {
47        return false;
48    }
49    if looks_like_log(l) || looks_like_shell_prompt(l) {
50        return false;
51    }
52    // Real intent: has letters and at least two whitespace-separated words.
53    l.chars().any(char::is_alphabetic) && l.split_whitespace().count() >= 2
54}
55
56/// `685] INFO: Mapped …`, `… INFO: …`, `… ERROR: …` etc.
57fn looks_like_log(l: &str) -> bool {
58    // "<digits>] " near the start (a numeric log index).
59    if let Some(rb) = l.find(']') {
60        if rb > 0 && rb <= 8 && l[..rb].chars().all(|c| c.is_ascii_digit()) {
61            return true;
62        }
63    }
64    const LEVELS: [&str; 5] = ["INFO:", "WARN:", "ERROR:", "DEBUG:", "TRACE:"];
65    LEVELS.iter().any(|lvl| l.contains(lvl))
66}
67
68/// `shahinyanm@DESKTOP-KM9V32O:~/docker-local-env$ claude …`
69fn looks_like_shell_prompt(l: &str) -> bool {
70    let Some(at) = l.find('@') else {
71        return false;
72    };
73    if at >= 40 {
74        return false;
75    }
76    let after = &l[at + 1..];
77    after.contains(":~") || after.contains(":/") || after.contains("$ ") || after.contains("# ")
78}
79
80/// Char-safe truncate with an ellipsis when cut.
81fn truncate(s: &str, max: usize) -> String {
82    if s.chars().count() <= max {
83        return s.to_string();
84    }
85    let cut: String = s.chars().take(max.saturating_sub(1)).collect();
86    format!("{}…", cut.trim_end())
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn rejects_numeric_log_line() {
95        assert_eq!(
96            humanize_title("685] INFO: Mapped {/rest-api/paymentlnk-notify, POST} route"),
97            None
98        );
99    }
100
101    #[test]
102    fn rejects_timestamped_log_line() {
103        assert_eq!(
104            humanize_title("[11:13:30.685] INFO: Mapped {/rest-api/qiwi-notify, POST}"),
105            None
106        );
107    }
108
109    #[test]
110    fn rejects_resume_banner() {
111        assert_eq!(
112            humanize_title("[Task Journal resumed: tj-ma8393mg0d — Так, давай ты сделаешь match]"),
113            None
114        );
115    }
116
117    #[test]
118    fn rejects_shell_prompt() {
119        assert_eq!(
120            humanize_title(
121                "shahinyanm@DESKTOP-KM9V32O:~/docker-local-env$ claude plugin marketplace update"
122            ),
123            None
124        );
125    }
126
127    #[test]
128    fn rejects_json_and_paths() {
129        assert_eq!(humanize_title("{\"command\":\"task-journal\"}"), None);
130        assert_eq!(humanize_title("/home/shahinyanm/www/claude-memory"), None);
131    }
132
133    #[test]
134    fn accepts_plain_prose() {
135        assert_eq!(
136            humanize_title("Fix the auth bug in the payment middleware"),
137            Some("Fix the auth bug in the payment middleware".to_string())
138        );
139    }
140
141    #[test]
142    fn accepts_russian_prose() {
143        assert_eq!(
144            humanize_title("Сделай так чтобы имя сессии не ломалось"),
145            Some("Сделай так чтобы имя сессии не ломалось".to_string())
146        );
147    }
148
149    #[test]
150    fn picks_first_human_line_after_noise() {
151        let raw = "685] INFO: Mapped {/x, POST}\n\
152                   shahinyanm@host:~$ ls\n\
153                   Add validation for negative order amounts";
154        assert_eq!(
155            humanize_title(raw),
156            Some("Add validation for negative order amounts".to_string())
157        );
158    }
159
160    #[test]
161    fn truncates_long_title_to_80_chars() {
162        let long = "Implement ".repeat(20);
163        let out = humanize_title(&long).unwrap();
164        assert!(
165            out.chars().count() <= 80,
166            "got {} chars",
167            out.chars().count()
168        );
169        assert!(out.ends_with('…'));
170    }
171
172    #[test]
173    fn none_when_only_noise() {
174        let raw = "685] INFO: a\n$ git status\n/clear\n{\"k\":1}";
175        assert_eq!(humanize_title(raw), None);
176    }
177
178    #[test]
179    fn goal_allows_longer_text() {
180        let line = "Make sure every coding session always records its reasoning chain \
181                    into the task journal without spawning a model";
182        let goal = humanize_goal(line, 200).unwrap();
183        assert!(goal.chars().count() > 80);
184        assert!(goal.starts_with("Make sure every coding session"));
185    }
186}