SLOPSHOPPER

cache-glance

輸入框下方常駐一行提示快取狀態:還有多久過期、過期後重寫要多少錢

newbandstatustimer
v0.1.1no licenseupdated 2026-10-08Toriwuuu/claude-mods/mods/cache-glance
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-glance
› 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 ⟨Claude Code's own drawing⟩ 快取 warm · 59m 後過期 · 過期重寫約 $0.97 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ 快取 warm · 59m 後過期 · 過期重寫約 $0.97
README

claude-mods

Daniel 的 Claude Code mod 清單,讓每台電腦都裝到同一套 mod。

  • 自己做的 mod:程式碼放在 mods/。
  • 別人的 mod:只在清單裡指定 repo,並鎖定審查過的版本(commit),作者之後的更新不會自動進來。

清單本體是 .claude-plugin/marketplace.json。需要 Claude Code 2.1.287 以上。

收錄的 mod

mod來源做什麼指令
cache-glance自己做的輸入框上方常駐一行快取狀態:還有多久過期、過期後重寫要多少錢無
task-progress自己做的給 Claude 一個回報工具,多步驟工作時把任務清單和進度寫進 cmux 左側欄(只在 cmux 裡啟動;搭配下方的 cmux 側欄)無
auto-panes自己做的每次開 Claude Code 等 1.5 秒自動打開右側的 Diff(/diff)和 Files(/filetree)。Source Control 不自動開:它的 /git 打開時會搶走鍵盤,第一個打的字會進 commit 欄,所以用 ◨ 手動開無
image-viewjarrodwatts/claude-image-view貼上的圖片在輸入框上方顯示縮圖(需要支援圖片的終端機)無
filetreedata-goblin/claude-code-filetree右側欄的檔案樹,標出 Claude 正在讀寫的檔案(需要全螢幕模式)/filetree
cache-taxkaranb192/cache-tax快取保溫;閒置過久後的第一則訊息會擋下一次並顯示重寫價格/keepwarm、/cache-tax
next-stepsanthropics/claude-plugins-community(Thariq Shihipar)每次回覆後在輸入框上方給最多三個下一步建議;對話框是空的時按 1、2、3 填進去,再按 Enter 送出,0 關掉,Tab 採用第一個。不會自己送出無
source-controlmanuel-will/cc-source-controlVS Code 風格的原始碼控制:commit 歷史分支圖、暫存、commit、同步(介面只有英文、德文)/git

別人的 mod 都在 2026-10-08 讀過程式碼:image-view、filetree 不連網;cache-tax 不連網,但開了 /keepwarm 會定時送請求、吃額度;source-control 只執行 git、沒有破壞性操作,背景每 10 分鐘 git fetch 一次(設定 fetchIntervalMinutes 為 0 可關閉),從它的面板 commit 時不會跑專案的 git hooks;next-steps 只讀指令清單、填對話框,不連網、不執行程式,但每輪回覆後會送一次分身請求問建議,對話越長這次請求越貴(讀一次整段快取,跟一次保溫差不多)。

filetree 和 source-control 共用右側面板,用 source-control 那列 Commit 按鈕右邊的 ◨ 切換。

cmux 側欄:task-cards

cmux/task-cards.js 是 cmux 的自訂左側欄(cmux 的 Custom Sidebars beta 功能),不是 Claude Code 的 mod。它照 cmux 內建的 workspaces 範本改,每個工作區一張卡片,把「最後一則訊息」換成 task-progress 寫進來的任務清單:

  • 選取中的工作區:列出每一步(✓ 完成、▸ 進行中、· 還沒做)
  • 其他工作區:進度條+目前做到哪一步
  • 沒有清單時:顯示 Claude 的狀態(工作中、等你回覆、閒置)

task-progress 把清單寫在工作區的「描述」、摘要寫在「進度」。換回 cmux 預設側欄時,這兩項也會顯示在工作區下面。

