diff options
| author | LaneBirmingham <150401101+LaneBirmingham@users.noreply.github.com> | 2026-05-23 10:12:56 +1000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-23 03:12:56 +0300 |
| commit | 5954ece9f7e13346a9ae6e7bcabda307280c26ea (patch) | |
| tree | f080af869b1b8c43eb946aca08636ec0287b51c4 | |
| parent | cbcf0e6f5ad3ed81fa96caa528cbef3b37145ac7 (diff) | |
feat: add state detection heuristics for GitHub Copilot CLI (#256)
* feat: add github copilot cli state detection
Add robust state detection heuristics for the GitHub Copilot CLI by matching specific interactive prompt combinations, freeform inputs, and thinking status frames.
refs #232
* docs: keep copilot docs staged for next release
---------
Co-authored-by: Can Celik <ogulcancelik@gmail.com>
| -rw-r--r-- | docs/next/README.md | 3 | ||||
| -rw-r--r-- | src/detect.rs | 203 | ||||
| -rw-r--r-- | src/platform/macos.rs | 1 |
3 files changed, 182 insertions, 25 deletions
diff --git a/docs/next/README.md b/docs/next/README.md index 60921cf5..63a32ca7 100644 --- a/docs/next/README.md +++ b/docs/next/README.md @@ -182,9 +182,10 @@ automatic detection works out of the box. process name matching plus terminal ou | cursor agent | ✓ | ✓ | ✓ | | antigravity cli | ✓ | ✓ | ✓ | | kimi code cli | ✓ | ✓ | ✓ | +| [github copilot cli](https://github.com/features/copilot) | ✓ | ✓ | ✓ | | [kiro cli](https://kiro.dev/docs/cli/) | ✓ | ✓ | — | -detected but not fully tested: gemini cli, cline, github copilot cli. +detected but not fully tested: gemini cli, cline. for agents outside the built-in list, herdr still works as a terminal multiplexer with workspaces, panes, and tiling. custom integrations can report agent labels over the socket api. see the [socket api docs](https://herdr.dev/docs/socket-api/). diff --git a/src/detect.rs b/src/detect.rs index e50c3ad9..38ec562b 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -370,15 +370,19 @@ fn detect_github_copilot(content: &str) -> AgentState { let lower = content.to_lowercase(); // Blocked - if lower.contains("│ do you want") { - return AgentState::Blocked; - } - if lower.contains("confirm with") && lower.contains("enter") { + if lower.contains("esc to cancel") + && (lower.contains("enter to select") + || lower.contains("enter to confirm") + || lower.contains("enter to submit")) + { return AgentState::Blocked; } // Working - if lower.contains("esc to cancel") { + if lower.contains("esc to cancel") + || lower.contains("esc cancel") + || lower.contains("esc again to cancel") + { return AgentState::Working; } @@ -2024,34 +2028,185 @@ mod tests { // ---- GitHub Copilot ---- #[test] - fn copilot_waiting_confirm() { + fn copilot_waiting_fetch_approval() { + let content = "\ +╭───────────────────────────────────────────────────────────────────╮ +│ Fetch web content │ +│ ───────────────────────────────────────────────────────────────── │ +│ Copilot is attempting to access the following URL: │ +│ │ +│ ╭───────────────────────────────────────────────────────────────╮ │ +│ │ https://www.google.com/ │ │ +│ ╰───────────────────────────────────────────────────────────────╯ │ +│ │ +│ Do you want to allow this access? │ +│ │ +│ ❯ 1. Yes │ +│ 2. No │ +│ │ +│ ↑/↓ to navigate · enter to select · esc to cancel │ +╰───────────────────────────────────────────────────────────────────╯"; + assert_eq!(detect_github_copilot(content), AgentState::Blocked); + } + + #[test] + fn copilot_waiting_allow_directory_access() { + let content = "\ +╭──────────────────────────────────────────────────────────────────╮ +│ Allow directory access │ +│ ──────────────────────────────────────────────────────────────── │ +│ This action may read or write the following path outside your │ +│ allowed directory list. │ +│ │ +│ ╭──────────────────────────────────────────────────────────────╮ │ +│ │ /Users/user/Dev/workspace │ │ +│ ╰──────────────────────────────────────────────────────────────╯ │ +│ │ +│ Do you want to allow this? │ +│ │ +│ 1. Yes │ +│ ❯ 2. Yes, and add these directories to the allowed list │ +│ 3. No (Esc) │ +│ │ +│ ↑/↓ to navigate · enter to select · esc to cancel │ +╰──────────────────────────────────────────────────────────────────╯"; + assert_eq!(detect_github_copilot(content), AgentState::Blocked); + } + + #[test] + fn copilot_waiting_directory_permission() { + let content = "\ +○ Asking user Requesting permission to access directory 'src/' for r… + +╭───────────────────────────────────────────────────────────────────╮ +│ Question │ +│ ───────────────────────────────────────────────────────────────── │ +│ Requesting permission to access directory 'src/' for reading │ +│ files. Allow? │ +│ │ +│ ❯ 1. Yes (Allow) │ +│ 2. No (Deny) │ +│ │ +│ ↑/↓ to select · enter to confirm · esc to cancel │ +╰───────────────────────────────────────────────────────────────────╯"; + assert_eq!(detect_github_copilot(content), AgentState::Blocked); + } + + #[test] + fn copilot_waiting_action_confirmation() { + let content = "\ +○ Asking user Confirm action: 'Reset local changes in working tree' … + +╭───────────────────────────────────────────────────────────────────╮ +│ Question │ +│ ───────────────────────────────────────────────────────────────── │ +│ Confirm action: 'Reset local changes in working tree' — proceed? │ +│ │ +│ ❯ 1. Yes, reset changes │ +│ 2. No, cancel │ +│ │ +│ ↑/↓ to select · enter to confirm · esc to cancel │ +╰───────────────────────────────────────────────────────────────────╯"; + assert_eq!(detect_github_copilot(content), AgentState::Blocked); + } + + #[test] + fn copilot_waiting_db_choice() { + let content = "\ +○ Asking user Choose a database for the project: + +╭───────────────────────────────────────────────────────────────────╮ +│ Question │ +│ ───────────────────────────────────────────────────────────────── │ +│ Choose a database for the project: │ +│ │ +│ ❯ 1. PostgreSQL (Recommended) │ +│ 2. SQLite │ +│ 3. MySQL │ +│ 4. MongoDB │ +│ │ +│ ↑/↓ to select · enter to confirm · esc to cancel │ +╰───────────────────────────────────────────────────────────────────╯"; + assert_eq!(detect_github_copilot(content), AgentState::Blocked); + } + + #[test] + fn copilot_waiting_freeform_input() { + let content = "\ +○ Asking user Enter the name for the new branch: + +╭───────────────────────────────────────────────────────────────────╮ +│ Question │ +│ ───────────────────────────────────────────────────────────────── │ +│ Enter the name for the new branch: │ +│ │ +│ ❯ Type your answer... │ +│ │ +│ enter to submit · esc to cancel │ +╰───────────────────────────────────────────────────────────────────╯"; + assert_eq!(detect_github_copilot(content), AgentState::Blocked); + } + + #[test] + fn copilot_waiting_plan_review() { + let content = "\ +○ Plan ready for review - Create a 200-word inspirational poem. - Fi… + +╭───────────────────────────────────────────────────────────────────╮ +│ Plan Ready for Review │ +│ ───────────────────────────────────────────────────────────────── │ +│ - Create a 200-word inspirational poem. │ +│ - Files/changes: none (deliver poem in chat). │ +│ - Steps: draft poem, verify ~200 words, offer up to 2 revisions. │ +│ - Decision: Inspirational tone, exact length target ~200 words. │ +│ │ +│ ❯ 1. Accept plan and build on default permissions (recommended) │ +│ 2. Exit plan mode and I will prompt myself │ +│ 3. Suggest changes │ +│ │ +│ ↑/↓ to navigate · enter to select · ctrl+e to show full plan · │ +│ esc to cancel │ +╰───────────────────────────────────────────────────────────────────╯"; + assert_eq!(detect_github_copilot(content), AgentState::Blocked); + } + + #[test] + fn copilot_waiting_enable_autopilot() { + let content = "\ +╭───────────────────────────────────────────────────────────────────╮ +│ Enable autopilot mode │ +│ ───────────────────────────────────────────────────────────────── │ +│ Autopilot mode works best with all permissions enabled. Without │ +│ them, permission requests will be auto-denied and the agent may │ +│ not complete tasks requiring file edits or shell commands. │ +│ │ +│ You can also enable permissions later with /allow-all │ +│ │ +│ ❯ 1. Enable all permissions (recommended) │ +│ 2. Continue with limited permissions │ +│ 3. Cancel (Esc) │ +│ │ +│ ↑/↓ to navigate · enter to select · esc to cancel │ +╰───────────────────────────────────────────────────────────────────╯"; + assert_eq!(detect_github_copilot(content), AgentState::Blocked); + } + + #[test] + fn copilot_working_thinking_spinner() { assert_eq!( - detect_github_copilot("confirm with enter"), - AgentState::Blocked + detect_github_copilot("○ Thinking esc cancel"), + AgentState::Working ); - } - - #[test] - fn copilot_waiting_do_you_want() { assert_eq!( - detect_github_copilot("│ do you want to apply?"), - AgentState::Blocked + detect_github_copilot("◎ Thinking esc cancel"), + AgentState::Working ); - } - - #[test] - fn copilot_working() { assert_eq!( - detect_github_copilot("generating\nesc to cancel"), + detect_github_copilot("◉ Thinking esc cancel"), AgentState::Working ); } - #[test] - fn copilot_idle() { - assert_eq!(detect_github_copilot("> "), AgentState::Idle); - } - // ---- Kimi ---- #[test] diff --git a/src/platform/macos.rs b/src/platform/macos.rs index add51aa4..bfce664a 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -103,6 +103,7 @@ pub fn foreground_process_group_id(pid: u32) -> Option<u32> { let fg = info.e_tpgid; if fg > 0 { + #[allow(clippy::unnecessary_cast)] // info.e_tpgid (pid_t) type is platform-dependent Some(fg as u32) } else { None |
