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