Skip to content
Open
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
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@

CodexIsland is a native macOS overlay that turns the MacBook notch into a
Dynamic-Island-style live activity for Claude Code and Codex usage limits. It
sits quietly over the notch, peeks on hover with the 5-hour headline, and
expands on click to show both providers' 5-hour and weekly windows with reset
sits quietly over the notch and expands on hover to show both providers'
5-hour and weekly windows with reset
timing, chart controls, local-log cost estimates, and a year-at-a-glance usage
history.

Expand All @@ -36,10 +36,10 @@ providers' own usage endpoints.
physical notch, drawn with continuous (squircle) corners that match the
hardware. On non-notched displays it falls back to a configurable menu-bar
pill.
- **Hover to peek.** The silhouette widens just enough to show each visible
provider's 5-hour percentage and reset headline, or keep those headlines
visible at rest with **Always show usage**.
- **Three swipeable screens.** Click to expand, then swipe between **Usage**,
- **Hover to expand.** Hovering opens the full panel; moving away collapses it
after a 1.5-second grace period. Clicking remains available as a fallback.
**Always show usage** keeps the usage headlines visible at rest.
- **Three swipeable screens.** Expand the panel, then swipe between **Usage**,
**Cost**, and **Overview**. Cost estimates today and month-to-date spend and
token throughput from local Claude Code, Codex CLI, and OpenCode session
data. Overview renders the current year's activity as a contribution-style
Expand Down Expand Up @@ -160,8 +160,9 @@ the first peek. Opening Settings also triggers a fresh fetch.

## Using the app

- Hover the notch to peek at the current 5-hour usage.
- Click the island to expand the full panel.
- Hover over the island to expand the full panel. Moving the pointer away
collapses it after a 1.5-second grace period; clicking remains available as
a fallback.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- Swipe horizontally on the panel (or use the indicator dots) to move between
**Usage**, **Cost**, and **Overview**.
- Move away to collapse it.
Expand Down
8 changes: 3 additions & 5 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,15 @@

> 你的 AI 用量限额,住在 Mac 刘海里。

CodexIsland 是一个原生 macOS 悬浮层,把 MacBook 刘海变成类似 Dynamic Island 的实时用量状态。它支持 Claude Code 和 Codex,用悬停预览 5 小时窗口,用点击展开完整面板,展示 5 小时与周窗口的用量、重置时间、图表样式,以及从本地会话日志估算的美元成本和 token 吞吐量。
CodexIsland 是一个原生 macOS 悬浮层,把 MacBook 刘海变成类似 Dynamic Island 的实时用量状态。它支持 Claude Code 和 Codex,悬停即可展开完整面板,展示 5 小时与周窗口的用量、重置时间、图表样式,以及从本地会话日志估算的美元成本和 token 吞吐量;移开鼠标 1.5 秒后自动收起,也可点击展开作为备用操作

应用免费、开源、未签名,并且以本地优先为原则。它读取 Claude Code / Claude Desktop 和 Codex 已经写入本机的凭据,只调用对应服务自己的用量接口。

## 功能

- **两个服务,四个窗口。** 在一个面板里显示 Claude 5 小时 + 7 天,以及 Codex 5 小时 + 7 天。
- **贴合刘海的悬浮层。** 紧凑状态是一个对齐物理刘海的黑色胶囊;没有刘海的 Mac 会退回到菜单栏胶囊。
- **悬停预览。** 鼠标移到刘海附近时,胶囊会展开到足够显示每个可见服务的 5 小时百分比和重置提示。
- **点击展开。** 点击岛可打开完整 Usage / Cost / Overview 面板,包含服务列、图表控制和分页。
- **悬停展开。** 将鼠标移到岛上即可打开完整 Usage / Cost / Overview 面板;移开后会保留 1.5 秒缓冲再自动收起,点击仍可作为备用操作。开启“始终显示用量”后,收起状态也会保留用量摘要。
- **Usage 与 Cost 横向切换。** Cost 页面会从本地 Claude Code 和 Codex 日志估算今天与本月至今的美元成本、token 吞吐量和趋势。
- **可配置 token 统计口径。** 可以选择统计所有 token(包含缓存,接近 ccusage 口径),或只统计输入 + 输出(接近 Anthropic claude.ai 统计面板)。
- **不遮挡岛外点击。** 窗口会忽略可见轮廓外的鼠标事件,菜单栏和后面的 app 仍能正常操作。
Expand Down Expand Up @@ -72,8 +71,7 @@ Claude:

