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