Status line with 5-hour and weekly usage, reset day, session cost and prompt-cache countdown; toasts at 90% usage and 5 minutes before the cache goes cold…

English | 中文文档
cyxj-notch is a macOS notch dashboard for Claude Code: hover over the MacBook notch to see your 5-hour and weekly usage limits, every open Claude Code session (working / idle / just finished), task progress, prompt-cache countdown, and your content to-dos — fed by five small Claude Code mods that are included in this repo.
v0.1.0 · updated 2026-10-09 · tested on Claude Code 2.1.295 and macOS 26.7.1 · Changelog
When the mouse is away, the panel is exactly the size of the hardware notch, so you don't see it. Hover for 0.15 s and it grows out of the notch into a frosted-glass panel; move away and it tucks back in.
| Section | What you see | Data comes from |
|---|---|---|
| Usage | 5-hour and weekly limit used (%), when each resets | quota-status mod |
| Cache | The idle session whose prompt cache expires soonest ("12 min left") | quota-status mod |
| Sessions | Every open Claude Code session: running / idle / just finished, step 3/5 with a progress bar; click a session to jump to its Terminal.app tab | quota-status + task-progress mods |
| Versions | Preview servers started by headless claude -p runs, click to open | version-board mod |
| To-dos | Items from a Markdown log, "waiting on you" first, then "AI can continue", then "waiting until a time" | todo-pane mod |
| Publish cadence | Days since the last release and the next scheduled one | publish-pulse mod |
Design notes:
NSVisualEffectView glass, so there's no visible seam with the hardware.| Claude Code status line | cyxj-notch | |
|---|---|---|
| Where | Inside one terminal | Top of the screen, reachable from any app |
| Sessions shown | The one it's running in | Every open session, across terminals and projects |
| Usage limits (5 h / weekly) | Yes, via rate_limits | Yes, same data, written by the quota-status mod |
| Task progress, cache countdown, to-dos | Only what you script | Built in, from the five mods |
| Setup | One script in settings.json | Build the app + load five mods |
The two work together: quota-status keeps its own status line and also feeds the notch.
git clone https://github.com/chenyuxiaojin/cyxj-notch.git
cd cyxj-notch
./build.sh # swift build + package into build/刘海台.app
open build/刘海台.app # no Dock or menu-bar icon
pkill -x NotchDesk # quit
Then load the mods so the panel has something to show — add their folders to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (colon-separated, absolute paths):
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/cyxj-notch/mods/quota-status:/path/to/cyxj-notch/mods/task-progress:/path/to/cyxj-notch/mods/version-board:/path/to/cyxj-notch/mods/todo-pane:/path/to/cyxj-notch/mods/publish-pulse",
"NOTCH_TODO_LOG": "/path/to/your/todo-log.md",
"NOTCH_PUBLISH_ROOT": "/path/to/your/publish-folder",
"NOTCH_TZ_OFFSET_HOURS": "8"
}
}
CLAUDE_CODE_PLUGIN_DIRS loads plugin folders the same way as --plugin-dir (docs); to try a mod for one session instead, run claude --plugin-dir mods/quota-status.
quota-status and task-progress need no configuration. The last three variables are optional; without them the to-do and publish sections simply stay hidden.
To tag "waiting on you" with your own name instead of 我:
defaults write com.xiaochen.notchdesk ownerName YourName
A Claude Code mod is a plugin with a hooks module: a JavaScript or TypeScript file whose functions (hooks) Claude Code calls on events such as session.start, turn.complete, tool.call and ui.render, and which can call the mod API ($.ui.status, $.ui.toast, $.fs.write, $.session.usage(), $.clock.every, $.tool.register, $.env.get, …). In an interactive session, mods loaded with --plugin-dir or CLAUDE_CODE_PLUGIN_DIRS reload when you save them. Official docs: overview · create a mod · reference · testing · loading plugins · example mods.
This repo is a working example of one pattern: mods write small JSON files, a native app reads them. Claude Code stays the source of truth; the notch app is read-only and never talks to Claude Code directly.
Claude Code session ──(mod hooks)──► ~/.claude/notch/*.json ──(poll every 2 s)──► notch app
| Mod | What it does inside Claude Code | What it writes for the notch |
|---|---|---|
quota-status | Status line with 5 h / weekly usage, reset day, session cost, cache countdown; toast at 90 % usage and 5 min before cache goes cold | sessions/<id>.quota.json every 60 s, marked ended on exit |
task-progress | Registers a progress tool so Claude reports multi-step work; draws a progress bar above the input box | sessions/<id>.progress.json on every change |
version-board | Side pane listing preview servers from headless claude -p runs, with effort level and done/running; blocks opening more than 3 at once | versions.json every 8 s |
todo-pane | /daiban side pane showing "in progress" and "published, wrapping up" from a Markdown log | todo.json every 5 min and after each turn |
publish-pulse | Status line with days since last publish (traffic light) and the next scheduled release | pulse.json every 10 min and after each turn |
Each mod has its configuration at the top of hooks/register.ts(x) and its own tests:
cd mods/quota-status
claude plugin validate . # checks the manifest and lists the events and API calls the mod uses
claude plugin test . # runs every *.test.ts / *.test.tsx; quota-status: 9 passed, 0 failed
All five mods pass both commands on Claude Code 2.1.295 (44 tests in total: quota-status 9, task-progress 11, version-board 13, todo-pane 2, publish-pulse 9).
Lessons from building these:
at timestamp; the app treats a session as closed after 150 s without an update, so crashes clean themselves up.quota.ts, bar.ts, scan.ts, parse.ts, pulse.ts have no engine calls, so they're unit-tested without Claude Code running._ or -, up to 64 characters (limits). On 2.1.295 /daiban registers but /待办 and /café are rejected at runtime, and claude plugin validate doesn't catch it.Everything lives in ~/.claude/notch/:
| File | Shape | |
|---|---|---|
sessions/<id>.quota.json | `{ id, cwd, at, working, cache, limits: [{ kind: "five_hour" \ | "seven_day", percentUsed, resetsAt }], tty?, app? }` |
sessions/<id>.progress.json | `{ id, at, progress: { done, total, now } \ | null }` |
versions.json | { at, rows: [{ label, port, effort, state, isRunning, isVersion }] } | |
todo.json | { at, busy: [{ title, next }], wrapUp: [{ title, next }] } | |
pulse.json | { at, text } |
The to-do log is plain Markdown with two sections. Each "next step" starts with who's up — 我: (you), AI:, or 等 10-13 14:59: (waiting until a time):
## 当前在忙
### Notch app open source
- 状态(10-09): screenshots done, README drafted.
- 下一步: 我: record a 30-second demo
- 入口: `projects/notch/README.md`
## 已发布待收尾
- Last video(10-01): add links on two platforms.
NOTCH_FEED=<folder with sample JSON> NOTCH_OPEN=1 ./build/刘海台.app/Contents/MacOS/NotchDesk
NOTCH_FEED points the app at another data folder; NOTCH_OPEN=1 opens the panel on launch and keeps it open, handy for screenshots.
Source layout:
Sources/NotchDesk/main.swift borderless NSPanel above the menu bar, hover tracking, notch geometry
Sources/NotchDesk/NotchView.swift SwiftUI panel, glass background, motion, all sections
Sources/NotchDesk/Feed.swift reads ~/.claude/notch/
mods/ the five Claude Code mods
docs/ screenshots and demo GIF
How do I see Claude Code usage limits on my Mac without opening a terminal? Run cyxj-notch with the quota-status mod. Hovering the notch shows your 5-hour and weekly percentages and their reset times, refreshed every 60 seconds.
What does "cache 12 min left" mean? Claude Code reuses a prompt cache while you keep talking. Each cache hit resets the timer; once a session sits idle past the lifetime, the next message re-sends the whole context — slower and more expensive. By default the main conversation gets 1 hour on a Pro/Max subscription within plan usage, and 5 minutes with an API key, a cloud provider, or usage credits beyond the plan. The mod assumes 1 hour when Claude Code reports usage windows and 5 minutes otherwise; it doesn't detect a custom promptCacheTtl, or the switch to 5 minutes once you go past plan usage. The panel shows the idle session closest to expiring, so you know which one to continue first.
Does it work without a notch? Yes. On a screen without a notch it uses a 200 pt-wide area at the top center of the main screen.
Does it send any data anywhere? No. The app only reads local files in ~/.claude/notch/; the mods only write there. No network calls.
Can I use only some of the mods? Yes. Each section hides itself when its file is missing or stale.
llms.txt lists every file worth reading in this repo, with one line each.
Write-up (in Chinese) on how it was designed and iterated: 给 MacBook 刘海装了个 Claude Code 面板.
Made by @cyxj_ai, a non-programmer building tools with Claude Code. More projects: cyxj-groksearch · cyxj-hyperframes · cyxj-remotion-starter
hooks/register.ts 118 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { formatCache, formatQuota, labelOf, newlyOver, shouldWarnCache, ttlOf, WARN_LEFT_MIN } from './quota'
4
5// ── 可配置项 ──
6// 多久刷新一次额度
7const REFRESH_MS = 60 * 1000
8// 刘海台读这里:~/.claude/notch/sessions/<对话id>.quota.json,每个对话一份,按 at 判断还活着没
9const feedDir = async ($: EngineInterface) => `${(await $.env.get('HOME')) ?? ''}/.claude/notch/sessions`
10
11// 已经弹过 90% 提醒的窗口;重新加载后清空,最多多提醒一次
12const warned = new Set<string>()
13
14// 缓存倒计时:主对话最后一次回复完成的时刻、是否正在干活、这段空闲是否已提醒过
15let lastAt: number | undefined
16let isWorking = false
17let cacheWarnedFor: number | undefined
18
19// 这个对话所在的终端标签页(如 /dev/ttys001),刘海台点对话时靠它跳回来;第一次刷新时查一次
20let terminal: { tty: string; app: string | null } | undefined
21let terminalLooked = false
22
23// 从 $.process.run 起的命令往上找祖先进程,第一个挂在终端上的就是 Claude Code 所在的标签页
24const TTY_SCRIPT = 'p=$PPID; while [ "$p" -gt 1 ]; do t=$(ps -o tty= -p $p | tr -d " "); case $t in ""|"?"*) p=$(ps -o ppid= -p $p | tr -d " ");; *) echo $t; exit;; esac; done'
25
26const findTerminal = async ($: EngineInterface) => {
27 const { stdout } = await $.process.run(['sh', '-c', TTY_SCRIPT])
28 const tty = stdout.trim()
29 if (!tty || tty.startsWith('?')) return undefined
30 return { tty: `/dev/${tty}`, app: (await $.env.get('TERM_PROGRAM')) ?? null }
31}
32
33const warnCache = ($: EngineInterface) => {
34 const text = `缓存 ${WARN_LEFT_MIN} 分钟内变凉,要走开的话先 /compact`
35 $.ui.toast(text, { timeoutMs: 15_000 })
36 // 人可能不在终端前,再弹一个系统横幅
37 void $.process
38 .run(['osascript', '-e', `display notification "${text}" with title "Claude Code 缓存" sound name "Submarine"`])
39 .catch(() => undefined)
40}
41
42const refresh = async ($: EngineInterface) => {
43 const usage = await $.session.usage()
44 const now = await $.clock.now()
45 const ttl = ttlOf(usage.rateLimits)
46 const cache = formatCache(now, lastAt, isWorking, ttl, usage.context.tokens)
47 const quota = formatQuota(usage.rateLimits, usage.cost?.usd)
48
49 $.ui.status(cache ? `${quota} · ${cache}` : quota)
50
51 if (!terminalLooked) {
52 terminalLooked = true
53 terminal = await findTerminal($).catch(() => undefined)
54 }
55
56 const id = await $.session.id()
57 const cwd = await $.session.cwd()
58 const feed = { id, cwd, at: now, working: isWorking, quota, cache: cache ?? null, limits: usage.rateLimits, ...terminal }
59 void $.fs.write(`${await feedDir($)}/${id}.quota.json`, JSON.stringify(feed)).catch(() => undefined)
60
61 for (const limit of newlyOver(usage.rateLimits, warned)) {
62 warned.add(limit.kind)
63 $.ui.toast(`${labelOf(limit.kind)}已用 ${Math.round(limit.percentUsed)}%`, { timeoutMs: 10_000 })
64 }
65
66 // 窗口重置、降回 90% 以下后,下次再越线还会提醒
67 for (const limit of usage.rateLimits) {
68 if (limit.percentUsed < 90) warned.delete(limit.kind)
69 }
70
71 if (shouldWarnCache(now, lastAt, isWorking, ttl) && cacheWarnedFor !== lastAt) {
72 cacheWarnedFor = lastAt
73 warnCache($)
74 }
75}
76
77export const register: Register = on => {
78 on('session.start', async ($, e, next) => {
79 void refresh($).catch(() => undefined)
80 $.clock.every(REFRESH_MS, () => refresh($).catch(() => undefined))
81
82 return next(e)
83 })
84
85 on('prompt.submit', async ($, e, next) => {
86 isWorking = true
87 lastAt = await $.clock.now()
88 void refresh($).catch(() => undefined)
89
90 return next(e)
91 })
92
93 // 只看主对话:子任务的回合不算
94 on('turn.complete', async ($, e, next) => {
95 if (e.agentId === undefined) {
96 isWorking = false
97 lastAt = await $.clock.now()
98 void refresh($).catch(() => undefined)
99 }
100
101 return next(e)
102 })
103
104 // 对话关掉时告诉刘海台这一行可以撤了
105 on('session.end', async ($, e, next) => {
106 const id = await $.session.id()
107 await $.fs.write(`${await feedDir($)}/${id}.quota.json`, JSON.stringify({ id, ended: true })).catch(() => undefined)
108
109 return next(e)
110 })
111
112 on('session.measure', async ($, e, next) => {
113 void refresh($).catch(() => undefined)
114
115 return next(e)
116 })
117}
118hooks/quota.ts 72 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3const WEEKDAYS = '日一二三四五六'
4const WARN_AT = 90
5
6// 重置时间按北京时间算星期几
7const weekdayOf = (iso: string) => {
8 const ms = Date.parse(iso)
9
10 return Number.isNaN(ms) ? undefined : WEEKDAYS[new Date(ms + 8 * 3_600_000).getUTCDay()]
11}
12
13export const formatQuota = (limits: readonly Limit[], usd: number | undefined): string => {
14 const five = limits.find(l => l.kind === 'five_hour')
15 const week = limits.find(l => l.kind === 'seven_day')
16 const parts: string[] = []
17
18 if (five) parts.push(`5h ${Math.round(five.percentUsed)}%`)
19
20 if (week) {
21 const day = week.resetsAt ? weekdayOf(week.resetsAt) : undefined
22 parts.push(`本周 ${Math.round(week.percentUsed)}%${day ? `(周${day}重置)` : ''}`)
23 }
24
25 const line = parts.length > 0 ? `额度 ${parts.join(' · ')}` : '额度 —'
26
27 // 订阅(有额度窗口)时金额只是按 API 价折算,带 ≈;没有窗口就是真实花费
28 if (usd === undefined) return line
29
30 return `${line} · 本对话${parts.length > 0 ? '≈' : ''}$${usd.toFixed(2)}`
31}
32
33// 刚越过 90% 的窗口:这次 ≥90,上次没提醒过
34export const newlyOver = (limits: readonly Limit[], warned: ReadonlySet<string>) =>
35 limits.filter(l => l.percentUsed >= WARN_AT && !warned.has(l.kind))
36
37export const labelOf = (kind: string) =>
38 kind === 'five_hour' ? '5 小时额度' : kind === 'seven_day' ? '本周额度' : kind
39
40// 订阅缓存 1 小时,按量付费(没有额度窗口)5 分钟
41export const ttlOf = (limits: readonly Limit[]) => (limits.length > 0 ? 60 : 5)
42
43const minutesLeft = (nowMs: number, lastAt: number, ttlMin: number) => Math.ceil(ttlMin - (nowMs - lastAt) / 60_000)
44
45// Claude 干活时缓存一直是热的;停下后从最后一次回复完成开始倒数(按回复结束时间估,会差一两分钟)
46export const formatCache = (
47 nowMs: number,
48 lastAt: number | undefined,
49 isWorking: boolean,
50 ttlMin: number,
51 tokens: number | undefined,
52): string | undefined => {
53 if (lastAt === undefined) return undefined
54 if (isWorking) return `🔥 缓存 ${ttlMin} 分钟`
55
56 const left = minutesLeft(nowMs, lastAt, ttlMin)
57 if (left > 0) return `🔥 缓存 ${left} 分钟`
58
59 return tokens ? `🧊 缓存已凉 · 下条重发 ${Math.round(tokens / 1000)}K` : '🧊 缓存已凉'
60}
61
62export const WARN_LEFT_MIN = 5
63
64// 停着、还热、只剩 5 分钟以内:该提醒了
65export const shouldWarnCache = (nowMs: number, lastAt: number | undefined, isWorking: boolean, ttlMin: number) => {
66 if (lastAt === undefined || isWorking || ttlMin <= WARN_LEFT_MIN) return false
67
68 const left = minutesLeft(nowMs, lastAt, ttlMin)
69
70 return left > 0 && left <= WARN_LEFT_MIN
71}
72