Skip to main content

ytil_agents/
agent.rs

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                // `PermissionRequest` runs before Codex falls back to user or
101                // guardian approval, so it is not a reliable "waiting for user"
102                // signal. Keep it busy to avoid false red indicators.
103                ("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    /// Parse a lowercase agent identifier.
125    ///
126    /// # Errors
127    /// Returns [`ParseError`] when `s` is not a supported agent name.
128    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}