1use std::fmt::Display;
2use std::fmt::Formatter;
3use std::path::Path;
4use std::path::PathBuf;
5
6use strum::EnumIter;
7
8use crate::ParseError;
9
10pub mod session;
11#[cfg(not(target_arch = "wasm32"))]
12pub mod session_loader;
13pub mod session_parser;
14
15#[derive(Clone, Copy, Debug, EnumIter, Eq, Hash, Ord, PartialEq, PartialOrd)]
16pub enum Agent {
17 Claude,
18 Codex,
19 Cursor,
20 Gemini,
21 Opencode,
22}
23
24impl Agent {
25 pub const fn name(self) -> &'static str {
26 match self {
27 Self::Claude => "claude",
28 Self::Codex => "codex",
29 Self::Cursor => "cursor",
30 Self::Gemini => "gemini",
31 Self::Opencode => "opencode",
32 }
33 }
34
35 pub const fn short_name(self) -> &'static str {
36 match self {
37 Self::Claude => "cl",
38 Self::Codex => "cx",
39 Self::Cursor => "cu",
40 Self::Gemini => "gm",
41 Self::Opencode => "oc",
42 }
43 }
44
45 pub const fn default_config(self) -> &'static str {
46 match self {
47 Self::Cursor => r#"{"version":1,"hooks":{}}"#,
48 Self::Claude | Self::Codex => r#"{"hooks":{}}"#,
49 Self::Gemini => r#"{"hooksConfig":{"enabled":true},"hooks":{}}"#,
50 Self::Opencode => "{}",
51 }
52 }
53
54 pub const fn root_path(self) -> &'static [&'static str] {
55 match self {
56 Self::Claude => &[".claude"],
57 Self::Cursor => &[".cursor"],
58 Self::Codex => &[".codex"],
59 Self::Gemini => &[".gemini"],
60 Self::Opencode => &[".config", "opencode"],
61 }
62 }
63
64 pub const fn sessions_root_path(self) -> &'static [&'static str] {
65 match self {
66 Self::Claude => &[".claude", "projects"],
67 Self::Cursor => &[".cursor", "chats"],
68 Self::Codex => &[".codex", "sessions"],
69 Self::Gemini | Self::Opencode => Self::root_path(self),
70 }
71 }
72
73 pub const fn config_path(self) -> &'static [&'static str] {
74 match self {
75 Self::Claude => &[".claude", "settings.json"],
76 Self::Cursor => &[".cursor", "hooks.json"],
77 Self::Codex => &[".codex", "hooks.json"],
78 Self::Gemini => &[".gemini", "settings.json"],
79 Self::Opencode => &[],
80 }
81 }
82
83 pub const fn hook_events(self) -> &'static [(&'static str, AgentEventKind)] {
84 match self {
85 Self::Claude => &[
86 ("SessionStart", AgentEventKind::Start),
87 ("UserPromptSubmit", AgentEventKind::Busy),
88 ("Stop", AgentEventKind::Idle),
89 ("SessionEnd", AgentEventKind::Exit),
90 ],
91 Self::Cursor => &[
92 ("sessionStart", AgentEventKind::Start),
93 ("beforeSubmitPrompt", AgentEventKind::Busy),
94 ("stop", AgentEventKind::Idle),
95 ("sessionEnd", AgentEventKind::Exit),
96 ],
97 Self::Codex => &[
98 ("SessionStart", AgentEventKind::Start),
102 ("UserPromptSubmit", AgentEventKind::Busy),
103 ("PreToolUse", AgentEventKind::Busy),
104 ("PostToolUse", AgentEventKind::Busy),
105 ("PermissionRequest", AgentEventKind::Busy),
106 ("Stop", AgentEventKind::Idle),
107 ],
108 Self::Gemini => &[
109 ("SessionStart", AgentEventKind::Start),
110 ("BeforeAgent", AgentEventKind::Busy),
111 ("BeforeModel", AgentEventKind::Busy),
112 ("BeforeToolSelection", AgentEventKind::Busy),
113 ("BeforeTool", AgentEventKind::Busy),
114 ("Notification", AgentEventKind::Idle),
115 ("AfterAgent", AgentEventKind::Idle),
116 ("SessionEnd", AgentEventKind::Exit),
117 ],
118 Self::Opencode => &[],
119 }
120 }
121
122 pub fn from_name(s: &str) -> Result<Self, ParseError> {
127 match s {
128 "claude" => Ok(Self::Claude),
129 "cursor" => Ok(Self::Cursor),
130 "codex" => Ok(Self::Codex),
131 "gemini" => Ok(Self::Gemini),
132 "opencode" => Ok(Self::Opencode),
133 _ => Err(ParseError::Invalid {
134 field: "agent",
135 value: format!("{s:?}"),
136 }),
137 }
138 }
139
140 pub const fn priority(self) -> u8 {
141 match self {
142 Self::Claude => 0,
143 Self::Codex => 1,
144 Self::Cursor => 2,
145 Self::Gemini => 3,
146 Self::Opencode => 4,
147 }
148 }
149
150 pub fn detect(name: &str) -> Option<Self> {
151 let lower = name.to_ascii_lowercase();
152 if lower.contains("claude") {
153 Some(Self::Claude)
154 } else if lower.contains("cursor") {
155 Some(Self::Cursor)
156 } else if lower.contains("codex") {
157 Some(Self::Codex)
158 } else if lower.contains("gemini") {
159 Some(Self::Gemini)
160 } else if lower.contains("opencode") {
161 Some(Self::Opencode)
162 } else {
163 None
164 }
165 }
166}
167
168impl Display for Agent {
169 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
170 let repr = match self {
171 Self::Claude => "Claude",
172 Self::Codex => "Codex",
173 Self::Cursor => "Cursor",
174 Self::Gemini => "Gemini",
175 Self::Opencode => "Opencode",
176 };
177 write!(f, "{repr}")
178 }
179}
180
181#[derive(Clone, Copy, Debug, Eq, PartialEq)]
182pub enum AgentEventKind {
183 Start,
184 Busy,
185 Idle,
186 Exit,
187}
188
189impl AgentEventKind {
190 pub const fn as_str(self) -> &'static str {
191 match self {
192 Self::Start => "start",
193 Self::Busy => "busy",
194 Self::Idle => "idle",
195 Self::Exit => "exit",
196 }
197 }
198
199 pub fn parse(s: &str) -> Result<Self, ParseError> {
204 match s.trim() {
205 "start" => Ok(Self::Start),
206 "busy" => Ok(Self::Busy),
207 "idle" => Ok(Self::Idle),
208 "exit" => Ok(Self::Exit),
209 _ => Err(ParseError::Invalid {
210 field: "event kind",
211 value: format!("{s:?}"),
212 }),
213 }
214 }
215}
216
217#[derive(Clone, Debug, Eq, PartialEq)]
218pub struct AgentEventPayload {
219 pub pane_id: u32,
220 pub agent: Agent,
221 pub kind: AgentEventKind,
222}
223
224impl AgentEventPayload {
225 pub fn parse(pane_id: &str, agent: &str, payload: &str) -> Result<Self, ParseError> {
230 let pane_id = pane_id.trim().parse().map_err(|_| ParseError::Invalid {
231 field: "pane_id",
232 value: format!("{pane_id:?}"),
233 })?;
234 let agent = Agent::from_name(agent.trim())?;
235 let kind = AgentEventKind::parse(payload)?;
236 Ok(Self { pane_id, agent, kind })
237 }
238}
239
240#[derive(Clone, Copy, Debug, Eq, PartialEq)]
241pub struct AgentIcon {
242 pub cache_key: &'static str,
243}
244
245impl AgentIcon {
246 pub fn dir(home_dir: &Path) -> PathBuf {
247 home_dir.join(".cache").join("yog").join("agents")
248 }
249
250 pub fn path(self, home_dir: &Path) -> PathBuf {
251 Self::dir(home_dir).join(format!("{}.png", self.cache_key))
252 }
253}
254
255impl From<Agent> for AgentIcon {
256 fn from(agent: Agent) -> Self {
257 match agent {
258 Agent::Claude => Self { cache_key: "claude" },
259 Agent::Codex => Self { cache_key: "codex" },
260 Agent::Cursor => Self { cache_key: "cursor" },
261 Agent::Gemini => Self { cache_key: "gemini" },
262 Agent::Opencode => Self { cache_key: "opencode" },
263 }
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use rstest::rstest;
270
271 use super::*;
272
273 #[rstest]
274 #[case("claude", Ok(Agent::Claude))]
275 #[case("cursor", Ok(Agent::Cursor))]
276 #[case("codex", Ok(Agent::Codex))]
277 #[case("gemini", Ok(Agent::Gemini))]
278 #[case("opencode", Ok(Agent::Opencode))]
279 #[case("unknown", Err("invalid agent: \"unknown\"".to_string()))]
280 fn test_agent_from_name_when_name_varies_returns_expected_result(
281 #[case] name: &str,
282 #[case] expected: Result<Agent, String>,
283 ) {
284 let actual = Agent::from_name(name).map_err(|e| e.to_string());
285 pretty_assertions::assert_eq!(actual, expected);
286 }
287
288 #[rstest]
289 #[case("Claude-3.5-Sonnet", Some(Agent::Claude))]
290 #[case("Cursor-IDE", Some(Agent::Cursor))]
291 #[case("GitHub-Codex", Some(Agent::Codex))]
292 #[case("Gemini-1.5-Pro", Some(Agent::Gemini))]
293 #[case("OpenCode-Agent", Some(Agent::Opencode))]
294 #[case("Vim", None)]
295 fn test_agent_detect_when_name_varies_returns_expected_agent(#[case] name: &str, #[case] expected: Option<Agent>) {
296 pretty_assertions::assert_eq!(Agent::detect(name), expected);
297 }
298
299 #[test]
300 fn test_agent_gemini_hook_events_match_supported_lifecycle() {
301 let expected = [
302 ("SessionStart", AgentEventKind::Start),
303 ("BeforeAgent", AgentEventKind::Busy),
304 ("BeforeModel", AgentEventKind::Busy),
305 ("BeforeToolSelection", AgentEventKind::Busy),
306 ("BeforeTool", AgentEventKind::Busy),
307 ("Notification", AgentEventKind::Idle),
308 ("AfterAgent", AgentEventKind::Idle),
309 ("SessionEnd", AgentEventKind::Exit),
310 ];
311
312 pretty_assertions::assert_eq!(Agent::Gemini.hook_events(), expected);
313 }
314
315 #[test]
316 fn test_agent_codex_permission_request_remains_busy() {
317 let permission_request_kind = Agent::Codex
318 .hook_events()
319 .iter()
320 .find_map(|(event, kind)| (*event == "PermissionRequest").then_some(*kind));
321
322 pretty_assertions::assert_eq!(permission_request_kind, Some(AgentEventKind::Busy));
323 }
324
325 #[rstest]
326 #[case(Agent::Claude, "claude")]
327 #[case(Agent::Cursor, "cursor")]
328 #[case(Agent::Codex, "codex")]
329 #[case(Agent::Gemini, "gemini")]
330 #[case(Agent::Opencode, "opencode")]
331 fn test_agent_icon_from_agent_returns_agent_icon(#[case] agent: Agent, #[case] cache_key: &str) {
332 let icon = AgentIcon::from(agent);
333
334 pretty_assertions::assert_eq!(icon.cache_key, cache_key);
335 }
336
337 #[test]
338 fn test_agent_icon_path_uses_yog_agents_dir() {
339 let icon = AgentIcon::from(Agent::Codex);
340
341 pretty_assertions::assert_eq!(
342 icon.path(Path::new("/home/me")),
343 PathBuf::from("/home/me/.cache/yog/agents/codex.png")
344 );
345 }
346}