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    /// Parse an agent event payload kind.
200    ///
201    /// # Errors
202    /// Returns [`ParseError`] when `s` is not one of the supported event kinds.
203    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    /// Parse a Zellij pipe payload into a typed agent event.
226    ///
227    /// # Errors
228    /// Returns [`ParseError`] when the pane id, agent, or event kind is invalid.
229    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}