-
Notifications
You must be signed in to change notification settings - Fork 51
feat: add adaptive appearance and Codex task status #67
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
982ae9e
4183810
24c99fd
bc544fe
89d136b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
|
Comment on lines
+38
to
+83
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Oversized tail-window record can silently report a stale When the tail read ( Consider returning 🐛 Sketch of the fix let complete = completeLines(
in: raw,
droppingLeadingPartialLine: !canContinue && readStart > 0
)
+ guard canContinue || complete.consumedBytes > 0 else {
+ // Tail window is entirely consumed by one oversized record with
+ // no other line boundary; report unknown rather than a
+ // fabricated `.idle` until the window advances past it.
+ return nil
+ }
let result = parse(Also applies to: 85-106 🤖 Prompt for AI Agents |
||
|
|
||
| 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..<upperBound]), | ||
| data.distance(from: data.startIndex, to: upperBound) | ||
| ) | ||
| } | ||
|
|
||
| private static func parse( | ||
| _ data: Data, | ||
| initialState: CodexTaskLogState, | ||
| currentTurnFailed initialFailure: Bool | ||
| ) -> (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 | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win
StateCachehas no eviction path — unbounded growth over the app's lifetime.StateCacheonly exposesentry(for:)/set(_:for:); there is no way to remove an entry. Since Codex creates a new session.jsonlfile per task, this dictionary grows for as long as the app runs, with no bound.♻️ Proposed fix: expose a removal path for stale sessions
private final class StateCache: `@unchecked` Sendable { private let lock = NSLock() private var entries: [URL: CacheEntry] = [:] func entry(for url: URL) -> CacheEntry? { lock.lock() defer { lock.unlock() } return entries[url] } func set(_ entry: CacheEntry, for url: URL) { lock.lock() defer { lock.unlock() } entries[url] = entry } + + func remove(for url: URL) { + lock.lock() + defer { lock.unlock() } + entries.removeValue(forKey: url) + } }Have the store call this when a session file is no longer tracked (rotated away, task removed from the aggregation set).
📝 Committable suggestion
🤖 Prompt for AI Agents