## 使用

- 悬停刘海,预览当前 5 小时用量。
- 点击岛,展开完整面板。
- 悬停岛即可展开完整面板;移开 1.5 秒后自动收起,点击也可作为备用操作。
- 在面板上横向滑动,或点击底部圆点,在 **Usage**、**Cost** 和 **Overview** 之间切换。
- 移开鼠标,面板会收起。
- 在展开面板里 Cmd 点击,可切换当前页面的可视化样式。
Expand Down
2 changes: 2 additions & 0 deletions Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@
"Click to expand. Command-click to cycle visualization." = "Click to expand. Command-click to cycle visualization.";
"Command-click to cycle visualization." = "Command-click to cycle visualization.";
"Hover to peek usage. Click to expand. Command-click to cycle visualization." = "Hover to peek usage. Click to expand. Command-click to cycle visualization.";
"Hover to expand. Move away to collapse." = "Hover to expand. Move away to collapse.";
"Hover or activate to expand. Move away to collapse." = "Hover or activate to expand. Move away to collapse.";
"Hover to show reset expiration details" = "Hover to show reset expiration details";
"How often to refresh." = "How often to refresh.";
"Percentages" = "Percentages";
Expand Down
2 changes: 2 additions & 0 deletions Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@
"Click to expand. Command-click to cycle visualization." = "点击展开。Command 点击可切换可视化。";
"Command-click to cycle visualization." = "Command 点击可切换可视化。";
"Hover to peek usage. Click to expand. Command-click to cycle visualization." = "悬停预览用量。点击展开。Command 点击可切换可视化。";
"Hover to expand. Move away to collapse." = "悬停即可展开,移开后自动收起。";
"Hover or activate to expand. Move away to collapse." = "悬停或激活即可展开,移开后自动收起。";
"Hover to show reset expiration details" = "悬停查看重置到期详情";
"How often to refresh." = "刷新频率。";
"Percentages" = "百分比";
Expand Down
142 changes: 61 additions & 81 deletions Sources/Views/IslandRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ struct IslandRootView: View {
@State private var contentVisible = false
@State private var pillsVisible = false
@State private var pulseToken: UUID?
@State private var collapseRequest = UUID()

/// Image decode from disk is ~150µs per call. Computed properties
/// re-decoded both logos every render — inside a 120Hz TimelineView
Expand Down Expand Up @@ -139,31 +140,9 @@ struct IslandRootView: View {
}
return
}
// Plain click: enter the full panel. Works from .peek
// (the common case after hover) or .compact (cold click).
// Pills travel outward with the growing shape under the
// single openMorph spring, then quietly retire after the
// expanded content has settled.
guard model.state == .peek || model.state == .compact else { return }
withAnimation(.openMorph) {
model.setState(.expanded)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) {
guard model.state == .expanded else { return }
withAnimation(.strongEaseOut) {
contentVisible = true
}
}
// Guard against a hover-out landing inside the 250ms
// wait: under always-show it restores the pills at peek,
// and this stale callback would hide them again — leaving
// the rest state pill-less until the next hover cycle.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
guard model.state == .expanded else { return }
withAnimation(.easeOut(duration: 0.18)) {
pillsVisible = false
}
}
// Keep click as an accessibility/fallback path; ordinary
// pointer use expands immediately on hover.
expandPanel()
}
.onHover { h in
hovering = h
Expand All @@ -174,60 +153,9 @@ struct IslandRootView: View {
NSHapticFeedbackManager.defaultPerformer.perform(
.levelChange, performanceTime: .now
)
// PEEK ENTER: shape morphs out to peek width. Pills
// fade in 60ms later so the eye sees the shape commit
// first, then content arrives. Hover does NOT open
// the full panel — that requires a click.
if model.state == .compact {
withAnimation(.openMorph) {
model.setState(.peek)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) {
guard model.state == .peek else { return }
withAnimation(.easeOut(duration: 0.18)) {
pillsVisible = true
}
}
}
expandPanel()
} else {
// EXIT: pills fade first (unless we're pinning peek),
// then the shape settles at the rest state — `.compact`
// normally, `.peek` under always-show.
if !alwaysShow.enabled {
withAnimation(.easeOut(duration: 0.08)) {
pillsVisible = false
}
}
withAnimation(.easeOut(duration: 0.10)) {
contentVisible = false
}
// Start the shape morph after only 20ms — overlapping
// with the content fade — so the silhouette begins
// shrinking while the content is still fading out.
// The original 100ms wait caused a visible "flash black"
// because the full-size black shape was exposed for the
// entire fade before the closeMorph fired.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) {
guard !hovering else { return }
// Re-read restState here — the user may have flipped
// the always-show toggle during the 20ms wait, and
// a captured-at-creation-time `target` would settle
// at the wrong state for them.
let target = restState
if model.state != target {
withAnimation(.closeMorph) {
model.setState(target)
}
}
// Coming out of `.expanded` under always-show, the
// pills were hidden by the open-panel branch — bring
// them back as the shape resettles at peek.
if alwaysShow.enabled && !pillsVisible {
withAnimation(.easeOut(duration: 0.18)) {
pillsVisible = true
}
}
}
scheduleCollapseAfterHoverExit()
}
}
Spacer(minLength: 0)
Expand Down Expand Up @@ -345,13 +273,65 @@ struct IslandRootView: View {
alwaysShow.enabled ? .peek : .compact
}

