diff --git a/README.md b/README.md index a5382fdb..169c9571 100644 --- a/README.md +++ b/README.md @@ -189,13 +189,35 @@ runs as an accessory app with no Dock icon and no menu bar. - **Display:** used/remaining percentages, Usage and Cost visualization styles, target display, and island width on non-notched screens. - **Providers:** Claude/Codex visibility and status, token-counting mode, and a - manual refresh for local cost data. + manual refresh for local cost data. When Claude is hidden, an optional local + Codex task signal can fill the freed half. Preferences are stored in `UserDefaults` under `MacIsland.*` keys (Sparkle manages its own `SU*` update keys, and Launch at Login uses `SMAppService.mainApp`). Refresh, display, and provider changes apply live; changing the app language offers to restart CodexIsland. +Codex task status is inferred locally from recent +`~/.codex/sessions/**/*.jsonl` rollout lifecycle events. It reports only the +states the current rollout format can support reliably: running, waiting for +approval, idle, cancelled, error, or unavailable. Approval is detected from an +unresolved `request_permissions` call. User-input waits are not claimed because +current rollout files do not expose a reliable event for that state. The +feature is off by default, polls only while enabled with Claude hidden and +Codex visible, and never displays prompts, commands, or output. + +Optional status sounds are event-specific: completion uses Glass, approval +uses Ping, cancellation uses Funk, and errors use Basso. Notifications are +emitted only for top-level sessions; Codex guardian/subagent rollouts are +excluded so internal work cannot produce a false completion alert. Monitoring +generations also prevent an old scan or queued sound from leaking across a +provider or setting change. + +The compact and peek views are visual-only so their normal click-to-expand +behavior is preserved. The expanded status card can open the related task in +the Codex app when available. Disable the feature at any time to restore the +original per-model token breakdown. + ## Build from source Requires macOS 13+ and a Swift toolchain from Xcode / Command Line Tools. diff --git a/README.zh-CN.md b/README.zh-CN.md index 9513e634..597484ac 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -98,10 +98,25 @@ Claude: | 低功耗模式 | `LowPowerModeStore` | `MacIsland.lowPowerMode` | Boolean,默认 `false` | | Claude 可见 | `ProviderVisibilityStore` | `MacIsland.claudeVisible` | Boolean,默认 `true` | | Codex 可见 | `ProviderVisibilityStore` | `MacIsland.codexVisible` | Boolean,默认 `true` | +| Codex 任务状态 | `CodexTaskStatusStore` | `MacIsland.codexTaskStatus` | Boolean,默认 `false` | +| 状态显示 | `CodexTaskStatusStore` | `MacIsland.codexTaskStatusDisplayMode` | `icon`, `iconAndText`,默认 `icon` | | 登录启动 | `LaunchAtLoginStore` | 由 `SMAppService.mainApp` 管理 | 系统登录项状态 | 刷新间隔会立即生效。`UsageStore` 会重置当前计时器,并用新的间隔重新安排下一次拉取。 +隐藏 Claude、保留 Codex 时,可以选择显示本地 Codex 任务状态。状态从 +`~/.codex/sessions/**/*.jsonl` 当前实际记录的生命周期事件推断,只显示运行中、等待审批、 +空闲、已取消、异常和不可用。等待审批通过尚未获得响应的 `request_permissions` 调用识别; +当前 rollout 文件仍没有可靠的等待用户输入事件,因此本功能不会声称能够识别该状态。 + +状态提示音可选开启:完成使用 Glass,等待审批使用 Ping,取消使用 Funk, +报错使用 Basso。只有顶层任务会触发提示,Codex 内部的 guardian/子任务日志会被排除, +避免主任务尚未完成时误播放完成音。监控代次也会防止旧扫描结果或排队中的声音跨设置切换继续生效。 + +该功能默认关闭,并且只有在功能已开启、Claude 已隐藏且 Codex 可见时才轮询。收起和预览 +状态仅用于显示,不会抢占灵动岛原本的点击展开行为;只有展开后的状态卡会尝试打开 Codex +任务。关闭功能后会恢复原有的按模型 Token 用量视图。 + ## 从源码构建 需要 macOS 13+ 和来自 Xcode / Command Line Tools 的 Swift 工具链。 diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index 7408446c..d8403def 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -58,7 +58,6 @@ "General" = "General"; "Follows macOS" = "Follows macOS"; "Glow only on refresh, hover, or limit alerts." = "Glow only on refresh, hover, or limit alerts."; -"Idle" = "Idle"; "idle" = "idle"; "Input + output" = "Input + output"; "Input + output only. Matches Anthropic's claude.ai stats." = "Input + output only. Matches Anthropic's claude.ai stats."; @@ -101,6 +100,33 @@ "Ring" = "Ring"; "scanning local logs…" = "scanning local logs…"; "Settings" = "Settings"; +"Codex task status" = "Codex task status"; +"Show local Codex task state when the Claude side is hidden." = "Show local Codex task state when the Claude side is hidden."; +"Running" = "Running"; +"Running short" = "Running"; +"Waiting for approval" = "Waiting for approval"; +"Approval short" = "Approval"; +"Idle" = "Idle"; +"Cancelled" = "Cancelled"; +"Cancelled short" = "Cancelled"; +"Error" = "Error"; +"Unavailable" = "Unavailable"; +"Unavailable short" = "Unknown"; +"Open this task in Codex" = "Open this task in Codex"; +"Unable to open Codex" = "Unable to open Codex"; +"Codex is not installed on this Mac." = "Codex is not installed on this Mac."; +"Codex could not be opened." = "Codex could not be opened."; +"OK" = "OK"; +"Codex status: %@" = "Codex status: %@"; +"%@ — open in Codex" = "%@ — open in Codex"; +"Status display" = "Status display"; +"Choose an icon only or include a localized label." = "Choose an icon only or include a localized label."; +"Icon" = "Icon"; +"Icon + Text" = "Icon + Text"; +"Updated %@" = "Updated %@"; +"Status sounds" = "Status sounds"; +"Play a sound when a running task completes, is cancelled, or fails." = "Play a sound when a running task completes, is cancelled, or fails."; +"Use distinct sounds for completion, approval, cancellation, and errors." = "Use distinct sounds for completion, approval, cancellation, and errors."; "Show on" = "Show on"; "Sparkline" = "Sparkline"; "Spacing" = "Spacing"; diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index 54c83716..2908c67c 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -58,7 +58,6 @@ "General" = "通用"; "Follows macOS" = "跟随 macOS"; "Glow only on refresh, hover, or limit alerts." = "仅在刷新、悬停或限额提醒时显示辉光。"; -"Idle" = "空闲"; "idle" = "空闲"; "Input + output" = "输入 + 输出"; "Input + output only. Matches Anthropic's claude.ai stats." = "只统计输入 + 输出;与 Anthropic 的 claude.ai 统计一致。"; @@ -101,6 +100,33 @@ "Ring" = "环形"; "scanning local logs…" = "正在扫描本地日志…"; "Settings" = "设置"; +"Codex task status" = "Codex 任务状态"; +"Show local Codex task state when the Claude side is hidden." = "隐藏 Claude 一侧时显示本地 Codex 任务状态。"; +"Running" = "运行中"; +"Running short" = "运行"; +"Waiting for approval" = "等待审批"; +"Approval short" = "审批"; +"Idle" = "空闲"; +"Cancelled" = "已取消"; +"Cancelled short" = "取消"; +"Error" = "异常"; +"Unavailable" = "不可用"; +"Unavailable short" = "未知"; +"Open this task in Codex" = "在 Codex 中打开此任务"; +"Unable to open Codex" = "无法打开 Codex"; +"Codex is not installed on this Mac." = "这台 Mac 尚未安装 Codex。"; +"Codex could not be opened." = "Codex 无法启动。"; +"OK" = "好"; +"Codex status: %@" = "Codex 状态:%@"; +"%@ — open in Codex" = "%@ — 在 Codex 中打开"; +"Status display" = "状态显示"; +"Choose an icon only or include a localized label." = "选择仅显示图标,或同时显示本地化文字。"; +"Icon" = "仅图标"; +"Icon + Text" = "图标与文字"; +"Updated %@" = "更新于%@"; +"Status sounds" = "状态提示音"; +"Play a sound when a running task completes, is cancelled, or fails." = "运行中的任务完成、取消或失败时播放提示音。"; +"Use distinct sounds for completion, approval, cancellation, and errors." = "任务完成、等待审批、取消和报错时使用不同的提示音。"; "Show on" = "显示在"; "Sparkline" = "折线"; "Spacing" = "间距"; diff --git a/Sources/App.swift b/Sources/App.swift index aca958d1..d77e3b90 100644 --- a/Sources/App.swift +++ b/Sources/App.swift @@ -51,6 +51,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Wire the alert engine after the usage store so its initial // recompute sees whatever values the first refresh has produced. AlertEngine.shared.start() + CodexTaskStatusStore.shared.start() // Touch the shared updater so Sparkle starts its background scheduler. _ = UpdaterController.shared diff --git a/Sources/Model/CodexTaskStatusLogParser.swift b/Sources/Model/CodexTaskStatusLogParser.swift new file mode 100644 index 00000000..3ace4f42 --- /dev/null +++ b/Sources/Model/CodexTaskStatusLogParser.swift @@ -0,0 +1,388 @@ +import Foundation + +enum CodexTaskLogState: Equatable, Sendable { + case running + case waitingApproval + case idle + case cancelled + case error + case unavailable +} + +enum CodexTaskStatusSoundEvent: Equatable, Sendable { + case completed + case error + case cancelled + case approvalRequired +} + +struct CodexTaskLogParseResult: Equatable, Sendable { + let state: CodexTaskLogState + let soundEvents: [CodexTaskStatusSoundEvent] + let isInitialRead: Bool +} + +enum CodexTaskStatusFilePolicy { + static func selectTopLevelFiles( + from filesByRecency: [URL], + maximumCount: Int + ) -> [URL] { + guard maximumCount > 0 else { return [] } + return Array(filesByRecency.lazy.filter { + !CodexTaskStatusLogParser.isSubagentSession(at: $0) + }.prefix(maximumCount)) + } +} + +enum CodexTaskStatusPolicy { + static let terminalDecayInterval: TimeInterval = 10 * 60 + + static func isPastTerminalDecay(updatedAt: Date, now: Date = Date()) -> Bool { + now.timeIntervalSince(updatedAt) > terminalDecayInterval + } + + static func priority( + for state: CodexTaskLogState, + updatedAt: Date?, + now: Date = Date() + ) -> Int { + if state == .error || state == .cancelled, + let updatedAt, + isPastTerminalDecay(updatedAt: updatedAt, now: now) { + return 0 + } + switch state { + case .waitingApproval: return 6 + case .running: return 5 + case .error: return 3 + case .cancelled: return 2 + case .idle: return 1 + case .unavailable: return 0 + } + } +} + +enum CodexTaskStatusDirectoryPolicy { + static func utcDateComponents(for date: Date) -> DateComponents { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar.dateComponents([.year, .month, .day], from: date) + } + + static func date(daysBefore offset: Int, from date: Date) -> Date? { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar.date(byAdding: .day, value: -offset, to: date) + } +} + +struct CodexTaskStatusLogParser { + private static let newline: UInt8 = 0x0A + private static let cache = StateCache() + private static let lifecycleMarkers = [ + "task_started", "user_message", "task_complete", "turn_aborted", + "error", "stream_error", "exec_command_end", "patch_apply_end", + "mcp_tool_call_end", + ].map { Data("\"\($0)\"".utf8) } + private static let permissionRequestMarker = Data("\"request_permissions\"".utf8) + private static let functionOutputMarker = Data("\"function_call_output\"".utf8) + + private struct CacheEntry { + let offset: UInt64 + let state: CodexTaskLogState + let currentTurnFailed: Bool + let pendingPermissionCallIDs: Set + } + + private final class StateCache: @unchecked Sendable { + private let lock = NSLock() + private var entries: [URL: CacheEntry] = [:] + private var subagentSessions: [URL: Bool] = [:] + + func entry(for url: URL) -> CacheEntry? { + lock.lock() + defer { lock.unlock() } + return entries[url] + } + + func set(_ entry: CacheEntry, for url: URL) { + lock.lock() + defer { lock.unlock() } + entries[url] = entry + } + + func retain(urls: Set) { + lock.lock() + defer { lock.unlock() } + entries = entries.filter { urls.contains($0.key) } + subagentSessions = subagentSessions.filter { urls.contains($0.key) } + } + + func subagentSession(for url: URL) -> Bool? { + lock.lock() + defer { lock.unlock() } + return subagentSessions[url] + } + + func setSubagentSession(_ isSubagent: Bool, for url: URL) { + lock.lock() + defer { lock.unlock() } + subagentSessions[url] = isSubagent + } + } + + static func retainCache(for urls: Set) { + cache.retain(urls: urls) + } + + static func isSubagentSession(at url: URL, maxBytes: Int = 64 * 1024) -> Bool { + if let cached = cache.subagentSession(for: url) { return cached } + guard maxBytes > 0, + let handle = try? FileHandle(forReadingFrom: url), + let data = try? handle.read(upToCount: maxBytes) + else { return false } + try? handle.close() + + for line in data.split(separator: newline) { + guard let raw = try? JSONSerialization.jsonObject( + with: Data(line) + ) as? [String: Any], + (raw["type"] as? String) == "session_meta", + let payload = raw["payload"] as? [String: Any] + else { continue } + let source = payload["source"] as? [String: Any] + let isSubagent = source?["subagent"] != nil + cache.setSubagentSession(isSubagent, for: url) + return isSubagent + } + return false + } + + static func parse(at url: URL, maxBytes: UInt64 = 512 * 1024) -> CodexTaskLogState? { + parseUpdate(at: url, maxBytes: maxBytes)?.state + } + + static func parseUpdate( + at url: URL, + maxBytes: UInt64 = 512 * 1024 + ) -> CodexTaskLogParseResult? { + guard maxBytes > 0, + let handle = try? FileHandle(forReadingFrom: url) + else { return nil } + defer { try? handle.close() } + + let length = (try? handle.seekToEnd()) ?? 0 + let cached = cache.entry(for: url) + let hasCachedBaseline = cached.map { + length >= $0.offset + } ?? false + let canContinue = cached.map { + length >= $0.offset && length - $0.offset <= maxBytes + } ?? false + let readStart: UInt64 + let initialState: CodexTaskLogState + let initialFailure: Bool + let initialPendingPermissionCallIDs: Set + if hasCachedBaseline, let cached { + readStart = canContinue + ? cached.offset + : (length > maxBytes ? length - maxBytes : 0) + initialState = cached.state + initialFailure = cached.currentTurnFailed + initialPendingPermissionCallIDs = cached.pendingPermissionCallIDs + } else { + readStart = length > maxBytes ? length - maxBytes : 0 + initialState = .idle + initialFailure = false + initialPendingPermissionCallIDs = [] + } + + try? handle.seek(toOffset: readStart) + let readLimit = Int(min(maxBytes, UInt64(Int.max))) + guard let raw = try? handle.read(upToCount: readLimit) else { return nil } + let complete = completeLines( + in: raw, + droppingLeadingPartialLine: !canContinue && readStart > 0 + ) + if complete.data.isEmpty, complete.consumedBytes == 0 { + return hasCachedBaseline + ? cached.map { + CodexTaskLogParseResult( + state: $0.state, + soundEvents: [], + isInitialRead: false + ) + } + : nil + } + let result = parse( + complete.data, + initialState: initialState, + currentTurnFailed: initialFailure, + pendingPermissionCallIDs: initialPendingPermissionCallIDs + ) + if !result.recognizedLifecycle, !hasCachedBaseline { + return nil + } + cache.set( + CacheEntry( + offset: readStart + UInt64(complete.consumedBytes), + state: result.state, + currentTurnFailed: result.currentTurnFailed, + pendingPermissionCallIDs: result.pendingPermissionCallIDs + ), + for: url + ) + return CodexTaskLogParseResult( + state: result.state, + soundEvents: result.soundEvents, + isInitialRead: !hasCachedBaseline + ) + } + + private static func completeLines( + in data: Data, + droppingLeadingPartialLine: Bool + ) -> (data: Data, consumedBytes: Int) { + var lowerBound = data.startIndex + if droppingLeadingPartialLine { + guard let firstNewline = data.firstIndex(of: newline) else { + return (Data(), 0) + } + lowerBound = data.index(after: firstNewline) + } + guard let lastNewline = data.lastIndex(of: newline), + lastNewline >= lowerBound + else { + return (Data(), 0) + } + let upperBound = data.index(after: lastNewline) + return ( + Data(data[lowerBound.. + ) -> ( + state: CodexTaskLogState, + currentTurnFailed: Bool, + recognizedLifecycle: Bool, + soundEvents: [CodexTaskStatusSoundEvent], + pendingPermissionCallIDs: Set + ) { + var state = initialState + var currentTurnFailed = initialFailure + var pendingPermissionCallIDs = initialPendingPermissionCallIDs + var recognizedLifecycle = false + var soundEvents: [CodexTaskStatusSoundEvent] = [] + + for line in data.split(separator: newline) { + guard let event = parsedEvent( + in: line, + expectsPermissionOutput: !pendingPermissionCallIDs.isEmpty + ) else { continue } + switch event { + case .lifecycle("task_started"), .lifecycle("user_message"): + recognizedLifecycle = true + currentTurnFailed = false + pendingPermissionCallIDs.removeAll() + state = .running + case .lifecycle("exec_command_end"), + .lifecycle("patch_apply_end"), + .lifecycle("mcp_tool_call_end"): + recognizedLifecycle = true + if !currentTurnFailed && pendingPermissionCallIDs.isEmpty { + state = .running + } + case .lifecycle("task_complete"): + recognizedLifecycle = true + if !pendingPermissionCallIDs.isEmpty { + state = .waitingApproval + } else if currentTurnFailed { + state = .error + } else { + state = .idle + soundEvents.append(.completed) + } + case .lifecycle("turn_aborted"): + recognizedLifecycle = true + currentTurnFailed = false + pendingPermissionCallIDs.removeAll() + state = .cancelled + soundEvents.append(.cancelled) + case .lifecycle("error"), .lifecycle("stream_error"): + recognizedLifecycle = true + if !currentTurnFailed { + soundEvents.append(.error) + } + currentTurnFailed = true + pendingPermissionCallIDs.removeAll() + state = .error + case let .permissionRequested(callID): + recognizedLifecycle = true + if pendingPermissionCallIDs.insert(callID).inserted { + soundEvents.append(.approvalRequired) + } + state = .waitingApproval + case let .permissionResolved(callID): + guard pendingPermissionCallIDs.remove(callID) != nil else { continue } + recognizedLifecycle = true + if pendingPermissionCallIDs.isEmpty { + state = currentTurnFailed ? .error : .running + } + default: + break + } + } + return ( + state, + currentTurnFailed, + recognizedLifecycle, + soundEvents, + pendingPermissionCallIDs + ) + } + + private enum ParsedEvent { + case lifecycle(String) + case permissionRequested(String) + case permissionResolved(String) + } + + private static func parsedEvent( + in line: Data.SubSequence, + expectsPermissionOutput: Bool + ) -> ParsedEvent? { + guard line.count < 1_048_576, + lifecycleMarkers.contains(where: { line.range(of: $0) != nil }) + || line.range(of: permissionRequestMarker) != nil + || (expectsPermissionOutput && line.range(of: functionOutputMarker) != nil), + let raw = try? JSONSerialization.jsonObject( + with: Data(line) + ) as? [String: Any], + let payload = raw["payload"] as? [String: Any] + else { return nil } + + if (raw["type"] as? String) == "event_msg", + let type = payload["type"] as? String { + return .lifecycle(type) + } + guard (raw["type"] as? String) == "response_item", + let type = payload["type"] as? String, + let callID = payload["call_id"] as? String + else { return nil } + if type == "function_call", + (payload["name"] as? String) == "request_permissions" { + return .permissionRequested(callID) + } + if type == "function_call_output", expectsPermissionOutput { + return .permissionResolved(callID) + } + return nil + } +} diff --git a/Sources/Model/CodexTaskStatusStore.swift b/Sources/Model/CodexTaskStatusStore.swift new file mode 100644 index 00000000..4b70c736 --- /dev/null +++ b/Sources/Model/CodexTaskStatusStore.swift @@ -0,0 +1,531 @@ +import AppKit +import Combine +import Foundation + +@MainActor +final class CodexTaskStatusStore: ObservableObject { + static let shared = CodexTaskStatusStore() + private static let enabledKey = "MacIsland.codexTaskStatus" + private static let displayModeKey = "MacIsland.codexTaskStatusDisplayMode" + private static let soundEnabledKey = "MacIsland.codexTaskStatusSound" + private static let pollingInterval: TimeInterval = 15 + nonisolated private static let recentFileAge: TimeInterval = 86_400 + nonisolated private static let fullDirectoryScanInterval: TimeInterval = 5 * 60 + nonisolated private static let maximumDayLookback = 30 + nonisolated private static let maximumTrackedFiles = 24 + + enum DisplayMode: String, CaseIterable, Hashable { + case icon + case iconAndText + + var label: String { + switch self { + case .icon: "Icon" + case .iconAndText: "Icon + Text" + } + } + } + + enum Status: String, CaseIterable, Sendable { + case running + case waitingApproval + case idle + case cancelled + case error + case unavailable + + var label: String { + switch self { + case .running: "Running" + case .waitingApproval: "Waiting for approval" + case .idle: "Idle" + case .cancelled: "Cancelled" + case .error: "Error" + case .unavailable: "Unavailable" + } + } + + var compactLabel: String { + switch self { + case .running: "Running short" + case .waitingApproval: "Approval short" + case .idle: "Idle" + case .cancelled: "Cancelled short" + case .error: "Error" + case .unavailable: "Unavailable short" + } + } + + } + + struct Snapshot: Equatable, Sendable { + let status: Status + let threadID: String? + let updatedAt: Date? + } + + @Published var enabled: Bool { + didSet { UserDefaults.standard.set(enabled, forKey: Self.enabledKey) } + } + @Published var displayMode: DisplayMode { + didSet { + UserDefaults.standard.set( + displayMode.rawValue, + forKey: Self.displayModeKey + ) + } + } + @Published var soundEnabled: Bool { + didSet { + UserDefaults.standard.set(soundEnabled, forKey: Self.soundEnabledKey) + } + } + @Published private(set) var snapshot = Snapshot( + status: .unavailable, + threadID: nil, + updatedAt: nil + ) + + private var timer: Timer? + private var activityCancellable: AnyCancellable? + private var refreshInFlightGeneration: UInt64? + private var monitoringGeneration: UInt64 = 0 + private var lastScanFingerprint: String? + private var cachedDayDirectories: [URL] = [] + private var lastFullDirectoryScan: Date? + private var hasCompletedInitialScan = false + private var monitoringStartedAt: Date? + + private init() { + enabled = Pref.seededBool( + key: Self.enabledKey, + default: false + ) + displayMode = Pref.enumValue( + key: Self.displayModeKey, + default: .icon + ) + soundEnabled = Pref.seededBool( + key: Self.soundEnabledKey, + default: false + ) + } + + func start() { + guard activityCancellable == nil else { return } + let visibility = ProviderVisibilityStore.shared + activityCancellable = Publishers.CombineLatest3( + $enabled, + visibility.$claudeVisible, + visibility.$codexVisible + ) + .map { enabled, claudeVisible, codexVisible in + enabled && !claudeVisible && codexVisible + } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] active in + self?.setPollingActive(active) + } + } + + private func setPollingActive(_ active: Bool) { + timer?.invalidate() + timer = nil + monitoringGeneration &+= 1 + guard active else { + hasCompletedInitialScan = false + monitoringStartedAt = nil + return + } + hasCompletedInitialScan = false + monitoringStartedAt = Date() + refresh() + timer = Timer.scheduledTimer( + withTimeInterval: Self.pollingInterval, + repeats: true + ) { [weak self] _ in + Task { @MainActor in self?.refresh() } + } + } + + func openThread() { + guard let threadID = snapshot.threadID else { + openCodexApp() + return + } + var components = URLComponents() + components.scheme = "codex" + components.host = "threads" + components.path = "/\(threadID)" + if let url = components.url, NSWorkspace.shared.open(url) { return } + openCodexApp() + } + + private func openCodexApp() { + guard let appURL = NSWorkspace.shared.urlForApplication( + withBundleIdentifier: "com.openai.codex" + ) else { + showOpenFailure(L10n.tr("Codex is not installed on this Mac.")) + return + } + NSWorkspace.shared.openApplication( + at: appURL, + configuration: NSWorkspace.OpenConfiguration() + ) { [weak self] _, error in + guard error != nil else { return } + Task { @MainActor in + self?.showOpenFailure(L10n.tr("Codex could not be opened.")) + } + } + } + + private func showOpenFailure(_ detail: String) { + let alert = NSAlert() + alert.messageText = L10n.tr("Unable to open Codex") + alert.informativeText = detail + alert.alertStyle = .informational + alert.addButton(withTitle: L10n.tr("OK")) + alert.runModal() + } + + private func refresh() { + let generation = monitoringGeneration + guard isRenderable, refreshInFlightGeneration == nil else { return } + refreshInFlightGeneration = generation + let previousFingerprint = lastScanFingerprint + let cachedDayDirectories = cachedDayDirectories + let lastFullDirectoryScan = lastFullDirectoryScan + let monitoringStartedAt = monitoringStartedAt ?? Date() + Task { [weak self] in + guard let self else { return } + defer { + if self.refreshInFlightGeneration == generation { + self.refreshInFlightGeneration = nil + if self.monitoringGeneration != generation, + self.isRenderable { + self.refresh() + } + } + } + let result = await Task.detached(priority: .utility) { + Self.scan( + previousFingerprint: previousFingerprint, + cachedDayDirectories: cachedDayDirectories, + lastFullDirectoryScan: lastFullDirectoryScan, + monitoringStartedAt: monitoringStartedAt + ) + }.value + guard self.monitoringGeneration == generation, + self.isRenderable else { return } + self.lastScanFingerprint = result.fingerprint + self.cachedDayDirectories = result.cachedDayDirectories + self.lastFullDirectoryScan = result.lastFullDirectoryScan + if let snapshot = result.snapshot { + self.apply(snapshot) + } + let shouldPlaySounds = self.hasCompletedInitialScan && self.soundEnabled + self.hasCompletedInitialScan = true + if shouldPlaySounds { + self.playSounds(result.soundEvents, generation: generation) + } + } + } + + private func apply(_ nextSnapshot: Snapshot) { + snapshot = nextSnapshot + } + + private func playSounds( + _ events: [CodexTaskStatusSoundEvent], + generation: UInt64 + ) { + for (index, event) in events.enumerated() { + DispatchQueue.main.asyncAfter( + deadline: .now() + Double(index) * 0.45 + ) { [weak self] in + guard let self, + self.monitoringGeneration == generation, + self.isRenderable, + self.soundEnabled else { return } + self.playSound(for: event) + } + } + } + + private func playSound(for event: CodexTaskStatusSoundEvent) { + let name = switch event { + case .completed: "Glass" + case .error: "Basso" + case .cancelled: "Funk" + case .approvalRequired: "Ping" + } + if NSSound(named: NSSound.Name(name))?.play() != true { + NSSound.beep() + } + } + + private var isRenderable: Bool { + let visibility = ProviderVisibilityStore.shared + return enabled && !visibility.claudeVisible && visibility.codexVisible + } + + private struct ScanResult: Sendable { + let fingerprint: String + let snapshot: Snapshot? + let soundEvents: [CodexTaskStatusSoundEvent] + let cachedDayDirectories: [URL] + let lastFullDirectoryScan: Date? + } + + private struct RolloutDiscovery: Sendable { + let files: [URL] + let cachedDayDirectories: [URL] + let lastFullDirectoryScan: Date? + } + + nonisolated private static func scan( + previousFingerprint: String?, + cachedDayDirectories: [URL], + lastFullDirectoryScan: Date?, + monitoringStartedAt: Date + ) -> ScanResult { + let now = Date() + guard let discovery = recentRolloutFiles( + cachedDayDirectories: cachedDayDirectories, + lastFullDirectoryScan: lastFullDirectoryScan, + now: now + ) else { + return ScanResult( + fingerprint: "unavailable", + snapshot: previousFingerprint == "unavailable" + ? nil + : Snapshot(status: .unavailable, threadID: nil, updatedAt: nil), + soundEvents: [], + cachedDayDirectories: cachedDayDirectories, + lastFullDirectoryScan: lastFullDirectoryScan + ) + } + let files = discovery.files + CodexTaskStatusLogParser.retainCache(for: Set(files)) + let fingerprint = files.map { url in + let values = try? url.resourceValues( + forKeys: [.contentModificationDateKey, .fileSizeKey] + ) + let modified = values?.contentModificationDate + let decayPhase = modified.map { + CodexTaskStatusPolicy.isPastTerminalDecay(updatedAt: $0, now: now) ? 1 : 0 + } ?? 0 + return "\(url.path)|\(modified?.timeIntervalSince1970 ?? 0)|\(values?.fileSize ?? 0)|\(decayPhase)" + }.joined(separator: "\n") + guard fingerprint != previousFingerprint else { + return ScanResult( + fingerprint: fingerprint, + snapshot: nil, + soundEvents: [], + cachedDayDirectories: discovery.cachedDayDirectories, + lastFullDirectoryScan: discovery.lastFullDirectoryScan + ) + } + + if files.isEmpty { + return ScanResult( + fingerprint: fingerprint, + snapshot: Snapshot(status: .idle, threadID: nil, updatedAt: nil), + soundEvents: [], + cachedDayDirectories: discovery.cachedDayDirectories, + lastFullDirectoryScan: discovery.lastFullDirectoryScan + ) + } + + let parsedStates = files.compactMap { + parseState(at: $0, monitoringStartedAt: monitoringStartedAt) + } + let states = parsedStates.map(\.snapshot) + let soundEvents = parsedStates.flatMap(\.soundEvents) + guard let selected = states.max(by: { lhs, rhs in + let lhsPriority = selectionPriority(lhs) + let rhsPriority = selectionPriority(rhs) + if lhsPriority != rhsPriority { + return lhsPriority < rhsPriority + } + return (lhs.updatedAt ?? .distantPast) < (rhs.updatedAt ?? .distantPast) + }) else { + return ScanResult( + fingerprint: fingerprint, + snapshot: Snapshot(status: .unavailable, threadID: nil, updatedAt: nil), + soundEvents: soundEvents, + cachedDayDirectories: discovery.cachedDayDirectories, + lastFullDirectoryScan: discovery.lastFullDirectoryScan + ) + } + return ScanResult( + fingerprint: fingerprint, + snapshot: selected, + soundEvents: soundEvents, + cachedDayDirectories: discovery.cachedDayDirectories, + lastFullDirectoryScan: discovery.lastFullDirectoryScan + ) + } + + nonisolated private static func selectionPriority(_ snapshot: Snapshot) -> Int { + let state: CodexTaskLogState = switch snapshot.status { + case .running: .running + case .waitingApproval: .waitingApproval + case .idle: .idle + case .cancelled: .cancelled + case .error: .error + case .unavailable: .unavailable + } + return CodexTaskStatusPolicy.priority( + for: state, + updatedAt: snapshot.updatedAt + ) + } + + nonisolated private static func recentRolloutFiles( + cachedDayDirectories: [URL], + lastFullDirectoryScan: Date?, + now: Date + ) -> RolloutDiscovery? { + let home = FileManager.default.homeDirectoryForCurrentUser + let root: URL + if let codexHome = ProcessInfo.processInfo.environment["CODEX_HOME"], + !codexHome.isEmpty { + root = URL(fileURLWithPath: codexHome).appendingPathComponent("sessions") + } else { + root = home.appendingPathComponent(".codex/sessions") + } + + var isDirectory = ObjCBool(false) + guard FileManager.default.fileExists(atPath: root.path, isDirectory: &isDirectory), + isDirectory.boolValue, + FileManager.default.isReadableFile(atPath: root.path) + else { return nil } + + let cutoff = now.addingTimeInterval(-recentFileAge) + var files: [(URL, Date)] = [] + func dayDirectory(for date: Date) -> URL? { + let components = CodexTaskStatusDirectoryPolicy.utcDateComponents(for: date) + guard let year = components.year, + let month = components.month, + let day = components.day + else { return nil } + return root + .appendingPathComponent(String(format: "%04d", year)) + .appendingPathComponent(String(format: "%02d", month)) + .appendingPathComponent(String(format: "%02d", day)) + } + + guard let currentDayDirectory = dayDirectory(for: now) else { return nil } + let rootPrefix = root.path + "/" + let cacheMatchesRoot = !cachedDayDirectories.isEmpty + && cachedDayDirectories.allSatisfy { $0.path.hasPrefix(rootPrefix) } + let needsFullScan = !cacheMatchesRoot + || lastFullDirectoryScan.map { + now.timeIntervalSince($0) >= fullDirectoryScanInterval + } ?? true + + var directories: [URL] + if needsFullScan { + directories = [] + for dayOffset in 0...maximumDayLookback { + guard let date = CodexTaskStatusDirectoryPolicy.date( + daysBefore: dayOffset, + from: now + ), let directory = dayDirectory(for: date) else { + continue + } + directories.append(directory) + } + } else { + directories = Array(Set(cachedDayDirectories + [currentDayDirectory])) + } + + var directoriesWithRecentFiles: Set = [currentDayDirectory] + for dayDirectory in directories { + let urls = (try? FileManager.default.contentsOfDirectory( + at: dayDirectory, + includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + )) ?? [] + for url in urls { + guard url.lastPathComponent.hasPrefix("rollout-"), + url.pathExtension == "jsonl", + let values = try? url.resourceValues( + forKeys: [.isRegularFileKey, .contentModificationDateKey] + ), + values.isRegularFile == true, + let modified = values.contentModificationDate, + modified >= cutoff + else { continue } + files.append((url, modified)) + directoriesWithRecentFiles.insert(dayDirectory) + } + } + let filesByRecency = files + .sorted { $0.1 > $1.1 } + .map(\.0) + let selectedFiles = CodexTaskStatusFilePolicy.selectTopLevelFiles( + from: filesByRecency, + maximumCount: maximumTrackedFiles + ) + return RolloutDiscovery( + files: selectedFiles, + cachedDayDirectories: directoriesWithRecentFiles.sorted { + $0.path < $1.path + }, + lastFullDirectoryScan: needsFullScan ? now : lastFullDirectoryScan + ) + } + + private struct ParsedSnapshot: Sendable { + let snapshot: Snapshot + let soundEvents: [CodexTaskStatusSoundEvent] + } + + nonisolated private static func parseState( + at url: URL, + monitoringStartedAt: Date + ) -> ParsedSnapshot? { + guard let parsed = CodexTaskStatusLogParser.parseUpdate(at: url), + let modified = try? url.resourceValues( + forKeys: [.contentModificationDateKey] + ).contentModificationDate + else { return nil } + + let status: Status = switch parsed.state { + case .running: .running + case .waitingApproval: .waitingApproval + case .idle: .idle + case .cancelled: .cancelled + case .error: .error + case .unavailable: .unavailable + } + + let mayNotifyFromInitialRead = modified >= monitoringStartedAt + && status != .running + && status != .unavailable + let soundEvents = parsed.isInitialRead + ? (mayNotifyFromInitialRead ? Array(parsed.soundEvents.suffix(1)) : []) + : parsed.soundEvents + + return ParsedSnapshot( + snapshot: Snapshot( + status: status, + threadID: threadID(from: url), + updatedAt: modified + ), + soundEvents: soundEvents + ) + } + + nonisolated private static func threadID(from url: URL) -> String? { + let stem = url.deletingPathExtension().lastPathComponent + guard let range = stem.range( + of: #"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"#, + options: .regularExpression + ) else { return nil } + return String(stem[range]) + } +} diff --git a/Sources/Views/CodexTaskStatusView.swift b/Sources/Views/CodexTaskStatusView.swift new file mode 100644 index 00000000..60a485aa --- /dev/null +++ b/Sources/Views/CodexTaskStatusView.swift @@ -0,0 +1,155 @@ +import SwiftUI + +struct CodexTaskStatusView: View { + @ObservedObject private var store = CodexTaskStatusStore.shared + @State private var hovered = false + + var body: some View { + Button { + store.openThread() + } label: { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + CodexTaskStatusGlyph(status: store.snapshot.status, size: 42) + .shadow(color: statusColor.opacity(0.28), radius: 7) + + VStack(alignment: .leading, spacing: 3) { + if store.displayMode == .iconAndText { + Text(L10n.tr(store.snapshot.status.label)) + .font(Typography.providerTitle) + .foregroundStyle(.white.opacity(0.94)) + .transition(.opacity.combined(with: .scale(scale: 0.96))) + } else { + Text("Codex") + .font(Typography.providerTitle) + .foregroundStyle(.white.opacity(0.78)) + } + + if let updatedAt = store.snapshot.updatedAt { + Text(L10n.tr("Updated %@", relative(updatedAt))) + .font(Typography.caption) + .foregroundStyle(.white.opacity(0.38)) + } + } + + Spacer(minLength: 0) + + Image(systemName: "arrow.up.forward.app") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.white.opacity(hovered ? 0.72 : 0.30)) + } + + statusRail + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 12) + .background(statusColor.opacity(hovered ? 0.035 : 0)) + .contentShape(Rectangle()) + } + .buttonStyle(PressableButtonStyle(scale: 0.98)) + .onHover { hovered = $0 } + .help(L10n.tr("%@ — open in Codex", L10n.tr(store.snapshot.status.label))) + .accessibilityLabel(L10n.tr("Codex status: %@", L10n.tr(store.snapshot.status.label))) + .accessibilityHint(L10n.tr("Open this task in Codex")) + .animation(.hoverFade, value: hovered) + .animation(.strongEaseOut, value: store.snapshot) + .animation(.strongEaseOut, value: store.displayMode) + } + + private var statusRail: some View { + HStack(spacing: 0) { + ForEach(CodexTaskStatusStore.Status.allCases, id: \.rawValue) { status in + CodexTaskStatusGlyph( + status: status, + size: 19, + showsBackground: status == store.snapshot.status + ) + .opacity(status == store.snapshot.status ? 1 : 0.28) + .frame(maxWidth: .infinity) + .help(L10n.tr(status.label)) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background { + Capsule() + .fill(.black.opacity(0.22)) + .overlay { + Capsule() + .strokeBorder(.white.opacity(0.055), lineWidth: 0.5) + } + } + .accessibilityHidden(true) + } + + private var statusColor: Color { + CodexTaskStatusGlyph.color(for: store.snapshot.status) + } + + private static let relativeFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.locale = L10n.locale + formatter.unitsStyle = .abbreviated + return formatter + }() + + private func relative(_ date: Date) -> String { + Self.relativeFormatter.localizedString(for: date, relativeTo: Date()) + } +} + +struct CodexTaskStatusGlyph: View { + let status: CodexTaskStatusStore.Status + let size: CGFloat + var showsBackground = true + + var body: some View { + ZStack { + if showsBackground { + Circle() + .fill( + LinearGradient( + colors: [color.opacity(0.30), color.opacity(0.09)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay { + Circle() + .strokeBorder(color.opacity(0.32), lineWidth: 0.6) + } + } + Image(systemName: icon) + .font(.system( + size: size * (showsBackground ? 0.41 : 0.82), + weight: .semibold + )) + .foregroundStyle(color) + } + .frame(width: size, height: size) + } + + static func color(for status: CodexTaskStatusStore.Status) -> Color { + switch status { + case .running: Color(red: 0.30, green: 0.70, blue: 1.0) + case .waitingApproval: Color(red: 1.0, green: 0.72, blue: 0.24) + case .idle: Color(red: 0.48, green: 0.78, blue: 1.0) + case .cancelled: Color(red: 0.72, green: 0.62, blue: 0.48) + case .error: Color(red: 1.0, green: 0.34, blue: 0.34) + case .unavailable: Color(red: 0.55, green: 0.57, blue: 0.62) + } + } + + private var color: Color { Self.color(for: status) } + + private var icon: String { + switch status { + case .running: "bolt.fill" + case .waitingApproval: "hand.raised.fill" + case .idle: "moon.zzz.fill" + case .cancelled: "xmark.circle.fill" + case .error: "exclamationmark.triangle.fill" + case .unavailable: "questionmark.circle.fill" + } + } +} diff --git a/Sources/Views/IslandRootView.swift b/Sources/Views/IslandRootView.swift index 51e9a466..66ce8871 100644 --- a/Sources/Views/IslandRootView.swift +++ b/Sources/Views/IslandRootView.swift @@ -85,6 +85,15 @@ struct IslandRootView: View { topPadding: max(0, (model.notch.height - 20) / 2) ) } + .overlay(alignment: .topLeading) { + if model.state != .expanded { + CompactCodexTaskStatusOverlay( + edgePadding: logoEdgePadding, + topPadding: max(0, (model.notch.height - 20) / 2), + showsDetails: model.state == .peek + ) + } + } .overlay(alignment: .topTrailing) { LogoOverlay( image: openaiLogo, @@ -373,6 +382,104 @@ struct IslandRootView: View { } } +/// Uses the hidden Claude logo slot for a compact Codex task signal. This +/// keeps the collapsed silhouette visually balanced without adding text or +/// changing its width. The expanded panel continues to use the full status +/// card. +private struct CompactCodexTaskStatusOverlay: View { + let edgePadding: CGFloat + let topPadding: CGFloat + let showsDetails: Bool + + @ObservedObject private var visibility = ProviderVisibilityStore.shared + @ObservedObject private var store = CodexTaskStatusStore.shared + + var body: some View { + if shouldShow { + Group { + if showsDetails { + ZStack { + HStack(spacing: 0) { + Group { + if store.snapshot.status == .idle { + Text(Duration.compact(0)) + } else { + TimelineView(.periodic(from: .now, by: 30)) { context in + Text(elapsedUpdate(at: context.date)) + } + } + } + .font(Typography.bodyNumber) + .foregroundStyle(statusColor) + .frame(width: 44, alignment: .center) + + Group { + if store.displayMode == .iconAndText { + Text(L10n.tr(store.snapshot.status.compactLabel)) + .font(Typography.bodyNumber) + .foregroundStyle(.white.opacity(0.68)) + .lineLimit(1) + .minimumScaleFactor(0.75) + } else { + Color.clear + } + } + .frame(width: 44, alignment: .center) + + CodexTaskStatusGlyph( + status: store.snapshot.status, + size: 22, + showsBackground: false + ) + .shadow(color: statusColor.opacity(0.40), radius: 4) + .frame(width: 44, alignment: .center) + } + + if store.displayMode == .iconAndText { + Text("·") + .font(Typography.bodyNumber) + .foregroundStyle(.white.opacity(0.32)) + .offset(x: -22) + } + } + .frame(width: 132) + .padding(.leading, edgePadding) + .padding(.top, max(0, topPadding - 1)) + .offset(x: -109) + } else { + CodexTaskStatusGlyph( + status: store.snapshot.status, + size: 22, + showsBackground: false + ) + .shadow(color: statusColor.opacity(0.40), radius: 4) + .padding(.leading, edgePadding) + .padding(.top, max(0, topPadding - 1)) + } + } + .allowsHitTesting(false) + .help(L10n.tr("Codex status: %@", L10n.tr(store.snapshot.status.label))) + .accessibilityLabel( + L10n.tr("Codex status: %@", L10n.tr(store.snapshot.status.label)) + ) + .animation(.strongEaseOut, value: store.snapshot) + } + } + + private var shouldShow: Bool { + store.enabled && !visibility.claudeVisible && visibility.codexVisible + } + + private var statusColor: Color { + CodexTaskStatusGlyph.color(for: store.snapshot.status) + } + + private func elapsedUpdate(at now: Date) -> String { + guard let date = store.snapshot.updatedAt else { return "—" } + return Duration.compact(max(0, now.timeIntervalSince(date))) + } +} + /// Silhouette + halo + animated sweep. Bundles every layer whose /// appearance depends on alert severity or the Low Power Mode event /// predicate, so a UsageStore/AlertEngine/CostStore emission only diff --git a/Sources/Views/PanelHeader.swift b/Sources/Views/PanelHeader.swift index 5b4e9ec2..1816f09f 100644 --- a/Sources/Views/PanelHeader.swift +++ b/Sources/Views/PanelHeader.swift @@ -11,18 +11,27 @@ struct PanelHeader: View { let notch: NotchInfo @ObservedObject private var visibility = ProviderVisibilityStore.shared @ObservedObject private var usageStore = UsageStore.shared + @ObservedObject private var taskStatus = CodexTaskStatusStore.shared var body: some View { HStack(spacing: 0) { let claudeOn = visibility.claudeVisible let codexOn = visibility.codexVisible - providerTitle(name: "Claude", tag: usageStore.claude.plan?.uppercased(), - color: IslandColor.claude, alignment: .leading) { - EmptyView() + Group { + if claudeOn { + providerTitle(name: "Claude", tag: usageStore.claude.plan?.uppercased(), + color: IslandColor.claude, alignment: .leading) { + EmptyView() + } + } else if codexOn && taskStatus.enabled { + codexStatusTitle + } else { + Color.clear + } } - .opacity(claudeOn ? 1 : 0) - .animation(.openMorph, value: claudeOn) - .accessibilityHidden(!claudeOn) + .frame(maxWidth: .infinity) + .animation(.openMorph, value: claudeOn) + .animation(.openMorph, value: taskStatus.enabled) Color.clear.frame(width: notch.width) providerTitle(name: "Codex", tag: usageStore.codex.plan?.uppercased(), color: IslandColor.codex, alignment: .trailing) { @@ -42,6 +51,33 @@ struct PanelHeader: View { .padding(.bottom, min(14, max(0, notch.height - 22 - 4))) } + private var codexStatusTitle: some View { + HStack(spacing: 8) { + CodexTaskStatusGlyph(status: taskStatus.snapshot.status, size: 20) + Text(L10n.tr("Codex task status")) + .font(Typography.providerTitle) + .foregroundStyle(.white) + if taskStatus.displayMode == .iconAndText { + Text(L10n.tr(taskStatus.snapshot.status.compactLabel)) + .font(Typography.chip) + .tracking(0.5) + .foregroundStyle(CodexTaskStatusGlyph.color(for: taskStatus.snapshot.status)) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background { + RoundedRectangle(cornerRadius: 3) + .fill(CodexTaskStatusGlyph.color(for: taskStatus.snapshot.status).opacity(0.10)) + } + } + Spacer(minLength: 0) + } + .padding(.leading, 9) + .accessibilityElement(children: .combine) + .accessibilityLabel( + L10n.tr("Codex status: %@", L10n.tr(taskStatus.snapshot.status.label)) + ) + } + @ViewBuilder private func providerTitle( name: String, diff --git a/Sources/Views/SettingsView.swift b/Sources/Views/SettingsView.swift index 9db95d40..bebcd162 100644 --- a/Sources/Views/SettingsView.swift +++ b/Sources/Views/SettingsView.swift @@ -20,6 +20,7 @@ struct SettingsView: View { @ObservedObject private var usageDisplay = UsageDisplayModeStore.shared @ObservedObject private var targetDisplay = IslandTargetDisplayStore.shared @ObservedObject private var appLanguage = AppLanguageStore.shared + @ObservedObject private var codexTaskStatus = CodexTaskStatusStore.shared @ObservedObject private var usage = UsageStore.shared @ObservedObject private var cost = CostStore.shared @ObservedObject private var updater = UpdaterController.shared @@ -515,6 +516,37 @@ struct SettingsView: View { } } } + SettingsRow( + title: "Codex task status", + subtitle: "Show local Codex task state when the Claude side is hidden." + ) { + SettingsToggle(isOn: codexTaskStatus.enabled) { + codexTaskStatus.enabled.toggle() + } + } + SettingsRow( + title: "Status display", + subtitle: "Choose an icon only or include a localized label." + ) { + SegmentedControl( + items: CodexTaskStatusStore.DisplayMode.allCases, + selected: $codexTaskStatus.displayMode, + label: \.label, + accessibilityPrefix: "Status display" + ) + } + .disabled(!codexTaskStatus.enabled) + .opacity(codexTaskStatus.enabled ? 1 : 0.4) + SettingsRow( + title: "Status sounds", + subtitle: "Use distinct sounds for completion, approval, cancellation, and errors." + ) { + SettingsToggle(isOn: codexTaskStatus.soundEnabled) { + codexTaskStatus.soundEnabled.toggle() + } + } + .disabled(!codexTaskStatus.enabled) + .opacity(codexTaskStatus.enabled ? 1 : 0.4) } .padding(.horizontal, 14) .padding(.top, 18) diff --git a/Sources/Views/UsageView.swift b/Sources/Views/UsageView.swift index c6b7ac9e..760bc2e8 100644 --- a/Sources/Views/UsageView.swift +++ b/Sources/Views/UsageView.swift @@ -14,6 +14,7 @@ struct UsageView: View { @ObservedObject private var store = UsageStore.shared @ObservedObject private var pref = StylePref.shared @ObservedObject private var visibility = ProviderVisibilityStore.shared + @ObservedObject private var taskStatus = CodexTaskStatusStore.shared private var style: ChartStyle { pref.style } @@ -38,10 +39,15 @@ struct UsageView: View { .padding(.horizontal, 12) .transition(breakdownTransition) case (false, true): - PerModelBreakdown(provider: .codex, metric: .tokens) - .frame(maxWidth: .infinity, alignment: .top) - .padding(.horizontal, 12) - .transition(breakdownTransition) + if taskStatus.enabled { + CodexTaskStatusView() + .transition(breakdownTransition) + } else { + PerModelBreakdown(provider: .codex, metric: .tokens) + .frame(maxWidth: .infinity, alignment: .top) + .padding(.horizontal, 12) + .transition(breakdownTransition) + } hairline ChartsBlock(color: IslandColor.codex, usage: store.codex, style: style, seed: 3, provider: .codex) diff --git a/Tests/CodexTaskStatusLogParserTests.swift b/Tests/CodexTaskStatusLogParserTests.swift new file mode 100644 index 00000000..89768231 --- /dev/null +++ b/Tests/CodexTaskStatusLogParserTests.swift @@ -0,0 +1,323 @@ +import Foundation + +@main +struct CodexTaskStatusLogParserTests { + static var failures = 0 + + static func expect(_ condition: Bool, _ label: String) { + if condition { + print("PASS \(label)") + } else { + print("FAIL \(label)") + failures += 1 + } + } + + static func event(_ type: String, detail: String = "") -> Data { + let payload: [String: Any] = [ + "type": "event_msg", + "payload": ["type": type, "detail": detail], + ] + let data = try! JSONSerialization.data(withJSONObject: payload) + return data + Data([0x0A]) + } + + static func responseItem(_ payload: [String: Any]) -> Data { + let data = try! JSONSerialization.data(withJSONObject: [ + "type": "response_item", + "payload": payload, + ]) + return data + Data([0x0A]) + } + + static func permissionRequest(_ callID: String) -> Data { + responseItem([ + "type": "function_call", + "name": "request_permissions", + "call_id": callID, + ]) + } + + static func functionOutput(_ callID: String) -> Data { + responseItem([ + "type": "function_call_output", + "call_id": callID, + "output": "approved", + ]) + } + + static func sessionMeta(subagent: Bool) -> Data { + let source: Any = subagent + ? ["subagent": ["other": "guardian"]] + : "vscode" + let data = try! JSONSerialization.data(withJSONObject: [ + "type": "session_meta", + "payload": ["source": source], + ]) + return data + Data([0x0A]) + } + + static func main() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: directory) } + + let log = directory.appendingPathComponent("rollout-test.jsonl") + var data = event("task_started") + data.append(event("stream_error")) + try data.write(to: log) + expect( + CodexTaskStatusLogParser.parse(at: log) == .error, + "failure marker is cached" + ) + + let filler = String(repeating: "x", count: 180) + let growthHandle = try FileHandle(forWritingTo: log) + try growthHandle.seekToEnd() + while data.count < 530 * 1024 { + let update = event("response_item", detail: filler) + data.append(update) + try growthHandle.write(contentsOf: update) + if data.count % (64 * 1024) < update.count { + _ = CodexTaskStatusLogParser.parse(at: log) + } + } + try growthHandle.close() + data.append(event("task_complete")) + try event("task_complete").append(to: log) + + expect( + CodexTaskStatusLogParser.parse(at: log) == .error, + "failure outside tail survives task_complete" + ) + + let oversized = directory.appendingPathComponent("rollout-oversized.jsonl") + var oversizedData = event("task_started") + oversizedData.append( + event("response_item", detail: String(repeating: "x", count: 1024)) + ) + oversizedData.append(event("task_complete")) + try oversizedData.write(to: oversized) + expect( + CodexTaskStatusLogParser.parse(at: oversized, maxBytes: 128) == .idle, + "oversized record fallback stays within the read cap" + ) + + let noBoundary = directory.appendingPathComponent("rollout-no-boundary.jsonl") + try Data(repeating: 0x78, count: 1024).write(to: noBoundary) + expect( + CodexTaskStatusLogParser.parse(at: noBoundary, maxBytes: 128) == nil, + "fresh read without a complete line returns nil" + ) + + let continued = directory.appendingPathComponent("rollout-continued.jsonl") + try event("stream_error").write(to: continued) + expect( + CodexTaskStatusLogParser.parse(at: continued) == .error, + "continuation state is initialized" + ) + try Data("partial".utf8).append(to: continued) + expect( + CodexTaskStatusLogParser.parse(at: continued) == .error, + "cached continuation without a complete line preserves state" + ) + + let currentVocabulary = directory.appendingPathComponent("rollout-current.jsonl") + var currentData = event("task_started") + currentData.append(event("mcp_tool_call_end")) + try currentData.write(to: currentVocabulary) + expect( + CodexTaskStatusLogParser.parse(at: currentVocabulary) == .running, + "current rollout end events preserve running state" + ) + try event("turn_aborted").append(to: currentVocabulary) + expect( + CodexTaskStatusLogParser.parse(at: currentVocabulary) == .cancelled, + "turn_aborted is cancelled rather than error" + ) + + let growthGap = directory.appendingPathComponent("rollout-growth-gap.jsonl") + var growthData = event("task_started") + growthData.append( + event("response_item", detail: String(repeating: "x", count: 1024)) + ) + try growthData.write(to: growthGap) + expect( + CodexTaskStatusLogParser.parse(at: growthGap, maxBytes: 128) == nil, + "unrecognized truncated tail reports unavailable" + ) + + let cachedGrowthGap = directory.appendingPathComponent("rollout-cached-growth-gap.jsonl") + try event("task_started").write(to: cachedGrowthGap) + expect( + CodexTaskStatusLogParser.parse(at: cachedGrowthGap, maxBytes: 128) == .running, + "long-running task establishes a cached running state" + ) + try event( + "response_item", + detail: String(repeating: "x", count: 1024) + ).append(to: cachedGrowthGap) + expect( + CodexTaskStatusLogParser.parse(at: cachedGrowthGap, maxBytes: 128) == .running, + "large unrecognized growth preserves the cached running state" + ) + try event("task_complete").append(to: cachedGrowthGap) + expect( + CodexTaskStatusLogParser.parse(at: cachedGrowthGap, maxBytes: 128) == .idle, + "completion after a large growth gap is still detected" + ) + + let now = Date() + expect( + CodexTaskStatusPolicy.priority(for: .running, updatedAt: now, now: now) + > CodexTaskStatusPolicy.priority(for: .error, updatedAt: now, now: now), + "live running work outranks a recent error" + ) + expect( + CodexTaskStatusPolicy.priority( + for: .error, + updatedAt: now.addingTimeInterval(-11 * 60), + now: now + ) < CodexTaskStatusPolicy.priority(for: .idle, updatedAt: now, now: now), + "stale terminal state decays below idle" + ) + expect( + !CodexTaskStatusPolicy.isPastTerminalDecay( + updatedAt: now.addingTimeInterval(-9 * 60), + now: now + ) && CodexTaskStatusPolicy.isPastTerminalDecay( + updatedAt: now.addingTimeInterval(-11 * 60), + now: now + ), + "terminal decay phase changes after ten minutes" + ) + + let utcBoundary = ISO8601DateFormatter().date( + from: "2026-08-07T01:00:00Z" + )! + let utcComponents = CodexTaskStatusDirectoryPolicy.utcDateComponents( + for: utcBoundary + ) + expect( + utcComponents.year == 2026 + && utcComponents.month == 8 + && utcComponents.day == 7, + "rollout directory components use the UTC date" + ) + + let shortTask = directory.appendingPathComponent("rollout-short-task.jsonl") + var shortTaskData = event("task_started") + shortTaskData.append(event("task_complete")) + try shortTaskData.write(to: shortTask) + let shortTaskResult = CodexTaskStatusLogParser.parseUpdate(at: shortTask) + expect( + shortTaskResult?.state == .idle + && shortTaskResult?.soundEvents == [.completed], + "short task completed between polls still emits a completion event" + ) + + let incrementalTask = directory.appendingPathComponent("rollout-incremental-task.jsonl") + try event("task_started").write(to: incrementalTask) + let startedResult = CodexTaskStatusLogParser.parseUpdate(at: incrementalTask) + try event("task_complete").append(to: incrementalTask) + let completedResult = CodexTaskStatusLogParser.parseUpdate(at: incrementalTask) + expect( + startedResult?.soundEvents.isEmpty == true + && completedResult?.soundEvents == [.completed], + "incremental task completion emits exactly one completion event" + ) + + let parallelTask = directory.appendingPathComponent("rollout-parallel-task.jsonl") + var parallelTaskData = event("task_started") + parallelTaskData.append(event("turn_aborted")) + try parallelTaskData.write(to: parallelTask) + expect( + CodexTaskStatusLogParser.parseUpdate(at: parallelTask)?.soundEvents + == [.cancelled], + "each independently parsed task emits its own terminal event" + ) + + let errorTask = directory.appendingPathComponent("rollout-error-task.jsonl") + var errorTaskData = event("task_started") + errorTaskData.append(event("stream_error")) + errorTaskData.append(event("task_complete")) + try errorTaskData.write(to: errorTask) + expect( + CodexTaskStatusLogParser.parseUpdate(at: errorTask)?.soundEvents == [.error], + "error emits its own sound event without a completion event" + ) + + let approvalTask = directory.appendingPathComponent("rollout-approval-task.jsonl") + let approvalCallID = "call-approval" + var approvalData = event("task_started") + approvalData.append(permissionRequest(approvalCallID)) + approvalData.append(event("task_complete")) + try approvalData.write(to: approvalTask) + let approvalResult = CodexTaskStatusLogParser.parseUpdate(at: approvalTask) + expect( + approvalResult?.state == .waitingApproval + && approvalResult?.soundEvents == [.approvalRequired], + "unresolved permission request waits for approval without completion sound" + ) + try functionOutput(approvalCallID).append(to: approvalTask) + let approvedResult = CodexTaskStatusLogParser.parseUpdate(at: approvalTask) + expect( + approvedResult?.state == .running + && approvedResult?.soundEvents.isEmpty == true, + "permission output resumes running without another alert" + ) + + let subagentLog = directory.appendingPathComponent("rollout-subagent.jsonl") + var subagentData = sessionMeta(subagent: true) + subagentData.append(event("task_started")) + subagentData.append(event("task_complete")) + try subagentData.write(to: subagentLog) + expect( + CodexTaskStatusLogParser.isSubagentSession(at: subagentLog), + "subagent session metadata is detected for notification filtering" + ) + + let topLevelLog = directory.appendingPathComponent("rollout-top-level.jsonl") + try sessionMeta(subagent: false).write(to: topLevelLog) + expect( + !CodexTaskStatusLogParser.isSubagentSession(at: topLevelLog), + "top-level session metadata remains eligible for notifications" + ) + + var subagentLogs: [URL] = [] + for index in 0..<30 { + let url = directory.appendingPathComponent( + "rollout-subagent-\(index).jsonl" + ) + try sessionMeta(subagent: true).write(to: url) + subagentLogs.append(url) + } + let selectedTopLevelFiles = CodexTaskStatusFilePolicy.selectTopLevelFiles( + from: subagentLogs + [topLevelLog], + maximumCount: 1 + ) + expect( + selectedTopLevelFiles == [topLevelLog], + "subagents do not consume the top-level task file limit" + ) + + if failures > 0 { + print("\(failures) failure(s)") + exit(1) + } + print("all CodexTaskStatusLogParserTests passed") + } +} + +private extension Data { + func append(to url: URL) throws { + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: self) + } +} diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 6c9977d6..af4e00b3 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -30,6 +30,14 @@ swiftc \ "$OUT_DIR/notch-height-tests" +swiftc \ + -parse-as-library \ + -o "$OUT_DIR/codex-task-status-log-parser-tests" \ + Sources/Model/CodexTaskStatusLogParser.swift \ + Tests/CodexTaskStatusLogParserTests.swift + +"$OUT_DIR/codex-task-status-log-parser-tests" + swiftc \ -parse-as-library \ -o "$OUT_DIR/usage-merge-tests" \ @@ -39,16 +47,6 @@ swiftc \ "$OUT_DIR/usage-merge-tests" -swiftc \ - -parse-as-library \ - -o "$OUT_DIR/pricing-tests" \ - Sources/Cost/TokenEvent.swift \ - Sources/Cost/PricingCatalog.swift \ - Sources/Cost/Pricing.swift \ - Tests/PricingTests.swift - -"$OUT_DIR/pricing-tests" - swiftc \ -parse-as-library \ -o "$OUT_DIR/pricing-catalog-tests" \