From 7aa55c436defcc29ff666a14462d19c91bff8fae Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Tue, 4 Aug 2026 19:46:17 +0800 Subject: [PATCH 1/9] feat: add rollout-backed Codex task status --- README.md | 16 +- README.zh-CN.md | 11 + Resources/en.lproj/Localizable.strings | 23 +- Resources/zh-Hans.lproj/Localizable.strings | 23 +- Sources/App.swift | 1 + Sources/Model/CodexTaskStatusLogParser.swift | 200 ++++++++++++ Sources/Model/CodexTaskStatusStore.swift | 327 +++++++++++++++++++ Sources/Views/CodexTaskStatusView.swift | 152 +++++++++ Sources/Views/IslandRootView.swift | 101 ++++++ Sources/Views/PanelHeader.swift | 45 ++- Sources/Views/SettingsView.swift | 22 ++ Sources/Views/UsageView.swift | 14 +- Tests/CodexTaskStatusLogParserTests.swift | 146 +++++++++ scripts/run-tests.sh | 8 + 14 files changed, 1076 insertions(+), 13 deletions(-) create mode 100644 Sources/Model/CodexTaskStatusLogParser.swift create mode 100644 Sources/Model/CodexTaskStatusStore.swift create mode 100644 Sources/Views/CodexTaskStatusView.swift create mode 100644 Tests/CodexTaskStatusLogParserTests.swift diff --git a/README.md b/README.md index a5382fdb..5e728e14 100644 --- a/README.md +++ b/README.md @@ -189,13 +189,27 @@ 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, idle, +cancelled, error, or unavailable. Approval and user-input waits are not claimed +because current rollout files do not record those app-server events. The +feature is off by default, polls only while enabled with Claude hidden and +Codex visible, and never displays prompts, commands, or output. + +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..4895ea0c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -98,10 +98,21 @@ 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` 当前实际记录的生命周期事件推断,只显示运行中、空闲、 +已取消、异常和不可用。当前 rollout 文件并不记录审批或等待用户输入事件,因此本功能不会 +声称能够识别这两种状态。 + +该功能默认关闭,并且只有在功能已开启、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..f26078ca 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,28 @@ "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"; +"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 %@"; "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..a8cee64d 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,28 @@ "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" = "运行"; +"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 %@" = "更新于%@"; "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..6b1f9854 --- /dev/null +++ b/Sources/Model/CodexTaskStatusLogParser.swift @@ -0,0 +1,200 @@ +import Foundation + +enum CodexTaskLogState: Equatable { + case running + case idle + case cancelled + case error + case unavailable +} + +enum CodexTaskStatusPolicy { + static func priority( + for state: CodexTaskLogState, + updatedAt: Date?, + now: Date = Date() + ) -> Int { + if state == .error || state == .cancelled, + let updatedAt, + now.timeIntervalSince(updatedAt) > 10 * 60 { + return 0 + } + switch state { + case .running: return 5 + case .error: return 3 + case .cancelled: return 2 + case .idle: return 1 + case .unavailable: return 0 + } + } +} + +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 struct CacheEntry { + let offset: UInt64 + let state: CodexTaskLogState + let currentTurnFailed: Bool + } + + private final class StateCache: @unchecked Sendable { + private let lock = NSLock() + private var entries: [URL: CacheEntry] = [:] + + 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) } + } + } + + static func retainCache(for urls: Set) { + cache.retain(urls: urls) + } + + static func parse(at url: URL, maxBytes: UInt64 = 512 * 1024) -> CodexTaskLogState? { + 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 canContinue = cached.map { + length >= $0.offset && length - $0.offset <= maxBytes + } ?? false + let readStart: UInt64 + let initialState: CodexTaskLogState + let initialFailure: Bool + if canContinue, let cached { + readStart = cached.offset + initialState = cached.state + initialFailure = cached.currentTurnFailed + } else { + readStart = length > maxBytes ? length - maxBytes : 0 + initialState = .idle + initialFailure = false + } + + 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 !canContinue, complete.data.isEmpty, complete.consumedBytes == 0 { + return nil + } + let result = parse( + complete.data, + initialState: initialState, + currentTurnFailed: initialFailure + ) + if !canContinue, !result.recognizedLifecycle { + return nil + } + cache.set( + CacheEntry( + offset: readStart + UInt64(complete.consumedBytes), + state: result.state, + currentTurnFailed: result.currentTurnFailed + ), + for: url + ) + return result.state + } + + 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) { + var state = initialState + var currentTurnFailed = initialFailure + var recognizedLifecycle = false + + for line in data.split(separator: newline) { + guard let event = eventType(in: line) else { continue } + switch event { + case "task_started", "user_message": + recognizedLifecycle = true + currentTurnFailed = false + state = .running + case "exec_command_end", "patch_apply_end", "mcp_tool_call_end": + recognizedLifecycle = true + if !currentTurnFailed { + state = .running + } + case "task_complete": + recognizedLifecycle = true + state = currentTurnFailed ? .error : .idle + case "turn_aborted": + recognizedLifecycle = true + currentTurnFailed = false + state = .cancelled + case "error", "stream_error": + recognizedLifecycle = true + currentTurnFailed = true + state = .error + default: + break + } + } + return (state, currentTurnFailed, recognizedLifecycle) + } + + private static func eventType(in line: Data.SubSequence) -> String? { + guard line.count < 1_048_576, + lifecycleMarkers.contains(where: { line.range(of: $0) != nil }), + let raw = try? JSONSerialization.jsonObject( + with: Data(line) + ) as? [String: Any], + (raw["type"] as? String) == "event_msg", + let payload = raw["payload"] as? [String: Any] + else { return nil } + return payload["type"] as? String + } +} diff --git a/Sources/Model/CodexTaskStatusStore.swift b/Sources/Model/CodexTaskStatusStore.swift new file mode 100644 index 00000000..f226e9a2 --- /dev/null +++ b/Sources/Model/CodexTaskStatusStore.swift @@ -0,0 +1,327 @@ +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" + + 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 idle + case cancelled + case error + case unavailable + + var label: String { + switch self { + case .running: "Running" + case .idle: "Idle" + case .cancelled: "Cancelled" + case .error: "Error" + case .unavailable: "Unavailable" + } + } + + var compactLabel: String { + switch self { + case .running: "Running 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 private(set) var snapshot = Snapshot( + status: .unavailable, + threadID: nil, + updatedAt: nil + ) + + private var timer: Timer? + private var activityCancellable: AnyCancellable? + private var refreshInFlight = false + private var lastScanFingerprint: String? + + private init() { + enabled = Pref.seededBool( + key: Self.enabledKey, + default: false + ) + displayMode = Pref.enumValue( + key: Self.displayModeKey, + default: .icon + ) + } + + 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() + .sink { [weak self] active in + self?.setPollingActive(active) + } + } + + private func setPollingActive(_ active: Bool) { + timer?.invalidate() + timer = nil + guard active else { return } + refresh() + timer = Timer.scheduledTimer(withTimeInterval: 15, 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() { + guard isRenderable, !refreshInFlight else { return } + refreshInFlight = true + let previousFingerprint = lastScanFingerprint + Task { [weak self] in + let result = await Task.detached(priority: .utility) { + Self.scan(previousFingerprint: previousFingerprint) + }.value + guard let self else { return } + self.lastScanFingerprint = result.fingerprint + if let snapshot = result.snapshot { + self.snapshot = snapshot + } + self.refreshInFlight = false + } + } + + private var isRenderable: Bool { + let visibility = ProviderVisibilityStore.shared + return enabled && !visibility.claudeVisible && visibility.codexVisible + } + + private struct ScanResult: Sendable { + let fingerprint: String + let snapshot: Snapshot? + } + + nonisolated private static func scan(previousFingerprint: String?) -> ScanResult { + guard let files = recentRolloutFiles() else { + return ScanResult( + fingerprint: "unavailable", + snapshot: Snapshot(status: .unavailable, threadID: nil, updatedAt: nil) + ) + } + CodexTaskStatusLogParser.retainCache(for: Set(files)) + let fingerprint = files.map { url in + let values = try? url.resourceValues( + forKeys: [.contentModificationDateKey, .fileSizeKey] + ) + return "\(url.path)|\(values?.contentModificationDate?.timeIntervalSince1970 ?? 0)|\(values?.fileSize ?? 0)" + }.joined(separator: "\n") + guard fingerprint != previousFingerprint else { + return ScanResult(fingerprint: fingerprint, snapshot: nil) + } + + if files.isEmpty { + return ScanResult( + fingerprint: fingerprint, + snapshot: Snapshot(status: .idle, threadID: nil, updatedAt: nil) + ) + } + + let states = files.compactMap(parseState) + 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) + ) + } + return ScanResult(fingerprint: fingerprint, snapshot: selected) + } + + nonisolated private static func selectionPriority(_ snapshot: Snapshot) -> Int { + let state: CodexTaskLogState = switch snapshot.status { + case .running: .running + 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() -> [URL]? { + 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 = Date().addingTimeInterval(-86400) + var files: [(URL, Date)] = [] + let calendar = Calendar(identifier: .gregorian) + for dayOffset in 0...30 { + guard let date = calendar.date(byAdding: .day, value: -dayOffset, to: Date()) else { + continue + } + let components = calendar.dateComponents([.year, .month, .day], from: date) + guard let year = components.year, + let month = components.month, + let day = components.day + else { continue } + let dayDirectory = root + .appendingPathComponent(String(format: "%04d", year)) + .appendingPathComponent(String(format: "%02d", month)) + .appendingPathComponent(String(format: "%02d", day)) + 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)) + } + } + return files + .sorted { $0.1 > $1.1 } + .prefix(24) + .map(\.0) + } + + nonisolated private static func parseState(at url: URL) -> Snapshot? { + guard let parsed = CodexTaskStatusLogParser.parse(at: url), + let modified = try? url.resourceValues( + forKeys: [.contentModificationDateKey] + ).contentModificationDate + else { return nil } + + let status: Status = switch parsed { + case .running: .running + case .idle: .idle + case .cancelled: .cancelled + case .error: .error + case .unavailable: .unavailable + } + + return Snapshot( + status: status, + threadID: threadID(from: url), + updatedAt: modified + ) + } + + 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..04e703dc --- /dev/null +++ b/Sources/Views/CodexTaskStatusView.swift @@ -0,0 +1,152 @@ +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) + } + } + .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 .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 .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..0bea1aa6 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,98 @@ 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) { + 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..bba58e05 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,30 @@ 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) + } + @ViewBuilder private func providerTitle( name: String, diff --git a/Sources/Views/SettingsView.swift b/Sources/Views/SettingsView.swift index 9db95d40..fcc1ada2 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,27 @@ 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) } .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..83d86dc1 --- /dev/null +++ b/Tests/CodexTaskStatusLogParserTests.swift @@ -0,0 +1,146 @@ +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 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) + while data.count < 530 * 1024 { + let update = event("response_item", detail: filler) + data.append(update) + try update.append(to: log) + if data.count % (64 * 1024) < update.count { + _ = CodexTaskStatusLogParser.parse(at: log) + } + } + 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 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" + ) + + 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..9877aeaf 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" \ From 935de3a6324f305ae39b995eea347ca0f4f22c27 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Wed, 5 Aug 2026 20:12:12 +0800 Subject: [PATCH 2/9] feat: add Codex task status sounds --- Resources/en.lproj/Localizable.strings | 2 + Resources/zh-Hans.lproj/Localizable.strings | 2 + Sources/Model/CodexTaskStatusLogParser.swift | 22 ++++++++++ Sources/Model/CodexTaskStatusStore.swift | 45 +++++++++++++++++++- Sources/Views/SettingsView.swift | 10 +++++ Tests/CodexTaskStatusLogParserTests.swift | 24 +++++++++++ 6 files changed, 104 insertions(+), 1 deletion(-) diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index f26078ca..c2ecb783 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -122,6 +122,8 @@ "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."; "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 a8cee64d..99a35aae 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -122,6 +122,8 @@ "Icon" = "仅图标"; "Icon + Text" = "图标与文字"; "Updated %@" = "更新于%@"; +"Status sounds" = "状态提示音"; +"Play a sound when a running task completes, is cancelled, or fails." = "运行中的任务完成、取消或失败时播放提示音。"; "Show on" = "显示在"; "Sparkline" = "折线"; "Spacing" = "间距"; diff --git a/Sources/Model/CodexTaskStatusLogParser.swift b/Sources/Model/CodexTaskStatusLogParser.swift index 6b1f9854..7a4ccd1f 100644 --- a/Sources/Model/CodexTaskStatusLogParser.swift +++ b/Sources/Model/CodexTaskStatusLogParser.swift @@ -8,6 +8,28 @@ enum CodexTaskLogState: Equatable { case unavailable } +enum CodexTaskStatusSoundEvent: Equatable { + case completed + case attention +} + +enum CodexTaskStatusSoundPolicy { + static func event( + previous: CodexTaskLogState, + current: CodexTaskLogState + ) -> CodexTaskStatusSoundEvent? { + guard previous == .running else { return nil } + switch current { + case .idle: + return .completed + case .cancelled, .error: + return .attention + case .running, .unavailable: + return nil + } + } +} + enum CodexTaskStatusPolicy { static func priority( for state: CodexTaskLogState, diff --git a/Sources/Model/CodexTaskStatusStore.swift b/Sources/Model/CodexTaskStatusStore.swift index f226e9a2..d4c87fe1 100644 --- a/Sources/Model/CodexTaskStatusStore.swift +++ b/Sources/Model/CodexTaskStatusStore.swift @@ -7,6 +7,7 @@ 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" enum DisplayMode: String, CaseIterable, Hashable { case icon @@ -66,6 +67,11 @@ final class CodexTaskStatusStore: ObservableObject { ) } } + @Published var soundEnabled: Bool { + didSet { + UserDefaults.standard.set(soundEnabled, forKey: Self.soundEnabledKey) + } + } @Published private(set) var snapshot = Snapshot( status: .unavailable, threadID: nil, @@ -86,6 +92,10 @@ final class CodexTaskStatusStore: ObservableObject { key: Self.displayModeKey, default: .icon ) + soundEnabled = Pref.seededBool( + key: Self.soundEnabledKey, + default: false + ) } func start() { @@ -166,12 +176,45 @@ final class CodexTaskStatusStore: ObservableObject { guard let self else { return } self.lastScanFingerprint = result.fingerprint if let snapshot = result.snapshot { - self.snapshot = snapshot + self.apply(snapshot) } self.refreshInFlight = false } } + private func apply(_ nextSnapshot: Snapshot) { + let previousStatus = logState(for: snapshot.status) + let nextStatus = logState(for: nextSnapshot.status) + snapshot = nextSnapshot + guard soundEnabled, + let event = CodexTaskStatusSoundPolicy.event( + previous: previousStatus, + current: nextStatus + ) + else { return } + playSound(for: event) + } + + private func logState(for status: Status) -> CodexTaskLogState { + switch status { + case .running: .running + case .idle: .idle + case .cancelled: .cancelled + case .error: .error + case .unavailable: .unavailable + } + } + + private func playSound(for event: CodexTaskStatusSoundEvent) { + let name = switch event { + case .completed: "Glass" + case .attention: "Basso" + } + if NSSound(named: NSSound.Name(name))?.play() != true { + NSSound.beep() + } + } + private var isRenderable: Bool { let visibility = ProviderVisibilityStore.shared return enabled && !visibility.claudeVisible && visibility.codexVisible diff --git a/Sources/Views/SettingsView.swift b/Sources/Views/SettingsView.swift index fcc1ada2..7fd0d591 100644 --- a/Sources/Views/SettingsView.swift +++ b/Sources/Views/SettingsView.swift @@ -537,6 +537,16 @@ struct SettingsView: View { } .disabled(!codexTaskStatus.enabled) .opacity(codexTaskStatus.enabled ? 1 : 0.4) + SettingsRow( + title: "Status sounds", + subtitle: "Play a sound when a running task completes, is cancelled, or fails." + ) { + 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/Tests/CodexTaskStatusLogParserTests.swift b/Tests/CodexTaskStatusLogParserTests.swift index 83d86dc1..3a663033 100644 --- a/Tests/CodexTaskStatusLogParserTests.swift +++ b/Tests/CodexTaskStatusLogParserTests.swift @@ -128,6 +128,30 @@ struct CodexTaskStatusLogParserTests { "stale terminal state decays below idle" ) + expect( + CodexTaskStatusSoundPolicy.event(previous: .running, current: .idle) + == .completed, + "running to idle emits a completion sound event" + ) + expect( + CodexTaskStatusSoundPolicy.event(previous: .running, current: .error) + == .attention, + "running to error emits an attention sound event" + ) + expect( + CodexTaskStatusSoundPolicy.event(previous: .running, current: .cancelled) + == .attention, + "running to cancelled emits an attention sound event" + ) + expect( + CodexTaskStatusSoundPolicy.event(previous: .idle, current: .error) == nil, + "startup and non-running transitions stay silent" + ) + expect( + CodexTaskStatusSoundPolicy.event(previous: .running, current: .unavailable) == nil, + "temporary unavailable state stays silent" + ) + if failures > 0 { print("\(failures) failure(s)") exit(1) From cad721cf6962cc924f51a0509f9c46381d664eee Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Fri, 7 Aug 2026 11:50:46 +0800 Subject: [PATCH 3/9] fix: preserve long-running Codex task state --- Sources/Model/CodexTaskStatusLogParser.swift | 39 +++++++++++------ Sources/Model/CodexTaskStatusStore.swift | 9 ++-- Sources/Views/IslandRootView.swift | 12 +++-- Tests/CodexTaskStatusLogParserTests.swift | 46 ++++++++++++++++---- 4 files changed, 75 insertions(+), 31 deletions(-) diff --git a/Sources/Model/CodexTaskStatusLogParser.swift b/Sources/Model/CodexTaskStatusLogParser.swift index 7a4ccd1f..ce570cdb 100644 --- a/Sources/Model/CodexTaskStatusLogParser.swift +++ b/Sources/Model/CodexTaskStatusLogParser.swift @@ -13,19 +13,25 @@ enum CodexTaskStatusSoundEvent: Equatable { case attention } -enum CodexTaskStatusSoundPolicy { - static func event( - previous: CodexTaskLogState, - current: CodexTaskLogState - ) -> CodexTaskStatusSoundEvent? { - guard previous == .running else { return nil } - switch current { +struct CodexTaskStatusSoundTracker { + private(set) var hasRunningTask = false + + mutating func event(for state: CodexTaskLogState) -> CodexTaskStatusSoundEvent? { + switch state { + case .running: + hasRunningTask = true + return nil + case .unavailable: + // A temporary read gap must not erase a known running task. + return nil case .idle: + guard hasRunningTask else { return nil } + hasRunningTask = false return .completed case .cancelled, .error: + guard hasRunningTask else { return nil } + hasRunningTask = false return .attention - case .running, .unavailable: - return nil } } } @@ -101,14 +107,19 @@ struct CodexTaskStatusLogParser { 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 - if canContinue, let cached { - readStart = cached.offset + if hasCachedBaseline, let cached { + readStart = canContinue + ? cached.offset + : (length > maxBytes ? length - maxBytes : 0) initialState = cached.state initialFailure = cached.currentTurnFailed } else { @@ -124,15 +135,15 @@ struct CodexTaskStatusLogParser { in: raw, droppingLeadingPartialLine: !canContinue && readStart > 0 ) - if !canContinue, complete.data.isEmpty, complete.consumedBytes == 0 { - return nil + if complete.data.isEmpty, complete.consumedBytes == 0 { + return hasCachedBaseline ? cached?.state : nil } let result = parse( complete.data, initialState: initialState, currentTurnFailed: initialFailure ) - if !canContinue, !result.recognizedLifecycle { + if !result.recognizedLifecycle, !hasCachedBaseline { return nil } cache.set( diff --git a/Sources/Model/CodexTaskStatusStore.swift b/Sources/Model/CodexTaskStatusStore.swift index d4c87fe1..daa70516 100644 --- a/Sources/Model/CodexTaskStatusStore.swift +++ b/Sources/Model/CodexTaskStatusStore.swift @@ -82,6 +82,7 @@ final class CodexTaskStatusStore: ObservableObject { private var activityCancellable: AnyCancellable? private var refreshInFlight = false private var lastScanFingerprint: String? + private var soundTracker = CodexTaskStatusSoundTracker() private init() { enabled = Pref.seededBool( @@ -183,14 +184,10 @@ final class CodexTaskStatusStore: ObservableObject { } private func apply(_ nextSnapshot: Snapshot) { - let previousStatus = logState(for: snapshot.status) let nextStatus = logState(for: nextSnapshot.status) snapshot = nextSnapshot - guard soundEnabled, - let event = CodexTaskStatusSoundPolicy.event( - previous: previousStatus, - current: nextStatus - ) + let event = soundTracker.event(for: nextStatus) + guard soundEnabled, let event else { return } playSound(for: event) } diff --git a/Sources/Views/IslandRootView.swift b/Sources/Views/IslandRootView.swift index 0bea1aa6..66ce8871 100644 --- a/Sources/Views/IslandRootView.swift +++ b/Sources/Views/IslandRootView.swift @@ -400,12 +400,18 @@ private struct CompactCodexTaskStatusOverlay: View { if showsDetails { ZStack { HStack(spacing: 0) { - TimelineView(.periodic(from: .now, by: 30)) { context in - Text(elapsedUpdate(at: context.date)) + 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 { diff --git a/Tests/CodexTaskStatusLogParserTests.swift b/Tests/CodexTaskStatusLogParserTests.swift index 3a663033..23b2552f 100644 --- a/Tests/CodexTaskStatusLogParserTests.swift +++ b/Tests/CodexTaskStatusLogParserTests.swift @@ -113,6 +113,26 @@ struct CodexTaskStatusLogParserTests { "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) @@ -128,29 +148,39 @@ struct CodexTaskStatusLogParserTests { "stale terminal state decays below idle" ) + var completionSounds = CodexTaskStatusSoundTracker() + _ = completionSounds.event(for: .running) expect( - CodexTaskStatusSoundPolicy.event(previous: .running, current: .idle) - == .completed, + completionSounds.event(for: .idle) == .completed, "running to idle emits a completion sound event" ) + var errorSounds = CodexTaskStatusSoundTracker() + _ = errorSounds.event(for: .running) expect( - CodexTaskStatusSoundPolicy.event(previous: .running, current: .error) - == .attention, + errorSounds.event(for: .error) == .attention, "running to error emits an attention sound event" ) + var cancelledSounds = CodexTaskStatusSoundTracker() + _ = cancelledSounds.event(for: .running) expect( - CodexTaskStatusSoundPolicy.event(previous: .running, current: .cancelled) - == .attention, + cancelledSounds.event(for: .cancelled) == .attention, "running to cancelled emits an attention sound event" ) + var startupSounds = CodexTaskStatusSoundTracker() expect( - CodexTaskStatusSoundPolicy.event(previous: .idle, current: .error) == nil, + startupSounds.event(for: .error) == nil, "startup and non-running transitions stay silent" ) + var interruptedSounds = CodexTaskStatusSoundTracker() + _ = interruptedSounds.event(for: .running) expect( - CodexTaskStatusSoundPolicy.event(previous: .running, current: .unavailable) == nil, + interruptedSounds.event(for: .unavailable) == nil, "temporary unavailable state stays silent" ) + expect( + interruptedSounds.event(for: .idle) == .completed, + "completion after a temporary unavailable state still emits a sound" + ) if failures > 0 { print("\(failures) failure(s)") From 089f996d2941612f456fcc3dea56863c7bae926b Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Fri, 7 Aug 2026 17:39:52 +0800 Subject: [PATCH 4/9] fix: address task status review feedback --- Sources/Model/CodexTaskStatusLogParser.swift | 22 ++- Sources/Model/CodexTaskStatusStore.swift | 142 +++++++++++++++---- Sources/Views/PanelHeader.swift | 3 + Tests/CodexTaskStatusLogParserTests.swift | 28 +++- 4 files changed, 169 insertions(+), 26 deletions(-) diff --git a/Sources/Model/CodexTaskStatusLogParser.swift b/Sources/Model/CodexTaskStatusLogParser.swift index ce570cdb..6d0304ac 100644 --- a/Sources/Model/CodexTaskStatusLogParser.swift +++ b/Sources/Model/CodexTaskStatusLogParser.swift @@ -37,6 +37,12 @@ struct CodexTaskStatusSoundTracker { } 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?, @@ -44,7 +50,7 @@ enum CodexTaskStatusPolicy { ) -> Int { if state == .error || state == .cancelled, let updatedAt, - now.timeIntervalSince(updatedAt) > 10 * 60 { + isPastTerminalDecay(updatedAt: updatedAt, now: now) { return 0 } switch state { @@ -57,6 +63,20 @@ enum CodexTaskStatusPolicy { } } +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() diff --git a/Sources/Model/CodexTaskStatusStore.swift b/Sources/Model/CodexTaskStatusStore.swift index daa70516..4efe9e7b 100644 --- a/Sources/Model/CodexTaskStatusStore.swift +++ b/Sources/Model/CodexTaskStatusStore.swift @@ -8,6 +8,11 @@ final class CodexTaskStatusStore: ObservableObject { 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 @@ -82,6 +87,8 @@ final class CodexTaskStatusStore: ObservableObject { private var activityCancellable: AnyCancellable? private var refreshInFlight = false private var lastScanFingerprint: String? + private var cachedDayDirectories: [URL] = [] + private var lastFullDirectoryScan: Date? private var soundTracker = CodexTaskStatusSoundTracker() private init() { @@ -111,6 +118,7 @@ final class CodexTaskStatusStore: ObservableObject { enabled && !claudeVisible && codexVisible } .removeDuplicates() + .receive(on: DispatchQueue.main) .sink { [weak self] active in self?.setPollingActive(active) } @@ -121,7 +129,10 @@ final class CodexTaskStatusStore: ObservableObject { timer = nil guard active else { return } refresh() - timer = Timer.scheduledTimer(withTimeInterval: 15, repeats: true) { [weak self] _ in + timer = Timer.scheduledTimer( + withTimeInterval: Self.pollingInterval, + repeats: true + ) { [weak self] _ in Task { @MainActor in self?.refresh() } } } @@ -170,16 +181,24 @@ final class CodexTaskStatusStore: ObservableObject { guard isRenderable, !refreshInFlight else { return } refreshInFlight = true let previousFingerprint = lastScanFingerprint + let cachedDayDirectories = cachedDayDirectories + let lastFullDirectoryScan = lastFullDirectoryScan Task { [weak self] in + guard let self else { return } + defer { self.refreshInFlight = false } let result = await Task.detached(priority: .utility) { - Self.scan(previousFingerprint: previousFingerprint) + Self.scan( + previousFingerprint: previousFingerprint, + cachedDayDirectories: cachedDayDirectories, + lastFullDirectoryScan: lastFullDirectoryScan + ) }.value - guard let self else { return } self.lastScanFingerprint = result.fingerprint + self.cachedDayDirectories = result.cachedDayDirectories + self.lastFullDirectoryScan = result.lastFullDirectoryScan if let snapshot = result.snapshot { self.apply(snapshot) } - self.refreshInFlight = false } } @@ -220,30 +239,61 @@ final class CodexTaskStatusStore: ObservableObject { private struct ScanResult: Sendable { let fingerprint: String let snapshot: Snapshot? + let cachedDayDirectories: [URL] + let lastFullDirectoryScan: Date? } - nonisolated private static func scan(previousFingerprint: String?) -> ScanResult { - guard let files = recentRolloutFiles() else { + private struct RolloutDiscovery: Sendable { + let files: [URL] + let cachedDayDirectories: [URL] + let lastFullDirectoryScan: Date? + } + + nonisolated private static func scan( + previousFingerprint: String?, + cachedDayDirectories: [URL], + lastFullDirectoryScan: Date? + ) -> ScanResult { + let now = Date() + guard let discovery = recentRolloutFiles( + cachedDayDirectories: cachedDayDirectories, + lastFullDirectoryScan: lastFullDirectoryScan, + now: now + ) else { return ScanResult( fingerprint: "unavailable", - snapshot: Snapshot(status: .unavailable, threadID: nil, updatedAt: nil) + snapshot: Snapshot(status: .unavailable, threadID: nil, updatedAt: nil), + 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] ) - return "\(url.path)|\(values?.contentModificationDate?.timeIntervalSince1970 ?? 0)|\(values?.fileSize ?? 0)" + 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) + return ScanResult( + fingerprint: fingerprint, + snapshot: nil, + cachedDayDirectories: discovery.cachedDayDirectories, + lastFullDirectoryScan: discovery.lastFullDirectoryScan + ) } if files.isEmpty { return ScanResult( fingerprint: fingerprint, - snapshot: Snapshot(status: .idle, threadID: nil, updatedAt: nil) + snapshot: Snapshot(status: .idle, threadID: nil, updatedAt: nil), + cachedDayDirectories: discovery.cachedDayDirectories, + lastFullDirectoryScan: discovery.lastFullDirectoryScan ) } @@ -258,10 +308,17 @@ final class CodexTaskStatusStore: ObservableObject { }) else { return ScanResult( fingerprint: fingerprint, - snapshot: Snapshot(status: .unavailable, threadID: nil, updatedAt: nil) + snapshot: Snapshot(status: .unavailable, threadID: nil, updatedAt: nil), + cachedDayDirectories: discovery.cachedDayDirectories, + lastFullDirectoryScan: discovery.lastFullDirectoryScan ) } - return ScanResult(fingerprint: fingerprint, snapshot: selected) + return ScanResult( + fingerprint: fingerprint, + snapshot: selected, + cachedDayDirectories: discovery.cachedDayDirectories, + lastFullDirectoryScan: discovery.lastFullDirectoryScan + ) } nonisolated private static func selectionPriority(_ snapshot: Snapshot) -> Int { @@ -278,7 +335,11 @@ final class CodexTaskStatusStore: ObservableObject { ) } - nonisolated private static func recentRolloutFiles() -> [URL]? { + 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"], @@ -294,22 +355,47 @@ final class CodexTaskStatusStore: ObservableObject { FileManager.default.isReadableFile(atPath: root.path) else { return nil } - let cutoff = Date().addingTimeInterval(-86400) + let cutoff = now.addingTimeInterval(-recentFileAge) var files: [(URL, Date)] = [] - let calendar = Calendar(identifier: .gregorian) - for dayOffset in 0...30 { - guard let date = calendar.date(byAdding: .day, value: -dayOffset, to: Date()) else { - continue - } - let components = calendar.dateComponents([.year, .month, .day], from: 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 { continue } - let dayDirectory = root + 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], @@ -326,12 +412,20 @@ final class CodexTaskStatusStore: ObservableObject { modified >= cutoff else { continue } files.append((url, modified)) + directoriesWithRecentFiles.insert(dayDirectory) } } - return files + let selectedFiles = files .sorted { $0.1 > $1.1 } - .prefix(24) + .prefix(maximumTrackedFiles) .map(\.0) + return RolloutDiscovery( + files: selectedFiles, + cachedDayDirectories: directoriesWithRecentFiles.sorted { + $0.path < $1.path + }, + lastFullDirectoryScan: needsFullScan ? now : lastFullDirectoryScan + ) } nonisolated private static func parseState(at url: URL) -> Snapshot? { diff --git a/Sources/Views/PanelHeader.swift b/Sources/Views/PanelHeader.swift index bba58e05..1816f09f 100644 --- a/Sources/Views/PanelHeader.swift +++ b/Sources/Views/PanelHeader.swift @@ -73,6 +73,9 @@ struct PanelHeader: View { } .padding(.leading, 9) .accessibilityElement(children: .combine) + .accessibilityLabel( + L10n.tr("Codex status: %@", L10n.tr(taskStatus.snapshot.status.label)) + ) } @ViewBuilder diff --git a/Tests/CodexTaskStatusLogParserTests.swift b/Tests/CodexTaskStatusLogParserTests.swift index 23b2552f..5e46e876 100644 --- a/Tests/CodexTaskStatusLogParserTests.swift +++ b/Tests/CodexTaskStatusLogParserTests.swift @@ -41,14 +41,17 @@ struct CodexTaskStatusLogParserTests { ) 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 update.append(to: log) + 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) @@ -147,6 +150,29 @@ struct CodexTaskStatusLogParserTests { ) < 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" + ) var completionSounds = CodexTaskStatusSoundTracker() _ = completionSounds.event(for: .running) From 1f3fa598c2979f9f7c7f55be2edf639b016534d4 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sat, 8 Aug 2026 09:23:22 +0800 Subject: [PATCH 5/9] fix: suppress repeated unavailable snapshots --- Sources/Model/CodexTaskStatusStore.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/Model/CodexTaskStatusStore.swift b/Sources/Model/CodexTaskStatusStore.swift index 4efe9e7b..7ff043fe 100644 --- a/Sources/Model/CodexTaskStatusStore.swift +++ b/Sources/Model/CodexTaskStatusStore.swift @@ -262,7 +262,9 @@ final class CodexTaskStatusStore: ObservableObject { ) else { return ScanResult( fingerprint: "unavailable", - snapshot: Snapshot(status: .unavailable, threadID: nil, updatedAt: nil), + snapshot: previousFingerprint == "unavailable" + ? nil + : Snapshot(status: .unavailable, threadID: nil, updatedAt: nil), cachedDayDirectories: cachedDayDirectories, lastFullDirectoryScan: lastFullDirectoryScan ) From be962f46e9d8f527820c94e2ad3daa6b9c663092 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sun, 9 Aug 2026 18:38:39 +0800 Subject: [PATCH 6/9] fix: notify for every Codex task completion --- Sources/Model/CodexTaskStatusLogParser.swift | 40 +++++++++++-- Sources/Model/CodexTaskStatusStore.swift | 59 +++++++++++++------- Tests/CodexTaskStatusLogParserTests.swift | 32 +++++++++++ 3 files changed, 104 insertions(+), 27 deletions(-) diff --git a/Sources/Model/CodexTaskStatusLogParser.swift b/Sources/Model/CodexTaskStatusLogParser.swift index 6d0304ac..b0611a72 100644 --- a/Sources/Model/CodexTaskStatusLogParser.swift +++ b/Sources/Model/CodexTaskStatusLogParser.swift @@ -1,6 +1,6 @@ import Foundation -enum CodexTaskLogState: Equatable { +enum CodexTaskLogState: Equatable, Sendable { case running case idle case cancelled @@ -8,11 +8,16 @@ enum CodexTaskLogState: Equatable { case unavailable } -enum CodexTaskStatusSoundEvent: Equatable { +enum CodexTaskStatusSoundEvent: Equatable, Sendable { case completed case attention } +struct CodexTaskLogParseResult: Equatable, Sendable { + let state: CodexTaskLogState + let soundEvents: [CodexTaskStatusSoundEvent] +} + struct CodexTaskStatusSoundTracker { private(set) var hasRunningTask = false @@ -120,6 +125,13 @@ struct CodexTaskStatusLogParser { } 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 } @@ -156,7 +168,9 @@ struct CodexTaskStatusLogParser { droppingLeadingPartialLine: !canContinue && readStart > 0 ) if complete.data.isEmpty, complete.consumedBytes == 0 { - return hasCachedBaseline ? cached?.state : nil + return hasCachedBaseline + ? cached.map { CodexTaskLogParseResult(state: $0.state, soundEvents: []) } + : nil } let result = parse( complete.data, @@ -174,7 +188,10 @@ struct CodexTaskStatusLogParser { ), for: url ) - return result.state + return CodexTaskLogParseResult( + state: result.state, + soundEvents: result.soundEvents + ) } private static func completeLines( @@ -204,10 +221,18 @@ struct CodexTaskStatusLogParser { _ data: Data, initialState: CodexTaskLogState, currentTurnFailed initialFailure: Bool - ) -> (state: CodexTaskLogState, currentTurnFailed: Bool, recognizedLifecycle: Bool) { + ) -> ( + state: CodexTaskLogState, + currentTurnFailed: Bool, + recognizedLifecycle: Bool, + soundEvents: [CodexTaskStatusSoundEvent] + ) { var state = initialState var currentTurnFailed = initialFailure var recognizedLifecycle = false + var soundTracker = CodexTaskStatusSoundTracker() + var soundEvents: [CodexTaskStatusSoundEvent] = [] + _ = soundTracker.event(for: initialState) for line in data.split(separator: newline) { guard let event = eventType(in: line) else { continue } @@ -235,8 +260,11 @@ struct CodexTaskStatusLogParser { default: break } + if let soundEvent = soundTracker.event(for: state) { + soundEvents.append(soundEvent) + } } - return (state, currentTurnFailed, recognizedLifecycle) + return (state, currentTurnFailed, recognizedLifecycle, soundEvents) } private static func eventType(in line: Data.SubSequence) -> String? { diff --git a/Sources/Model/CodexTaskStatusStore.swift b/Sources/Model/CodexTaskStatusStore.swift index 7ff043fe..5f653ec8 100644 --- a/Sources/Model/CodexTaskStatusStore.swift +++ b/Sources/Model/CodexTaskStatusStore.swift @@ -89,7 +89,7 @@ final class CodexTaskStatusStore: ObservableObject { private var lastScanFingerprint: String? private var cachedDayDirectories: [URL] = [] private var lastFullDirectoryScan: Date? - private var soundTracker = CodexTaskStatusSoundTracker() + private var hasCompletedInitialScan = false private init() { enabled = Pref.seededBool( @@ -199,25 +199,26 @@ final class CodexTaskStatusStore: ObservableObject { if let snapshot = result.snapshot { self.apply(snapshot) } + let shouldPlaySounds = self.hasCompletedInitialScan && self.soundEnabled + self.hasCompletedInitialScan = true + if shouldPlaySounds { + self.playSounds(result.soundEvents) + } } } private func apply(_ nextSnapshot: Snapshot) { - let nextStatus = logState(for: nextSnapshot.status) snapshot = nextSnapshot - let event = soundTracker.event(for: nextStatus) - guard soundEnabled, let event - else { return } - playSound(for: event) } - private func logState(for status: Status) -> CodexTaskLogState { - switch status { - case .running: .running - case .idle: .idle - case .cancelled: .cancelled - case .error: .error - case .unavailable: .unavailable + private func playSounds(_ events: [CodexTaskStatusSoundEvent]) { + for (index, event) in events.enumerated() { + DispatchQueue.main.asyncAfter( + deadline: .now() + Double(index) * 0.45 + ) { [weak self] in + guard let self, self.soundEnabled else { return } + self.playSound(for: event) + } } } @@ -239,6 +240,7 @@ final class CodexTaskStatusStore: ObservableObject { private struct ScanResult: Sendable { let fingerprint: String let snapshot: Snapshot? + let soundEvents: [CodexTaskStatusSoundEvent] let cachedDayDirectories: [URL] let lastFullDirectoryScan: Date? } @@ -265,6 +267,7 @@ final class CodexTaskStatusStore: ObservableObject { snapshot: previousFingerprint == "unavailable" ? nil : Snapshot(status: .unavailable, threadID: nil, updatedAt: nil), + soundEvents: [], cachedDayDirectories: cachedDayDirectories, lastFullDirectoryScan: lastFullDirectoryScan ) @@ -285,6 +288,7 @@ final class CodexTaskStatusStore: ObservableObject { return ScanResult( fingerprint: fingerprint, snapshot: nil, + soundEvents: [], cachedDayDirectories: discovery.cachedDayDirectories, lastFullDirectoryScan: discovery.lastFullDirectoryScan ) @@ -294,12 +298,15 @@ final class CodexTaskStatusStore: ObservableObject { return ScanResult( fingerprint: fingerprint, snapshot: Snapshot(status: .idle, threadID: nil, updatedAt: nil), + soundEvents: [], cachedDayDirectories: discovery.cachedDayDirectories, lastFullDirectoryScan: discovery.lastFullDirectoryScan ) } - let states = files.compactMap(parseState) + let parsedStates = files.compactMap(parseState) + 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) @@ -311,6 +318,7 @@ final class CodexTaskStatusStore: ObservableObject { return ScanResult( fingerprint: fingerprint, snapshot: Snapshot(status: .unavailable, threadID: nil, updatedAt: nil), + soundEvents: soundEvents, cachedDayDirectories: discovery.cachedDayDirectories, lastFullDirectoryScan: discovery.lastFullDirectoryScan ) @@ -318,6 +326,7 @@ final class CodexTaskStatusStore: ObservableObject { return ScanResult( fingerprint: fingerprint, snapshot: selected, + soundEvents: soundEvents, cachedDayDirectories: discovery.cachedDayDirectories, lastFullDirectoryScan: discovery.lastFullDirectoryScan ) @@ -430,14 +439,19 @@ final class CodexTaskStatusStore: ObservableObject { ) } - nonisolated private static func parseState(at url: URL) -> Snapshot? { - guard let parsed = CodexTaskStatusLogParser.parse(at: url), + private struct ParsedSnapshot: Sendable { + let snapshot: Snapshot + let soundEvents: [CodexTaskStatusSoundEvent] + } + + nonisolated private static func parseState(at url: URL) -> ParsedSnapshot? { + guard let parsed = CodexTaskStatusLogParser.parseUpdate(at: url), let modified = try? url.resourceValues( forKeys: [.contentModificationDateKey] ).contentModificationDate else { return nil } - let status: Status = switch parsed { + let status: Status = switch parsed.state { case .running: .running case .idle: .idle case .cancelled: .cancelled @@ -445,10 +459,13 @@ final class CodexTaskStatusStore: ObservableObject { case .unavailable: .unavailable } - return Snapshot( - status: status, - threadID: threadID(from: url), - updatedAt: modified + return ParsedSnapshot( + snapshot: Snapshot( + status: status, + threadID: threadID(from: url), + updatedAt: modified + ), + soundEvents: parsed.soundEvents ) } diff --git a/Tests/CodexTaskStatusLogParserTests.swift b/Tests/CodexTaskStatusLogParserTests.swift index 5e46e876..3c16e7b2 100644 --- a/Tests/CodexTaskStatusLogParserTests.swift +++ b/Tests/CodexTaskStatusLogParserTests.swift @@ -208,6 +208,38 @@ struct CodexTaskStatusLogParserTests { "completion after a temporary unavailable state still emits a sound" ) + 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 + == [.attention], + "each independently parsed task emits its own terminal event" + ) + if failures > 0 { print("\(failures) failure(s)") exit(1) From 1d9e5c0a3b1ab19ca50ebd1346c619ce94ab966a Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sun, 9 Aug 2026 21:52:51 +0800 Subject: [PATCH 7/9] fix: distinguish Codex task alerts --- README.md | 7 +- README.zh-CN.md | 6 +- Resources/en.lproj/Localizable.strings | 2 + Resources/zh-Hans.lproj/Localizable.strings | 2 + Sources/Model/CodexTaskStatusLogParser.swift | 191 ++++++++++++++----- Sources/Model/CodexTaskStatusStore.swift | 49 ++++- Sources/Views/CodexTaskStatusView.swift | 2 + Tests/CodexTaskStatusLogParserTests.swift | 118 ++++++++---- 8 files changed, 279 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 5e728e14..e4d48305 100644 --- a/README.md +++ b/README.md @@ -199,9 +199,10 @@ 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, idle, -cancelled, error, or unavailable. Approval and user-input waits are not claimed -because current rollout files do not record those app-server events. 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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 4895ea0c..d1f00c46 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -105,9 +105,9 @@ Claude: 刷新间隔会立即生效。`UsageStore` 会重置当前计时器,并用新的间隔重新安排下一次拉取。 隐藏 Claude、保留 Codex 时,可以选择显示本地 Codex 任务状态。状态从 -`~/.codex/sessions/**/*.jsonl` 当前实际记录的生命周期事件推断,只显示运行中、空闲、 -已取消、异常和不可用。当前 rollout 文件并不记录审批或等待用户输入事件,因此本功能不会 -声称能够识别这两种状态。 +`~/.codex/sessions/**/*.jsonl` 当前实际记录的生命周期事件推断,只显示运行中、等待审批、 +空闲、已取消、异常和不可用。等待审批通过尚未获得响应的 `request_permissions` 调用识别; +当前 rollout 文件仍没有可靠的等待用户输入事件,因此本功能不会声称能够识别该状态。 该功能默认关闭,并且只有在功能已开启、Claude 已隐藏且 Codex 可见时才轮询。收起和预览 状态仅用于显示,不会抢占灵动岛原本的点击展开行为;只有展开后的状态卡会尝试打开 Codex diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index c2ecb783..765f4c29 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -104,6 +104,8 @@ "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"; diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index 99a35aae..b2033291 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -104,6 +104,8 @@ "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" = "取消"; diff --git a/Sources/Model/CodexTaskStatusLogParser.swift b/Sources/Model/CodexTaskStatusLogParser.swift index b0611a72..4e63fa27 100644 --- a/Sources/Model/CodexTaskStatusLogParser.swift +++ b/Sources/Model/CodexTaskStatusLogParser.swift @@ -2,6 +2,7 @@ import Foundation enum CodexTaskLogState: Equatable, Sendable { case running + case waitingApproval case idle case cancelled case error @@ -10,35 +11,15 @@ enum CodexTaskLogState: Equatable, Sendable { enum CodexTaskStatusSoundEvent: Equatable, Sendable { case completed - case attention + case error + case cancelled + case approvalRequired } struct CodexTaskLogParseResult: Equatable, Sendable { let state: CodexTaskLogState let soundEvents: [CodexTaskStatusSoundEvent] -} - -struct CodexTaskStatusSoundTracker { - private(set) var hasRunningTask = false - - mutating func event(for state: CodexTaskLogState) -> CodexTaskStatusSoundEvent? { - switch state { - case .running: - hasRunningTask = true - return nil - case .unavailable: - // A temporary read gap must not erase a known running task. - return nil - case .idle: - guard hasRunningTask else { return nil } - hasRunningTask = false - return .completed - case .cancelled, .error: - guard hasRunningTask else { return nil } - hasRunningTask = false - return .attention - } - } + let isInitialRead: Bool } enum CodexTaskStatusPolicy { @@ -59,6 +40,7 @@ enum CodexTaskStatusPolicy { return 0 } switch state { + case .waitingApproval: return 6 case .running: return 5 case .error: return 3 case .cancelled: return 2 @@ -90,16 +72,20 @@ struct CodexTaskStatusLogParser { "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() @@ -117,6 +103,19 @@ struct CodexTaskStatusLogParser { 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 } } @@ -124,6 +123,29 @@ struct CodexTaskStatusLogParser { 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 } @@ -148,16 +170,19 @@ struct CodexTaskStatusLogParser { 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) @@ -169,13 +194,20 @@ struct CodexTaskStatusLogParser { ) if complete.data.isEmpty, complete.consumedBytes == 0 { return hasCachedBaseline - ? cached.map { CodexTaskLogParseResult(state: $0.state, soundEvents: []) } + ? cached.map { + CodexTaskLogParseResult( + state: $0.state, + soundEvents: [], + isInitialRead: false + ) + } : nil } let result = parse( complete.data, initialState: initialState, - currentTurnFailed: initialFailure + currentTurnFailed: initialFailure, + pendingPermissionCallIDs: initialPendingPermissionCallIDs ) if !result.recognizedLifecycle, !hasCachedBaseline { return nil @@ -184,13 +216,15 @@ struct CodexTaskStatusLogParser { CacheEntry( offset: readStart + UInt64(complete.consumedBytes), state: result.state, - currentTurnFailed: result.currentTurnFailed + currentTurnFailed: result.currentTurnFailed, + pendingPermissionCallIDs: result.pendingPermissionCallIDs ), for: url ) return CodexTaskLogParseResult( state: result.state, - soundEvents: result.soundEvents + soundEvents: result.soundEvents, + isInitialRead: !hasCachedBaseline ) } @@ -220,62 +254,123 @@ struct CodexTaskStatusLogParser { private static func parse( _ data: Data, initialState: CodexTaskLogState, - currentTurnFailed initialFailure: Bool + currentTurnFailed initialFailure: Bool, + pendingPermissionCallIDs initialPendingPermissionCallIDs: Set ) -> ( state: CodexTaskLogState, currentTurnFailed: Bool, recognizedLifecycle: Bool, - soundEvents: [CodexTaskStatusSoundEvent] + soundEvents: [CodexTaskStatusSoundEvent], + pendingPermissionCallIDs: Set ) { var state = initialState var currentTurnFailed = initialFailure + var pendingPermissionCallIDs = initialPendingPermissionCallIDs var recognizedLifecycle = false - var soundTracker = CodexTaskStatusSoundTracker() var soundEvents: [CodexTaskStatusSoundEvent] = [] - _ = soundTracker.event(for: initialState) for line in data.split(separator: newline) { - guard let event = eventType(in: line) else { continue } + guard let event = parsedEvent( + in: line, + expectsPermissionOutput: !pendingPermissionCallIDs.isEmpty + ) else { continue } switch event { - case "task_started", "user_message": + case .lifecycle("task_started"), .lifecycle("user_message"): recognizedLifecycle = true currentTurnFailed = false + pendingPermissionCallIDs.removeAll() state = .running - case "exec_command_end", "patch_apply_end", "mcp_tool_call_end": + case .lifecycle("exec_command_end"), + .lifecycle("patch_apply_end"), + .lifecycle("mcp_tool_call_end"): recognizedLifecycle = true - if !currentTurnFailed { + if !currentTurnFailed && pendingPermissionCallIDs.isEmpty { state = .running } - case "task_complete": + case .lifecycle("task_complete"): recognizedLifecycle = true - state = currentTurnFailed ? .error : .idle - case "turn_aborted": + 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 - case "error", "stream_error": + 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 } - if let soundEvent = soundTracker.event(for: state) { - soundEvents.append(soundEvent) - } } - return (state, currentTurnFailed, recognizedLifecycle, soundEvents) + return ( + state, + currentTurnFailed, + recognizedLifecycle, + soundEvents, + pendingPermissionCallIDs + ) + } + + private enum ParsedEvent { + case lifecycle(String) + case permissionRequested(String) + case permissionResolved(String) } - private static func eventType(in line: Data.SubSequence) -> 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 }), + 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], - (raw["type"] as? String) == "event_msg", let payload = raw["payload"] as? [String: Any] else { return nil } - return payload["type"] as? String + + 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 index 5f653ec8..b9bf7939 100644 --- a/Sources/Model/CodexTaskStatusStore.swift +++ b/Sources/Model/CodexTaskStatusStore.swift @@ -28,6 +28,7 @@ final class CodexTaskStatusStore: ObservableObject { enum Status: String, CaseIterable, Sendable { case running + case waitingApproval case idle case cancelled case error @@ -36,6 +37,7 @@ final class CodexTaskStatusStore: ObservableObject { var label: String { switch self { case .running: "Running" + case .waitingApproval: "Waiting for approval" case .idle: "Idle" case .cancelled: "Cancelled" case .error: "Error" @@ -46,6 +48,7 @@ final class CodexTaskStatusStore: ObservableObject { var compactLabel: String { switch self { case .running: "Running short" + case .waitingApproval: "Approval short" case .idle: "Idle" case .cancelled: "Cancelled short" case .error: "Error" @@ -90,6 +93,7 @@ final class CodexTaskStatusStore: ObservableObject { private var cachedDayDirectories: [URL] = [] private var lastFullDirectoryScan: Date? private var hasCompletedInitialScan = false + private var monitoringStartedAt: Date? private init() { enabled = Pref.seededBool( @@ -127,7 +131,13 @@ final class CodexTaskStatusStore: ObservableObject { private func setPollingActive(_ active: Bool) { timer?.invalidate() timer = nil - guard active else { return } + guard active else { + hasCompletedInitialScan = false + monitoringStartedAt = nil + return + } + hasCompletedInitialScan = false + monitoringStartedAt = Date() refresh() timer = Timer.scheduledTimer( withTimeInterval: Self.pollingInterval, @@ -183,6 +193,7 @@ final class CodexTaskStatusStore: ObservableObject { let previousFingerprint = lastScanFingerprint let cachedDayDirectories = cachedDayDirectories let lastFullDirectoryScan = lastFullDirectoryScan + let monitoringStartedAt = monitoringStartedAt ?? Date() Task { [weak self] in guard let self else { return } defer { self.refreshInFlight = false } @@ -190,7 +201,8 @@ final class CodexTaskStatusStore: ObservableObject { Self.scan( previousFingerprint: previousFingerprint, cachedDayDirectories: cachedDayDirectories, - lastFullDirectoryScan: lastFullDirectoryScan + lastFullDirectoryScan: lastFullDirectoryScan, + monitoringStartedAt: monitoringStartedAt ) }.value self.lastScanFingerprint = result.fingerprint @@ -225,7 +237,9 @@ final class CodexTaskStatusStore: ObservableObject { private func playSound(for event: CodexTaskStatusSoundEvent) { let name = switch event { case .completed: "Glass" - case .attention: "Basso" + case .error: "Basso" + case .cancelled: "Funk" + case .approvalRequired: "Ping" } if NSSound(named: NSSound.Name(name))?.play() != true { NSSound.beep() @@ -254,7 +268,8 @@ final class CodexTaskStatusStore: ObservableObject { nonisolated private static func scan( previousFingerprint: String?, cachedDayDirectories: [URL], - lastFullDirectoryScan: Date? + lastFullDirectoryScan: Date?, + monitoringStartedAt: Date ) -> ScanResult { let now = Date() guard let discovery = recentRolloutFiles( @@ -272,8 +287,10 @@ final class CodexTaskStatusStore: ObservableObject { lastFullDirectoryScan: lastFullDirectoryScan ) } - let files = discovery.files - CodexTaskStatusLogParser.retainCache(for: Set(files)) + CodexTaskStatusLogParser.retainCache(for: Set(discovery.files)) + let files = discovery.files.filter { + !CodexTaskStatusLogParser.isSubagentSession(at: $0) + } let fingerprint = files.map { url in let values = try? url.resourceValues( forKeys: [.contentModificationDateKey, .fileSizeKey] @@ -304,7 +321,9 @@ final class CodexTaskStatusStore: ObservableObject { ) } - let parsedStates = files.compactMap(parseState) + 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 @@ -335,6 +354,7 @@ final class CodexTaskStatusStore: ObservableObject { 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 @@ -444,7 +464,10 @@ final class CodexTaskStatusStore: ObservableObject { let soundEvents: [CodexTaskStatusSoundEvent] } - nonisolated private static func parseState(at url: URL) -> ParsedSnapshot? { + 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] @@ -453,19 +476,27 @@ final class CodexTaskStatusStore: ObservableObject { 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: parsed.soundEvents + soundEvents: soundEvents ) } diff --git a/Sources/Views/CodexTaskStatusView.swift b/Sources/Views/CodexTaskStatusView.swift index 04e703dc..584a91a8 100644 --- a/Sources/Views/CodexTaskStatusView.swift +++ b/Sources/Views/CodexTaskStatusView.swift @@ -131,6 +131,7 @@ struct CodexTaskStatusGlyph: View { 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) @@ -143,6 +144,7 @@ struct CodexTaskStatusGlyph: View { 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" diff --git a/Tests/CodexTaskStatusLogParserTests.swift b/Tests/CodexTaskStatusLogParserTests.swift index 3c16e7b2..9d805a39 100644 --- a/Tests/CodexTaskStatusLogParserTests.swift +++ b/Tests/CodexTaskStatusLogParserTests.swift @@ -22,6 +22,41 @@ struct CodexTaskStatusLogParserTests { 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) @@ -174,40 +209,6 @@ struct CodexTaskStatusLogParserTests { "rollout directory components use the UTC date" ) - var completionSounds = CodexTaskStatusSoundTracker() - _ = completionSounds.event(for: .running) - expect( - completionSounds.event(for: .idle) == .completed, - "running to idle emits a completion sound event" - ) - var errorSounds = CodexTaskStatusSoundTracker() - _ = errorSounds.event(for: .running) - expect( - errorSounds.event(for: .error) == .attention, - "running to error emits an attention sound event" - ) - var cancelledSounds = CodexTaskStatusSoundTracker() - _ = cancelledSounds.event(for: .running) - expect( - cancelledSounds.event(for: .cancelled) == .attention, - "running to cancelled emits an attention sound event" - ) - var startupSounds = CodexTaskStatusSoundTracker() - expect( - startupSounds.event(for: .error) == nil, - "startup and non-running transitions stay silent" - ) - var interruptedSounds = CodexTaskStatusSoundTracker() - _ = interruptedSounds.event(for: .running) - expect( - interruptedSounds.event(for: .unavailable) == nil, - "temporary unavailable state stays silent" - ) - expect( - interruptedSounds.event(for: .idle) == .completed, - "completion after a temporary unavailable state still emits a sound" - ) - let shortTask = directory.appendingPathComponent("rollout-short-task.jsonl") var shortTaskData = event("task_started") shortTaskData.append(event("task_complete")) @@ -236,10 +237,57 @@ struct CodexTaskStatusLogParserTests { try parallelTaskData.write(to: parallelTask) expect( CodexTaskStatusLogParser.parseUpdate(at: parallelTask)?.soundEvents - == [.attention], + == [.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" + ) + if failures > 0 { print("\(failures) failure(s)") exit(1) From 8264e0f1c42655c2b38ea6965b78443dfc09dfc3 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sun, 9 Aug 2026 23:02:55 +0800 Subject: [PATCH 8/9] fix: harden Codex task monitoring lifecycle --- Sources/Model/CodexTaskStatusLogParser.swift | 12 +++++ Sources/Model/CodexTaskStatusStore.swift | 46 ++++++++++++++------ Tests/CodexTaskStatusLogParserTests.swift | 17 ++++++++ scripts/run-tests.sh | 10 ----- 4 files changed, 62 insertions(+), 23 deletions(-) diff --git a/Sources/Model/CodexTaskStatusLogParser.swift b/Sources/Model/CodexTaskStatusLogParser.swift index 4e63fa27..3ace4f42 100644 --- a/Sources/Model/CodexTaskStatusLogParser.swift +++ b/Sources/Model/CodexTaskStatusLogParser.swift @@ -22,6 +22,18 @@ struct CodexTaskLogParseResult: Equatable, Sendable { 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 diff --git a/Sources/Model/CodexTaskStatusStore.swift b/Sources/Model/CodexTaskStatusStore.swift index b9bf7939..4b70c736 100644 --- a/Sources/Model/CodexTaskStatusStore.swift +++ b/Sources/Model/CodexTaskStatusStore.swift @@ -88,7 +88,8 @@ final class CodexTaskStatusStore: ObservableObject { private var timer: Timer? private var activityCancellable: AnyCancellable? - private var refreshInFlight = false + private var refreshInFlightGeneration: UInt64? + private var monitoringGeneration: UInt64 = 0 private var lastScanFingerprint: String? private var cachedDayDirectories: [URL] = [] private var lastFullDirectoryScan: Date? @@ -131,6 +132,7 @@ final class CodexTaskStatusStore: ObservableObject { private func setPollingActive(_ active: Bool) { timer?.invalidate() timer = nil + monitoringGeneration &+= 1 guard active else { hasCompletedInitialScan = false monitoringStartedAt = nil @@ -188,15 +190,24 @@ final class CodexTaskStatusStore: ObservableObject { } private func refresh() { - guard isRenderable, !refreshInFlight else { return } - refreshInFlight = true + 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 { self.refreshInFlight = false } + 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, @@ -205,6 +216,8 @@ final class CodexTaskStatusStore: ObservableObject { monitoringStartedAt: monitoringStartedAt ) }.value + guard self.monitoringGeneration == generation, + self.isRenderable else { return } self.lastScanFingerprint = result.fingerprint self.cachedDayDirectories = result.cachedDayDirectories self.lastFullDirectoryScan = result.lastFullDirectoryScan @@ -214,7 +227,7 @@ final class CodexTaskStatusStore: ObservableObject { let shouldPlaySounds = self.hasCompletedInitialScan && self.soundEnabled self.hasCompletedInitialScan = true if shouldPlaySounds { - self.playSounds(result.soundEvents) + self.playSounds(result.soundEvents, generation: generation) } } } @@ -223,12 +236,18 @@ final class CodexTaskStatusStore: ObservableObject { snapshot = nextSnapshot } - private func playSounds(_ events: [CodexTaskStatusSoundEvent]) { + 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.soundEnabled else { return } + guard let self, + self.monitoringGeneration == generation, + self.isRenderable, + self.soundEnabled else { return } self.playSound(for: event) } } @@ -287,10 +306,8 @@ final class CodexTaskStatusStore: ObservableObject { lastFullDirectoryScan: lastFullDirectoryScan ) } - CodexTaskStatusLogParser.retainCache(for: Set(discovery.files)) - let files = discovery.files.filter { - !CodexTaskStatusLogParser.isSubagentSession(at: $0) - } + let files = discovery.files + CodexTaskStatusLogParser.retainCache(for: Set(files)) let fingerprint = files.map { url in let values = try? url.resourceValues( forKeys: [.contentModificationDateKey, .fileSizeKey] @@ -446,10 +463,13 @@ final class CodexTaskStatusStore: ObservableObject { directoriesWithRecentFiles.insert(dayDirectory) } } - let selectedFiles = files + let filesByRecency = files .sorted { $0.1 > $1.1 } - .prefix(maximumTrackedFiles) .map(\.0) + let selectedFiles = CodexTaskStatusFilePolicy.selectTopLevelFiles( + from: filesByRecency, + maximumCount: maximumTrackedFiles + ) return RolloutDiscovery( files: selectedFiles, cachedDayDirectories: directoriesWithRecentFiles.sorted { diff --git a/Tests/CodexTaskStatusLogParserTests.swift b/Tests/CodexTaskStatusLogParserTests.swift index 9d805a39..89768231 100644 --- a/Tests/CodexTaskStatusLogParserTests.swift +++ b/Tests/CodexTaskStatusLogParserTests.swift @@ -288,6 +288,23 @@ struct CodexTaskStatusLogParserTests { "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) diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 9877aeaf..af4e00b3 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -47,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" \ From 3236051973024e381918dadc644a8f7bdc8ce846 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sun, 9 Aug 2026 23:32:58 +0800 Subject: [PATCH 9/9] docs: explain Codex task status alerts --- README.md | 7 +++++++ README.zh-CN.md | 4 ++++ Resources/en.lproj/Localizable.strings | 1 + Resources/zh-Hans.lproj/Localizable.strings | 1 + Sources/Views/CodexTaskStatusView.swift | 1 + Sources/Views/SettingsView.swift | 2 +- 6 files changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e4d48305..169c9571 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,13 @@ 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 diff --git a/README.zh-CN.md b/README.zh-CN.md index d1f00c46..597484ac 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -109,6 +109,10 @@ Claude: 空闲、已取消、异常和不可用。等待审批通过尚未获得响应的 `request_permissions` 调用识别; 当前 rollout 文件仍没有可靠的等待用户输入事件,因此本功能不会声称能够识别该状态。 +状态提示音可选开启:完成使用 Glass,等待审批使用 Ping,取消使用 Funk, +报错使用 Basso。只有顶层任务会触发提示,Codex 内部的 guardian/子任务日志会被排除, +避免主任务尚未完成时误播放完成音。监控代次也会防止旧扫描结果或排队中的声音跨设置切换继续生效。 + 该功能默认关闭,并且只有在功能已开启、Claude 已隐藏且 Codex 可见时才轮询。收起和预览 状态仅用于显示,不会抢占灵动岛原本的点击展开行为;只有展开后的状态卡会尝试打开 Codex 任务。关闭功能后会恢复原有的按模型 Token 用量视图。 diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index 765f4c29..d8403def 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -126,6 +126,7 @@ "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 b2033291..2908c67c 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -126,6 +126,7 @@ "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/Views/CodexTaskStatusView.swift b/Sources/Views/CodexTaskStatusView.swift index 584a91a8..60a485aa 100644 --- a/Sources/Views/CodexTaskStatusView.swift +++ b/Sources/Views/CodexTaskStatusView.swift @@ -66,6 +66,7 @@ struct CodexTaskStatusView: View { ) .opacity(status == store.snapshot.status ? 1 : 0.28) .frame(maxWidth: .infinity) + .help(L10n.tr(status.label)) } } .padding(.horizontal, 6) diff --git a/Sources/Views/SettingsView.swift b/Sources/Views/SettingsView.swift index 7fd0d591..bebcd162 100644 --- a/Sources/Views/SettingsView.swift +++ b/Sources/Views/SettingsView.swift @@ -539,7 +539,7 @@ struct SettingsView: View { .opacity(codexTaskStatus.enabled ? 1 : 0.4) SettingsRow( title: "Status sounds", - subtitle: "Play a sound when a running task completes, is cancelled, or fails." + subtitle: "Use distinct sounds for completion, approval, cancellation, and errors." ) { SettingsToggle(isOn: codexTaskStatus.soundEnabled) { codexTaskStatus.soundEnabled.toggle()