SLOPSHOPPER

usage-meter

輸入框上方的圓角框用量列:context、五小時額度與重置時間、本週額度、累計 token、快取倒數,Context 右邊常駐交接按鈕

newbandguardcommandtoastmodel
v0.6.0no licenseupdated 2026-10-14nozomichi00/claude-code-mods/plugins/usage-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-meter
› 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 › /handoff ⎿ usage-meter: 正在寫交接摘要,完成後會清空對話並送出摘要。 ⟨Claude Code's own drawing⟩ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Context 49% 交接中… │ 5H 31% ↻ 09:53 │ 7D — │ Token ↑ 0 ↓ 0 Cache — │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Context 49% 交接中… │ 5H 31% ↻ 09:53 │ 7D — │ Token ↑ 0 ↓ 0 Cache — │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

claude-code-mods

個人專用的 Claude Code mods,本身就是一個本機 plugin 市集。

安裝

  1. 先裝好 Claude Code、Git。
  2. 把 repo clone 到使用者資料夾,執行安裝腳本:
git clone https://github.com/nozomichi00/claude-code-mods $HOME\claude-code-mods
powershell -ExecutionPolicy Bypass -File $HOME\claude-code-mods\install.ps1

腳本會把這個資料夾註冊成 claude-code-mods 市集,並以 user 範圍安裝下表所有 mod,重複執行也沒關係。裝完重開 Claude Code(或在執行中的 session 打 /reload-plugins)。

市集來源是資料夾,Claude Code 直接讀這個資料夾。改完程式碼執行 /reload-plugins 即可生效,不用重裝。

Mods

Mod說明
usage-meter輸入框上方的圓角框用量列:Context、5H 額度(重置時間與倒數)、7D 額度(重置倒數,例如 3d4h)、累計 Token(↑ 輸入 ↓ 輸出),最右邊是 Cache 倒數。1/8 格精度的膠囊進度條,>50% 黃、>80% 紅;依寬度自動切換三種版面。<br>Context 右邊常駐 [交接] 按鈕(或打 /handoff):用 fork 寫交接摘要(目前目標、已完成、尚未完成、改過的檔案、注意事項),存到 ~/.claude/handoffs/<專案>-<時間>.md,執行 /clear,再把摘要當新對話第一句送出。摘要沒寫成就不清空;改過的檔案取自 Edit/Write 工具紀錄。
session-changes側邊面板(/changes 開關;全螢幕版面下 session 一開始就自動打開,中途關掉後該 session 不再自動開)。列出本 session 用 Edit/Write 改過的檔案,最新的在上,附 +增 −刪、修改次數與時間,子代理改的標 [agent]。點檔案看 diff,可切換「逐次」(每次修改的片段)與「累計」(與本 session 第一次修改前相比)。/clear 會清空。用 Bash 或編輯器改的檔案不會列出。

usage-meter 畫在輸入框上方;session-changes 是另開的側邊面板。

開發

