diff options
| author | Coder Wayne <1418551940@qq.com> | 2026-05-27 22:42:26 +0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-27 17:42:26 +0300 |
| commit | bfb9e756a01a8ddf4e20d2451c8316ab33454bb4 (patch) | |
| tree | 2cf90e050e5a6e4d32bbda2f13321aecb4615345 /src/detect.rs | |
| parent | 858c36974bad616e9e6c1897882b8c9a97396bec (diff) | |
feat: add Qoder CLI integration (#309)
* feat: add qodercli agent integration
Recognize qodercli as an agent in herdr panes, with basic Idle/Working/
Blocked screen-state detection mirroring the existing claude / codex
patterns. Adds an IntegrationTarget::Qodercli variant, install_target
wiring for shell hooks under src/integration/assets/qodercli/, the
herdr integration install qodercli CLI surface, and sound-override
coverage so qodercli notifications follow the same routing rules as
other agents.
refs #308
* fix: detect qodercli permission required as blocked
refs #308
* fix: detect qodercli ask-user prompt as blocked
refs #308
* refactor: extract has_qodercli_blocked_prompt helper aligning detect_claude
refs #308
* fix: tighten qodercli working state detection
Combine spinner-row strictness and explicit cancel-hint matching, and
stop matching the bare word 'working'. This avoids false positives when
narrative output (logs, commit messages, Markdown) contains the word
'working' or when a stale braille glyph is sitting in the scrollback
from a previous frame.
- has_qodercli_spinner_row: require row-start braille glyph + space +
alphabetic char so only an active spinner row triggers Working.
- has_qodercli_working_hint: match '(esc to cancel,' as a positive
signal that survives spinner masking (e.g. when a hook icon replaces
the spinner glyph).
refs #308
* fix: short-circuit qodercli press-again banners to idle
refs #308
* fix: detect qodercli AskUser dialog by its 'Asking User' title
refs #308
* feat: recognize qodercli, qoderclicn, qoder, qodercn process names
Map qodercli, qoderclicn, qoder, and qodercn process names all to the
Qodercli agent. Covers both international (qodercli/qoder) and CN
(qoderclicn/qodercn) Qoder CLI binary names.
refs #308
* feat: register qodercli hook in qoder settings.json with stdin payload
Address the integration-layer feedback on #308:
- `herdr integration install qodercli` now writes the bundled hook into
~/.qoder/hooks/herdr-agent-state.sh AND registers it in
~/.qoder/settings.json under the schema documented at
https://docs.qoder.com/zh/cli/hooks (and confirmed against the
packages/core/src/hooks/types.ts type definitions in qodercli's own
source tree). The hooks object is keyed by event name —
SessionStart / UserPromptSubmit / PreToolUse / PermissionRequest /
Stop / SessionEnd — with each entry holding a matcher and a list of
`{ type: "command", command, timeout }` invocations, mirroring how
install_claude already wires claude code's settings.json.
- The bundled assets/qodercli/herdr-agent-state.sh now reads the hook
payload from stdin (per HookInput in qodercli's types.ts:
session_id, transcript_path, cwd, hook_event_name, agent_id, ...).
No environment variable is consulted to identify the event;
SubagentStop and subagent release events return early so a recap or
away-summary frame cannot revive an idle pane, mirroring
assets/claude/herdr-agent-state.sh.
- uninstall_qodercli reverses the same set of entries while preserving
any foreign hook entries the user may have configured by hand.
- Tests cover write / idempotency / uninstall-preserves-others and
lock the stdin contract via a bundled-asset assertion.
refs #308
---------
Co-authored-by: coderwayne <jlu1318079810@163.com>
Co-authored-by: Can Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src/detect.rs')
| -rw-r--r-- | src/detect.rs | 255 |
1 files changed, 255 insertions, 0 deletions
diff --git a/src/detect.rs b/src/detect.rs index 500d6eb3..a38251ce 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -52,6 +52,7 @@ pub enum Agent { Amp, Grok, Hermes, + Qodercli, } pub fn agent_label(agent: Agent) -> &'static str { @@ -71,6 +72,7 @@ pub fn agent_label(agent: Agent) -> &'static str { Agent::Amp => "amp", Agent::Grok => "grok", Agent::Hermes => "hermes", + Agent::Qodercli => "qodercli", } } @@ -92,6 +94,7 @@ pub fn parse_agent_label(agent: &str) -> Option<Agent> { "amp" | "amp-local" => Some(Agent::Amp), "grok" | "grok-build" => Some(Agent::Grok), "hermes" | "hermes-agent" => Some(Agent::Hermes), + "qodercli" | "qoderclicn" | "qoder" | "qodercn" => Some(Agent::Qodercli), _ => None, } } @@ -117,6 +120,7 @@ pub fn identify_agent(process_name: &str) -> Option<Agent> { "amp" | "amp-local" => Some(Agent::Amp), "grok" | "grok-build" => Some(Agent::Grok), "hermes" | "hermes-agent" => Some(Agent::Hermes), + "qodercli" | "qoderclicn" | "qoder" | "qodercn" => Some(Agent::Qodercli), _ => None, } } @@ -184,6 +188,7 @@ pub fn detect_agent(agent: Option<Agent>, screen_content: &str) -> AgentDetectio Agent::Amp => detect_amp(screen_content), Agent::Grok => detect_grok(screen_content), Agent::Hermes => detect_hermes(screen_content), + Agent::Qodercli => detect_qodercli(screen_content), }; AgentDetection { state, @@ -628,6 +633,104 @@ fn has_kiro_tool_spinner(content: &str) -> bool { }) } +/// 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. +fn detect_qodercli(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_qodercli_blocked_prompt(&lower) { + 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 [`detect_claude`]: 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+2800–U+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 [`has_claude_blocked_prompt`] 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. +fn has_qodercli_blocked_prompt(lower_content: &str) -> bool { + lower_content.contains("waiting for user confirmation") + || lower_content.contains("awaiting approval") + || 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") +} + // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- @@ -2544,6 +2647,158 @@ mod tests { assert_eq!(detect_state(Some(Agent::Hermes), screen), AgentState::Idle); } + // ---- Qodercli ---- + + #[test] + fn qodercli_identified_by_process_name() { + assert_eq!(identify_agent("qodercli"), Some(Agent::Qodercli)); + assert_eq!(identify_agent("qoderclicn"), Some(Agent::Qodercli)); + assert_eq!(identify_agent("qoder"), Some(Agent::Qodercli)); + assert_eq!(identify_agent("qodercn"), Some(Agent::Qodercli)); + } + + #[test] + fn qodercli_blocked_on_confirmation() { + assert_eq!( + detect_qodercli("Waiting for user confirmation..."), + AgentState::Blocked, + ); + } + + #[test] + fn qodercli_working_on_spinner() { + assert_eq!(detect_qodercli("\u{280B} Thinking..."), AgentState::Working); + } + + #[test] + fn qodercli_idle_on_prompt() { + assert_eq!(detect_qodercli("> "), AgentState::Idle); + } + + #[test] + fn qodercli_idle_when_only_stale_braille_glyph_in_scrollback() { + // A single stray braille character in a previous output line must not + // flip the pane to Working — only an actual spinner *row* should. + let screen = "\ +agent finished a previous task.\n\ +\u{280B}\n\ +> \n"; + assert_eq!(detect_qodercli(screen), AgentState::Idle); + } + + #[test] + fn qodercli_working_on_full_spinner_row() { + // Real spinner row: braille glyph + space + alphabetic phrase. + let screen = "\u{280B} Thinking...\n"; + assert_eq!(detect_qodercli(screen), AgentState::Working); + } + + #[test] + fn qodercli_working_on_esc_to_cancel_hint() { + // The "(esc to cancel, …)" suffix is qodercli's explicit working + // marker. It must trigger Working even if a hook icon replaced the + // spinner glyph in this frame. + let screen = "Thinking... (esc to cancel, 5s)\n"; + assert_eq!(detect_qodercli(screen), AgentState::Working); + } + + #[test] + fn qodercli_idle_when_text_mentions_working_in_prose() { + // The previous heuristic treated the bare word "working" as Working, + // which produced false positives for narrative output (commits, logs, + // Markdown). The pane should remain Idle until a real working signal + // appears. + let screen = "\ +fix: keep working set warm across reloads\n\ +\n\ +> \n"; + assert_eq!(detect_qodercli(screen), AgentState::Idle); + } + + #[test] + fn qodercli_idle_override_wins_over_spinner_row() { + // While the user is holding Ctrl+C, qodercli flashes a "press again" + // banner over the prompt. The pane is effectively idle there even if + // a stale spinner row is still in the buffer. + let screen = "\ +\u{280B} Thinking...\n\ +Press Ctrl+C again to exit.\n"; + assert_eq!(detect_qodercli(screen), AgentState::Idle); + } + + #[test] + fn qodercli_idle_override_wins_over_esc_rewind() { + let screen = "Press Esc again to rewind.\n"; + assert_eq!(detect_qodercli(screen), AgentState::Idle); + } + + #[test] + fn qodercli_blocked_on_permission_required_dialog() { + // qodercli renders this dialog when a tool call needs user approval. + let screen = "\ +Permission Required\n\ +Caller: test\n\ +Command: mkdir -p /root/example\n\ +Allow once or always?\n\ + \u{276F} 1. Allow Once - allow `mkdir` for one\n\ + 2. Always allow `mkdir` for future sessions\n\ + 3. Reject and tell qodercli something\n"; + assert_eq!(detect_qodercli(screen), AgentState::Blocked); + } + + #[test] + fn qodercli_blocked_on_permission_required_alone() { + // Even when the prompt copy gets truncated by the viewport, the title + // alone should be enough to flip the pane to blocked. + assert_eq!(detect_qodercli("Permission Required"), AgentState::Blocked,); + } + + #[test] + fn qodercli_blocked_on_askuser_enter_response_placeholder() { + // qodercli's ask-user tool renders an input box with this placeholder + // when waiting for the user to type a response. + let screen = "\ +What kind of project are you working on?\n\ +> \n\ + Enter your response\n"; + assert_eq!(detect_qodercli(screen), AgentState::Blocked); + } + + #[test] + fn qodercli_blocked_on_askuser_review_tab() { + // The multi-question/multi-select review tab heading is unique to the + // ask-user dialog and means the agent is parked waiting on user input. + let screen = "\ +Review your answers:\n\ +\n\ +Project type \u{2192} Web app\n\ +Stack \u{2192} (not answered)\n"; + assert_eq!(detect_qodercli(screen), AgentState::Blocked); + } + + #[test] + fn qodercli_blocked_on_interactive_shell_waiting() { + // When qodercli spawns an interactive shell, the loading row turns + // into a "Shell awaiting input" hint until the user takes focus. + let screen = "! Shell awaiting input (Tab to focus)\n"; + assert_eq!(detect_qodercli(screen), AgentState::Blocked); + } + + #[test] + fn qodercli_blocked_on_askuser_single_choice_dialog() { + // Single-select ask-user has no "Enter your response" placeholder and + // no "Review your answers:" heading. The BaseTabDialog title + // "Asking User" is the only stable signal across every ask-user form. + let screen = "\ +Asking User\n\ +\n\ +Which framework should we use?\n\ + React\n\ + Vue\n\ + Svelte\n"; + assert_eq!(detect_qodercli(screen), AgentState::Blocked); + } + // ---- Helpers ---- #[test] |