private func expandPanel() {
// Invalidates any delayed collapse that was scheduled on a brief
// pointer exit while the shape was morphing.
collapseRequest = UUID()
if model.state == .expanded {
if !contentVisible {
withAnimation(.strongEaseOut) {
contentVisible = true
}
}
return
}

withAnimation(.easeOut(duration: 0.08)) {
pillsVisible = false
}
withAnimation(.openMorph) {
model.setState(.expanded)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.14) {
guard model.state == .expanded else { return }
withAnimation(.strongEaseOut) {
contentVisible = true
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}

private func scheduleCollapseAfterHoverExit() {
// 1.5s is long enough to cross a small pointer gap or return after an
// accidental exit, without leaving the expanded dashboard hanging.
let request = UUID()
collapseRequest = request
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
guard collapseRequest == request, !hovering else { return }
withAnimation(.easeOut(duration: 0.12)) {
contentVisible = false
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) {
guard collapseRequest == request, !hovering else { return }
let target = restState
withAnimation(.closeMorph) {
model.setState(target)
}
if target == .peek {
withAnimation(.easeOut(duration: 0.18)) {
pillsVisible = true
}
}
}
}
}

private var accessibilityHintForState: String {
switch model.state {
case .compact:
return alwaysShow.enabled
? L10n.tr("Click to expand. Command-click to cycle visualization.")
: L10n.tr("Hover to peek usage. Click to expand. Command-click to cycle visualization.")
case .peek: return L10n.tr("Click to expand. Command-click to cycle visualization.")
? L10n.tr("Hover or activate to expand. Move away to collapse.")
: L10n.tr("Hover or activate to expand. Move away to collapse.")
case .peek: return L10n.tr("Hover or activate to expand. Move away to collapse.")
case .expanded:
return ScreenPref.shared.screen == .overview
? L10n.tr("Swipe to change pages.")
Expand Down
Loading