claude plugin validate plugins/<mod>
claude plugin test plugins/<mod>
Source 4 files
hooks/register.tsx 251 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import {
5  addTokens, barCells, cacheState, formatMinutes, formatPercent,
6  formatResetTime, formatTokens, formatUntil, levelOf,
7  toSnapshot,
8} from './meter'
9import type { Level } from './meter'
10import { addFile, buildPrompt, cleanSummary, fileContent, firstMessage, handoffPath } from './handoff'
11
12// 每秒推進時鐘,倒數(重置時間、快取)隨時鐘即時跳動
13const TICK_MS = 1_000
14const DEFAULT_TTL_MINUTES = 60
15// 圓角框:左右框線各 1 欄、左右內距各 1 欄
16const FRAME_COLUMNS = 4
17
18// Catppuccin Macchiato 色系:深色主題下柔和、三段對比清楚
19const COLOR: Record<Level, string> = { ok: '#8BD5CA', warn: '#EED49F', hot: '#ED8796' }
20const TRACK = '#363A4F'
21const BORDER = '#5B6078'
22const ACCENT = '#8AADF4'
23
24// 依可用寬度切換版面:寬版長條+相對時間、中版短條、窄版純文字
25const WIDE_COLUMNS = 140
26const MEDIUM_COLUMNS = 110
27const WIDE_BAR = 8
28const MEDIUM_BAR = 5
29const CACHE_BAR = 6
30
31
32const snapshot = atom({ plugin: 'usage-meter', key: 'snapshot' } as const, null)
33const tokens = atom({ plugin: 'usage-meter', key: 'tokens' } as const, { input: 0, output: 0 })
34const now = atom({ plugin: 'usage-meter', key: 'now' } as const, 0)
35const handoffPhase = atom({ plugin: 'usage-meter', key: 'handoffPhase' } as const, 'idle')
36const editedFiles = atom({ plugin: 'usage-meter', key: 'editedFiles' } as const, [])
37
38type Layout = { bar: number; isWide: boolean }
39
40function layoutFor(columns: number): Layout {
41  if (columns >= WIDE_COLUMNS) return { bar: WIDE_BAR, isWide: true }
42  if (columns >= MEDIUM_COLUMNS) return { bar: MEDIUM_BAR, isWide: false }
43  return { bar: 0, isWide: false }
44}
45
46async function homeDir($: EngineInterface): Promise<string> {
47  return (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? (await $.session.cwd())
48}
49
50/** 對話清空後:檔案紀錄歸零,Context 與快取等下一輪重新量。 */
51async function resetConversation($: EngineInterface): Promise<void> {
52  await update($, editedFiles, () => [])
53  await update($, snapshot, prev => (prev === null ? null : { ...prev, contextPercent: null, lastResponseAt: null }))
54}
55
56/** 重讀用量;hasResponded 表示剛收到一次回應,快取倒數從此刻重算。 */
57async function refreshSnapshot($: EngineInterface, hasResponded: boolean): Promise<void> {
58  const [usage, t] = await Promise.all([$.session.usage(), $.clock.now()])
59  await update($, snapshot, prev => toSnapshot(usage, hasResponded ? t : (prev?.lastResponseAt ?? null)))
60  await update($, now, () => t)
61}
62
63async function trackFile($: EngineInterface, path: string, agentId: string | undefined): Promise<void> {
64  if (agentId === undefined) await update($, editedFiles, list => addFile(list, path))
65}
66
67// 交接:寫摘要 → 存檔 → /clear → 把摘要當新對話第一句送出。
68// 任何一步失敗都停在清空之前,原對話不會遺失。
69async function runHandoff($: EngineInterface): Promise<void> {
70  if ((await read($, handoffPhase)) === 'working') return
71  await update($, handoffPhase, () => 'working')
72  try {
73    const reply = await $.model.fork({ prompt: buildPrompt(await read($, editedFiles)) })
74    if (!reply.isAnswered) {
75      $.ui.toast(`交接摘要沒寫成(${reply.reason}),對話保持原樣`)
76      return
77    }
78    const summary = cleanSummary(reply.text)
79    if (summary === '') {
80      $.ui.toast('交接摘要是空的,對話保持原樣')
81      return
82    }
83    const [home, cwd, t] = await Promise.all([homeDir($), $.session.cwd(), $.clock.now()])
84    const path = handoffPath(home, cwd, t)
85    await $.fs.write(path, fileContent(summary, cwd, t))
86
87    await $.command.run({ command: 'clear' })
88    await resetConversation($)
89    await $.prompt.submit({ text: firstMessage(summary, path) })
90  } catch (error) {
91    $.ui.toast(`交接失敗:${String(error)}`)
92  } finally {
93    await update($, handoffPhase, () => 'idle')
94  }
95}
96
97export const register: Register = (on, options) => {
98  const ttlMinutes = Number(options.cacheTtlMinutes) || DEFAULT_TTL_MINUTES
99
100  on('session.start', async ($, e, next) => {
101    const result = await next(e)
102    const [usage, previous] = await Promise.all([$.session.usage(), read($, snapshot)])
103    await update($, snapshot, () => toSnapshot(usage, previous?.lastResponseAt ?? null))
104    const startedAt = await $.clock.now()
105    await update($, now, () => startedAt)
106    $.clock.every(TICK_MS, async () => {
107      const t = await $.clock.now()
108      await update($, now, () => t)
109    })
110    await $.command.register({ name: 'handoff', description: '寫交接摘要並存檔,清空對話後把摘要送進新對話' })
111    return result
112  })
113
114  on('command.run', { command: 'handoff' }, ($) => {
115    void runHandoff($)
116    return { text: '正在寫交接摘要,完成後會清空對話並送出摘要。' }
117  })
118
119  on('session.end', async ($, e, next) => {
120    if (e.reason === 'clear') await resetConversation($)
121    return next(e)
122  })
123
124  // 記下主對話改過的檔案,交接摘要的「改過的檔案」以此為準
125  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
126    const result = await next(e)
127    if (result.deny === undefined && result.isError !== true) await trackFile($, e.file_path, e.agentId)
128    return result
129  })
130  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
131    const result = await next(e)
132    if (result.deny === undefined && result.isError !== true) await trackFile($, e.file_path, e.agentId)
133    return result
134  })
135  on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
136    const result = await next(e)
137    if (result.deny === undefined && result.isError !== true) await trackFile($, e.notebook_path, e.agentId)
138    return result
139  })
140
141  // 每次模型回應(回合中的每一步)就更新:Context、額度、Token、快取倒數不必等整個回合結束
142  on('turn.step', async function* ($, e, next) {
143    const result = yield* next(e)
144    const stepUsage = result.usage
145    if (stepUsage !== null) await update($, tokens, total => addTokens(total, stepUsage))
146    if (e.agentId === undefined) await refreshSnapshot($, stepUsage !== null)
147    return result
148  })
149
150  // 額度跨過整數百分點時引擎會主動推送,不必等下一次回應
151  on('session.measure', async ($, e, next) => {
152    const result = await next(e)
153    await update($, snapshot, prev => toSnapshot({ startedAt: 0, context: e.context, rateLimits: e.rateLimits }, prev?.lastResponseAt ?? null))
154    return result
155  })
156
157  on('turn.complete', async ($, e, next) => {
158    const result = await next(e)
159    if (e.agentId === undefined) await refreshSnapshot($, false)
160    return result
161  })
162
163  // 同一個 band 可能還有其他 mod:先取下層畫的內容,再接上用量列
164  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
165    const below = await next(e)
166    if (e.props.hasSurvey) return below
167    const snap = await read($, snapshot)
168    if (snap === null) return below
169    const [t, used, phase] = await Promise.all([read($, now), read($, tokens), read($, handoffPhase)])
170    const { Box, Text, Button } = $.ui.resolve(e)
171    const columns = e.props.bodyColumns - FRAME_COLUMNS
172    const layout = layoutFor(columns)
173
174    const bar = (key: string, percent: number | null, width: number, level: Level) => {
175      if (width === 0) return null
176      const { fill, rest } = barCells(percent ?? 0, width)
177      return (
178        <Text key={key} color={COLOR[level]} backgroundColor={TRACK}>{fill}{rest}</Text>
179      )
180    }
181
182    // 交接按鈕常駐在 Context 右邊,隨時可按;交接進行中改成提示文字
183    const handoff = phase === 'working'
184      ? <Text key="handoff-busy" color={COLOR.warn}>交接中…</Text>
185      : <Button key="handoff" plain label="[交接]" onPress={() => void runHandoff($)} />
186
187    const gauge = (key: string, label: string, percent: number | null, extra: string) => {
188      const level = percent === null ? 'ok' : levelOf(percent)
189      return (
190        <Box key={key} flexDirection="row" gap={1}>
191          <Text dimColor>{label}</Text>
192          {bar(`${key}-bar`, percent, layout.bar, level)}
193          {percent === null
194            ? <Text dimColor>—</Text>
195            : <Text bold color={COLOR[level]}>{formatPercent(percent)}</Text>}
196          {extra === '' ? null : <Text dimColor>{extra}</Text>}
197        </Box>
198      )
199    }
200
201    const divider = (key: string) => <Text key={key} color={TRACK}>│</Text>
202
203    const five = snap.fiveHour
204    const fiveReset = five?.resetsAt === undefined ? ''
205      : layout.isWide ? `↻ ${formatResetTime(five.resetsAt, t)} · ${formatUntil(five.resetsAt, t)}`
206      : `↻ ${formatResetTime(five.resetsAt, t)}`
207    const week = snap.sevenDay
208    const weekReset = week?.resetsAt === undefined ? '' : `↻ ${formatUntil(week.resetsAt, t)}`
209
210    const cache = cacheState(snap.lastResponseAt, t, ttlMinutes)
211    const cacheLevel: Level = cache === 'expired' ? 'hot' : cache === 'unknown' ? 'ok' : levelOf(cache.usedPercent)
212    const cacheRemaining = cache === 'expired' || cache === 'unknown' ? 0 : 100 - cache.usedPercent
213    const cacheText = cache === 'unknown' ? '—'
214      : cache === 'expired' ? 'expired'
215      : formatMinutes(cache.remainingMinutes)
216
217    return (
218      <Box flexDirection="column">
219        {below}
220        <Box key="frame" borderStyle="round" borderColor={BORDER} paddingX={1} width={e.props.bodyColumns}>
221          <Box flexDirection="row" justifyContent="space-between" width={columns}>
222            <Box flexDirection="row" gap={1}>
223              {gauge('context', 'Context', snap.contextPercent, '')}
224              {handoff}
225              {divider('d1')}
226              {gauge('five-hour', '5H', five?.percent ?? null, fiveReset)}
227              {divider('d2')}
228              {gauge('seven-day', '7D', week?.percent ?? null, weekReset)}
229              {divider('d3')}
230              <Box key="tokens" flexDirection="row" gap={1}>
231                <Text dimColor>Token</Text>
232                <Text dimColor>↑</Text>
233                <Text bold color={ACCENT}>{formatTokens(used.input)}</Text>
234                <Text dimColor>↓</Text>
235                <Text bold color={ACCENT}>{formatTokens(used.output)}</Text>
236              </Box>
237            </Box>
238            <Box key="cache" flexDirection="row" gap={1}>
239              <Text color={ACCENT}>Cache</Text>
240              {bar('cache-bar', cacheRemaining, layout.bar === 0 ? 0 : CACHE_BAR, cacheLevel)}
241              {cache === 'unknown'
242                ? <Text dimColor>{cacheText}</Text>
243                : <Text bold color={COLOR[cacheLevel]}>{cacheText}</Text>}
244            </Box>
245          </Box>
246        </Box>
247      </Box>
248    )
249  })
250}
251
hooks/meter.ts 113 lines
1import type { SessionRateLimit, SessionUsage, TurnUsage } from 'claude-code'
2
3import type { UsageMeterSnapshot, UsageMeterTokens, UsageMeterWindow } from '../types'
4
5export const WARN_PERCENT = 50
6export const HOT_PERCENT = 80
7const MINUTE_MS = 60_000
8const MINUTES_PER_DAY = 24 * 60
9const PARTIAL_BLOCKS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
10
11export type Level = 'ok' | 'warn' | 'hot'
12
13function toWindow(limits: SessionRateLimit[], kind: string): UsageMeterWindow | null {
14  const limit = limits.find(l => l.kind === kind)
15  if (limit === undefined) return null
16  return { percent: limit.percentUsed, resetsAt: limit.resetsAt }
17}
18
19export function toSnapshot(usage: SessionUsage, lastResponseAt: number | null): UsageMeterSnapshot {
20  return {
21    contextPercent: usage.context.percent ?? null,
22    fiveHour: toWindow(usage.rateLimits, 'five_hour'),
23    sevenDay: toWindow(usage.rateLimits, 'seven_day'),
24    lastResponseAt,
25  }
26}
27
28/** 累加一輪的用量:input 含未快取、快取寫入與快取讀取。 */
29export function addTokens(total: UsageMeterTokens, usage: TurnUsage): UsageMeterTokens {
30  return {
31    input: total.input + usage.input_tokens + usage.cache_creation_input_tokens + usage.cache_read_input_tokens,
32    output: total.output + usage.output_tokens,
33  }
34}
35
36/** 812、45.3k、1.2M。 */
37export function formatTokens(count: number): string {
38  if (count < 1000) return String(count)
39  if (count < 1_000_000) return `${trimZero((count / 1000).toFixed(count < 10_000 ? 1 : 0))}k`
40  return `${trimZero((count / 1_000_000).toFixed(1))}M`
41}
42
43function trimZero(text: string): string {
44  return text.endsWith('.0') ? text.slice(0, -2) : text
45}
46
47export function isHot(percent: number): boolean {
48  return percent > HOT_PERCENT
49}
50
51export function levelOf(percent: number): Level {
52  if (percent > HOT_PERCENT) return 'hot'
53  if (percent > WARN_PERCENT) return 'warn'
54  return 'ok'
55}
56
57export function formatPercent(percent: number): string {
58  return `${Math.round(percent)}%`
59}
60
61/** 以 1/8 格精度畫出的進度條:整格 █ 加一個部分格,其餘以空白補滿到 width。 */
62export function barCells(percent: number, width: number): { fill: string; rest: string } {
63  const clamped = Math.min(100, Math.max(0, Number.isFinite(percent) ? percent : 0))
64  const eighths = Math.round((clamped / 100) * width * 8)
65  const full = Math.floor(eighths / 8)
66  const partial = PARTIAL_BLOCKS[eighths % 8] ?? ''
67  const fill = '█'.repeat(full) + partial
68  const used = full + (partial === '' ? 0 : 1)
69  return { fill, rest: ' '.repeat(Math.max(0, width - used)) }
70}
71
72function hhmm(at: Date): string {
73  return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
74}
75
76/** 本地時間 HH:MM;跨日加上 M/D。 */
77export function formatResetTime(iso: string, now: number): string {
78  const at = new Date(iso)
79  if (Number.isNaN(at.getTime())) return '?'
80  const today = new Date(now)
81  const isSameDay = at.getFullYear() === today.getFullYear()
82    && at.getMonth() === today.getMonth()
83    && at.getDate() === today.getDate()
84  return isSameDay ? hhmm(at) : `${at.getMonth() + 1}/${at.getDate()} ${hhmm(at)}`
85}
86
87/** 距離 iso 還有多久:3d4h、2h05m、45m、<1m。 */
88export function formatUntil(iso: string, now: number): string {
89  const ms = new Date(iso).getTime() - now
90  if (!Number.isFinite(ms)) return '?'
91  return formatMinutes(Math.ceil(ms / MINUTE_MS))
92}
93
94export function formatMinutes(minutes: number): string {
95  if (minutes < 1) return '<1m'
96  if (minutes < 60) return `${minutes}m`
97  if (minutes >= MINUTES_PER_DAY) return `${Math.floor(minutes / MINUTES_PER_DAY)}d${Math.floor((minutes % MINUTES_PER_DAY) / 60)}h`
98  return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
99}
100
101export type CacheState = { remainingMinutes: number; usedPercent: number } | 'expired' | 'unknown'
102
103export function cacheState(lastResponseAt: number | null, now: number, ttlMinutes: number): CacheState {
104  if (lastResponseAt === null) return 'unknown'
105  const ttlMs = ttlMinutes * MINUTE_MS
106  const elapsed = Math.max(0, now - lastResponseAt)
107  if (elapsed >= ttlMs) return 'expired'
108  return {
109    remainingMinutes: Math.ceil((ttlMs - elapsed) / MINUTE_MS),
110    usedPercent: (elapsed / ttlMs) * 100,
111  }
112}
113
hooks/handoff.ts 75 lines
1// 交接流程的純函式:組 fork 提示、清理摘要、檔名與第一句訊息。
2
3export const SECTIONS = ['目前目標', '已完成', '尚未完成', '改過的檔案', '注意事項'] as const
4
5const ESCAPE_SEQUENCES = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g
6// 保留換行與 tab,其餘控制字元、格式字元、私用區與 Unicode tag 一律去掉
7const UNSEEN = /[\u0000-\u0008\u000b-\u001f\u007f\p{Cf}\p{Co}\u{E0000}-\u{E007F}]/gu
8const FENCE = /^```[\w-]*\n([\s\S]*?)\n```$/
9
10export function buildPrompt(files: readonly string[]): string {
11  const fileList = files.length === 0
12    ? '(工具紀錄中沒有修改過的檔案;若對話裡有提到改過的檔案,依對話補上)'
13    : files.map(f => `- ${f}`).join('\n')
14  return [
15    '不要繼續任務。請替接手這份工作的新對話寫一份交接摘要:繁體中文、Markdown,只輸出摘要本身,不要前言或結語。',
16    `依序包含以下 ${SECTIONS.length} 節,標題固定用「## 」開頭:`,
17    SECTIONS.map(s => `## ${s}`).join('\n'),
18    '',
19    '寫法:',
20    '- 目前目標:使用者要達成什麼,以及目前做到哪一步。',
21    '- 已完成/尚未完成:條列、具體(檔名、函式、指令、commit),尚未完成依優先順序排列。',
22    '- 改過的檔案:以下清單來自工具呼叫紀錄,以它為準,每個檔案補一句改了什麼:',
23    fileList,
24    '- 注意事項:使用者的偏好與裁定、踩過的坑、尚未驗證的假設、不能做的事。',
25    '讓沒看過這段對話的人能直接接手。',
26  ].join('\n')
27}
28
29export function cleanSummary(text: string): string {
30  const stripped = text
31    .replace(/\r\n?/g, '\n')
32    .replace(ESCAPE_SEQUENCES, '')
33    .replace(UNSEEN, '')
34    .trim()
35  const fenced = FENCE.exec(stripped)
36  return (fenced?.[1] ?? stripped).trim()
37}
38
39function pad(n: number): string {
40  return String(n).padStart(2, '0')
41}
42
43export function timestamp(now: number): string {
44  const d = new Date(now)
45  return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
46}
47
48export function projectName(cwd: string): string {
49  const base = cwd.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
50  const safe = base.replace(/[^\w.-]+/g, '_')
51  return safe === '' ? 'session' : safe
52}
53
54/** ~/.claude/handoffs/<專案>-<時間>.md,一律用正斜線。 */
55export function handoffPath(home: string, cwd: string, now: number): string {
56  const root = home.replace(/\\/g, '/').replace(/\/+$/, '')
57  return `${root}/.claude/handoffs/${projectName(cwd)}-${timestamp(now)}.md`
58}
59
60export function fileContent(summary: string, cwd: string, now: number): string {
61  const d = new Date(now)
62  const when = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
63  return `# 交接摘要\n\n- 專案:${cwd}\n- 時間:${when}\n\n${summary}\n`
64}
65
66export function firstMessage(summary: string, path: string): string {
67  return `以下是上一段對話的交接摘要(完整檔案:${path})。請先讀完並用幾句話確認目前狀態,等我指示再繼續。\n\n${summary}`
68}
69
70/** 依首次出現排序並去重,Windows 路徑大小寫不分。 */
71export function addFile(files: readonly string[], path: string): string[] {
72  const key = path.replace(/\\/g, '/').toLowerCase()
73  return files.some(f => f.replace(/\\/g, '/').toLowerCase() === key) ? [...files] : [...files, path]
74}
75
types/index.d.ts 28 lines
1export type UsageMeterWindow = { percent: number; resetsAt?: string }
2
3export type UsageMeterSnapshot = {
4  contextPercent: number | null
5  fiveHour: UsageMeterWindow | null
6  sevenDay: UsageMeterWindow | null
7  lastResponseAt: number | null
8}
9
10/** 本 session 累計的 token:input 含快取讀寫,output 為模型產出。 */
11export type UsageMeterTokens = { input: number; output: number }
12
13/** 交接流程:idle 可按,working 正在寫摘要/清空/送出。 */
14export type UsageMeterHandoffPhase = 'idle' | 'working'
15
16declare module 'claude-code' {
17  interface PluginState {
18    'usage-meter': {
19      snapshot: UsageMeterSnapshot | null
20      tokens: UsageMeterTokens
21      now: number
22      handoffPhase: UsageMeterHandoffPhase
23      /** 本段對話用 Edit / Write / NotebookEdit 改過的檔案,依首次修改排序。 */
24      editedFiles: string[]
25    }
26  }
27}
28