安裝:在 ~/.config/cmux/sidebars/ 放一個捷徑,再到 cmux 側欄切換鈕上按右鍵,選 task-cards。

mkdir -p ~/.config/cmux/sidebars
# 主力電腦(清單在 ~/claude-mods)
ln -s ~/claude-mods/cmux/task-cards.js ~/.config/cmux/sidebars/
# 其他電腦(清單是從 GitHub 加的,放在 Claude Code 的下載資料夾)
ln -s ~/.claude/plugins/marketplaces/claude-mods/cmux/task-cards.js ~/.config/cmux/sidebars/
cmux sidebar validate task-cards

在另一台電腦安裝(例如公司 Mac mini)

repo 是私有的,第一次要先讓那台電腦能讀 GitHub:

gh auth login
gh auth setup-git

加入清單,再裝想要的 mod:

claude plugin marketplace add Toriwuuu/claude-mods
claude plugin install cache-glance@claude-mods
claude plugin install image-view@claude-mods
claude plugin install filetree@claude-mods
claude plugin install cache-tax@claude-mods
claude plugin install source-control@claude-mods
claude plugin install task-progress@claude-mods
claude plugin install auto-panes@claude-mods
claude plugin install next-steps@claude-mods

裝好後重開 Claude Code。要用 task-cards 側欄的話,照上面「cmux 側欄」那段放捷徑。

更新

在主力電腦改完並 push 之後,另一台執行:

claude plugin marketplace update claude-mods
claude plugin update cache-glance@claude-mods

然後重開 Claude Code。

注意:Claude Code 是看 plugin.json 裡的 version 判斷有沒有新版。改了自己的 mod,要把版本號往上加(例如 0.1.0 → 0.1.1),另一台才會更新。

開發(主力電腦)

主力電腦的清單直接指向本機資料夾:

claude plugin marketplace add ~/claude-mods

這樣改了 mods/ 裡的檔案,在 Claude Code 裡執行 /reload-plugins 就會生效,不用先 push。確認沒問題再 commit、push。

改完可以先跑檢查:

claude plugin validate mods/cache-glance
claude plugin test mods/cache-glance

升級別人的 mod

不要直接換 sha。先讀過新版本的程式碼,確認沒問題再把 marketplace.json 裡的 sha 換成新的 commit。

