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..319173a3 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -101,6 +101,36 @@ "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"; +"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 %@"; +"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..48225b7d 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -101,6 +101,36 @@ "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 中打开此任务"; +"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 %@" = "更新于%@"; +"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/CodexTaskStatusLogParser.swift b/Sources/Model/CodexTaskStatusLogParser.swift new file mode 100644 index 00000000..1415e68a --- /dev/null +++ b/Sources/Model/CodexTaskStatusLogParser.swift @@ -0,0 +1,156 @@ +import Foundation + +enum CodexTaskLogState: Equatable { + case running + case waitingApproval + case waitingUserInput + case idle + case error +} + +struct CodexTaskStatusLogParser { + private static let newline: UInt8 = 0x0A + private static let cache = StateCache() + + 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 + } + } + + 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 + ) + 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 + } + + 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) { + var state = initialState + 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 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 new file mode 100644 index 00000000..3ae7b7e9 --- /dev/null +++ b/Sources/Model/CodexTaskStatusStore.swift @@ -0,0 +1,275 @@ +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 var lastScanFingerprint: String? + + 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: 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 enabled, !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 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 { + return lhs.status.priority < rhs.status.priority + } + return (lhs.updatedAt ?? .distantPast) < (rhs.updatedAt ?? .distantPast) + }) else { + return ScanResult( + fingerprint: fingerprint, + snapshot: Snapshot(status: .idle, threadID: nil, updatedAt: nil) + ) + } + return ScanResult(fingerprint: fingerprint, snapshot: 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 cutoff = Date().addingTimeInterval(-86400) + var files: [(URL, Date)] = [] + 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 } + 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 .waitingApproval: .waitingApproval + case .waitingUserInput: .waitingUserInput + case .idle: .idle + case .error: .error + } + + 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/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..271cd07d 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,93 @@ 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) { + 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) + .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 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/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..5ab90623 100644 --- a/Sources/Views/Settings/StyleTile.swift +++ b/Sources/Views/Settings/StyleTile.swift @@ -19,8 +19,8 @@ struct StyleTile: View { Text(displayLabel) .font(Typography.micro) .foregroundStyle(isOn - ? Color(red: 0.58, green: 0.75, blue: 1.0) - : .white.opacity(0.55)) + ? Color.primary.opacity(0.90) + : 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/Tests/CodexTaskStatusLogParserTests.swift b/Tests/CodexTaskStatusLogParserTests.swift new file mode 100644 index 00000000..7bc6023a --- /dev/null +++ b/Tests/CodexTaskStatusLogParserTests.swift @@ -0,0 +1,87 @@ +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" + ) + + 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/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)" 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" \