Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 25 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,40 @@ 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` |
| 刷新间隔 | `RefreshIntervalStore` | `MacIsland.refreshInterval` | `300`, `900`, `1800` |
| 低功耗模式 | `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/<id>` 打开对应任务。

### 为什么设计 Codex 任务状态视图

并不是每位用户都会同时订阅 Claude 和 Codex。当用户只使用 Codex,并在设置中隐藏 Claude
时,如果左半区始终留空,灵动岛中最适合随时查看的一块空间就没有得到利用。

可选的 Codex 任务状态视图会把这块空出来的区域变成只服务于当前工具的轻量状态面板。它只
显示最需要关注的本地任务处于运行中、等待审批、等待用户输入、空闲还是异常,不展示提示词、
命令或输出。用户因此可以一眼判断是否需要回到 Codex 处理;点击状态还可以打开被选中的
Codex 任务。

在收起状态下,左侧状态组与右侧 Codex 额度组采用对应的三个组件:持续时间、当前状态和状态
图标。展开后,同一套五状态视觉语言会填充原本空置的服务商栏位。如果用户更喜欢原有的按模型
Token 用量视图,也可以随时在设置中关闭此功能。

## 从源码构建

Expand Down
30 changes: 30 additions & 0 deletions Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
30 changes: 30 additions & 0 deletions Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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" = "间距";
Expand Down
1 change: 1 addition & 0 deletions Sources/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions Sources/Model/AppearanceStore.swift
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
}
}
156 changes: 156 additions & 0 deletions Sources/Model/CodexTaskStatusLogParser.swift
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
}
}
Comment on lines +15 to +36

Copy link
Copy Markdown

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

StateCache has no eviction path — unbounded growth over the app's lifetime.

StateCache only exposes entry(for:)/set(_:for:); there is no way to remove an entry. Since Codex creates a new session .jsonl file 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
}
}
private struct CacheEntry {
let offset: UInt64
let state: CodexTaskLogState
let currentTurnFailed: Bool
}
private final class StateCache: `@unchecked` Sendable {
private let lock = NSLock()
private var entries: [URL: CacheEntry] = [:]
func entry(for url: URL) -> CacheEntry? {
lock.lock()
defer { lock.unlock() }
return entries[url]
}
func set(_ entry: CacheEntry, for url: URL) {
lock.lock()
defer { lock.unlock() }
entries[url] = entry
}
func remove(for url: URL) {
lock.lock()
defer { lock.unlock() }
entries.removeValue(forKey: url)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Model/CodexTaskStatusLogParser.swift` around lines 15 - 36, Add a
thread-safe removal method to StateCache for deleting an entry by URL, then
invoke it from the store whenever a session file is no longer tracked, including
rotation and task removal from the aggregation set. Preserve the existing
locking pattern used by entry(for:) and set(_:for:) to keep cache access
synchronized.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 .idle state.

When the tail read (raw, bounded by maxBytes) is dominated by a single oversized JSONL record such that the window contains only that record's own terminating newline, completeLines hits the lastNewline >= lowerBound guard failure and returns (Data(), 0). parse(at:) then returns initialState unchanged, which on a fresh (non-continuing) read is hardcoded to .idle — even if the true state is .running, .waitingApproval, or .error. This self-heals once the file grows past the record, but until then the UI can show a misleading idle status. This is a residual gap in the oversized-record handling that the earlier review flagged (I/O is now bounded, but correctness for this specific case is not).

Consider returning nil (unknown/no-change) instead of defaulting to .idle when no complete line boundary could be found and there's no prior cached state to preserve, so callers keep the last known state rather than showing a fabricated idle.

🐛 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Model/CodexTaskStatusLogParser.swift` around lines 38 - 83, Update
parse(at:) to detect when completeLines returns no data and zero consumedBytes
because no complete line boundary was available on a fresh, non-continuing read.
Return nil instead of parsing and returning the hardcoded initial .idle state;
preserve cached-state continuation behavior and normal parsing when complete
data is available.


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
}
}
Loading