diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 4a3223dbb383..3593e3ebe2b9 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -19506,6 +19506,8 @@ struct CMUXCLI { launchCommand: launchCommand, isRestorable: false, agentLifecycle: shouldPromoteActiveSession ? .running : .unknown, + runtimeStatus: shouldPromoteActiveSession ? .running : nil, + updateRuntimeStatus: shouldPromoteActiveSession, markActive: shouldPromoteActiveSession, turnId: parsedInput.turnId ) @@ -19553,7 +19555,7 @@ struct CMUXCLI { client: client, workspaceId: workspaceId, surfaceId: surfaceId, - value: "Running", + value: String(localized: "agent.generic.status.running", defaultValue: "Running"), icon: "bolt.fill", color: "#4C8DFF", pid: claudePid @@ -19618,6 +19620,10 @@ struct CMUXCLI { agentLifecycle: .idle, lastSubtitle: completion?.subtitle, lastBody: completion?.body, + lastNotificationStatus: .idle, + updateLastNotificationStatus: true, + runtimeStatus: .idle, + updateRuntimeStatus: true, markActive: true, allowsNewSessionReplacement: true ) @@ -19644,7 +19650,7 @@ struct CMUXCLI { client: client, workspaceId: workspaceId, surfaceId: surfaceId, - value: "Idle", + value: String(localized: "agent.generic.notification.status.idle", defaultValue: "Idle"), icon: "pause.circle.fill", color: "#8E8E93" ) @@ -19718,6 +19724,8 @@ struct CMUXCLI { transcriptPath: parsedInput.transcriptPath, isRestorable: true, agentLifecycle: .running, + runtimeStatus: .running, + updateRuntimeStatus: true, markActive: true, turnId: parsedInput.turnId ) @@ -19744,7 +19752,7 @@ struct CMUXCLI { client: client, workspaceId: workspaceId, surfaceId: surfaceId, - value: "Running", + value: String(localized: "agent.generic.status.running", defaultValue: "Running"), icon: "bolt.fill", color: "#4C8DFF" ) @@ -19786,6 +19794,14 @@ struct CMUXCLI { summary.body.contains("needs your attention") || summary.body.contains("needs your input") { summary = (subtitle: mappedSession.lastSubtitle ?? summary.subtitle, body: savedBody) } + if shouldSuppressClaudeIdlePromptNotification( + parsedInput: parsedInput, + mappedSession: mappedSession + ) { + telemetry.breadcrumb("claude-hook.notification.idle-prompt-suppressed") + print("{}") + return + } let surfaceId = try resolvePreferredSurfaceIdForClaudeHook( preferred: mappedSession?.surfaceId, @@ -19809,7 +19825,11 @@ struct CMUXCLI { transcriptPath: parsedInput.transcriptPath, agentLifecycle: .needsInput, lastSubtitle: summary.subtitle, - lastBody: summary.body + lastBody: summary.body, + lastNotificationStatus: .needsInput, + updateLastNotificationStatus: true, + runtimeStatus: .needsInput, + updateRuntimeStatus: true ) } @@ -19824,7 +19844,10 @@ struct CMUXCLI { client: client, workspaceId: workspaceId, surfaceId: surfaceId, - value: "Needs input", + value: String.localizedStringWithFormat( + String(localized: "agent.generic.notification.status.needsInput", defaultValue: "%@ needs input"), + title + ), icon: "bell.fill", color: "#4C8DFF" ) @@ -20001,7 +20024,7 @@ struct CMUXCLI { let toolStatus = describeToolUse(parsedInput.object) { statusValue = toolStatus } else { - statusValue = "Running" + statusValue = String(localized: "agent.generic.status.running", defaultValue: "Running") } try setClaudeStatus( client: client, @@ -20202,6 +20225,39 @@ struct CMUXCLI { return source.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "clear" } + private func shouldSuppressClaudeIdlePromptNotification( + parsedInput: ClaudeHookParsedInput, + mappedSession: ClaudeHookSessionRecord? + ) -> Bool { + guard isIdlePromptNotification(parsedInput) else { + return false + } + return mappedSession?.runtimeStatus == .idle + } + + private func isIdlePromptNotification(_ parsedInput: ClaudeHookParsedInput) -> Bool { + let signalParts = notificationSignalParts(in: parsedInput.object) + + notificationSignalParts(in: parsedInput.rawObject) + return signalParts.contains(where: notificationSignalIsIdlePrompt) + } + + private func notificationSignalParts(in object: [String: Any]?) -> [String] { + guard let object else { return [] } + let nested = (object["notification"] as? [String: Any]) ?? (object["data"] as? [String: Any]) ?? [:] + return [ + firstString(in: object, keys: ["notification_type", "notificationType", "matcher", "reason", "type", "kind"]), + firstString(in: nested, keys: ["notification_type", "notificationType", "matcher", "reason", "type", "kind"]), + ].compactMap { $0 } + } + + private func notificationSignalIsIdlePrompt(_ signal: String) -> Bool { + let lower = signal.lowercased() + let tokens = notificationCueTokens(lower) + let compact = lower.filter { $0.isLetter || $0.isNumber } + return (tokens.count == 2 && tokens[0] == "idle" && tokens[1] == "prompt") + || compact == "idleprompt" + } + private func socketPanelOption(_ surfaceId: String?) -> String { guard let surfaceId = surfaceId?.trimmingCharacters(in: .whitespacesAndNewlines), !surfaceId.isEmpty, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 36908317e36f..81719ad2b2de 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -22122,34 +22122,250 @@ "agent.generic.notification.status.idle": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "خامل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Neaktivno" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Inaktiv" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Inaktiv" + } + }, "en": { "stringUnit": { "state": "translated", "value": "Idle" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Inactivo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Inactif" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Inattivo" + } + }, "ja": { "stringUnit": { "state": "translated", "value": "待機中" } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ទំនេរ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "유휴" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Inaktiv" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Bezczynny" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Inativo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ожидание" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่ได้ใช้งาน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Boşta" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Очікування" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "空闲" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "閒置" + } } } }, "agent.generic.notification.status.needsInput": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ يحتاج إلى إدخال" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "%@ treba unos" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%@ kræver input" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ benötigt Eingabe" + } + }, "en": { "stringUnit": { "state": "translated", "value": "%@ needs input" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ necesita entrada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ attend une saisie" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%@ richiede input" + } + }, "ja": { "stringUnit": { "state": "translated", "value": "%@ 入力待ち" } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "%@ ត្រូវការការបញ្ចូល" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 입력 필요" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%@ trenger inndata" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "%@ wymaga danych wejściowych" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%@ precisa de entrada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ ожидает ввода" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "%@ ต้องการข้อมูลป้อนเข้า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ giriş bekliyor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "%@ очікує введення" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 需要输入" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 需要輸入" + } } } }, @@ -22238,7 +22454,7 @@ } } }, - "agent.generic.status.running": { "extractionState": "manual", "localizations": {"ar":{"stringUnit":{"state":"translated","value":"Running"}},"bs":{"stringUnit":{"state":"translated","value":"Running"}},"da":{"stringUnit":{"state":"translated","value":"Running"}},"de":{"stringUnit":{"state":"translated","value":"Running"}},"en":{"stringUnit":{"state":"translated","value":"Running"}},"es":{"stringUnit":{"state":"translated","value":"Running"}},"fr":{"stringUnit":{"state":"translated","value":"Running"}},"it":{"stringUnit":{"state":"translated","value":"Running"}},"ja":{"stringUnit":{"state":"translated","value":"実行中"}},"ko":{"stringUnit":{"state":"translated","value":"Running"}},"nb":{"stringUnit":{"state":"translated","value":"Running"}},"pl":{"stringUnit":{"state":"translated","value":"Running"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Running"}},"ru":{"stringUnit":{"state":"translated","value":"Running"}},"th":{"stringUnit":{"state":"translated","value":"Running"}},"tr":{"stringUnit":{"state":"translated","value":"Running"}},"uk":{"stringUnit":{"state":"translated","value":"Running"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Running"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Running"}}} }, + "agent.generic.status.running": { "extractionState": "manual", "localizations": {"ar":{"stringUnit":{"state":"translated","value":"قيد التشغيل"}},"bs":{"stringUnit":{"state":"translated","value":"Pokrenuto"}},"da":{"stringUnit":{"state":"translated","value":"Kører"}},"de":{"stringUnit":{"state":"translated","value":"Wird ausgeführt"}},"en":{"stringUnit":{"state":"translated","value":"Running"}},"es":{"stringUnit":{"state":"translated","value":"En ejecución"}},"fr":{"stringUnit":{"state":"translated","value":"En cours d’exécution"}},"it":{"stringUnit":{"state":"translated","value":"In esecuzione"}},"ja":{"stringUnit":{"state":"translated","value":"実行中"}},"km":{"stringUnit":{"state":"translated","value":"កំពុង​ដំណើរការ"}},"ko":{"stringUnit":{"state":"translated","value":"실행 중"}},"nb":{"stringUnit":{"state":"translated","value":"Kjører"}},"pl":{"stringUnit":{"state":"translated","value":"Uruchomione"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Em execução"}},"ru":{"stringUnit":{"state":"translated","value":"Выполняется"}},"th":{"stringUnit":{"state":"translated","value":"กำลังทำงาน"}},"tr":{"stringUnit":{"state":"translated","value":"Çalışıyor"}},"uk":{"stringUnit":{"state":"translated","value":"Виконується"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"运行中"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"執行中"}}} }, "agent.codex.error.subtitle.auth": { "extractionState": "manual", "localizations": { diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 190c6237287f..0f6b870464c4 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -188,6 +188,84 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + func testClaudeIdlePromptNotificationAfterStopDoesNotMarkNeedsInput() throws { + let context = try makeClaudeHookContext(name: "claude-idle-prompt-after-stop") + defer { context.cleanup() } + + let sessionId = "claude-idle-prompt-session" + let start = runClaudeHook( + context: context, + arguments: ["hooks", "claude", "session-start"], + standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"# + ) + XCTAssertFalse(start.timedOut, start.stderr) + XCTAssertEqual(start.status, 0, start.stderr) + + let prompt = runClaudeHook( + context: context, + arguments: ["hooks", "claude", "prompt-submit"], + standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"finish the task"}"# + ) + XCTAssertFalse(prompt.timedOut, prompt.stderr) + XCTAssertEqual(prompt.status, 0, prompt.stderr) + + let stop = runClaudeHook( + context: context, + arguments: ["hooks", "claude", "stop"], + standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"finished"}"# + ) + XCTAssertFalse(stop.timedOut, stop.stderr) + XCTAssertEqual(stop.status, 0, stop.stderr) + + let idlePromptStart = context.state.commands.count + let idlePrompt = runClaudeHook( + context: context, + arguments: ["hooks", "claude", "notification"], + standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"Notification","notification_type":"idle_prompt","message":"Claude needs your input"}"# + ) + XCTAssertFalse(idlePrompt.timedOut, idlePrompt.stderr) + XCTAssertEqual(idlePrompt.status, 0, idlePrompt.stderr) + XCTAssertEqual(idlePrompt.stdout, "{}\n") + + let idlePromptCommands = Array(context.state.commands.dropFirst(idlePromptStart)) + XCTAssertFalse( + idlePromptCommands.contains { + $0.hasPrefix("set_status claude_code ") + && $0.lowercased().contains("needs input") + && $0.contains("--tab=\(context.workspaceId)") + }, + "Idle prompt notifications after a stopped turn must not relight Needs input, saw \(idlePromptCommands)" + ) + XCTAssertFalse( + idlePromptCommands.contains { $0.hasPrefix("notify_target_async ") }, + "Idle prompt notifications after a stopped turn must not fire a user-facing alert, saw \(idlePromptCommands)" + ) + + let nestedIdlePromptStart = context.state.commands.count + let nestedIdlePrompt = runClaudeHook( + context: context, + arguments: ["hooks", "claude", "notification"], + standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"Notification","data":{"notification_type":"idle_prompt"},"message":"Claude needs your input"}"# + ) + XCTAssertFalse(nestedIdlePrompt.timedOut, nestedIdlePrompt.stderr) + XCTAssertEqual(nestedIdlePrompt.status, 0, nestedIdlePrompt.stderr) + XCTAssertEqual(nestedIdlePrompt.stdout, "{}\n") + + let nestedIdlePromptCommands = Array(context.state.commands.dropFirst(nestedIdlePromptStart)) + XCTAssertFalse( + nestedIdlePromptCommands.contains { + $0.hasPrefix("set_status claude_code ") + && $0.lowercased().contains("needs input") + && $0.contains("--tab=\(context.workspaceId)") + }, + "Nested idle prompt notifications after a stopped turn must not relight Needs input, saw \(nestedIdlePromptCommands)" + ) + XCTAssertFalse( + nestedIdlePromptCommands.contains { $0.hasPrefix("notify_target_async ") }, + "Nested idle prompt notifications after a stopped turn must not fire a user-facing alert, saw \(nestedIdlePromptCommands)" + ) + } + func testClaudePromptSubmitResumeBindingPersistsAuthSelectionMarkersWithoutValues() throws { let context = try makeClaudeHookContext(name: "claude-resume-env-redaction") defer { context.cleanup() }