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