diff --git a/supacodeTests/AgentClassifierTests.swift b/supacodeTests/AgentClassifierTests.swift index b960b0e2..95028d8d 100644 --- a/supacodeTests/AgentClassifierTests.swift +++ b/supacodeTests/AgentClassifierTests.swift @@ -24,6 +24,7 @@ struct AgentClassifierTests { #expect(identifyAgent(processName: "amp") == .amp) #expect(identifyAgent(processName: "amp-local") == .amp) #expect(identifyAgent(processName: "qwen") == .qwen) + #expect(identifyAgent(processName: "qodercli")?.rawValue == "qodercli") #expect(identifyAgent(processName: "grok") == .grok) #expect(identifyAgent(processName: "grok-0.2.101-macos-aarch64") == .grok) // Model ids must not be treated as the install binary. @@ -228,6 +229,24 @@ struct AgentClassifierTests { #expect(result.name == "codex") } + @Test func identifiesQoderCLIWrappedByNode() throws { + let job = ForegroundJob( + processGroupID: 42, + processes: [ + ForegroundProcess( + pid: 100, + name: "node", + argv0: "node", + cmdline: "node /opt/homebrew/lib/node_modules/@qoder-ai/qodercli/bundle/qodercli.js" + ) + ] + ) + + let result = try #require(identifyAgentInJob(job)) + #expect(result.agent.rawValue == "qodercli") + #expect(result.name == "qodercli") + } + @Test func identifiesOmxAsCodexWrapper() throws { let job = ForegroundJob( processGroupID: 42, diff --git a/supacodeTests/CommandIconMapTests.swift b/supacodeTests/CommandIconMapTests.swift index 0ea6674f..821998b3 100644 --- a/supacodeTests/CommandIconMapTests.swift +++ b/supacodeTests/CommandIconMapTests.swift @@ -78,6 +78,7 @@ struct CommandIconMapTests { #expect(CommandIconMap.iconForFirstToken("cline")?.assetName == "Cline") #expect(CommandIconMap.iconForFirstToken("droid")?.assetName == "Droid") #expect(CommandIconMap.iconForFirstToken("qwen")?.assetName == "Qwen") + #expect(CommandIconMap.iconForFirstToken("qodercli")?.systemSymbol == "sparkle") #expect(CommandIconMap.iconForFirstToken("grok")?.assetName == "Grok") // Aider has no bundled brand asset — sparkle fallback only. #expect(CommandIconMap.iconForFirstToken("aider")?.systemSymbol == "sparkle") diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index f4a26630..eeb8a4fb 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -541,6 +541,61 @@ struct ScreenHeuristicsTests { #expect(DetectedAgent.qwen.detectState(in: "> Type your message") == .idle) } + @Test func qoderCLIDetection() throws { + let qoder = try #require(DetectedAgent(rawValue: "qodercli")) + + #expect( + qoder.detectState( + in: """ + Bash(git status) needs permission + > Allow once + Allow for this session + Reject and type something + No + """ + ) == .blocked + ) + #expect( + qoder.detectState( + in: "⠋ Thinking... (esc to cancel, 12s)" + ) == .working + ) + #expect(qoder.detectState(in: "Allow once\nNo") == .idle) + #expect( + qoder.detectState( + in: """ + > Allow once + Allow for this session + Reject and type something + No + Permission resolved. + Current prompt is ready. + Use /help for commands. + Workspace: Prowl + Mode: default + Model: Qoder + ? for shortcuts + ❯ + """ + ) == .idle + ) + #expect( + qoder.detectState( + in: """ + ⠋ Thinking... (esc to cancel, 12s) + Previous task completed. + Current prompt is ready. + Use /help for commands. + Workspace: Prowl + Mode: default + Model: Qoder + ? for shortcuts + ❯ + """ + ) == .idle + ) + } + @Test func grokDetection() { // Permission chrome (labels verified against Grok Build 0.2.101 binary). #expect( -- 2.51.2 From a7fa4eb5a53957ea7856c08457004035e18246da Mon Sep 17 00:00:00 2001 From: onevtail Date: Sat, 18 Jul 2026 19:39:42 +0900 Subject: [PATCH 2/5] feat: detect qodercli agents --- .../Domain/AgentDetection/DetectedAgent.swift | 1 + .../Terminal/Models/CommandIconMap.swift | 1 + .../AgentDetection/AgentClassifier.swift | 2 + .../AgentDetection/AgentSessionProfile.swift | 1 + .../AgentDetection/ScreenHeuristics.swift | 42 +++++++++++++++++++ 5 files changed, 47 insertions(+) diff --git a/supacode/Domain/AgentDetection/DetectedAgent.swift b/supacode/Domain/AgentDetection/DetectedAgent.swift index ae8f59cc..d3dd1b02 100644 --- a/supacode/Domain/AgentDetection/DetectedAgent.swift +++ b/supacode/Domain/AgentDetection/DetectedAgent.swift @@ -13,6 +13,7 @@ enum DetectedAgent: String, CaseIterable, Equatable, Identifiable, Sendable { case kimi case droid case amp + case qoder = "qodercli" case qwen case grok diff --git a/supacode/Features/Terminal/Models/CommandIconMap.swift b/supacode/Features/Terminal/Models/CommandIconMap.swift index c6d1601c..3ede5243 100644 --- a/supacode/Features/Terminal/Models/CommandIconMap.swift +++ b/supacode/Features/Terminal/Models/CommandIconMap.swift @@ -44,6 +44,7 @@ enum CommandIconMap { "gemini": TabIconSource(systemSymbol: "sparkle", assetName: "Gemini"), "kimi": TabIconSource(systemSymbol: "sparkle", assetName: "Kimi"), "opencode": TabIconSource(systemSymbol: "sparkle", assetName: "OpenCode"), + "qodercli": TabIconSource(systemSymbol: "sparkle"), "qwen": TabIconSource(systemSymbol: "sparkle", assetName: "Qwen"), "grok": TabIconSource(systemSymbol: "sparkle", assetName: "Grok"), "omp": TabIconSource(systemSymbol: "sparkle", assetName: "OMP"), diff --git a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift index 59e18020..e27e177e 100644 --- a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift +++ b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift @@ -25,6 +25,8 @@ func identifyAgent(processName: String) -> DetectedAgent? { return .droid case "amp", "amp-local": return .amp + case "qodercli": + return .qoder case "qwen": return .qwen case "grok": diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift index 104d0211..844a92fb 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift @@ -48,6 +48,7 @@ nonisolated struct AgentSessionProfile: Sendable { case .droid: .droid case .opencode: .opencode case .amp: .amp + case .qoder: .init() case .qwen: .qwen case .grok: .grok } diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index b10e6b04..aea3e18b 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -28,6 +28,8 @@ extension DetectedAgent { return detectDroid(screen) case .amp: return detectAmp(screen) + case .qoder: + return detectQoder(screen) case .qwen: return detectQwen(screen) case .grok: @@ -518,6 +520,46 @@ nonisolated private func detectQwen(_ content: String) -> AgentRawState { return .idle } +// Qoder CLI 1.0.48 renders a permission menu with the three option labels +// below, while an active turn uses a braille spinner footer ending in +// "(esc to cancel, )". Restrict both checks to the active tail so +// completed transcript text does not keep a pane blocked or working. +nonisolated private func detectQoder(_ content: String) -> AgentRawState { + let currentInteraction = qoderCurrentInteractionRegion(content) + if hasQoderPermissionPrompt(currentInteraction) { + return .blocked + } + if hasQoderWorkingFooter(currentInteraction) { + return .working + } + return .idle +} + +nonisolated private func qoderCurrentInteractionRegion(_ content: String) -> String { + content + .split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .suffix(8) + .joined(separator: "\n") +} + +nonisolated private func hasQoderPermissionPrompt(_ content: String) -> Bool { + let lower = content.lowercased() + return lower.contains("allow once") + && lower.contains("allow for this session") + && lower.contains("reject and type something") +} + +nonisolated private func hasQoderWorkingFooter(_ content: String) -> Bool { + content.split(separator: "\n", omittingEmptySubsequences: false).contains { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard let first = trimmed.unicodeScalars.first else { return false } + return (0x2800...0x28FF).contains(Int(first.value)) + && trimmed.lowercased().contains("(esc to cancel,") + } +} + // Grok Build (verified 0.2.101): cancel mid-turn is Ctrl+C (Esc is a no-op). // Approval dialogs pair numbered yes-rows ("Yes, proceed", "Yes, allow all // edits during this session") with a reject row ("No, reject (type to add -- 2.51.2 From 3bad4979c76c3f1135810b687d4f223c5b1179e3 Mon Sep 17 00:00:00 2001 From: onevtail Date: Sat, 18 Jul 2026 19:40:08 +0900 Subject: [PATCH 3/5] docs: list qodercli agent detection --- docs/components/agent-detection.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index bb1416cf..a45ca3c0 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -17,8 +17,8 @@ running and what state it's in. That signal drives the ## Agents it recognizes Claude (Claude Code), Codex, Gemini, Cursor, Cline, OpenCode, GitHub Copilot, -Kimi, Droid, Amp, Pi (`pi`), Oh My Pi (`omp`, `oh-my-pi`), Qwen Code (`qwen`), -and Grok Build (`grok`). +Kimi, Droid, Amp, Pi (`pi`), Oh My Pi (`omp`, `oh-my-pi`), Qoder CLI (`qodercli`), +Qwen Code (`qwen`), and Grok Build (`grok`). Detection covers common wrappers (node, python, bun, bash, etc.) so agents launched indirectly are still found. Oh My Pi reuses Pi-derived screen heuristics but uses its own command -- 2.51.2 From d446fbe5dee8a7c11dea9286beeb6c9aa1e64fb7 Mon Sep 17 00:00:00 2001 From: onevtail Date: Sat, 18 Jul 2026 19:44:17 +0900 Subject: [PATCH 4/5] fix: simplify qodercli classification --- .../Infrastructure/AgentDetection/AgentClassifier.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift index e27e177e..d451f9d3 100644 --- a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift +++ b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift @@ -25,10 +25,8 @@ func identifyAgent(processName: String) -> DetectedAgent? { return .droid case "amp", "amp-local": return .amp - case "qodercli": - return .qoder - case "qwen": - return .qwen + case "qodercli", "qwen": + return DetectedAgent(rawValue: lower) case "grok": return .grok default: -- 2.51.2 From b9945c5fcdb0a4a21e142e252031f5357a28b003 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 20 Jul 2026 01:05:21 +0900 Subject: [PATCH 5/5] Harden Qoder CLI detection against live 1.0.48 behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against a live qodercli 1.0.48 session driven through Prowl: - Blocked: the permission menu's second row is dynamic ("Always allow \"\" for future sessions" for exec/MCP; "Allow for this session" only for edits), so requiring all three fixed labels missed every exec permission prompt. Match the invariant "Allow once" + "Reject and type something" pair instead, and also detect the ask-user question dialog ("Asking User" + "Type Something") and the plan-ready dialog ("Yes, start executing" + "Reject plan"), which were not covered at all. - Drop the 8-line tail window: Qoder dialogs are ephemeral overlays that vanish once answered (verified live), and the window left only one line of slack under the working spinner's six persistent footer rows. - Add a real session profile: ~/.qoder/projects// .jsonl is Claude Code's layout under a different root, so `prowl agents` now resolves Qoder session id + transcript path. - Replace the qodercli/qwen rawValue-roundtrip in the classifier with a name→agent lookup table (also resolves the cyclomatic-complexity lint that motivated the trick). - Rewrite heuristic tests around screens captured from the live session. End-to-end verified in a debug Prowl instance: `prowl agents --json` reports type "qodercli" with idle → working → done transitions and blocked on a real exec permission menu, plus session resolution with confidence "high". --- .../AgentDetection/AgentClassifier.swift | 61 +++++----- .../AgentDetection/AgentSessionProfile.swift | 13 +- .../AgentDetection/ScreenHeuristics.swift | 40 ++++--- supacodeTests/AgentClassifierTests.swift | 4 +- supacodeTests/AgentSessionProfileTests.swift | 19 +++ supacodeTests/ScreenHeuristicsTests.swift | 112 ++++++++++++------ 6 files changed, 156 insertions(+), 93 deletions(-) diff --git a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift index d451f9d3..c23a5a9d 100644 --- a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift +++ b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift @@ -1,43 +1,36 @@ import Foundation +/// Known agent install/launcher binary names (lowercased). Aliases map to the +/// same agent; extend this table when onboarding a new CLI. +private let knownAgentBinaries: [String: DetectedAgent] = [ + "pi": .pi, "omp": .pi, "oh-my-pi": .pi, + "claude": .claude, "claude-code": .claude, + "codex": .codex, "omx": .codex, "oh-my-codex": .codex, + "gemini": .gemini, + "cursor": .cursor, "cursor-agent": .cursor, + "cline": .cline, + "opencode": .opencode, "open-code": .opencode, + "copilot": .copilot, "github-copilot": .copilot, "ghcs": .copilot, + "kimi": .kimi, "kimi code": .kimi, + "droid": .droid, + "amp": .amp, "amp-local": .amp, + "qodercli": .qoder, + "qwen": .qwen, + "grok": .grok, +] + func identifyAgent(processName: String) -> DetectedAgent? { let lower = processName.lowercased() - switch lower { - case "pi", "omp", "oh-my-pi": - return .pi - case "claude", "claude-code": - return .claude - case "codex", "omx", "oh-my-codex": - return .codex - case "gemini": - return .gemini - case "cursor", "cursor-agent": - return .cursor - case "cline": - return .cline - case "opencode", "open-code": - return .opencode - case "copilot", "github-copilot", "ghcs": - return .copilot - case "kimi", "kimi code": - return .kimi - case "droid": - return .droid - case "amp", "amp-local": - return .amp - case "qodercli", "qwen": - return DetectedAgent(rawValue: lower) - case "grok": + if let agent = knownAgentBinaries[lower] { + return agent + } + // Versioned install binary only, e.g. `grok-0.2.101-macos-aarch64`. + // Must not match model-id tokens like `grok-4` / `grok-4.5` that show up + // as argv fragments of other agents (score-40 wrapped-runtime candidates). + if isGrokVersionedBinaryName(lower) { return .grok - default: - // Versioned install binary only, e.g. `grok-0.2.101-macos-aarch64`. - // Must not match model-id tokens like `grok-4` / `grok-4.5` that show up - // as argv fragments of other agents (score-40 wrapped-runtime candidates). - if isGrokVersionedBinaryName(lower) { - return .grok - } - return nil } + return nil } /// Install packages are named `grok---` (verified diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift index 844a92fb..03b0a5aa 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift @@ -48,7 +48,7 @@ nonisolated struct AgentSessionProfile: Sendable { case .droid: .droid case .opencode: .opencode case .amp: .amp - case .qoder: .init() + case .qoder: .qoder case .qwen: .qwen case .grok: .grok } @@ -201,6 +201,17 @@ nonisolated extension AgentSessionProfile { } ) + /// Qoder CLI ≥ 1.0.48: `~/.qoder/projects//.jsonl`, + /// Claude Code's layout under a different root — every non-alphanumeric cwd + /// character becomes `-`. Verified against a local 1.0.48 install. + fileprivate static let qoder = AgentSessionProfile( + parsePath: { uuidJSONL(path: $0, marker: "/.qoder/projects/") }, + candidateRoots: { home, cwd, _, _ in + guard let cwd else { return [] } + return [home.appending(path: ".qoder/projects/\(alphanumericDashed(cwd.path))")] + } + ) + /// OpenCode ≥ 1.2: sessions live in the shared sqlite database /// `~/.local/share/opencode/opencode.db` (`session.directory` = plain cwd); /// the TUI holds no per-session file, so store rows stand in for candidate diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index aea3e18b..f93086d6 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -520,35 +520,37 @@ nonisolated private func detectQwen(_ content: String) -> AgentRawState { return .idle } -// Qoder CLI 1.0.48 renders a permission menu with the three option labels -// below, while an active turn uses a braille spinner footer ending in -// "(esc to cancel, )". Restrict both checks to the active tail so -// completed transcript text does not keep a pane blocked or working. +// Qoder CLI (verified 1.0.48, live session): blocked dialogs are ephemeral +// overlays that vanish once answered, so full-window matching is safe. +// Permission menus always pair "Allow once" with "Reject and type something" +// (the second row varies by tool: "Allow for this session" for edits, +// "Always allow \"\" for future sessions" for exec/MCP). Ask-user +// questions render an "Asking User" header with a fixed "Type Something" +// row; the plan-ready dialog pairs "Yes, start executing" with "Reject +// plan". An active turn shows a braille spinner footer ending in +// "(esc to cancel, )". Multi-token matches only — single labels +// like "Allow once" or "No" can appear in transcript text. nonisolated private func detectQoder(_ content: String) -> AgentRawState { - let currentInteraction = qoderCurrentInteractionRegion(content) - if hasQoderPermissionPrompt(currentInteraction) { + let lower = content.lowercased() + if hasQoderPermissionMenu(lower) || hasQoderQuestionPrompt(lower) || hasQoderPlanReadyPrompt(lower) { return .blocked } - if hasQoderWorkingFooter(currentInteraction) { + if hasQoderWorkingFooter(content) { return .working } return .idle } -nonisolated private func qoderCurrentInteractionRegion(_ content: String) -> String { - content - .split(separator: "\n", omittingEmptySubsequences: false) - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - .suffix(8) - .joined(separator: "\n") +nonisolated private func hasQoderPermissionMenu(_ lower: String) -> Bool { + lower.contains("allow once") && lower.contains("reject and type something") } -nonisolated private func hasQoderPermissionPrompt(_ content: String) -> Bool { - let lower = content.lowercased() - return lower.contains("allow once") - && lower.contains("allow for this session") - && lower.contains("reject and type something") +nonisolated private func hasQoderQuestionPrompt(_ lower: String) -> Bool { + lower.contains("asking user") && lower.contains("type something") +} + +nonisolated private func hasQoderPlanReadyPrompt(_ lower: String) -> Bool { + lower.contains("yes, start executing") && lower.contains("reject plan") } nonisolated private func hasQoderWorkingFooter(_ content: String) -> Bool { diff --git a/supacodeTests/AgentClassifierTests.swift b/supacodeTests/AgentClassifierTests.swift index 95028d8d..aa24f4da 100644 --- a/supacodeTests/AgentClassifierTests.swift +++ b/supacodeTests/AgentClassifierTests.swift @@ -24,7 +24,7 @@ struct AgentClassifierTests { #expect(identifyAgent(processName: "amp") == .amp) #expect(identifyAgent(processName: "amp-local") == .amp) #expect(identifyAgent(processName: "qwen") == .qwen) - #expect(identifyAgent(processName: "qodercli")?.rawValue == "qodercli") + #expect(identifyAgent(processName: "qodercli") == .qoder) #expect(identifyAgent(processName: "grok") == .grok) #expect(identifyAgent(processName: "grok-0.2.101-macos-aarch64") == .grok) // Model ids must not be treated as the install binary. @@ -243,7 +243,7 @@ struct AgentClassifierTests { ) let result = try #require(identifyAgentInJob(job)) - #expect(result.agent.rawValue == "qodercli") + #expect(result.agent == .qoder) #expect(result.name == "qodercli") } diff --git a/supacodeTests/AgentSessionProfileTests.swift b/supacodeTests/AgentSessionProfileTests.swift index 76dca0b6..c9f14051 100644 --- a/supacodeTests/AgentSessionProfileTests.swift +++ b/supacodeTests/AgentSessionProfileTests.swift @@ -34,6 +34,25 @@ struct AgentSessionProfileTests { #expect(roots.map(\.path) == ["/Users/me/.claude/projects/-private-tmp-prowl556-review----caf-"]) } + @Test func qoderReusesClaudeLayoutUnderQoderRoot() { + // Verified against Qoder CLI 1.0.48: ~/.qoder/projects//.jsonl. + let profile = AgentSessionProfile.profile(for: .qoder) + let cwd = URL(fileURLWithPath: "/Users/me/Sync/github/Prowl", isDirectory: true) + #expect( + profile.candidateRoots(home, cwd, now, now).map(\.path) + == ["/Users/me/.qoder/projects/-Users-me-Sync-github-Prowl"] + ) + #expect(profile.candidateRoots(home, nil, now, now).isEmpty) + let id = "40e2d9c9-dca4-4969-a9a0-56fb423e58e6" + let path = "/Users/me/.qoder/projects/-Users-me-Sync-github-Prowl/\(id).jsonl" + let parsed = profile.parsePath(path) + #expect(parsed?.id == id) + #expect(parsed?.transcriptPath?.path == path) + // Sibling checkpoint directories and foreign roots must not parse. + #expect(profile.parsePath("/Users/me/.qoder/projects/-Users-me-Sync-github-Prowl/\(id)") == nil) + #expect(profile.parsePath("/Users/me/.claude/projects/-Users-me-Sync-github-Prowl/\(id).jsonl") == nil) + } + @Test func piAndDroidRootsKeepDotsAndSpaces() { let cwd = URL(fileURLWithPath: "/Users/me/.prowl/repos/My App", isDirectory: true) let piRoots = AgentSessionProfile.profile(for: .pi).candidateRoots(home, cwd, now, now) diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index eeb8a4fb..8c9a0fae 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -541,59 +541,97 @@ struct ScreenHeuristicsTests { #expect(DetectedAgent.qwen.detectState(in: "> Type your message") == .idle) } - @Test func qoderCLIDetection() throws { - let qoder = try #require(DetectedAgent(rawValue: "qodercli")) - + @Test func qoderCLIDetection() { + // Exec permission menu captured from a live Qoder CLI 1.0.48 session; the + // second row is dynamic ("Always allow \"\" for future sessions"), + // so detection must not require "Allow for this session". + #expect( + DetectedAgent.qoder.detectState( + in: """ + Permission Required + Tool: Bash + Command: echo hello > /tmp/qoder_perm_test.txt + Allow this command to run? Redirection detected. + ❯ 1. Allow once + 2. Always allow "echo" for future sessions [local] + 3. Reject and type something + 4. No + """ + ) == .blocked + ) + // Edit-style menu keeps the session-scoped row. #expect( - qoder.detectState( + DetectedAgent.qoder.detectState( in: """ - Bash(git status) needs permission - > Allow once - Allow for this session - Reject and type something - No + ❯ 1. Allow once + 2. Allow for this session + 3. Reject and type something + 4. No """ ) == .blocked ) + // Ask-user question dialog (live capture): dynamic options plus the fixed + // "Type Something" row under an "Asking User" header. #expect( - qoder.detectState( - in: "⠋ Thinking... (esc to cancel, 12s)" - ) == .working + DetectedAgent.qoder.detectState( + in: """ + Asking User + Which color do you prefer? + ❯ 1. Red + 2. Blue + 3. Type Something + ↑↓ navigate · Enter select · Esc back + """ + ) == .blocked ) - #expect(qoder.detectState(in: "Allow once\nNo") == .idle) + // Plan-ready dialog (live capture). #expect( - qoder.detectState( + DetectedAgent.qoder.detectState( in: """ - > Allow once - Allow for this session - Reject and type something - No - Permission resolved. - Current prompt is ready. - Use /help for commands. - Workspace: Prowl - Mode: default - Model: Qoder - ? for shortcuts - ❯ + Qoder has written up a plan and is ready to execute. Would you like to proceed? + ❯ 1. Yes, start executing + 2. Yes, execute as Goal (auto) + 3. Refuse and say something + 4. Reject plan + Ctrl+X to edit plan """ - ) == .idle + ) == .blocked ) + // Working footer stays detected with the full input-box chrome below it + // (live capture: 6 persistent footer lines under the spinner). #expect( - qoder.detectState( + DetectedAgent.qoder.detectState( in: """ - ⠋ Thinking... (esc to cancel, 12s) - Previous task completed. - Current prompt is ready. - Use /help for commands. - Workspace: Prowl - Mode: default - Model: Qoder - ? for shortcuts - ❯ + ⠹ Thinking... (esc to cancel, 2s) + ───────────────────────────── + Shift+Tab to Accept Edits + ───────────────────────────── + > Type your message or @path/to/file + ───────────────────────────── + Ultimate Model · ctx ░░░░░░░░░░ 0% · ~/Sync/github/Prowl + """ + ) == .working + ) + // Resolved dialogs vanish entirely (ephemeral overlays); the transcript + // only keeps the tool row, so a finished screen is idle. + #expect( + DetectedAgent.qoder.detectState( + in: """ + x Bash(echo hello > /tmp/qoder_perm_test.txt) + └ User cancelled. + ───────────────────────────── + Shift+Tab to Accept Edits + ───────────────────────────── + > Type your message or @path/to/file + ───────────────────────────── + Ultimate Model · ctx ▓▓░░░░░░░░ 17% · ~/Sync/github/Prowl """ ) == .idle ) + // Single labels in prose must not block; a quoted footer without the + // braille spinner must not read as working. + #expect(DetectedAgent.qoder.detectState(in: "Allow once\nNo") == .idle) + #expect(DetectedAgent.qoder.detectState(in: "Thinking... (esc to cancel, 12s)") == .idle) } @Test func grokDetection() {