aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-21 04:00:57 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-21 04:00:57 +0300
commita5bf77f6e5f43ccea56f608fb6eab90dab47025e (patch)
tree472f5b44ca9aa700d050eeac65d5a2da137b303a
parent3769dcbb323d600dd40166413c46deb0894d1f4a (diff)
fix: detect cursor agent sessions
refs #225
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--docs/next/README.md3
-rw-r--r--docs/next/website/src/content/docs/agents.mdx3
-rw-r--r--src/detect.rs107
4 files changed, 95 insertions, 19 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index ae7e16c5..f9b4cc87 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -6,6 +6,7 @@
- Added `experimental.reveal_hidden_cursor_for_cjk_ime = false` (opt-in), `experimental.cjk_ime_agents = []` (optional allow-list), and `experimental.cjk_ime_cursor_shape = "steady_block"` to expose the focused pane's cursor anchor to the outer terminal even when the pane requested `?25l`, restoring macOS IME candidate-window tracking for TUIs that paint their own cursor (Claude Code, pi, codex). When `cjk_ime_agents` is non-empty, the reveal applies only to focused panes whose detected agent matches one of the listed names. When the pane reports no cursor position, the anchor falls back to the pane's top-left so a stable IME hint is always available. Trade-off when enabled: an extra hardware cursor may appear in the outer terminal for apps that hide the cursor without painting a replacement. (#149, thanks @ChihGodlee)
### Fixed
+- Cursor Agent sessions launched as `cursor-agent` are now detected, and their terminal UI now reports working and blocked states in the Agents panel. (#225)
- `herdr --remote` now offers to restart the remote server after installing or replacing a remote binary, or when the running server version differs, even if the client/server protocol is still compatible.
## [0.6.0] - 2026-05-20
diff --git a/docs/next/README.md b/docs/next/README.md
index 2e659930..d2198fb4 100644
--- a/docs/next/README.md
+++ b/docs/next/README.md
@@ -179,9 +179,10 @@ automatic detection works out of the box. process name matching plus terminal ou
| [opencode](https://github.com/anomalyco/opencode) | ✓ | ✓ | ✓ |
| [grok cli](https://x.ai/grok) | ✓ | ✓ | ✓ |
| [hermes agent](https://github.com/NousResearch/hermes-agent) | ✓ | ✓ | ✓ |
+| cursor agent | ✓ | ✓ | ✓ |
| [kiro cli](https://kiro.dev/docs/cli/) | ✓ | ✓ | — |
-detected but not fully tested: gemini cli, cursor agent, cline, kimi, github copilot cli.
+detected but not fully tested: gemini cli, cline, kimi, github copilot cli.
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/docs/next/website/src/content/docs/agents.mdx b/docs/next/website/src/content/docs/agents.mdx
index f1499021..979286d7 100644
--- a/docs/next/website/src/content/docs/agents.mdx
+++ b/docs/next/website/src/content/docs/agents.mdx
@@ -19,10 +19,11 @@ Automatic detection works out of the box for common coding agents.
| OpenCode | yes | yes | yes |
| Grok CLI | yes | yes | yes |
| Hermes Agent | yes | yes | yes |
+| Cursor Agent | yes | yes | yes |
| Kiro CLI | yes | yes | no |
| GitHub Copilot CLI | yes | yes | yes |
-Detected but less thoroughly tested: Gemini CLI, Cursor Agent, Cline, and Kimi.
+Detected but less thoroughly tested: Gemini CLI, Cline, and Kimi.
Unsupported agents still run normally as terminal processes. They just may not get rich state unless you add an integration or report state over the socket API.
diff --git a/src/detect.rs b/src/detect.rs
index 4f4cfbe1..b428968a 100644
--- a/src/detect.rs
+++ b/src/detect.rs
@@ -61,7 +61,7 @@ pub fn parse_agent_label(agent: &str) -> Option<Agent> {
"claude" | "claude-code" => Some(Agent::Claude),
"codex" => Some(Agent::Codex),
"gemini" => Some(Agent::Gemini),
- "cursor" => Some(Agent::Cursor),
+ "cursor" | "cursor-agent" => Some(Agent::Cursor),
"cline" => Some(Agent::Cline),
"opencode" | "open-code" => Some(Agent::OpenCode),
"copilot" | "github-copilot" | "ghcs" => Some(Agent::GithubCopilot),
@@ -85,7 +85,7 @@ pub fn identify_agent(process_name: &str) -> Option<Agent> {
"claude" | "claude-code" => Some(Agent::Claude),
"codex" => Some(Agent::Codex),
"gemini" => Some(Agent::Gemini),
- "cursor" => Some(Agent::Cursor),
+ "cursor" | "cursor-agent" => Some(Agent::Cursor),
"cline" => Some(Agent::Cline),
"opencode" | "open-code" => Some(Agent::OpenCode),
"copilot" | "github-copilot" | "ghcs" => Some(Agent::GithubCopilot),
@@ -265,14 +265,7 @@ fn detect_cursor(content: &str) -> AgentState {
let lower = content.to_lowercase();
// Blocked
- if lower.contains("(y) (enter)")
- || lower.contains("keep (n)")
- || lower.contains("skip (esc or n)")
- {
- return AgentState::Blocked;
- }
- // "allow ...(y)" or "run ...(y)" patterns
- if lower.contains("(y)") && (lower.contains("allow") || lower.contains("run")) {
+ if has_cursor_blocked_prompt(content, &lower) {
return AgentState::Blocked;
}
@@ -654,15 +647,59 @@ fn has_codex_working_header(content: &str) -> bool {
})
}
-/// Cursor spinner: ⬡ or ⬢ followed by a word ending in "ing"
+fn has_cursor_blocked_prompt(content: &str, lower: &str) -> bool {
+ if lower.contains("waiting for approval") || lower.contains("run this command?") {
+ return true;
+ }
+
+ if lower.contains("(y) (enter)")
+ || lower.contains("keep (n)")
+ || lower.contains("skip (esc or n)")
+ {
+ return true;
+ }
+
+ content.lines().any(|line| {
+ let line = line.trim().to_lowercase();
+ let has_yes_action = line.contains("(y)");
+ has_yes_action
+ && (line.contains("allow")
+ || line.contains("run (once)")
+ || line.contains("→ run")
+ || line.starts_with("run "))
+ })
+}
+
+/// Cursor status line: spinner glyphs followed by a live action label.
fn has_cursor_spinner(content: &str) -> bool {
- for line in content.lines() {
- let trimmed = line.trim();
- if (trimmed.starts_with('⬡') || trimmed.starts_with('⬢')) && trimmed.contains("ing") {
- return true;
+ content.lines().any(|line| {
+ let trimmed = line.trim_start();
+ let mut chars = trimmed.chars();
+ let Some(first) = chars.next() else {
+ return false;
+ };
+ let rest = chars.as_str().trim_start();
+
+ if matches!(first, '⬡' | '⬢') {
+ return cursor_status_word_is_active(rest);
}
- }
- false
+
+ if ('\u{2800}'..='\u{28FF}').contains(&first) {
+ let rest = rest.trim_start_matches(|c| ('\u{2800}'..='\u{28FF}').contains(&c));
+ return cursor_status_word_is_active(rest.trim_start());
+ }
+
+ false
+ })
+}
+
+fn cursor_status_word_is_active(rest: &str) -> bool {
+ let Some(word) = rest.split_whitespace().next() else {
+ return false;
+ };
+ word.trim_end_matches(|c: char| !c.is_alphabetic())
+ .to_ascii_lowercase()
+ .ends_with("ing")
}
fn has_opencode_question_prompt(content: &str) -> bool {
@@ -802,6 +839,7 @@ mod tests {
assert_eq!(identify_agent("codex"), Some(Agent::Codex));
assert_eq!(identify_agent("gemini"), Some(Agent::Gemini));
assert_eq!(identify_agent("cursor"), Some(Agent::Cursor));
+ assert_eq!(identify_agent("cursor-agent"), Some(Agent::Cursor));
assert_eq!(identify_agent("cline"), Some(Agent::Cline));
assert_eq!(identify_agent("opencode"), Some(Agent::OpenCode));
assert_eq!(identify_agent("kimi"), Some(Agent::Kimi));
@@ -819,6 +857,7 @@ mod tests {
fn parse_known_agent_labels() {
assert_eq!(parse_agent_label("pi"), Some(Agent::Pi));
assert_eq!(parse_agent_label("claude"), Some(Agent::Claude));
+ assert_eq!(parse_agent_label("cursor-agent"), Some(Agent::Cursor));
assert_eq!(parse_agent_label("copilot"), Some(Agent::GithubCopilot));
assert_eq!(
parse_agent_label("github-copilot"),
@@ -1241,6 +1280,35 @@ mod tests {
}
#[test]
+ fn cursor_working_braille_status() {
+ assert_eq!(
+ detect_cursor("⠠⠜ Running 5.52k tokens"),
+ AgentState::Working
+ );
+ assert_eq!(
+ detect_cursor("⠞ Working 5.62k tokens"),
+ AgentState::Working
+ );
+ assert_eq!(
+ detect_cursor("⠛ Grepping 1.2k tokens"),
+ AgentState::Working
+ );
+ }
+
+ #[test]
+ fn cursor_blocked_command_approval() {
+ let screen =
+ "Waiting for approval...\nRun this command?\n→ Run (once) (y)\nSkip (esc or n)";
+ assert_eq!(detect_cursor(screen), AgentState::Blocked);
+ }
+
+ #[test]
+ fn cursor_running_text_with_unrelated_yes_is_not_blocked() {
+ let screen = "previous answer mentioned (y)\n⠠⠜ Running 5.52k tokens";
+ assert_eq!(detect_cursor(screen), AgentState::Working);
+ }
+
+ #[test]
fn cursor_working_ctrl_c() {
assert_eq!(
detect_cursor("processing\nctrl+c to stop"),
@@ -1659,12 +1727,17 @@ mod tests {
fn cursor_spinner_detected() {
assert!(has_cursor_spinner("⬡ Grepping.."));
assert!(has_cursor_spinner("⬢ Reading…"));
+ assert!(has_cursor_spinner("⠠⠜ Running 5.52k tokens"));
+ assert!(has_cursor_spinner("⠞ Working 5.62k tokens"));
+ assert!(has_cursor_spinner("⠛ Grepping 1.2k tokens"));
+ assert!(has_cursor_spinner("⠛ Analyzing 1.2k tokens"));
}
#[test]
fn cursor_spinner_not_false_positive() {
assert!(!has_cursor_spinner("normal text"));
assert!(!has_cursor_spinner("some ⬡ in middle"));
+ assert!(!has_cursor_spinner("⠛ Read notes"));
}
// ---- Process identification (real PTY) ----