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
200#[derive(Clone, Copy, Debug, Eq, PartialEq)]
201pub struct AgentIcon {
202 pub cache_key: &'static str,
203}
204
205impl AgentIcon {
206 pub fn dir(home_dir: &Path) -> PathBuf {
207 home_dir.join(".cache").join("yog").join("agents")
208 }
209
210 pub fn path(self, home_dir: &Path) -> PathBuf {
211 Self::dir(home_dir).join(format!("{}.png", self.cache_key))
212 }
213}
214
215impl From<Agent> for AgentIcon {
216 fn from(agent: Agent) -> Self {
217 match agent {
218 Agent::Claude => Self { cache_key: "claude" },
219 Agent::Codex => Self { cache_key: "codex" },
220 Agent::Cursor => Self { cache_key: "cursor" },
221 Agent::Gemini => Self { cache_key: "gemini" },
222 Agent::Opencode => Self { cache_key: "opencode" },
223 }
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use rstest::rstest;
230 use test_that::prelude::*;
231
232 use super::*;
233
234 #[rstest]
235 #[case("claude", Ok(Agent::Claude))]
236 #[case("cursor", Ok(Agent::Cursor))]
237 #[case("codex", Ok(Agent::Codex))]
238 #[case("gemini", Ok(Agent::Gemini))]
239 #[case("opencode", Ok(Agent::Opencode))]
240 #[case("unknown", Err("invalid agent: \"unknown\"".to_string()))]
241 fn test_agent_from_name_when_name_varies_returns_expected_result(
242 #[case] name: &str,
243 #[case] expected: Result<Agent, String>,
244 ) {
245 let actual = Agent::from_name(name).map_err(|e| e.to_string());
246 assert_that!(actual, eq(expected));
247 }
248
249 #[rstest]
250 #[case("Claude-3.5-Sonnet", Some(Agent::Claude))]
251 #[case("Cursor-IDE", Some(Agent::Cursor))]
252 #[case("GitHub-Codex", Some(Agent::Codex))]
253 #[case("Gemini-1.5-Pro", Some(Agent::Gemini))]
254 #[case("OpenCode-Agent", Some(Agent::Opencode))]
255 #[case("Vim", None)]
256 fn test_agent_detect_when_name_varies_returns_expected_agent(#[case] name: &str, #[case] expected: Option<Agent>) {
257 assert_that!(Agent::detect(name), eq(expected));
258 }
259
260 #[test]
261 fn test_agent_gemini_hook_events_match_supported_lifecycle() {
262 let expected = [
263 ("SessionStart", AgentEventKind::Start),
264 ("BeforeAgent", AgentEventKind::Busy),
265 ("BeforeModel", AgentEventKind::Busy),
266 ("BeforeToolSelection", AgentEventKind::Busy),
267 ("BeforeTool", AgentEventKind::Busy),
268 ("Notification", AgentEventKind::Idle),
269 ("AfterAgent", AgentEventKind::Idle),
270 ("SessionEnd", AgentEventKind::Exit),
271 ];
272
273 assert_that!(Agent::Gemini.hook_events(), eq(expected));
274 }
275
276 #[test]
277 fn test_agent_codex_permission_request_remains_busy() {
278 let permission_request_kind = Agent::Codex
279 .hook_events()
280 .iter()
281 .find_map(|(event, kind)| (*event == "PermissionRequest").then_some(*kind));
282
283 assert_that!(permission_request_kind, eq(Some(AgentEventKind::Busy)));
284 }
285
286 #[rstest]
287 #[case(Agent::Claude, "claude")]
288 #[case(Agent::Cursor, "cursor")]
289 #[case(Agent::Codex, "codex")]
290 #[case(Agent::Gemini, "gemini")]
291 #[case(Agent::Opencode, "opencode")]
292 fn test_agent_icon_from_agent_returns_agent_icon(#[case] agent: Agent, #[case] cache_key: &str) {
293 let icon = AgentIcon::from(agent);
294
295 assert_that!(icon.cache_key, eq(cache_key));
296 }
297
298 #[test]
299 fn test_agent_icon_path_uses_yog_agents_dir() {
300 let icon = AgentIcon::from(Agent::Codex);
301
302 assert_that!(
303 icon.path(Path::new("/home/me")),
304 eq(PathBuf::from("/home/me/.cache/yog/agents/codex.png"))
305 );
306 }
307}