Source 3 files
hooks/register.tsx 144 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { DEFAULT_TTL_MS, SHORT_TTL_MS, readCache } from './format'
5import type { CacheState } from './format'
6
7// Kept in $.state so a reload of the mod keeps the clock it was running.
8const lastRequestAt = atom({ plugin: 'cache-glance', key: 'lastRequestAt' } as const, 0)
9const ctx = atom({ plugin: 'cache-glance', key: 'ctx' } as const, 0)
10const model = atom({ plugin: 'cache-glance', key: 'model' } as const, null as string | null)
11
12const REFRESH_MS = 30_000
13
14// Theme keys, so the line follows the person's Claude Code theme.
15const STATE_COLOR: Record<CacheState, string | undefined> = {
16  warm: 'success',
17  cold: 'warning',
18  unknown: undefined,
19}
20
21let ttlMs = DEFAULT_TTL_MS
22
23/** The cache lives five minutes when the person set it so, an hour otherwise. */
24async function readTtl($: EngineInterface): Promise<number> {
25  const fromEnv = await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL')
26  const fromSettings = (await $.settings.read()).promptCacheTtl
27  return (fromEnv ?? fromSettings) === '5m' ? SHORT_TTL_MS : DEFAULT_TTL_MS
28}
29
30export const register: Register = on => {
31  on('session.start', async ($, e, next) => {
32    const result = await next(e)
33    // An earlier version pinned its line under the prompt; that pin outlives a reload.
34    $.ui.status(undefined)
35    ttlMs = await readTtl($)
36    if ((await read($, model)) === null) {
37      const current = await $.session.model()
38      await update($, model, () => current)
39    }
40    // Loaded into a running session: the window's size is known, its clock is not.
41    if ((await read($, ctx)) === 0) {
42      const tokens = (await $.session.usage()).context.tokens ?? 0
43      await update($, ctx, () => tokens)
44    }
45    // The countdown moves with the clock, not with any value: redraw it on a timer.
46    $.clock.every(REFRESH_MS, () => $.ui.invalidate('ui.render'))
47    return result
48  })
49
50  on('classic.SessionStart', async ($, e, next) => {
51    const result = await next(e)
52    if (e.source === 'clear') {
53      await update($, lastRequestAt, () => 0)
54      await update($, ctx, () => 0)
55    } else if (e.source === 'compact') {
56      // The next request writes the compacted conversation afresh.
57      await update($, lastRequestAt, () => 0)
58    } else if (e.source === 'resume' || e.source === 'fork') {
59      const now = await $.clock.now()
60      const seconds = e.seconds_since_last_response
61      const tokens = e.context_tokens
62      const resumedModel = e.model
63      if (seconds !== undefined) await update($, lastRequestAt, () => now - seconds * 1000)
64      if (tokens !== undefined && tokens > 0) await update($, ctx, () => tokens)
65      if (resumedModel) await update($, model, () => resumedModel)
66    }
67    return result
68  })
69
70  // Each request of the main conversation reads the cache and restarts its clock.
71  on('turn.step', async function* ($, e, next) {
72    if (!e.agentId) {
73      const now = await $.clock.now()
74      await update($, lastRequestAt, () => now)
75    }
76    return yield* next(e)
77  })
78
79  // A fork of the main conversation (cache-tax's keepwarm ping is one) reads the
80  // same cache; when it did read it, the clock starts over just as for a turn.
81  on('model.fork', async ($, e, next) => {
82    const result = await next(e)
83    const answer = 'value' in result ? result.value : undefined
84    if (answer?.isAnswered && answer.usage.cache_read_input_tokens > 0) {
85      const now = await $.clock.now()
86      await update($, lastRequestAt, () => now)
87    }
88    return result
89  })
90
91  on('turn.complete', async ($, e, next) => {
92    const result = await next(e)
93    if (e.agentId) return result
94    const now = await $.clock.now()
95    const usage = e.usage
96    const live = (await $.session.usage()).context.tokens
97    const summed = usage
98      ? usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
99      : 0
100    const tokens = live && live > 0 ? live : summed
101    if (tokens > 0) await update($, ctx, () => tokens)
102    const answered = usage?.model
103    if (answered) await update($, model, () => answered)
104    // No step stamped this turn (a hook answered it): its end is the latest request.
105    await update($, lastRequestAt, at => (now - at > e.durationMs ? now : at))
106    return result
107  })
108
109  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
110    const rest = await next(e)
111    if (e.props.hasSurvey) return rest
112
113    const glance = {
114      lastRequestAt: await read($, lastRequestAt),
115      ctx: await read($, ctx),
116      model: await read($, model),
117    }
118    const reading = readCache(glance, await $.clock.now(), ttlMs)
119    if (!reading) return rest
120
121    const { Box, Text } = $.ui.resolve(e)
122    const color = STATE_COLOR[reading.state]
123    // Cold costs money on the next send, so its detail reads at full strength.
124    const isQuiet = reading.state !== 'cold'
125
126    return (
127      <Box flexDirection="column">
128        {rest}
129        <Box key="cache-glance" flexDirection="row">
130          <Text dimColor>快取 </Text>
131          {color ? (
132            <Text color={color} bold>
133              {reading.state}
134            </Text>
135          ) : (
136            <Text dimColor>·</Text>
137          )}
138          <Text dimColor={isQuiet}> · {reading.detail}</Text>
139        </Box>
140      </Box>
141    )
142  })
143}
144
hooks/format.ts 65 lines
1import type { Glance } from '../types'
2
3const MINUTE = 60_000
4const HOUR = 60 * MINUTE
5
6/** The prompt cache's lifetime after the last request: one hour on a subscription. */
7export const DEFAULT_TTL_MS = HOUR
8export const SHORT_TTL_MS = 5 * MINUTE
9
10// $ per million tokens for a one-hour cache write, list prices September 2026.
11// From cache-tax by Karan Bansal (MIT), so both mods quote the same figure.
12// Longer family names first: a model id matches the first row it contains.
13const WRITE_PRICES: Array<[string, number]> = [
14  ['fable-5-1', 20],
15  ['fable-5', 20],
16  ['opus-5', 10],
17  ['opus-4', 10],
18  ['sonnet-5', 4],
19  ['sonnet', 6],
20  ['haiku', 2],
21]
22
23// A five-minute write costs 1.25x the base input rate, a one-hour write 2x.
24const SHORT_WRITE_SHARE = 1.25 / 2
25
26/** What re-writing `tokens` into the cache costs on `model`, or null for a model not in the table. */
27export const rewriteUsd = (model: string | null, tokens: number, ttlMs = DEFAULT_TTL_MS): number | null => {
28  const id = (model ?? '').toLowerCase().replace(/[\s.]+/g, '-')
29  const row = WRITE_PRICES.find(([family]) => id.includes(family))
30  if (!row) return null
31  const perMillion = ttlMs <= SHORT_TTL_MS ? row[1] * SHORT_WRITE_SHARE : row[1]
32  return (tokens * perMillion) / 1e6
33}
34
35/** 47m, 1h 5m, <1m */
36export const formatDuration = (ms: number): string => {
37  if (ms < MINUTE) return '<1m'
38  const hours = Math.floor(ms / HOUR)
39  const minutes = Math.floor((ms % HOUR) / MINUTE)
40  return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`
41}
42
43export type CacheState = 'warm' | 'cold' | 'unknown'
44
45/** The line's two parts, drawn in their own colors; undefined before there is a conversation to cache. */
46export type Reading = { state: CacheState; detail: string }
47
48export const readCache = (g: Glance, now: number, ttlMs = DEFAULT_TTL_MS): Reading | undefined => {
49  if (g.ctx <= 0) return undefined
50  if (g.lastRequestAt <= 0) return { state: 'unknown', detail: '下次回覆後開始計時' }
51
52  const usd = rewriteUsd(g.model, g.ctx, ttlMs)
53  const left = g.lastRequestAt + ttlMs - now
54  if (left > 0) {
55    const cost = usd === null ? '' : ` · 過期重寫約 $${usd.toFixed(2)}`
56    return { state: 'warm', detail: `${formatDuration(left)} 後過期${cost}` }
57  }
58  const cost = usd === null ? '' : ` · 下次送出重寫約 $${usd.toFixed(2)}`
59  return { state: 'cold', detail: `已過期 ${formatDuration(-left)}${cost}` }
60}
61
62/** The whole line as plain text: 快取 warm · 47m 後過期 · 過期重寫約 $3.91 */
63export const lineText = (r: Reading): string =>
64  r.state === 'unknown' ? `快取 · ${r.detail}` : `快取 ${r.state} · ${r.detail}`
65
types/index.d.ts 20 lines
1/** What the line under the prompt is computed from. */
2export type Glance = {
3  /** When the main conversation last sent a request, in $.clock.now() ms; 0 while unknown. */
4  lastRequestAt: number
5  /** Tokens the prompt cache holds for the main conversation. */
6  ctx: number
7  /** The model that answered last, for its cache-write price. */
8  model: string | null
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'cache-glance': {
14      lastRequestAt: Glance['lastRequestAt']
15      ctx: Glance['ctx']
16      model: Glance['model']
17    }
18  }
19}
20