SLOPSHOPPER

token-weather

在輸入框上方顯示 context window 用量的天氣預報

newbandcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-weather
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /token-weather ⎿ token-weather: Token Weather 已關閉。 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

likephp-mods

兩個 Claude Code mods(以 function hooks 寫成的 plugin)。

這是個人作品,與 Anthropic 無關。Mods 是 Claude Code 的 early access 功能,需要 Claude Code 2.1.287 以上版本;API 可能隨版本變動。

安裝

在 Claude Code 裡輸入:

/plugin marketplace add likephp-github/likephp-mods
/plugin install token-weather@likephp-mods
/plugin install session-radar@likephp-mods

兩個 mod 互相獨立,可以只裝其中一個。

token-weather

在輸入框上方顯示兩行:

☂ 陣雨 45%  90k/200k  ▂▃▄▅  上一輪 +8.3k
[Opus 5.5] │ my-project (main) │ 5h ██▌░░░░░ 32% (3h 41m後重置) │ 7d 18% │ ⏱ 18m │ $1.23
  • 第一行:context 用量的天氣(☀ 晴 <25%、☁ 多雲、☂ 陣雨、☇ 暴風雨、↯ 即將壓縮 ≥90%)、已用/總 token、最近 12 次的走勢、上一輪增量
  • 第二行:模型、專案與 git 分支、5 小時/7 天用量限制與重置時間、session 時長、費用
  • /token-weather:切換顯示;也可 /token-weather on|off|status

session-radar

