From 982ae9ec87994cc096d10d22cd269162d9e65c04 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Wed, 29 Jul 2026 23:58:02 +0800 Subject: [PATCH 1/5] feat: add adaptive appearance and Codex task status --- README.md | 38 ++- README.zh-CN.md | 26 +- Resources/en.lproj/Localizable.strings | 26 ++ Resources/zh-Hans.lproj/Localizable.strings | 26 ++ Sources/App.swift | 1 + Sources/Model/AppearanceStore.swift | 40 +++ Sources/Model/CodexTaskStatusStore.swift | 247 ++++++++++++++++++ Sources/Theme/Colors.swift | 14 + Sources/Views/CodexTaskStatusView.swift | 152 +++++++++++ Sources/Views/IslandRootView.swift | 94 +++++++ Sources/Views/PageIndicator.swift | 27 +- Sources/Views/PanelFooter.swift | 46 ++-- Sources/Views/PanelHeader.swift | 43 ++- Sources/Views/Settings/BrandHeader.swift | 10 +- Sources/Views/Settings/ChartStylePicker.swift | 8 +- Sources/Views/Settings/CostStylePicker.swift | 6 +- Sources/Views/Settings/SegmentedControl.swift | 19 +- Sources/Views/Settings/SettingsFooter.swift | 12 +- Sources/Views/Settings/SettingsRow.swift | 10 +- Sources/Views/Settings/SettingsToggle.swift | 6 +- Sources/Views/Settings/StyleTile.swift | 4 +- Sources/Views/SettingsView.swift | 76 ++++-- Sources/Views/SettingsWindowController.swift | 14 +- Sources/Views/UsageView.swift | 14 +- Sources/Window/IslandWindowController.swift | 41 ++- build.sh | 4 + 26 files changed, 888 insertions(+), 116 deletions(-) create mode 100644 Sources/Model/AppearanceStore.swift create mode 100644 Sources/Model/CodexTaskStatusStore.swift create mode 100644 Sources/Views/CodexTaskStatusView.swift diff --git a/README.md b/README.md index a5382fdb..dc13f5f9 100644 --- a/README.md +++ b/README.md @@ -184,17 +184,45 @@ Settings is a custom `NSWindow`, not the system Settings scene. The app still runs as an accessory app with no Dock icon and no menu bar. - **General:** Launch at Login, 5m/15m/30m refresh interval, app language, - Always show usage, Low Power Mode, configurable limit alerts, and Sparkle - update controls. + light/dark/system appearance, Always show usage, Low Power Mode, + configurable limit alerts, and Sparkle update controls. - **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, the freed half + can show the most important local Codex task state and open that task. 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. +`SMAppService.mainApp`). Appearance, 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` lifecycle events. CodexIsland keeps only the +five display states (running, waiting for approval, waiting for input, idle, +and error), a thread ID for deep-linking, and the update time. It does not +display task prompts, commands, or output, and it does not modify Codex +configuration. The status uses icons by default; an optional setting adds a +localized label beside the icon. + +### Why the Codex task status view exists + +Not everyone subscribes to both Claude and Codex. When a user works only with +Codex and hides Claude, leaving the entire Claude half empty wastes the most +glanceable part of the island. + +The optional Codex task status view turns that freed space into a focused +companion for the service the user actually uses. It shows only whether the +most urgent local task is running, waiting for approval, waiting for input, +idle, or in an error state. This is enough to tell the user when attention is +needed without exposing prompts, commands, or output. Clicking the status +opens the selected task in Codex. + +In the compact island, the status group mirrors the Codex quota group with +three aligned components: elapsed time, current state, and a state icon. In +the expanded panel, the same five-state visual language fills the otherwise +unused provider column. Users who prefer the original per-model token +breakdown can disable the feature at any time. ## Build from source diff --git a/README.zh-CN.md b/README.zh-CN.md index 9513e634..fdbbf374 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -91,6 +91,7 @@ Claude: | 设置 | 存储 | UserDefaults key | 值 | | --- | --- | --- | --- | +| 外观 | `AppearanceStore` | `MacIsland.appearance` | `system`, `light`, `dark`,默认 `dark` | | 图表样式 | `StylePref` | `MacIsland.chartStyle` | `ring`, `bar`, `stepped`, `numeric`, `spark` | | 成本样式 | `CostStylePref` | `MacIsland.costStyle` | `dollar`, `multi`, `tokens`, `spark` | | Token 统计 | `TokenCountModeStore` | `MacIsland.tokenCountMode` | `all`, `billable` | @@ -98,9 +99,32 @@ Claude: | 低功耗模式 | `LowPowerModeStore` | `MacIsland.lowPowerMode` | Boolean,默认 `false` | | Claude 可见 | `ProviderVisibilityStore` | `MacIsland.claudeVisible` | Boolean,默认 `true` | | Codex 可见 | `ProviderVisibilityStore` | `MacIsland.codexVisible` | Boolean,默认 `true` | +| Codex 任务状态 | `CodexTaskStatusStore` | `MacIsland.codexTaskStatus` | Boolean,默认 `true` | +| 状态显示 | `CodexTaskStatusStore` | `MacIsland.codexTaskStatusDisplayMode` | `icon`, `iconAndText`,默认 `icon` | | 登录启动 | `LaunchAtLoginStore` | 由 `SMAppService.mainApp` 管理 | 系统登录项状态 | -刷新间隔会立即生效。`UsageStore` 会重置当前计时器,并用新的间隔重新安排下一次拉取。 +外观和刷新间隔都会立即生效;选择“跟随系统”后,设置窗口会随 macOS +浅色/深色外观自动切换。`UsageStore` 会重置当前计时器,并用新的间隔重新安排下一次拉取。 + +隐藏 Claude、保留 Codex 时,左半区可以显示本地 Codex 任务的五种状态: +运行中、等待审批、等待用户输入、空闲和异常。状态直接从 +`~/.codex/sessions/**/*.jsonl` 的生命周期事件推断,不修改 Codex 配置,也不显示任务正文、 +命令或输出。状态默认只显示图标,也可以在设置中选择在图标旁显示本地化文字。点击状态卡会 +尝试通过 `codex://threads/` 打开对应任务。 + +### 为什么设计 Codex 任务状态视图 + +并不是每位用户都会同时订阅 Claude 和 Codex。当用户只使用 Codex,并在设置中隐藏 Claude +时,如果左半区始终留空,灵动岛中最适合随时查看的一块空间就没有得到利用。 + +可选的 Codex 任务状态视图会把这块空出来的区域变成只服务于当前工具的轻量状态面板。它只 +显示最需要关注的本地任务处于运行中、等待审批、等待用户输入、空闲还是异常,不展示提示词、 +命令或输出。用户因此可以一眼判断是否需要回到 Codex 处理;点击状态还可以打开被选中的 +Codex 任务。 + +在收起状态下,左侧状态组与右侧 Codex 额度组采用对应的三个组件:持续时间、当前状态和状态 +图标。展开后,同一套五状态视觉语言会填充原本空置的服务商栏位。如果用户更喜欢原有的按模型 +Token 用量视图,也可以随时在设置中关闭此功能。 ## 从源码构建 diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index 7408446c..6da2113e 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -101,6 +101,32 @@ "Ring" = "Ring"; "scanning local logs…" = "scanning local logs…"; "Settings" = "Settings"; +"Appearance" = "Appearance"; +"Choose a light or dark skin, or follow macOS." = "Choose a light or dark skin, or follow macOS."; +"System" = "System"; +"Light" = "Light"; +"Dark" = "Dark"; +"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"; +"Approval" = "Approval"; +"Input" = "Input"; +"Waiting for approval" = "Waiting for approval"; +"Waiting for your input" = "Waiting for your input"; +"Error" = "Error"; +"Open Codex" = "Open Codex"; +"Open this task in Codex" = "Open this task in Codex"; +"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 %@"; +"cycle view" = "cycle view"; +"switch page" = "switch page"; +"Switch to %@ (⌘%d)" = "Switch to %@ (⌘%d)"; "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..92149021 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -101,6 +101,32 @@ "Ring" = "环形"; "scanning local logs…" = "正在扫描本地日志…"; "Settings" = "设置"; +"Appearance" = "外观"; +"Choose a light or dark skin, or follow macOS." = "选择浅色或深色皮肤,或跟随 macOS。"; +"System" = "跟随系统"; +"Light" = "浅色"; +"Dark" = "深色"; +"Codex task status" = "Codex 任务状态"; +"Show local Codex task state when the Claude side is hidden." = "隐藏 Claude 一侧时显示本地 Codex 任务状态。"; +"Running" = "运行中"; +"Running short" = "运行"; +"Approval" = "审批"; +"Input" = "输入"; +"Waiting for approval" = "等待审批"; +"Waiting for your input" = "等待用户输入"; +"Error" = "异常"; +"Open Codex" = "打开 Codex"; +"Open this task in Codex" = "在 Codex 中打开此任务"; +"Codex status: %@" = "Codex 状态:%@"; +"%@ — open in Codex" = "%@ — 在 Codex 中打开"; +"Status display" = "状态显示"; +"Choose an icon only or include a localized label." = "选择仅显示图标,或同时显示本地化文字。"; +"Icon" = "仅图标"; +"Icon + Text" = "图标与文字"; +"Updated %@" = "更新于%@"; +"cycle view" = "切换视图"; +"switch page" = "切换页面"; +"Switch to %@ (⌘%d)" = "切换到%@(⌘%d)"; "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/AppearanceStore.swift b/Sources/Model/AppearanceStore.swift new file mode 100644 index 00000000..f954ace5 --- /dev/null +++ b/Sources/Model/AppearanceStore.swift @@ -0,0 +1,40 @@ +import SwiftUI + +enum AppAppearance: String, CaseIterable, Hashable { + case system + case light + case dark + + var label: String { + switch self { + case .system: "System" + case .light: "Light" + case .dark: "Dark" + } + } + + var colorScheme: ColorScheme? { + switch self { + case .system: nil + case .light: .light + case .dark: .dark + } + } +} + +@MainActor +final class AppearanceStore: ObservableObject { + static let shared = AppearanceStore() + static let key = "MacIsland.appearance" + + @Published var appearance: AppAppearance { + didSet { + UserDefaults.standard.set(appearance.rawValue, forKey: Self.key) + } + } + + private init() { + let raw = UserDefaults.standard.string(forKey: Self.key) ?? "" + appearance = AppAppearance(rawValue: raw) ?? .dark + } +} diff --git a/Sources/Model/CodexTaskStatusStore.swift b/Sources/Model/CodexTaskStatusStore.swift new file mode 100644 index 00000000..a762ca79 --- /dev/null +++ b/Sources/Model/CodexTaskStatusStore.swift @@ -0,0 +1,247 @@ +import AppKit +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 waitingApproval + case waitingUserInput + case idle + case error + + var label: String { + switch self { + case .running: "Running" + case .waitingApproval: "Waiting for approval" + case .waitingUserInput: "Waiting for your input" + case .idle: "Idle" + case .error: "Error" + } + } + + var compactLabel: String { + switch self { + case .running: "Running short" + case .waitingApproval: "Approval" + case .waitingUserInput: "Input" + case .idle: "Idle" + case .error: "Error" + } + } + + fileprivate var priority: Int { + switch self { + case .waitingApproval: 5 + case .waitingUserInput: 4 + case .error: 3 + case .running: 2 + case .idle: 1 + } + } + } + + 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: .idle, + threadID: nil, + updatedAt: nil + ) + + private var timer: Timer? + private var refreshInFlight = false + + private init() { + enabled = Pref.seededBool( + key: Self.enabledKey, + default: true + ) + displayMode = Pref.enumValue( + key: Self.displayModeKey, + default: .icon + ) + } + + func start() { + guard timer == nil else { return } + refresh() + timer = Timer.scheduledTimer(withTimeInterval: 2, 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 { return } + NSWorkspace.shared.openApplication( + at: appURL, + configuration: NSWorkspace.OpenConfiguration() + ) + } + + private func refresh() { + guard enabled, !refreshInFlight else { return } + refreshInFlight = true + Task { [weak self] in + let result = await Task.detached(priority: .utility) { + Self.scan() + }.value + guard let self else { return } + self.snapshot = result + self.refreshInFlight = false + } + } + + nonisolated private static func scan() -> Snapshot { + let files = recentRolloutFiles() + let states = files.compactMap(parseState) + guard let selected = states.max(by: { lhs, rhs in + if lhs.status.priority != rhs.status.priority { + return lhs.status.priority < rhs.status.priority + } + return (lhs.updatedAt ?? .distantPast) < (rhs.updatedAt ?? .distantPast) + }) else { + return Snapshot(status: .idle, threadID: nil, updatedAt: nil) + } + return selected + } + + 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") + } + + let keys: [URLResourceKey] = [.isRegularFileKey, .contentModificationDateKey] + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: keys, + options: [.skipsHiddenFiles] + ) else { return [] } + + let cutoff = Date().addingTimeInterval(-86400) + var files: [(URL, Date)] = [] + for case let url as URL in enumerator { + guard url.lastPathComponent.hasPrefix("rollout-"), + url.pathExtension == "jsonl", + let values = try? url.resourceValues(forKeys: Set(keys)), + 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 data = tailData(at: url), + let modified = try? url.resourceValues( + forKeys: [.contentModificationDateKey] + ).contentModificationDate + else { return nil } + + var status = Status.idle + for line in data.split(separator: 0x0A) { + guard line.count < 1_048_576, + let raw = try? JSONSerialization.jsonObject(with: Data(line)) as? [String: Any], + (raw["type"] as? String) == "event_msg", + let payload = raw["payload"] as? [String: Any], + let event = payload["type"] as? String + else { continue } + + switch event { + case "task_started", "user_message", "exec_command_begin", + "apply_patch_begin", "mcp_tool_call_begin": + status = .running + case "exec_approval_request", "apply_patch_approval_request": + status = .waitingApproval + case "request_user_input", "elicitation_request": + status = .waitingUserInput + case "task_complete": + status = .idle + case "turn_aborted", "error", "stream_error": + status = .error + default: + break + } + } + + return Snapshot( + status: status, + threadID: threadID(from: url), + updatedAt: modified + ) + } + + nonisolated private static func tailData(at url: URL) -> Data? { + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + let length = (try? handle.seekToEnd()) ?? 0 + let maxBytes: UInt64 = 512 * 1024 + try? handle.seek(toOffset: length > maxBytes ? length - maxBytes : 0) + return try? handle.readToEnd() + } + + 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/Theme/Colors.swift b/Sources/Theme/Colors.swift index 85321f5b..b52e490e 100644 --- a/Sources/Theme/Colors.swift +++ b/Sources/Theme/Colors.swift @@ -1,7 +1,13 @@ +import AppKit import SwiftUI /// Locked color tokens for CodexIsland. enum IslandColor { + static let settingsBackground = Color( + light: NSColor(calibratedWhite: 0.965, alpha: 1), + dark: NSColor(calibratedRed: 0.020, green: 0.020, blue: 0.027, alpha: 1) + ) + /// #0047AB — loading sweep, glow halo. static let cobalt = Color(red: 0/255, green: 71/255, blue: 171/255) @@ -23,3 +29,11 @@ enum IslandColor { /// as "stop, you're cooked" without going full red-alert pure. static let alertRed = Color(red: 229/255, green: 72/255, blue: 77/255) } + +private extension Color { + init(light: NSColor, dark: NSColor) { + self.init(nsColor: NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? dark : light + }) + } +} diff --git a/Sources/Views/CodexTaskStatusView.swift b/Sources/Views/CodexTaskStatusView.swift new file mode 100644 index 00000000..7669fc38 --- /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 .waitingApproval: Color(red: 1.0, green: 0.67, blue: 0.22) + case .waitingUserInput: Color(red: 0.78, green: 0.57, blue: 1.0) + case .idle: Color(red: 0.48, green: 0.78, blue: 1.0) + case .error: Color(red: 1.0, green: 0.34, blue: 0.34) + } + } + + private var color: Color { Self.color(for: status) } + + private var icon: String { + switch status { + case .running: "bolt.fill" + case .waitingApproval: "checkmark.shield.fill" + case .waitingUserInput: "questionmark.bubble.fill" + case .idle: "moon.zzz.fill" + case .error: "exclamationmark.triangle.fill" + } + } +} diff --git a/Sources/Views/IslandRootView.swift b/Sources/Views/IslandRootView.swift index 51e9a466..d4ae4c29 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,91 @@ 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 { + Button { + store.openThread() + } label: { + if showsDetails { + ZStack { + HStack(spacing: 0) { + Text(elapsedUpdate) + .font(Typography.bodyNumber) + .foregroundStyle(statusColor) + .frame(width: 44, alignment: .center) + + Text(L10n.tr(store.snapshot.status.compactLabel)) + .font(Typography.bodyNumber) + .foregroundStyle(.white.opacity(0.68)) + .lineLimit(1) + .minimumScaleFactor(0.75) + .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) + } + + 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)) + } + } + .buttonStyle(.plain) + .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(.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 var elapsedUpdate: String { + guard let date = store.snapshot.updatedAt else { return "—" } + return Duration.compact(max(0, Date().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/PageIndicator.swift b/Sources/Views/PageIndicator.swift index 88a42f5e..5e0d01fe 100644 --- a/Sources/Views/PageIndicator.swift +++ b/Sources/Views/PageIndicator.swift @@ -2,31 +2,36 @@ import SwiftUI /// Page indicator that mirrors the active screen. Sits in the /// expanded panel footer between the style chip and the live-status group. -/// Each dot is tappable so regular-mouse users (no trackpad swipe, no -/// horizontal wheel) have a click-to-page affordance. +/// Each dot sits inside a 24pt button so regular-mouse users do not need +/// pixel-precise aim. The visible dots stay compact and quiet. struct PageIndicator: View { @ObservedObject var model: IslandModel @ObservedObject private var screenPref = ScreenPref.shared var body: some View { - HStack(spacing: 5) { + HStack(spacing: 0) { ForEach(ScreenPref.Screen.allCases, id: \.self) { screen in dot(for: screen) } } + .padding(.horizontal, 2) + .contentShape(Rectangle()) .animation(.strongEaseOut, value: screenPref.screen) } private func dot(for screen: ScreenPref.Screen) -> some View { let isActive = screenPref.screen == screen - return Circle() - .fill(.white.opacity(isActive ? 0.78 : 0.22)) - .frame(width: 5, height: 5) - // Visual stays 5pt; hit area expands ~6pt outward so the dot - // is reachable without pixel-precise aim. - .contentShape(Rectangle().inset(by: -6)) - .onTapGesture { model.showScreen(screen) } - .accessibilityElement() + return Button { + model.showScreen(screen) + } label: { + Circle() + .fill(.white.opacity(isActive ? 0.82 : 0.25)) + .frame(width: isActive ? 8 : 7, height: isActive ? 8 : 7) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(L10n.tr("Switch to %@ (⌘%d)", screen.pageLabel, screen.pageIndex + 1)) .accessibilityLabel(accessibilityLabel(for: screen)) .accessibilityAddTraits(.isButton) .accessibilityAddTraits(isActive ? .isSelected : []) diff --git a/Sources/Views/PanelFooter.swift b/Sources/Views/PanelFooter.swift index baf9e2a5..aba5900c 100644 --- a/Sources/Views/PanelFooter.swift +++ b/Sources/Views/PanelFooter.swift @@ -35,19 +35,7 @@ struct PanelFooter: View { ZStack(alignment: .center) { HStack(spacing: 10) { chip - - if !activeStyleCycled { - HStack(spacing: 5) { - Image(systemName: "command") - .font(Typography.micro) - Text(L10n.tr("click to cycle")) - .font(Typography.label) - } - .foregroundStyle(.white.opacity(0.42)) - .transition(.opacity.combined(with: .scale(scale: 0.92, anchor: .leading))) - .accessibilityElement(children: .combine) - .accessibilityLabel(cycleHintAccessibilityLabel) - } + shortcutHints Spacer() @@ -64,25 +52,35 @@ struct PanelFooter: View { .padding(.horizontal, 22) .padding(.top, 6) .padding(.bottom, 10) - .animation(.strongEaseOut, value: pref.hasCycledStyle) - .animation(.strongEaseOut, value: costPref.hasCycledStyle) .animation(.strongEaseOut, value: screenPref.screen) } } - private var activeStyleCycled: Bool { - switch screenPref.screen { - case .usage: return pref.hasCycledStyle - case .cost: return costPref.hasCycledStyle - case .overview: return true + private var shortcutHints: some View { + HStack(spacing: 9) { + if screenPref.screen != .overview { + shortcutHint(keys: "click", label: "cycle view") + Rectangle() + .fill(.white.opacity(0.12)) + .frame(width: 1, height: 10) + .accessibilityHidden(true) + } + shortcutHint(keys: "1 2 3", label: "switch page") } + .foregroundStyle(.white.opacity(0.42)) + .animation(.strongEaseOut, value: screenPref.screen) } - private var cycleHintAccessibilityLabel: String { - switch screenPref.screen { - case .overview: return L10n.tr("Overview shows %@ usage history", currentYearString) - case .usage, .cost: return L10n.tr("Tip: Command-click to cycle visualization") + private func shortcutHint(keys: String, label: String) -> some View { + HStack(spacing: 4) { + Image(systemName: "command") + .font(Typography.micro) + Text(L10n.tr(keys)) + .font(Typography.bodyNumber) + Text(L10n.tr(label)) + .font(Typography.label) } + .accessibilityElement(children: .combine) } @ViewBuilder diff --git a/Sources/Views/PanelHeader.swift b/Sources/Views/PanelHeader.swift index 5b4e9ec2..4c53aee9 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,28 @@ 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) + 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/Settings/BrandHeader.swift b/Sources/Views/Settings/BrandHeader.swift index d7a98a0e..c3dd6215 100644 --- a/Sources/Views/Settings/BrandHeader.swift +++ b/Sources/Views/Settings/BrandHeader.swift @@ -24,21 +24,21 @@ struct BrandHeader: View { Text("CodexIsland") .font(Typography.brand) .tracking(-0.15) - .foregroundStyle(.white.opacity(0.92)) + .foregroundStyle(Color.primary.opacity(0.92)) Text(L10n.tr("Your AI usage limits, living in your notch.")) .font(Typography.label) - .foregroundStyle(.white.opacity(0.55)) + .foregroundStyle(Color.primary.opacity(0.55)) } Spacer(minLength: 8) Text("v\(version)") .font(Typography.bodyNumber) - .foregroundStyle(.white.opacity(0.34)) + .foregroundStyle(Color.primary.opacity(0.34)) .padding(.horizontal, 9) .padding(.vertical, 4) .background( - Capsule().fill(.white.opacity(0.04)) + Capsule().fill(Color.primary.opacity(0.04)) ) } .padding(.horizontal, 24) @@ -55,7 +55,7 @@ struct BrandHeader: View { .interpolation(.high) .aspectRatio(contentMode: .fit) .frame(width: 26, height: 26) - .foregroundStyle(.white.opacity(0.92)) + .foregroundStyle(Color.primary.opacity(0.92)) .shadow(color: IslandColor.cobalt.opacity(0.35), radius: 6) } else { // Fallback if the resource is missing in the bundle: a plain diff --git a/Sources/Views/Settings/ChartStylePicker.swift b/Sources/Views/Settings/ChartStylePicker.swift index fb4cc890..ddf4120e 100644 --- a/Sources/Views/Settings/ChartStylePicker.swift +++ b/Sources/Views/Settings/ChartStylePicker.swift @@ -34,7 +34,7 @@ struct ChartStylePicker: View { case .ring: ZStack { Circle() - .stroke(.white.opacity(0.10), lineWidth: 3) + .stroke(Color.primary.opacity(0.10), lineWidth: 3) Circle() .trim(from: 0, to: 0.35) .stroke(claude, style: StrokeStyle(lineWidth: 3, lineCap: .round)) @@ -43,7 +43,7 @@ struct ChartStylePicker: View { .frame(width: 26, height: 26) case .bar: ZStack(alignment: .leading) { - Capsule().fill(.white.opacity(0.10)) + Capsule().fill(Color.primary.opacity(0.10)) Capsule().fill(claude) .frame(width: 28 * 0.35) } @@ -52,7 +52,7 @@ struct ChartStylePicker: View { HStack(spacing: 1.5) { ForEach(0..<8) { i in RoundedRectangle(cornerRadius: 0.75) - .fill(i < 3 ? claude : .white.opacity(0.10)) + .fill(i < 3 ? claude : Color.primary.opacity(0.10)) .frame(width: 2, height: 12) } } @@ -64,7 +64,7 @@ struct ChartStylePicker: View { .foregroundStyle(claude) Text("%") .font(Typography.micro) - .foregroundStyle(.white.opacity(0.5)) + .foregroundStyle(Color.primary.opacity(0.5)) } case .spark: SparkPath() diff --git a/Sources/Views/Settings/CostStylePicker.swift b/Sources/Views/Settings/CostStylePicker.swift index 45e45a12..28df5566 100644 --- a/Sources/Views/Settings/CostStylePicker.swift +++ b/Sources/Views/Settings/CostStylePicker.swift @@ -34,14 +34,14 @@ struct CostStylePicker: View { HStack(alignment: .firstTextBaseline, spacing: 1) { Text("$") .font(Typography.micro) - .foregroundStyle(.white.opacity(0.5)) + .foregroundStyle(Color.primary.opacity(0.5)) Text("87") .font(Typography.previewNumber) .foregroundStyle(claude) } case .multi: HStack(alignment: .bottom, spacing: 4) { - Capsule().fill(.white.opacity(0.20)) + Capsule().fill(Color.primary.opacity(0.20)) .frame(width: 8, height: 6) Capsule().fill(claude) .frame(width: 8, height: 18) @@ -54,7 +54,7 @@ struct CostStylePicker: View { .foregroundStyle(claude) Text("M") .font(Typography.micro) - .foregroundStyle(.white.opacity(0.5)) + .foregroundStyle(Color.primary.opacity(0.5)) } case .spark: CostSparkPath() diff --git a/Sources/Views/Settings/SegmentedControl.swift b/Sources/Views/Settings/SegmentedControl.swift index f2f4e2bd..8e7ba0bd 100644 --- a/Sources/Views/Settings/SegmentedControl.swift +++ b/Sources/Views/Settings/SegmentedControl.swift @@ -21,16 +21,19 @@ struct SegmentedControl: View { Text(itemLabel) .font(Typography.bodyNumber) .foregroundStyle(isOn - ? Color.white.opacity(0.95) - : .white.opacity(0.55)) + ? Color.primary.opacity(0.95) + : Color.primary.opacity(0.55)) .padding(.horizontal, 10) .padding(.vertical, 5) .background { RoundedRectangle(cornerRadius: 5) - .fill(isOn ? .white.opacity(0.10) : .clear) + .fill(isOn ? Color.primary.opacity(0.10) : Color.clear) .overlay { RoundedRectangle(cornerRadius: 5) - .strokeBorder(.white.opacity(isOn ? 0.08 : 0), lineWidth: 0.5) + .strokeBorder( + Color.primary.opacity(isOn ? 0.08 : 0), + lineWidth: 0.5 + ) } } } @@ -45,7 +48,7 @@ struct SegmentedControl: View { .padding(2) .background { RoundedRectangle(cornerRadius: 7) - .fill(.white.opacity(0.04)) + .fill(Color.primary.opacity(0.04)) } } } @@ -62,15 +65,15 @@ struct PillButton: View { Button(action: action) { Text(L10n.tr(label)) .font(Typography.button) - .foregroundStyle(.white.opacity(0.9)) + .foregroundStyle(Color.primary.opacity(0.9)) .padding(.horizontal, 12) .padding(.vertical, 5) .background { RoundedRectangle(cornerRadius: 6) - .fill(.white.opacity(0.10)) + .fill(Color.primary.opacity(0.10)) .overlay { RoundedRectangle(cornerRadius: 6) - .strokeBorder(.white.opacity(0.08), lineWidth: 0.5) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5) } } } diff --git a/Sources/Views/Settings/SettingsFooter.swift b/Sources/Views/Settings/SettingsFooter.swift index cab372e8..467ce70b 100644 --- a/Sources/Views/Settings/SettingsFooter.swift +++ b/Sources/Views/Settings/SettingsFooter.swift @@ -22,15 +22,15 @@ struct SettingsFooter: View { } label: { Text(L10n.tr("Quit")) .font(Typography.label) - .foregroundStyle(.white.opacity(quitHovered ? 0.92 : 0.55)) + .foregroundStyle(Color.primary.opacity(quitHovered ? 0.92 : 0.55)) .padding(.horizontal, 11) .padding(.vertical, 5) .background { RoundedRectangle(cornerRadius: 6) - .fill(.white.opacity(quitHovered ? 0.06 : 0.03)) + .fill(Color.primary.opacity(quitHovered ? 0.06 : 0.03)) .overlay { RoundedRectangle(cornerRadius: 6) - .strokeBorder(.white.opacity(0.07), lineWidth: 0.5) + .strokeBorder(Color.primary.opacity(0.07), lineWidth: 0.5) } } } @@ -62,14 +62,14 @@ private struct DottedLink: View { HStack(spacing: 4) { Text(L10n.tr(title)) .font(Typography.label) - .foregroundStyle(.white.opacity(hovered ? 0.92 : 0.55)) + .foregroundStyle(Color.primary.opacity(hovered ? 0.92 : 0.55)) Text("↗") .font(Typography.micro) - .foregroundStyle(.white.opacity(hovered ? 0.6 : 0.3)) + .foregroundStyle(Color.primary.opacity(hovered ? 0.6 : 0.3)) } .overlay(alignment: .bottom) { Rectangle() - .fill(.white.opacity(hovered ? 0.32 : 0.18)) + .fill(Color.primary.opacity(hovered ? 0.32 : 0.18)) .frame(height: 0.5) .offset(y: 1) .mask( diff --git a/Sources/Views/Settings/SettingsRow.swift b/Sources/Views/Settings/SettingsRow.swift index 3c169409..4ab0abb0 100644 --- a/Sources/Views/Settings/SettingsRow.swift +++ b/Sources/Views/Settings/SettingsRow.swift @@ -42,17 +42,17 @@ struct SettingsRow: View { Text(L10n.tr(title)) .font(Typography.rowTitle) .tracking(-0.07) - .foregroundStyle(.white.opacity(0.92)) + .foregroundStyle(Color.primary.opacity(0.92)) if let chip { Text(chip) .font(Typography.chip) .tracking(0.8) - .foregroundStyle(.white.opacity(0.6)) + .foregroundStyle(Color.primary.opacity(0.6)) .padding(.horizontal, 5) .padding(.vertical, 2) .background( RoundedRectangle(cornerRadius: 3) - .fill(.white.opacity(0.06)) + .fill(Color.primary.opacity(0.06)) ) .accessibilityLabel(L10n.tr("Plan: %@", chip)) } @@ -60,7 +60,7 @@ struct SettingsRow: View { if let subtitle { Text(L10n.tr(subtitle)) .font(Typography.label) - .foregroundStyle(.white.opacity(0.55)) + .foregroundStyle(Color.primary.opacity(0.55)) } } .accessibilityElement(children: .combine) @@ -73,7 +73,7 @@ struct SettingsRow: View { .padding(.vertical, 11) .background { RoundedRectangle(cornerRadius: 8) - .fill(.white.opacity(hovered ? 0.030 : 0)) + .fill(Color.primary.opacity(hovered ? 0.030 : 0)) } .contentShape(Rectangle()) .onHover { hovered = $0 } diff --git a/Sources/Views/Settings/SettingsToggle.swift b/Sources/Views/Settings/SettingsToggle.swift index ee4b4d6e..2bc7ddd8 100644 --- a/Sources/Views/Settings/SettingsToggle.swift +++ b/Sources/Views/Settings/SettingsToggle.swift @@ -16,16 +16,16 @@ struct SettingsToggle: View { Button(action: action) { ZStack(alignment: isOn ? .trailing : .leading) { Capsule() - .strokeBorder(.white.opacity(hovered ? 0.20 : 0.13), lineWidth: 1) + .strokeBorder(Color.primary.opacity(hovered ? 0.20 : 0.13), lineWidth: 1) .background { Capsule().fill(isOn ? IslandColor.cobalt.opacity(0.32) - : .white.opacity(0.07)) + : Color.primary.opacity(0.07)) } .frame(width: trackWidth, height: trackHeight) Circle() - .fill(isOn ? IslandColor.cobalt : Color.white.opacity(0.5)) + .fill(isOn ? IslandColor.cobalt : Color.primary.opacity(0.5)) .frame(width: dotSize, height: dotSize) .shadow( color: isOn ? IslandColor.cobalt.opacity(0.85) : .clear, diff --git a/Sources/Views/Settings/StyleTile.swift b/Sources/Views/Settings/StyleTile.swift index 3a48327f..53b64202 100644 --- a/Sources/Views/Settings/StyleTile.swift +++ b/Sources/Views/Settings/StyleTile.swift @@ -20,7 +20,7 @@ struct StyleTile: View { .font(Typography.micro) .foregroundStyle(isOn ? Color(red: 0.58, green: 0.75, blue: 1.0) - : .white.opacity(0.55)) + : Color.primary.opacity(0.55)) } .frame(maxWidth: .infinity) .padding(.top, 14) @@ -30,7 +30,7 @@ struct StyleTile: View { RoundedRectangle(cornerRadius: 9) .fill(isOn ? IslandColor.cobalt.opacity(0.14) - : .white.opacity(0.025)) + : Color.primary.opacity(0.025)) .overlay { RoundedRectangle(cornerRadius: 9) .strokeBorder(isOn diff --git a/Sources/Views/SettingsView.swift b/Sources/Views/SettingsView.swift index 9db95d40..9d87a77b 100644 --- a/Sources/Views/SettingsView.swift +++ b/Sources/Views/SettingsView.swift @@ -20,6 +20,8 @@ struct SettingsView: View { @ObservedObject private var usageDisplay = UsageDisplayModeStore.shared @ObservedObject private var targetDisplay = IslandTargetDisplayStore.shared @ObservedObject private var appLanguage = AppLanguageStore.shared + @ObservedObject private var appearanceStore = AppearanceStore.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 @@ -67,8 +69,8 @@ struct SettingsView: View { SettingsFooter() } .frame(minWidth: 440, minHeight: 420) - .background(Color(red: 0.020, green: 0.020, blue: 0.027)) - .preferredColorScheme(.dark) + .background(IslandColor.settingsBackground) + .preferredColorScheme(appearanceStore.appearance.colorScheme) } // MARK: - Tabs @@ -105,16 +107,16 @@ struct SettingsView: View { Text(L10n.tr(tab.label)) .font(Typography.tabLabel) .foregroundStyle(isOn - ? .white.opacity(0.95) - : .white.opacity(0.50)) + ? Color.primary.opacity(0.95) + : Color.primary.opacity(0.50)) .padding(.horizontal, 12) .padding(.vertical, 6) .background { RoundedRectangle(cornerRadius: 6) - .fill(isOn ? .white.opacity(0.08) : .clear) + .fill(isOn ? Color.primary.opacity(0.08) : .clear) .overlay { RoundedRectangle(cornerRadius: 6) - .strokeBorder(.white.opacity(isOn ? 0.08 : 0), lineWidth: 0.5) + .strokeBorder(Color.primary.opacity(isOn ? 0.08 : 0), lineWidth: 0.5) } } } @@ -166,7 +168,7 @@ struct SettingsView: View { private var hairline: some View { LinearGradient( - colors: [.clear, .white.opacity(0.055), .white.opacity(0.055), .clear], + colors: [.clear, Color.primary.opacity(0.055), Color.primary.opacity(0.055), .clear], startPoint: .leading, endPoint: .trailing ) .frame(height: 1) @@ -179,12 +181,12 @@ struct SettingsView: View { .font(Typography.sectionLabel) .tracking(1.05) .textCase(.uppercase) - .foregroundStyle(.white.opacity(0.34)) + .foregroundStyle(Color.primary.opacity(0.34)) Spacer(minLength: 8) if let hint { Text(L10n.tr(hint)) .font(Typography.micro) - .foregroundStyle(.white.opacity(0.18)) + .foregroundStyle(Color.primary.opacity(0.18)) } } .padding(.horizontal, 10) @@ -214,6 +216,12 @@ struct SettingsView: View { ) { languagePicker } + SettingsRow( + title: "Appearance", + subtitle: "Choose a light or dark skin, or follow macOS." + ) { + appearanceSegmented + } SettingsRow( title: "Always show usage", subtitle: "Keep the percentage and time remaining visible without hovering." @@ -296,17 +304,17 @@ struct SettingsView: View { Button(action: action) { Text(L10n.tr(label)) .font(Typography.bodyNumber) - .foregroundStyle(.white.opacity(0.85)) + .foregroundStyle(Color.primary.opacity(0.85)) .lineLimit(1) .fixedSize() .padding(.horizontal, 9) .padding(.vertical, 4) .background { RoundedRectangle(cornerRadius: 5) - .fill(.white.opacity(0.06)) + .fill(Color.primary.opacity(0.06)) .overlay { RoundedRectangle(cornerRadius: 5) - .strokeBorder(.white.opacity(0.10), lineWidth: 0.5) + .strokeBorder(Color.primary.opacity(0.10), lineWidth: 0.5) } } } @@ -371,7 +379,7 @@ struct SettingsView: View { Text(L10n.tr(label)) .font(Typography.rowTitle) .tracking(-0.07) - .foregroundStyle(.white.opacity(0.92)) + .foregroundStyle(Color.primary.opacity(0.92)) Spacer(minLength: 8) thresholdStepper(value: value, range: range) } @@ -412,21 +420,21 @@ struct SettingsView: View { .textFieldStyle(.plain) .multilineTextAlignment(.center) .font(Typography.bodyNumber) - .foregroundStyle(.white.opacity(0.95)) + .foregroundStyle(Color.primary.opacity(0.95)) .monospacedDigit() .frame(width: 22, height: 18) .clipped() Text("%") .font(Typography.bodyNumber) - .foregroundStyle(.white.opacity(0.55)) + .foregroundStyle(Color.primary.opacity(0.55)) } .frame(width: 64, height: 28) .background { RoundedRectangle(cornerRadius: 7) - .fill(.white.opacity(0.05)) + .fill(Color.primary.opacity(0.05)) .overlay { RoundedRectangle(cornerRadius: 7) - .strokeBorder(.white.opacity(0.10), lineWidth: 0.5) + .strokeBorder(Color.primary.opacity(0.10), lineWidth: 0.5) } } } @@ -466,6 +474,15 @@ struct SettingsView: View { .accessibilityLabel(L10n.tr("Language")) } + private var appearanceSegmented: some View { + SegmentedControl( + items: AppAppearance.allCases, + selected: $appearanceStore.appearance, + label: \.label, + accessibilityPrefix: "Appearance" + ) + } + private var languageSelection: Binding { Binding( get: { appLanguage.language }, @@ -515,6 +532,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) @@ -569,11 +607,11 @@ struct SettingsView: View { .font(Typography.sectionLabel) .tracking(1.05) .textCase(.uppercase) - .foregroundStyle(.white.opacity(0.34)) + .foregroundStyle(Color.primary.opacity(0.34)) Text(costSubtitle()) .font(Typography.label) - .foregroundStyle(.white.opacity(0.42)) + .foregroundStyle(Color.primary.opacity(0.42)) .lineLimit(1) .truncationMode(.tail) diff --git a/Sources/Views/SettingsWindowController.swift b/Sources/Views/SettingsWindowController.swift index 85038069..af4c05c1 100644 --- a/Sources/Views/SettingsWindowController.swift +++ b/Sources/Views/SettingsWindowController.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import SwiftUI /// Hand-rolled NSWindow for Settings instead of the SwiftUI `Settings` scene. @@ -9,6 +10,7 @@ import SwiftUI @MainActor final class SettingsWindowController: NSWindowController, NSWindowDelegate { static let shared = SettingsWindowController() + private var appearanceSubscription: AnyCancellable? private init() { let hosting = NSHostingController(rootView: SettingsView()) @@ -23,9 +25,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { window.titleVisibility = .hidden window.isMovableByWindowBackground = true window.isReleasedWhenClosed = false - window.backgroundColor = NSColor( - calibratedRed: 0.075, green: 0.077, blue: 0.090, alpha: 1 - ) + window.backgroundColor = .windowBackgroundColor window.minSize = NSSize(width: 440, height: 420) // Hide the dock-stow button (we have no dock icon) but keep zoom // alongside resize handles so the user controls size. @@ -33,6 +33,14 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { window.center() super.init(window: window) window.delegate = self + appearanceSubscription = AppearanceStore.shared.$appearance + .sink { [weak window] appearance in + switch appearance { + case .system: window?.appearance = nil + case .light: window?.appearance = NSAppearance(named: .aqua) + case .dark: window?.appearance = NSAppearance(named: .darkAqua) + } + } } @available(*, unavailable) 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/Sources/Window/IslandWindowController.swift b/Sources/Window/IslandWindowController.swift index 55d5b35c..5490c58e 100644 --- a/Sources/Window/IslandWindowController.swift +++ b/Sources/Window/IslandWindowController.swift @@ -136,13 +136,8 @@ final class IslandWindowController { if inside { NSApp.activate(ignoringOtherApps: true) window.makeKey() - cmdQMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in - if event.modifierFlags.contains(.command), - event.charactersIgnoringModifiers == "q" { - NSApp.terminate(nil) - return nil - } - return event + cmdQMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + self?.handleKeyDown(event) ?? event } } else { if let m = cmdQMonitor { NSEvent.removeMonitor(m) } @@ -151,6 +146,38 @@ final class IslandWindowController { } } + private func handleKeyDown(_ event: NSEvent) -> NSEvent? { + guard window.isKeyWindow else { return event } + + let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if modifiers == .command, event.charactersIgnoringModifiers == "q" { + NSApp.terminate(nil) + return nil + } + + guard model.state == .expanded else { return event } + + if modifiers == .command, + let character = event.charactersIgnoringModifiers, + let index = ["1", "2", "3"].firstIndex(of: character) { + model.showScreen(ScreenPref.Screen.allCases[index]) + return nil + } + + let navigationModifiers = modifiers.subtracting([.function, .numericPad, .capsLock]) + guard navigationModifiers.isEmpty else { return event } + switch event.keyCode { + case 123: + model.rewindScreen() + return nil + case 124: + model.advanceScreen() + return nil + default: + return event + } + } + @MainActor private static func targetScreen() -> NSScreen? { DisplayInfo.currentTarget()?.screen diff --git a/build.sh b/build.sh index efe95c64..4a139d1b 100755 --- a/build.sh +++ b/build.sh @@ -111,10 +111,14 @@ XPC_DIR="$FRAMEWORKS_DIR/Sparkle.framework/Versions/Current/XPCServices" for xpc in Installer.xpc Downloader.xpc; do XPC_PATH="$XPC_DIR/$xpc" if [[ -d "$XPC_PATH" ]]; then + # File Provider may reattach FinderInfo to package directories after a + # recursive xattr pass, so clear every entry immediately before signing. + find "$XPC_PATH" -exec xattr -c {} \; codesign --force --sign - --timestamp=none \ --preserve-metadata=identifier,entitlements,flags "$XPC_PATH" fi done +find "$FRAMEWORKS_DIR/Sparkle.framework" -exec xattr -c {} \; codesign --force --sign - --timestamp=none "$FRAMEWORKS_DIR/Sparkle.framework" echo "✓ built $APP_DIR ($VERSION)" From 4183810e6fd3e5273df55f76351fee94e3be3792 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Thu, 30 Jul 2026 00:43:13 +0800 Subject: [PATCH 2/5] fix: address Codex status review feedback --- Resources/en.lproj/Localizable.strings | 4 + Resources/zh-Hans.lproj/Localizable.strings | 4 + Sources/Model/CodexTaskStatusStore.swift | 101 +++++++++++++++----- Sources/Views/IslandRootView.swift | 14 +-- Sources/Views/Settings/StyleTile.swift | 2 +- 5 files changed, 95 insertions(+), 30 deletions(-) diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index 6da2113e..319173a3 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -117,6 +117,10 @@ "Error" = "Error"; "Open Codex" = "Open Codex"; "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"; diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index 92149021..48225b7d 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -117,6 +117,10 @@ "Error" = "异常"; "Open Codex" = "打开 Codex"; "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" = "状态显示"; diff --git a/Sources/Model/CodexTaskStatusStore.swift b/Sources/Model/CodexTaskStatusStore.swift index a762ca79..3afd4d06 100644 --- a/Sources/Model/CodexTaskStatusStore.swift +++ b/Sources/Model/CodexTaskStatusStore.swift @@ -82,6 +82,7 @@ final class CodexTaskStatusStore: ObservableObject { private var timer: Timer? private var refreshInFlight = false + private var lastScanFingerprint: String? private init() { enabled = Pref.seededBool( @@ -97,7 +98,7 @@ final class CodexTaskStatusStore: ObservableObject { func start() { guard timer == nil else { return } refresh() - timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in + timer = Timer.scheduledTimer(withTimeInterval: 15, repeats: true) { [weak self] _ in Task { @MainActor in self?.refresh() } } } @@ -118,28 +119,64 @@ final class CodexTaskStatusStore: ObservableObject { private func openCodexApp() { guard let appURL = NSWorkspace.shared.urlForApplication( withBundleIdentifier: "com.openai.codex" - ) else { return } + ) 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 enabled, !refreshInFlight else { return } refreshInFlight = true + let previousFingerprint = lastScanFingerprint Task { [weak self] in let result = await Task.detached(priority: .utility) { - Self.scan() + Self.scan(previousFingerprint: previousFingerprint) }.value guard let self else { return } - self.snapshot = result + self.lastScanFingerprint = result.fingerprint + if let snapshot = result.snapshot { + self.snapshot = snapshot + } self.refreshInFlight = false } } - nonisolated private static func scan() -> Snapshot { + private struct ScanResult: Sendable { + let fingerprint: String + let snapshot: Snapshot? + } + + nonisolated private static func scan(previousFingerprint: String?) -> ScanResult { let files = recentRolloutFiles() + 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) + } + let states = files.compactMap(parseState) guard let selected = states.max(by: { lhs, rhs in if lhs.status.priority != rhs.status.priority { @@ -147,9 +184,12 @@ final class CodexTaskStatusStore: ObservableObject { } return (lhs.updatedAt ?? .distantPast) < (rhs.updatedAt ?? .distantPast) }) else { - return Snapshot(status: .idle, threadID: nil, updatedAt: nil) + return ScanResult( + fingerprint: fingerprint, + snapshot: Snapshot(status: .idle, threadID: nil, updatedAt: nil) + ) } - return selected + return ScanResult(fingerprint: fingerprint, snapshot: selected) } nonisolated private static func recentRolloutFiles() -> [URL] { @@ -162,24 +202,39 @@ final class CodexTaskStatusStore: ObservableObject { root = home.appendingPathComponent(".codex/sessions") } - let keys: [URLResourceKey] = [.isRegularFileKey, .contentModificationDateKey] - guard let enumerator = FileManager.default.enumerator( - at: root, - includingPropertiesForKeys: keys, - options: [.skipsHiddenFiles] - ) else { return [] } - let cutoff = Date().addingTimeInterval(-86400) var files: [(URL, Date)] = [] - for case let url as URL in enumerator { - guard url.lastPathComponent.hasPrefix("rollout-"), - url.pathExtension == "jsonl", - let values = try? url.resourceValues(forKeys: Set(keys)), - values.isRegularFile == true, - let modified = values.contentModificationDate, - modified >= cutoff + let calendar = Calendar(identifier: .gregorian) + for dayOffset in 0...1 { + 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 } - files.append((url, modified)) + 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 } diff --git a/Sources/Views/IslandRootView.swift b/Sources/Views/IslandRootView.swift index d4ae4c29..271cd07d 100644 --- a/Sources/Views/IslandRootView.swift +++ b/Sources/Views/IslandRootView.swift @@ -402,10 +402,12 @@ private struct CompactCodexTaskStatusOverlay: View { if showsDetails { ZStack { HStack(spacing: 0) { - Text(elapsedUpdate) - .font(Typography.bodyNumber) - .foregroundStyle(statusColor) - .frame(width: 44, alignment: .center) + TimelineView(.periodic(from: .now, by: 30)) { context in + Text(elapsedUpdate(at: context.date)) + .font(Typography.bodyNumber) + .foregroundStyle(statusColor) + .frame(width: 44, alignment: .center) + } Text(L10n.tr(store.snapshot.status.compactLabel)) .font(Typography.bodyNumber) @@ -461,9 +463,9 @@ private struct CompactCodexTaskStatusOverlay: View { CodexTaskStatusGlyph.color(for: store.snapshot.status) } - private var elapsedUpdate: String { + private func elapsedUpdate(at now: Date) -> String { guard let date = store.snapshot.updatedAt else { return "—" } - return Duration.compact(max(0, Date().timeIntervalSince(date))) + return Duration.compact(max(0, now.timeIntervalSince(date))) } } diff --git a/Sources/Views/Settings/StyleTile.swift b/Sources/Views/Settings/StyleTile.swift index 53b64202..5ab90623 100644 --- a/Sources/Views/Settings/StyleTile.swift +++ b/Sources/Views/Settings/StyleTile.swift @@ -19,7 +19,7 @@ struct StyleTile: View { Text(displayLabel) .font(Typography.micro) .foregroundStyle(isOn - ? Color(red: 0.58, green: 0.75, blue: 1.0) + ? Color.primary.opacity(0.90) : Color.primary.opacity(0.55)) } .frame(maxWidth: .infinity) From 24c99fd9306935949a226cdd279a1be1e69ff427 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Thu, 30 Jul 2026 07:47:09 +0800 Subject: [PATCH 3/5] fix: preserve terminal Codex task errors --- Sources/Model/CodexTaskStatusStore.swift | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/Sources/Model/CodexTaskStatusStore.swift b/Sources/Model/CodexTaskStatusStore.swift index 3afd4d06..1467c5c8 100644 --- a/Sources/Model/CodexTaskStatusStore.swift +++ b/Sources/Model/CodexTaskStatusStore.swift @@ -250,6 +250,7 @@ final class CodexTaskStatusStore: ObservableObject { else { return nil } var status = Status.idle + var currentTurnFailed = false for line in data.split(separator: 0x0A) { guard line.count < 1_048_576, let raw = try? JSONSerialization.jsonObject(with: Data(line)) as? [String: Any], @@ -259,16 +260,25 @@ final class CodexTaskStatusStore: ObservableObject { else { continue } switch event { - case "task_started", "user_message", "exec_command_begin", - "apply_patch_begin", "mcp_tool_call_begin": + case "task_started", "user_message": + currentTurnFailed = false status = .running + case "exec_command_begin", "apply_patch_begin", "mcp_tool_call_begin": + if !currentTurnFailed { + status = .running + } case "exec_approval_request", "apply_patch_approval_request": - status = .waitingApproval + if !currentTurnFailed { + status = .waitingApproval + } case "request_user_input", "elicitation_request": - status = .waitingUserInput + if !currentTurnFailed { + status = .waitingUserInput + } case "task_complete": - status = .idle + status = currentTurnFailed ? .error : .idle case "turn_aborted", "error", "stream_error": + currentTurnFailed = true status = .error default: break From bc544fe315128a7786fd907c3501c313dbfce756 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Thu, 30 Jul 2026 08:40:34 +0800 Subject: [PATCH 4/5] fix: retain Codex failures across log tails --- Sources/Model/CodexTaskStatusLogParser.swift | 139 +++++++++++++++++++ Sources/Model/CodexTaskStatusStore.swift | 51 +------ Tests/CodexTaskStatusLogParserTests.swift | 62 +++++++++ scripts/run-tests.sh | 8 ++ 4 files changed, 216 insertions(+), 44 deletions(-) create mode 100644 Sources/Model/CodexTaskStatusLogParser.swift create mode 100644 Tests/CodexTaskStatusLogParserTests.swift diff --git a/Sources/Model/CodexTaskStatusLogParser.swift b/Sources/Model/CodexTaskStatusLogParser.swift new file mode 100644 index 00000000..3f98a10d --- /dev/null +++ b/Sources/Model/CodexTaskStatusLogParser.swift @@ -0,0 +1,139 @@ +import Foundation + +enum CodexTaskLogState: Equatable { + case running + case waitingApproval + case waitingUserInput + case idle + case error +} + +struct CodexTaskStatusLogParser { + private static let newline: UInt8 = 0x0A + + static func parse(at url: URL, maxBytes: UInt64 = 512 * 1024) -> CodexTaskLogState? { + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + + let length = (try? handle.seekToEnd()) ?? 0 + let rawStart = length > maxBytes ? length - maxBytes : 0 + let tailStart = completeLineStart(handle: handle, rawStart: rawStart) + let failedBeforeTail = failureMarker(handle: handle, before: tailStart) + try? handle.seek(toOffset: tailStart) + guard let tail = try? handle.readToEnd() else { return nil } + return parse(tail, currentTurnFailed: failedBeforeTail).state + } + + private static func completeLineStart( + handle: FileHandle, + rawStart: UInt64 + ) -> UInt64 { + guard rawStart > 0 else { return 0 } + try? handle.seek(toOffset: rawStart - 1) + guard let boundary = try? handle.read(upToCount: 1), + boundary.first != newline + else { return rawStart } + + try? handle.seek(toOffset: rawStart) + var offset = rawStart + while let chunk = try? handle.read(upToCount: 64 * 1024), + !chunk.isEmpty { + if let newlineIndex = chunk.firstIndex(of: newline) { + return offset + UInt64(newlineIndex + 1) + } + offset += UInt64(chunk.count) + } + return offset + } + + private static func failureMarker( + handle: FileHandle, + before endOffset: UInt64 + ) -> Bool { + guard endOffset > 0 else { return false } + try? handle.seek(toOffset: 0) + var remaining = endOffset + var pending = Data() + var currentTurnFailed = false + + while remaining > 0 { + let count = Int(min(remaining, 64 * 1024)) + guard let chunk = try? handle.read(upToCount: count), + !chunk.isEmpty + else { break } + remaining -= UInt64(chunk.count) + pending.append(chunk) + + while let newlineIndex = pending.firstIndex(of: newline) { + updateFailureMarker( + event: eventType(in: pending[.. (state: CodexTaskLogState, currentTurnFailed: Bool) { + var state: CodexTaskLogState = initialFailure ? .error : .idle + var currentTurnFailed = initialFailure + + for line in data.split(separator: newline) { + guard let event = eventType(in: line) else { continue } + switch event { + case "task_started", "user_message": + currentTurnFailed = false + state = .running + case "exec_command_begin", "apply_patch_begin", "mcp_tool_call_begin": + if !currentTurnFailed { + state = .running + } + case "exec_approval_request", "apply_patch_approval_request": + if !currentTurnFailed { + state = .waitingApproval + } + case "request_user_input", "elicitation_request": + if !currentTurnFailed { + state = .waitingUserInput + } + case "task_complete": + state = currentTurnFailed ? .error : .idle + case "turn_aborted", "error", "stream_error": + currentTurnFailed = true + state = .error + default: + break + } + } + return (state, currentTurnFailed) + } + + private static func updateFailureMarker( + event: String?, + currentTurnFailed: inout Bool + ) { + switch event { + case "task_started", "user_message": + currentTurnFailed = false + case "turn_aborted", "error", "stream_error": + currentTurnFailed = true + default: + break + } + } + + private static func eventType(in line: Data.SubSequence) -> String? { + guard line.count < 1_048_576, + 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 index 1467c5c8..3ae7b7e9 100644 --- a/Sources/Model/CodexTaskStatusStore.swift +++ b/Sources/Model/CodexTaskStatusStore.swift @@ -243,46 +243,18 @@ final class CodexTaskStatusStore: ObservableObject { } nonisolated private static func parseState(at url: URL) -> Snapshot? { - guard let data = tailData(at: url), + guard let parsed = CodexTaskStatusLogParser.parse(at: url), let modified = try? url.resourceValues( forKeys: [.contentModificationDateKey] ).contentModificationDate else { return nil } - var status = Status.idle - var currentTurnFailed = false - for line in data.split(separator: 0x0A) { - guard line.count < 1_048_576, - let raw = try? JSONSerialization.jsonObject(with: Data(line)) as? [String: Any], - (raw["type"] as? String) == "event_msg", - let payload = raw["payload"] as? [String: Any], - let event = payload["type"] as? String - else { continue } - - switch event { - case "task_started", "user_message": - currentTurnFailed = false - status = .running - case "exec_command_begin", "apply_patch_begin", "mcp_tool_call_begin": - if !currentTurnFailed { - status = .running - } - case "exec_approval_request", "apply_patch_approval_request": - if !currentTurnFailed { - status = .waitingApproval - } - case "request_user_input", "elicitation_request": - if !currentTurnFailed { - status = .waitingUserInput - } - case "task_complete": - status = currentTurnFailed ? .error : .idle - case "turn_aborted", "error", "stream_error": - currentTurnFailed = true - status = .error - default: - break - } + let status: Status = switch parsed { + case .running: .running + case .waitingApproval: .waitingApproval + case .waitingUserInput: .waitingUserInput + case .idle: .idle + case .error: .error } return Snapshot( @@ -292,15 +264,6 @@ final class CodexTaskStatusStore: ObservableObject { ) } - nonisolated private static func tailData(at url: URL) -> Data? { - guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } - defer { try? handle.close() } - let length = (try? handle.seekToEnd()) ?? 0 - let maxBytes: UInt64 = 512 * 1024 - try? handle.seek(toOffset: length > maxBytes ? length - maxBytes : 0) - return try? handle.readToEnd() - } - nonisolated private static func threadID(from url: URL) -> String? { let stem = url.deletingPathExtension().lastPathComponent guard let range = stem.range( diff --git a/Tests/CodexTaskStatusLogParserTests.swift b/Tests/CodexTaskStatusLogParserTests.swift new file mode 100644 index 00000000..6a0dc645 --- /dev/null +++ b/Tests/CodexTaskStatusLogParserTests.swift @@ -0,0 +1,62 @@ +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")) + let filler = String(repeating: "x", count: 530 * 1024) + data.append(event("response_item", detail: filler)) + data.append(event("task_complete")) + try data.write(to: log) + + expect( + CodexTaskStatusLogParser.parse(at: log) == .error, + "failure outside tail survives task_complete" + ) + + var incomplete = event("task_started") + incomplete.append(event("turn_aborted")) + incomplete.append(event("response_item", detail: filler)) + try incomplete.write(to: log) + expect( + CodexTaskStatusLogParser.parse(at: log) == .error, + "failure outside tail remains terminal without completion" + ) + + if failures > 0 { + print("\(failures) failure(s)") + exit(1) + } + print("all CodexTaskStatusLogParserTests passed") + } +} 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 89d136bb6a387dfcc84a384ae94cc95328887df8 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Thu, 30 Jul 2026 09:12:26 +0800 Subject: [PATCH 5/5] fix: bound Codex task log parsing --- Sources/Model/CodexTaskStatusLogParser.swift | 151 +++++++++++-------- Tests/CodexTaskStatusLogParserTests.swift | 43 ++++-- 2 files changed, 118 insertions(+), 76 deletions(-) diff --git a/Sources/Model/CodexTaskStatusLogParser.swift b/Sources/Model/CodexTaskStatusLogParser.swift index 3f98a10d..1415e68a 100644 --- a/Sources/Model/CodexTaskStatusLogParser.swift +++ b/Sources/Model/CodexTaskStatusLogParser.swift @@ -10,76 +10,107 @@ enum CodexTaskLogState: Equatable { struct CodexTaskStatusLogParser { private static let newline: UInt8 = 0x0A + private static let cache = StateCache() - static func parse(at url: URL, maxBytes: UInt64 = 512 * 1024) -> CodexTaskLogState? { - guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } - defer { try? handle.close() } - - let length = (try? handle.seekToEnd()) ?? 0 - let rawStart = length > maxBytes ? length - maxBytes : 0 - let tailStart = completeLineStart(handle: handle, rawStart: rawStart) - let failedBeforeTail = failureMarker(handle: handle, before: tailStart) - try? handle.seek(toOffset: tailStart) - guard let tail = try? handle.readToEnd() else { return nil } - return parse(tail, currentTurnFailed: failedBeforeTail).state + private struct CacheEntry { + let offset: UInt64 + let state: CodexTaskLogState + let currentTurnFailed: Bool } - private static func completeLineStart( - handle: FileHandle, - rawStart: UInt64 - ) -> UInt64 { - guard rawStart > 0 else { return 0 } - try? handle.seek(toOffset: rawStart - 1) - guard let boundary = try? handle.read(upToCount: 1), - boundary.first != newline - else { return rawStart } + private final class StateCache: @unchecked Sendable { + private let lock = NSLock() + private var entries: [URL: CacheEntry] = [:] - try? handle.seek(toOffset: rawStart) - var offset = rawStart - while let chunk = try? handle.read(upToCount: 64 * 1024), - !chunk.isEmpty { - if let newlineIndex = chunk.firstIndex(of: newline) { - return offset + UInt64(newlineIndex + 1) - } - offset += UInt64(chunk.count) + 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 } - return offset } - private static func failureMarker( - handle: FileHandle, - before endOffset: UInt64 - ) -> Bool { - guard endOffset > 0 else { return false } - try? handle.seek(toOffset: 0) - var remaining = endOffset - var pending = Data() - var currentTurnFailed = false + 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 + } - while remaining > 0 { - let count = Int(min(remaining, 64 * 1024)) - guard let chunk = try? handle.read(upToCount: count), - !chunk.isEmpty - else { break } - remaining -= UInt64(chunk.count) - pending.append(chunk) + 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 + ) + let result = parse( + complete.data, + initialState: initialState, + currentTurnFailed: initialFailure + ) + cache.set( + CacheEntry( + offset: readStart + UInt64(complete.consumedBytes), + state: result.state, + currentTurnFailed: result.currentTurnFailed + ), + for: url + ) + return result.state + } - while let newlineIndex = pending.firstIndex(of: newline) { - updateFailureMarker( - event: eventType(in: pending[.. (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) } - return currentTurnFailed + 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) { - var state: CodexTaskLogState = initialFailure ? .error : .idle + var state = initialState var currentTurnFailed = initialFailure for line in data.split(separator: newline) { @@ -112,20 +143,6 @@ struct CodexTaskStatusLogParser { return (state, currentTurnFailed) } - private static func updateFailureMarker( - event: String?, - currentTurnFailed: inout Bool - ) { - switch event { - case "task_started", "user_message": - currentTurnFailed = false - case "turn_aborted", "error", "stream_error": - currentTurnFailed = true - default: - break - } - } - private static func eventType(in line: Data.SubSequence) -> String? { guard line.count < 1_048_576, let raw = try? JSONSerialization.jsonObject( diff --git a/Tests/CodexTaskStatusLogParserTests.swift b/Tests/CodexTaskStatusLogParserTests.swift index 6a0dc645..7bc6023a 100644 --- a/Tests/CodexTaskStatusLogParserTests.swift +++ b/Tests/CodexTaskStatusLogParserTests.swift @@ -34,23 +34,39 @@ struct CodexTaskStatusLogParserTests { let log = directory.appendingPathComponent("rollout-test.jsonl") var data = event("task_started") data.append(event("stream_error")) - let filler = String(repeating: "x", count: 530 * 1024) - data.append(event("response_item", detail: filler)) - data.append(event("task_complete")) 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" ) - var incomplete = event("task_started") - incomplete.append(event("turn_aborted")) - incomplete.append(event("response_item", detail: filler)) - try incomplete.write(to: log) + 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: log) == .error, - "failure outside tail remains terminal without completion" + CodexTaskStatusLogParser.parse(at: oversized, maxBytes: 128) == .idle, + "oversized record fallback stays within the read cap" ) if failures > 0 { @@ -60,3 +76,12 @@ struct CodexTaskStatusLogParserTests { 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) + } +}