cove_cli/commands/
hook.rs1use std::fs;
14use std::io::{self, Read};
15use std::path::Path;
16
17use serde::Deserialize;
18
19use crate::cli::HookEvent;
20use crate::events;
21use crate::naming;
22use crate::tmux;
23
24#[derive(Deserialize)]
27struct HookInput {
28 session_id: String,
29 cwd: String,
30 #[serde(default)]
32 tool_name: String,
33}
34
35fn has_working_event(session_id: &str) -> bool {
38 has_working_event_in(session_id, &events::events_dir())
39}
40
41fn has_working_event_in(session_id: &str, dir: &Path) -> bool {
42 let path = dir.join(format!("{session_id}.jsonl"));
43 fs::read_to_string(path)
44 .map(|content| {
45 content
46 .lines()
47 .any(|line| line.contains(r#""state":"working""#))
48 })
49 .unwrap_or(false)
50}
51
52pub const ASKING_TOOLS: &[&str] = &["AskUserQuestion", "ExitPlanMode", "EnterPlanMode"];
56
57pub fn determine_state(event: &HookEvent, tool_name: &str) -> &'static str {
61 match event {
62 HookEvent::UserPrompt | HookEvent::AskDone | HookEvent::PostTool => "working",
63 HookEvent::Stop => "idle",
64 HookEvent::SessionEnd => "end",
65 HookEvent::Ask => "asking",
66 HookEvent::PreTool => {
67 if ASKING_TOOLS.contains(&tool_name) {
68 "asking"
69 } else {
70 "waiting"
71 }
72 }
73 }
74}
75
76pub fn run(event: HookEvent) -> Result<(), String> {
77 let mut input = String::new();
78 io::stdin()
79 .read_to_string(&mut input)
80 .map_err(|e| format!("read stdin: {e}"))?;
81
82 let hook: HookInput =
83 serde_json::from_str(&input).map_err(|e| format!("parse hook input: {e}"))?;
84
85 let state = determine_state(&event, &hook.tool_name);
86
87 if state == "idle" && !has_working_event(&hook.session_id) {
90 return Ok(());
91 }
92
93 let pane_id = std::env::var("TMUX_PANE").unwrap_or_default();
96
97 events::write_event(&hook.session_id, &hook.cwd, &pane_id, state)?;
98
99 let _ = maybe_rename_window(&hook.cwd, &pane_id);
102
103 Ok(())
104}
105
106fn maybe_rename_window(cwd: &str, pane_id: &str) -> Result<(), String> {
109 let base = tmux::get_window_option(pane_id, "@cove_base")?;
110 let expected = naming::build_window_name(&base, cwd);
111 let current = tmux::get_window_name(pane_id)?;
112
113 if current != expected {
114 tmux::rename_window(pane_id, &expected)?;
115 }
116 Ok(())
117}
118
119#[cfg(test)]
122mod tests {
123 use super::*;
124 use std::fs::OpenOptions;
125 use std::io::Write;
126
127 #[test]
128 fn test_write_event_creates_file() {
129 let dir = tempfile::tempdir().unwrap();
130 let events = dir.path().join("events");
131
132 fs::create_dir_all(&events).unwrap();
133 let path = events.join("test-session.jsonl");
134
135 let mut file = OpenOptions::new()
136 .create(true)
137 .append(true)
138 .open(&path)
139 .unwrap();
140 writeln!(file, r#"{{"state":"working","cwd":"/tmp","ts":1234}}"#).unwrap();
141
142 let content = fs::read_to_string(&path).unwrap();
143 assert!(content.contains(r#""state":"working""#));
144 assert!(content.contains(r#""cwd":"/tmp""#));
145 }
146
147 #[test]
148 fn test_has_working_event_empty() {
149 let dir = tempfile::tempdir().unwrap();
150 assert!(!has_working_event_in("no-such-session", dir.path()));
151 }
152
153 #[test]
154 fn test_has_working_event_with_working() {
155 let dir = tempfile::tempdir().unwrap();
156 let path = dir.path().join("test-session.jsonl");
157
158 fs::write(
159 &path,
160 r#"{"state":"working","cwd":"/tmp","pane_id":"%1","ts":1000}
161{"state":"idle","cwd":"/tmp","pane_id":"%1","ts":1001}
162"#,
163 )
164 .unwrap();
165
166 assert!(has_working_event_in("test-session", dir.path()));
167 }
168}