herdr/src/detect/agents/qodercli.rs

106 lines
4.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use super::super::AgentState;
/// Qodercli detection.
///
/// Qodercli is a Node.js coding-agent CLI. It surfaces a confirmation prompt
/// while awaiting tool approval and a braille spinner while working.
pub(super) fn detect(content: &str) -> AgentState {
let lower = content.to_lowercase();
// Idle short-circuit: double-press confirmation hints render *over* the
// input prompt while the user briefly holds Ctrl+C / Esc. The pane is
// effectively idle there — without this, a stale spinner row above could
// still flip it to Working.
if has_qodercli_idle_override(&lower) {
return AgentState::Idle;
}
if has_visible_blocker(content) {
return AgentState::Blocked;
}
// Working: explicit "(esc to cancel, …)" hint or an active spinner row.
if has_qodercli_working_hint(&lower) || has_qodercli_spinner_row(content) {
return AgentState::Working;
}
AgentState::Idle
}
/// Idle override hints. Mirrors the `⌕ Search…` / `ctrl+r to toggle` shortcut
/// in Claude detection: when these UI bits are visible the pane is sitting at
/// a static prompt and should not be classified as Working or Blocked.
///
/// Covers qodercli's "press again" exit/rewind banners.
fn has_qodercli_idle_override(lower_content: &str) -> bool {
lower_content.contains("press ctrl+c again to exit")
|| lower_content.contains("press ctrl+d again to exit")
|| lower_content.contains("press esc again to rewind")
}
/// Working hints qodercli prints alongside the spinner while the model is
/// responding. The "(esc to cancel, …)" suffix is unique to qodercli's loading
/// indicator and survives even when the spinner glyph is masked (e.g. by
/// a hook icon).
fn has_qodercli_working_hint(lower_content: &str) -> bool {
lower_content.contains("(esc to cancel,")
}
/// Strict spinner-row detection for qodercli.
///
/// Matches a line whose first non-whitespace glyph is a braille pattern
/// (U+2800U+28FF, the cli-spinners "dots" set qodercli renders), followed by
/// a space and at least one alphabetic character on the same line. This avoids
/// flagging the pane as Working when the scrollback merely contains a stale
/// braille glyph from an earlier frame.
fn has_qodercli_spinner_row(content: &str) -> bool {
for line in content.lines() {
let trimmed = line.trim_start();
let mut chars = trimmed.chars();
let Some(first) = chars.next() else {
continue;
};
if !('\u{2800}'..='\u{28FF}').contains(&first) {
continue;
}
let rest: String = chars.collect();
if rest.starts_with(' ') && rest.chars().any(|c| c.is_alphabetic()) {
return true;
}
}
false
}
/// Blocked patterns specific to qodercli.
///
/// Mirrors the helper structure used by Claude blocked prompt matching so the
/// pattern surface stays a single, easy-to-extend list.
///
/// Covered states:
/// * Tool-call confirmation banners ("Waiting for user confirmation",
/// "Awaiting approval").
/// * The "Permission Required / Allow once or always?" approval dialog.
/// * The `ask-user` tool's interactive prompt. "Asking User" is the dialog's
/// stable BaseTabDialog title and covers every form (single-select,
/// multi-select, free-form input, review tab). The "Enter your response"
/// placeholder and "Review your answers:" review heading are kept as
/// defensive fallbacks in case the title row scrolls off-screen.
/// * The interactive shell waiting hint emitted by qodercli when an agent
/// spawns a shell that is now parked for user keystrokes.
pub(super) fn has_visible_blocker(content: &str) -> bool {
let lower_content = content.to_lowercase();
(lower_content.contains("waiting for user confirmation")
&& (lower_content.contains("yes")
|| lower_content.contains("no")
|| lower_content.contains("allow")
|| lower_content.contains("reject")))
|| (lower_content.contains("awaiting approval")
&& (lower_content.contains("allow") || lower_content.contains("reject")))
|| lower_content.contains("permission required")
|| lower_content.contains("allow once or always?")
|| lower_content.contains("asking user")
|| lower_content.contains("enter your response")
|| lower_content.contains("review your answers:")
|| lower_content.contains("shell awaiting input")
}