側邊面板列出本機所有正在執行的 Claude Code session(讀取 ~/.claude/sessions/*.json,每 3 秒更新):

7 個 session · 1 個工作中 · 藍色為本視窗
● my-project-f8 工作中 3秒
○ 2: api-server-d8 閒置 12分
◆ 1: web-fb shell 2時

跳到其他 session:輸入 /sessions 2 就把面板上編號 2 的 session 所在的終端機分頁帶到最前面;也可以用滑鼠點面板上的 session 名稱(全螢幕版面)。

  • 數字依 session 啟動先後編號(最多 9 個),清單重新排序時不會變;本視窗那一行沒有編號
  • 支援 tmux、iTerm2、Terminal.app。session 在 tmux 裡時會先切到它的 pane;那個 tmux session 沒有連線中的 client 時,本視窗在 tmux 裡就直接切過去,否則提示 tmux attach -t <名稱>
  • 第一次使用時 macOS 會詢問是否允許控制 iTerm2/Terminal.app;拒絕後可到「系統設定 › 隱私權與安全性 › 自動化」重新允許

面板底部有一組像素角色演出本 session 的狀態,角色與背景由主題決定。狀態只有三種,與主題無關:

  • working:執行中
  • resting:回覆結束後 30 秒內
  • idle:閒置超過 30 秒

每個主題在三個狀態各有自己的動作。角色體型隨本 session 的 context 用量變大變小(100% 時為面板容得下最大尺寸的一半,最多 1.5 倍;最小 0.5 倍);面板比角色窄時不畫角色。

主題

/sessions theme 依序切換到下一個主題,/sessions theme tiger 或 /sessions theme puma 直接指定。選擇會寫進 ~/.claude/session-radar.json(例如 { "theme": "puma" },保留檔案裡的其他欄位),之後每個 session 都沿用;沒有這個檔案時用小老虎。

小老虎(tiger,預設)

老虎住在綠色的樹林裡:身後是一排樹,腳下是一行草地。樹固定高度、不隨老虎縮放,可以當比例尺看出老虎變胖了多少。老虎走動時背景以視差方式往反方向捲動(樹走得慢、草跟老虎同速),老虎停下來抓蝴蝶或睡覺時背景也停住。面板太矮放不下樹時只畫草地。

狀態動作
working<br>執行中小老虎 working:巡邏<br>巡邏:左右來回走
resting<br>回覆結束後 30 秒內小老虎 resting:抓蝴蝶<br>抓蝴蝶:甩尾、壓低身體、跳起來拍掌邊的蝴蝶
idle<br>閒置 30 秒後小老虎 idle:睡覺<br>睡覺:呼吸起伏、抖尾巴、抖耳朵、打呼
綠雲與愛心(puma)

綠雲帶著愛心住在閃爍的星空裡:沒有地面,角色腳底貼著面板底部;星星(·、✦、✧)位置固定,約一半會閃爍,不會蓋在角色或對話框上。最小倍數同樣從 0.5 倍起算,縮得太小看不到綠雲的黑眼珠時會自動稍微放大。

狀態動作
working<br>執行中綠雲與愛心 working:走路<br>走路:12 格走路圖循環,左右來回走,愛心跟在後面
resting<br>回覆結束後 30 秒內綠雲與愛心 resting:♥ 與名言<br>♥+名言:停在原地,頭頂冒出 ♥,上面隨機出現一句名言
idle<br>閒置 30 秒後綠雲與愛心 idle:吃丸子<br>吃丸子:一顆顆吃掉手上的三色丸子(吃到剩 2 顆時頭頂冒出 nom),吃完換新的一串

resting 的名言每進入一次 resting 隨機挑一句,這段期間不換;前後加「」,對話框自動換行,最多 3 行,面板太矮時縮成 1 行或只留 ♥:

  1. 我們不要灰心,我們也不應該喪志,為什麼?因為我來了。
  2. 人類的讚歌就是勇氣的讚歌,我們會把勇氣繼續傳承下去。
  3. 如果有人說懷抱希望是一種錯誤,那麼我會每一次都反駁他。無論幾次,我都會堅定地說。
  4. 未來的事情無人知,但就是因為這樣,可能性才會如此強大。
  5. 我本來就不是以外表取勝。
  6. 洋流是溫暖的,可以帶來漁獲,不會燙傷人。

動作圖由 scripts/gen-gifs.ts 以 mod 內實際的像素圖與動畫邏輯產生;終端機裡以半格字元繪製,蝴蝶、打呼、♥、星星與 nom 為文字符號。小老虎的圖是 40 欄寬、1 倍;綠雲與愛心的圖用原圖大小(1 倍)以看清楚臉,面板放寬到 64 欄。GIF 不畫中文,名言的位置以 … 代替。

/sessions:開關面板(不會自動開啟);/sessions 1~/sessions 9:跳到該編號的 session;/sessions theme [tiger|puma]:切換主題。面板在全螢幕版面下會停靠在右側。

安全性

Mods 不在沙盒裡執行,安裝前請先閱讀原始碼。這兩個 mod 只讀取:

  • token-weather:本 session 的用量資訊、git 的 .git/HEAD
  • session-radar:~/.claude/sessions/*.json(不讀取同目錄的 .key 檔)、~/.claude/session-radar.json(選的主題)

都不會連網。唯一會寫入的檔案是 session-radar 在你執行 /sessions theme 時寫的 ~/.claude/session-radar.json。

session-radar 只有在你按下某個 session 時,才會執行 ps(查它的 tty)、tmux(找 pane、切換)與 osascript(選取 iTerm2/Terminal.app 的分頁);不會啟動沒在執行的終端機 app。

開發

claude plugin validate ./token-weather
claude plugin test ./token-weather

session-radar 的動作圖(docs/images/<主題>-<狀態>.gif)由腳本產生,每一格都用面板同一套動畫邏輯算出;改了主題的角色、背景或動作後重新產生:

node scripts/gen-gifs.ts            # 重新產生每個主題 × 每個狀態的 GIF(需要 Node 24,不需安裝套件)
node scripts/gen-puma.ts            # 從原圖重新產生綠雲與愛心的走路圖與預覽圖(docs/images/puma-preview.png)
node --test 'scripts/*.test.ts'     # 腳本的測試

License

MIT

Source 3 files
hooks/register.tsx 177 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionUsage } from 'claude-code'
3
4import type { Info, Reading } from '../types'
5import {
6  bar,
7  branchFromHead,
8  duration,
9  forecast,
10  limitLabel,
11  loadColor,
12  modelLabel,
13  shortTokens,
14  signedTokens,
15  sparkline,
16} from './forecast'
17
18const HISTORY = 12
19const TICK_MS = 30_000
20
21const readings = atom({ plugin: 'token-weather', key: 'readings' } as const, [])
22const isHidden = atom({ plugin: 'token-weather', key: 'isHidden' } as const, false)
23const info = atom({ plugin: 'token-weather', key: 'info' } as const, null)
24const now = atom({ plugin: 'token-weather', key: 'now' } as const, 0)
25
26async function readBranch($: EngineInterface, root: string) {
27  const head = await $.fs.read(`${root}/.git/HEAD`).catch(() => '')
28  return branchFromHead(head)
29}
30
31async function refresh($: EngineInterface, usage: SessionUsage) {
32  const repo = await $.session.repo()
33  const root = repo?.root ?? (await $.session.root())
34  const next: Info = {
35    model: await $.session.model(),
36    project: root.split('/').filter(Boolean).pop() ?? root,
37    branch: repo === null ? null : await readBranch($, repo.root),
38    startedAt: usage.startedAt,
39    limits: usage.rateLimits.map(l => ({ kind: l.kind, percent: l.percentUsed, resetsAt: l.resetsAt })),
40    costUsd: usage.cost?.usd,
41  }
42  const t = await $.clock.now()
43  await update($, info, () => next)
44  await update($, now, () => t)
45}
46
47async function record($: EngineInterface, usage: SessionUsage) {
48  const { tokens, percent, window } = usage.context
49  if (tokens === undefined || percent === undefined) return
50
51  const reading: Reading = { percent, tokens, window }
52  await update($, readings, list => {
53    const last = list[list.length - 1]
54    const isSame = last !== undefined && last.tokens === tokens
55    return isSame ? list : [...list, reading].slice(-HISTORY)
56  })
57}
58
59export const register: Register = on => {
60  on('session.start', async ($, e, next) => {
61    await $.command.register({
62      name: 'token-weather',
63      description: '切換 context 天氣預報(on / off / status)',
64    })
65    await refresh($, await $.session.usage())
66    $.clock.every(TICK_MS, async () => {
67      const t = await $.clock.now()
68      await update($, now, () => t)
69    })
70
71    return next(e)
72  })
73
74  on('session.measure', async ($, e, next) => {
75    const usage = await $.session.usage()
76    if (e.changed.includes('context')) await record($, usage)
77    await refresh($, usage)
78
79    return next(e)
80  })
81
82  on('turn.complete', async ($, e, next) => {
83    const usage = await $.session.usage()
84    await record($, usage)
85    await refresh($, usage)
86
87    return next(e)
88  })
89
90  on('command.run', { command: 'token-weather' }, async ($, e) => {
91    const arg = e.args.trim().toLowerCase()
92
93    if (arg === 'status') {
94      const list = await read($, readings)
95      const last = list[list.length - 1]
96      const shown = (await read($, isHidden)) ? '隱藏中' : '顯示中'
97
98      if (last === undefined) {
99        return { text: `Token Weather ${shown},尚無資料(完成一輪對話後才會有)。` }
100      }
101
102      const w = forecast(last.percent)
103
104      return {
105        text: `Token Weather ${shown}:${w.icon} ${w.label} ${last.percent}%(${shortTokens(last.tokens)}/${shortTokens(last.window)})`,
106      }
107    }
108
109    const hide = arg === 'off' ? true : arg === 'on' ? false : !(await read($, isHidden))
110    await update($, isHidden, () => hide)
111
112    return { text: hide ? 'Token Weather 已關閉。' : 'Token Weather 已開啟。' }
113  })
114
115  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
116    if (e.props.hasSurvey || (await read($, isHidden))) {
117      return next(e)
118    }
119
120    const list = await read($, readings)
121    const meta = await read($, info)
122    const t = await read($, now)
123    const last = list[list.length - 1]
124    // 還沒有任何資料(例如剛重開)時也畫等待提示,不然看起來像 mod 沒載入
125    const { Box, Text } = $.ui.resolve(e)
126    const prev = list[list.length - 2]
127    const delta = last !== undefined && prev !== undefined ? last.tokens - prev.tokens : undefined
128    const w = last === undefined ? undefined : forecast(last.percent)
129
130    return (
131      <Box flexDirection="column">
132        <Box>
133          {last === undefined || w === undefined ? (
134            <Text dimColor>☁ 等待第一個回應…</Text>
135          ) : (
136            <Box>
137              <Text color={w.color} bold>
138                {w.icon} {w.label} {last.percent}%
139              </Text>
140              <Text dimColor>
141                {'  '}
142                {shortTokens(last.tokens)}/{shortTokens(last.window)}
143                {'  '}
144              </Text>
145              <Text color={w.color}>{sparkline(list.map(r => r.percent))}</Text>
146              {delta !== undefined && <Text dimColor>{'  '}上一輪 {signedTokens(delta)}</Text>}
147            </Box>
148          )}
149        </Box>
150        {meta !== null && (
151          <Box>
152            <Text wrap="truncate-end">
153              <Text color="cyan">[{modelLabel(meta.model)}]</Text>
154              <Text dimColor> │ </Text>
155              <Text>{meta.project}</Text>
156              {meta.branch !== null && <Text color="magenta"> ({meta.branch})</Text>}
157              {meta.limits.map(l => (
158                <Text key={l.kind}>
159                  <Text dimColor> │ {limitLabel(l.kind)} </Text>
160                  <Text color={loadColor(l.percent)}>
161                    {bar(l.percent, 8)} {l.percent}%
162                  </Text>
163                  {l.resetsAt !== undefined && (
164                    <Text dimColor> ({duration(Date.parse(l.resetsAt) - t)}後重置)</Text>
165                  )}
166                </Text>
167              ))}
168              <Text dimColor> │ ⏱ {duration(t - meta.startedAt)}</Text>
169              {meta.costUsd !== undefined && <Text dimColor> │ ${meta.costUsd.toFixed(2)}</Text>}
170            </Text>
171          </Box>
172        )}
173      </Box>
174    )
175  })
176}
177
hooks/forecast.ts 63 lines
1export type Weather = { icon: string; label: string; color: string }
2
3const BARS = '▁▂▃▄▅▆▇█'
4
5export const forecast = (percent: number): Weather => {
6  if (percent >= 90) return { icon: '↯', label: '即將壓縮', color: 'red' }
7  if (percent >= 75) return { icon: '☇', label: '暴風雨', color: 'magenta' }
8  if (percent >= 50) return { icon: '☂', label: '陣雨', color: 'yellow' }
9  if (percent >= 25) return { icon: '☁', label: '多雲', color: 'cyan' }
10  return { icon: '☀', label: '晴', color: 'green' }
11}
12
13export const sparkline = (percents: readonly number[]): string =>
14  percents
15    .map(p => BARS[Math.min(BARS.length - 1, Math.floor((Math.max(0, p) / 100) * BARS.length))])
16    .join('')
17
18export const shortTokens = (n: number): string => {
19  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
20  if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`
21  return `${n}`
22}
23
24export const signedTokens = (n: number): string =>
25  n >= 0 ? `+${shortTokens(n)}` : `-${shortTokens(-n)}`
26
27export const bar = (percent: number, width = 10): string => {
28  const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
29  return '█'.repeat(filled) + '░'.repeat(width - filled)
30}
31
32export const duration = (ms: number): string => {
33  const m = Math.max(0, Math.floor(ms / 60_000))
34  if (m < 60) return `${m}m`
35  const h = Math.floor(m / 60)
36  if (h < 24) return `${h}h ${m % 60}m`
37  return `${Math.floor(h / 24)}d ${h % 24}h`
38}
39
40/** 依用量決定顏色:低於 50% 綠、低於 80% 黃,其餘紅。 */
41export const loadColor = (percent: number): string =>
42  percent >= 80 ? 'red' : percent >= 50 ? 'yellow' : 'green'
43
44export const limitLabel = (kind: string): string =>
45  kind === 'five_hour' ? '5h' : kind === 'seven_day' ? '7d' : kind
46
47/** 從 .git/HEAD 的內容取出分支名稱;detached HEAD 回前 7 碼。 */
48export const branchFromHead = (head: string): string | null => {
49  const text = head.trim()
50  const ref = /^ref: refs\/heads\/(.+)$/.exec(text)
51  if (ref) return ref[1] ?? null
52  return /^[0-9a-f]{7,}$/.test(text) ? text.slice(0, 7) : null
53}
54
55/** 把模型 ID 轉成好讀的名稱:claude-opus-5-5 → Opus 5.5;認不得就原樣顯示。 */
56export const modelLabel = (id: string): string => {
57  const m = /^claude-([a-z]+)-(\d+)-(\d+)(?!\d)/.exec(id)
58  if (m === null) return id
59  const [, family = '', major, minor] = m
60  const name = family.charAt(0).toUpperCase() + family.slice(1)
61  return `${name} ${major}.${minor}${id.includes('[1m]') ? ' (1M)' : ''}`
62}
63
types/index.d.ts 24 lines
1export type Reading = { percent: number; tokens: number; window: number }
2
3export type Limit = { kind: string; percent: number; resetsAt?: string }
4
5export type Info = {
6  model: string
7  project: string
8  branch: string | null
9  startedAt: number
10  limits: Limit[]
11  costUsd?: number
12}
13
14declare module 'claude-code' {
15  interface PluginState {
16    'token-weather': {
17      readings: Reading[]
18      isHidden: boolean
19      info: Info | null
20      now: number
21    }
22  }
23}
24