SLOPSHOPPER

switchboard

Mods 总控台:/mods 一眼看到所有 Mod,随时开关、隐藏界面

newpanebandcommandtimer
v0.3.0no licenseupdated 2026-10-23weizhongl/claude-code-mods/switchboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · switchboard
│ ┃ Switchboard ✕ › fix the failing auth test and add an audit log call │ ┃ Switchboard · 已发现 0 个 Mod │ ┃ ⏺ Read(src/auth.ts) │ ┃ 还没有发现任何 Mod。 ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Off = 该 Mod 立即停手;Hide = ⎿ 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 │ │ › /mods │ ⎿ switchboard: Switchboard 总控台已打开。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Switchboard
Switchboard · 已发现 0 个 Mod 还没有发现任何 Mod。 Off = 该 Mod 立即停手;Hide = 后台照跑,只收起它的面板。立即生效,不用重启。
README

Claude Code Mods

十个给 Claude Code 用的 Mod。只能装在 Claude Code(终端和桌面 App 的 Code 页)。Codex、Anti-Gravity 没有 Mod 接口,装不了。

先装 switchboard(总控台),其余随意挑。

安装

终端里:

/plugin install switchboard --marketplace weizhongl/claude-code-mods

第一次会问 Add marketplace?,回 y,再选 user scope。其余 Mod 把名字换掉即可:usage-gauge、brain-dump、helper-desk、cache-clock、handoff、reflect、chat-inventory、screen-share、replay-theater。

在 shell 里:

claude plugin install switchboard --marketplace weizhongl/claude-code-mods

桌面 App(/plugin 在那里不能用):点输入框旁的 +,选 Plugins → Add marketplace,填 weizhongl/claude-code-mods,再逐个 Add plugin。

每个 Mod 怎么用

Mod怎么用注意
switchboard输入 /mods 打开总控台。每个 Mod 一行:On/Off、Hide UI Panel。输入框上方那一行条(额度、缓存倒计时、任务进度、👁 按钮)也是它画的:平时只占一行,别的 Mod 有事才在它旁边多出一行。条的右端有按钮:「收起」变成一小行(只剩几个百分比和倒计时),再点「隐藏」只留一个「📊 展开」按钮;也可以输入 /bar(或 /bar full、/bar mini、/bar off)切换,你的选择会记住。进度条在桌面版是细线,在终端里是小方块。也可以 /mods off 名字、/mods on 名字、/mods hide 名字、/mods show 名字。新放进同一个文件夹的 Mod 会自动出现。Off = 该 Mod 的钩子立刻停手;Hide = 后台照跑,只收起面板。
usage-gauge自动。显示在总控台那一行条里:对话上下文用了多少、5 小时(会话)额度、本周额度,各有短进度条、颜色(绿 / 黄 / 红)和重置时间,最后是本次对话花费。/gauge 看文字版。读的是引擎自己的数字(和状态栏同一份),零 Token。额度数字要等发过一条消息、引擎读到后才出现。
brain-dump直接打字或语音说一大段(默认 180 字以上、没有排版)。它会拦住,先问你 2–3 个问题;你在输入框直接回答(回「跳过」= 不补充),然后重写成 The Task / The Why / Guardrails / What Done Looks Like 再发给 Claude。输入框上方有 跳过提问 / 原话直接发 / 放弃 三个按钮,也可用 /dump skip、/dump raw、/dump cancel。消息开头加 >> 可以跳过拦截。会用到两次小的模型调用(提问用 haiku,重写用 sonnet)。
helper-desk自动。Claude 有待办清单时,一行条里显示「任务 n/m」和短进度条;Claude 派出子代理时,侧边栏出现 Helper Desk,每个子代理一张卡(进度、正在做什么、读入/输出 token、花费)。/helpers 手动打开。子代理花费是按公开标价估算;整段对话总花费取自引擎账本。零额外 Token。
cache-clock自动。第一条回复之后,一行条里显示「⏱ mm:ss」。点旁边的 Keep Warm 或输入 /keep warm,快冷之前会自动发一条极小的心跳续命。你发下一条真实消息,或连续保温满 2 小时,会自动关闭。拿不到服务器的缓存时间,所以倒计时是推算(订阅登入 60 分钟,API 5 分钟,并按真实缓存命中自动校正)。设置里的「缓存有效分钟数」可固定。
handoff对话累计到 250,000 token、或缓存变冷时,输入框上方提醒你。按「生成交接」或输入 /handoff,会写出一份详细的交接提示词(存在 ~/.claude/handoffs/,可直接发给同事),再按 Start Fresh Chat with Handoff 带去新对话。生成会花 Token,默认只提醒、不自动生成(设置里「触发后做什么」选 generate 才会自动生成)。桌面 App 不允许自动开新对话时,会改成复制到剪贴板。
reflect你在对话里说「以后永远不要…」「每次都要…」「记住…」这类话,输入框上方弹出小卡片,一键存进:内置 Memory / 项目 CLAUDE.md(没有就 AGENTS.md)/ 你指定的文件 / 仅本次忽略。只在句子里有规则关键词时才调用一次小模型判断。设置里可填默认的第二大脑文件路径。
chat-inventory自动。侧边栏「In this chat」:目标与待办、调用过的连接器(含次数)、用过的技能、读过和新建/修改过的文件(点文件在文件管理器里定位)。/inventory 手动打开。纯追踪,零 Token。
screen-share输入 /screen share 开或关,或点一行条右端的 👁 按钮。开启后,画面上的邮箱、电话、金额、API key、公司名、人名遮成灰条。只改画面,不改真实数据;不遮输入框里正在打的字。人名只遮设置里填的名单和「客户:xxx」「陈小姐」这类写法,没有真正的人名识别。
replay-theater自动录下 Claude 用 Edit / Write / NotebookEdit 对文件做的每次修改。输入 /replay 打开:选文件、播放/暂停、上一步/下一步、点步骤号跳转,看每一步的 diff。Revert 按钮按一次变成「再按一次确认」,第二次才会写回文件。/replay clear 清空。用 Bash 改文件(如 sed)录不到;单个文件超过 20 万字不录。记录只在当前会话。

目录

每个 Mod 一个文件夹:.claude-plugin/plugin.json、hooks/hooks.json、hooks/register.tsx,有状态的还有 types/index.d.ts。switchboard、screen-share、replay-theater 带自动测试(claude plugin test <文件夹>)。

各 Mod 里的 hooks/flag.ts 是同一小段代码(读总控台的开关),Mod 之间不能共享文件,所以每个放一份。

Source 3 files
hooks/register.tsx 420 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ModEntry, ModFlag } from '../types'
5import { BAR_W, GREEN, PURPLE, RED, YELLOW, bar, barSvg, colorOf, mmss, nameOf, resetLabel } from './strip'
6
7const PANE = 'switchboard'
8const SELF = 'switchboard'
9const DEFAULT_FLAG: ModFlag = { isOn: true, isHidden: false }
10const VERBS = ['on', 'off', 'hide', 'show']
11
12const flags = atom({ plugin: 'switchboard', key: 'flags' } as const, {})
13const registry = atom({ plugin: 'switchboard', key: 'registry' } as const, [])
14const toggles = atom({ plugin: 'switchboard', key: 'toggles' } as const, {})
15const view = atom({ plugin: 'switchboard', key: 'view' } as const, 'full')
16
17// Read-only views of what the other mods keep; each owner alone writes its own.
18const GAUGE = { plugin: 'usage-gauge', key: 'snap' } as const
19const CACHE_END = { plugin: 'cache-clock', key: 'expiresAt' } as const
20const CACHE_NOW = { plugin: 'cache-clock', key: 'now' } as const
21const CACHE_WARM = { plugin: 'cache-clock', key: 'isKeepWarm' } as const
22const PLAN = { plugin: 'helper-desk', key: 'plan' } as const
23const SHARE = { plugin: 'screen-share', key: 'isOn' } as const
24
25const parentOf = (path: string) => path.replace(/[\\/][^\\/]+[\\/]?$/, '')
26
27const readManifest = async (
28  $: EngineInterface,
29  root: string,
30): Promise<ModEntry | undefined> => {
31  try {
32    const raw = await $.fs.read(`${root}/.claude-plugin/plugin.json`)
33    const json = JSON.parse(raw) as {
34      name?: string
35      description?: string
36      version?: string
37    }
38
39    return json.name === undefined
40      ? undefined
41      : {
42          name: json.name,
43          description: json.description ?? '',
44          version: json.version,
45          root,
46        }
47  } catch {
48    return undefined
49  }
50}
51
52const merge = (list: ModEntry[], found: ModEntry[]): ModEntry[] => {
53  const next = [...list]
54  for (const mod of found) {
55    const at = next.findIndex(one => one.name === mod.name)
56    if (at >= 0) next[at] = mod
57    else next.push(mod)
58  }
59
60  return next.sort((a, b) => a.name.localeCompare(b.name))
61}
62
63const isSame = (a: ModEntry[], b: ModEntry[]) =>
64  JSON.stringify(a) === JSON.stringify(b)
65
66const addMods = async ($: EngineInterface, found: ModEntry[]) => {
67  const held = await read($, registry)
68  const merged = merge(held, found)
69  if (!isSame(held, merged)) await update($, registry, () => merged)
70}
71
72// Mods installed from a marketplace live in the plugin cache, one folder each;
73// the engine's own install record lists where. A mod is a plugin whose
74// hooks.json names a `modules` file.
75const scanInstalled = async ($: EngineInterface) => {
76  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
77  if (home === undefined) return
78  const raw = await $.fs.read(`${home}/.claude/plugins/installed_plugins.json`).catch(() => '')
79  if (raw === '') return
80  let record: { plugins?: Record<string, { installPath?: string }[]> }
81  try {
82    record = JSON.parse(raw)
83  } catch {
84    return
85  }
86  const found: ModEntry[] = []
87  for (const installs of Object.values(record.plugins ?? {})) {
88    for (const install of installs) {
89      if (install.installPath === undefined) continue
90      const hooks = await $.fs.read(`${install.installPath}/hooks/hooks.json`).catch(() => '')
91      if (!hooks.includes('"modules"')) continue
92      const mod = await readManifest($, install.installPath)
93      if (mod !== undefined && mod.name !== SELF) found.push(mod)
94    }
95  }
96  await addMods($, found)
97}
98
99// Every sibling folder of this mod is a mod written in the same place.
100const scan = async ($: EngineInterface) => {
101  await scanInstalled($)
102  const parent = parentOf($.plugin.root)
103  const entries = await $.fs.list(parent).catch(() => [])
104  const found: ModEntry[] = []
105  for (const entry of entries) {
106    if (entry.kind !== 'dir' || entry.name.startsWith('.')) continue
107    const mod = await readManifest($, `${parent}/${entry.name}`)
108    if (mod !== undefined) found.push(mod)
109  }
110  await addMods($, found)
111}
112
113const setFlag = async (
114  $: EngineInterface,
115  name: string,
116  patch: Partial<ModFlag>,
117) => {
118  await update($, flags, all => ({
119    ...all,
120    [name]: { ...(all[name] ?? DEFAULT_FLAG), ...patch },
121  }))
122  await $.store.set('flags', await read($, flags))
123}
124
125type View = 'full' | 'mini' | 'off'
126
127// How much of the strip shows: all of it, one short line, or only a small button.
128async function setView($: EngineInterface, next: View) {
129  await update($, view, () => next)
130  await $.store.set('stripView', next)
131}
132
133// A press bumps a counter; the mod that owns the switch sees it and flips it.
134async function request($: EngineInterface, key: string) {
135  await update($, toggles, all => ({ ...all, [key]: (all[key] ?? 0) + 1 }))
136}
137
138export const register: Register = on => {
139  on('session.start', async ($, e, next) => {
140    await $.command.register({
141      name: 'mods',
142      description: '总控台:查看所有 Mod,开关、隐藏界面',
143      argumentHint: '[on|off|hide|show <Mod 名称>]',
144    })
145
146    const savedView = await $.store.get('stripView')
147    if (savedView === 'full' || savedView === 'mini' || savedView === 'off') {
148      await update($, view, () => savedView)
149    }
150    await $.command.register({
151      name: 'bar',
152      description: '额度条:/bar 在 展开 → 收起 → 隐藏 之间切换,也可 /bar full | mini | off',
153      argumentHint: 'full | mini | off',
154    })
155    const saved = await $.store.get('flags')
156    if (typeof saved === 'object' && saved !== null) {
157      await update($, flags, () => saved as Record<string, ModFlag>)
158    }
159    await scan($)
160    // Mods written later in the same folder show up without a restart.
161    $.clock.every(5000, () => void scan($).catch(() => undefined))
162
163    return next(e)
164  })
165
166  on('plugin.register', async ($, e, next) => {
167    const ran = await next(e)
168    const isMod =
169      ran.refuse === undefined &&
170      e.name !== SELF &&
171      e.uses.events.length > 0 &&
172      !e.provenance.endsWith('@builtin')
173    if (isMod) {
174      try {
175        const mod = await readManifest($, e.root)
176        await addMods($, [
177          mod ?? {
178            name: e.name,
179            description: '',
180            version: e.version,
181            root: e.root,
182          },
183        ])
184      } catch {
185        // discovery is best effort; the next scan finds it again
186      }
187    }
188
189    return ran
190  }).catch(($, e, next) => next(e))
191
192  on('command.run', { command: 'bar' }, async ($, e) => {
193    const given = e.args.trim()
194    const now = await read($, view)
195    const next: View =
196      given === 'full' || given === 'mini' || given === 'off'
197        ? given
198        : now === 'full'
199          ? 'mini'
200          : now === 'mini'
201            ? 'off'
202            : 'full'
203    await setView($, next)
204
205    return { text: `额度条:${next === 'full' ? '展开' : next === 'mini' ? '收起(一小行)' : '隐藏(只留一个按钮)'}` }
206  })
207
208  on('command.run', { command: 'mods' }, async ($, e) => {
209    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
210    const target = rest.join(' ')
211
212    if (VERBS.includes(verb)) {
213      const mods = await read($, registry)
214      if (!mods.some(one => one.name === target)) {
215        return { text: `找不到 Mod「${target}」。输入 /mods 看名单。` }
216      }
217      const patch: Partial<ModFlag> =
218        verb === 'on'
219          ? { isOn: true }
220          : verb === 'off'
221            ? { isOn: false }
222            : { isHidden: verb === 'hide' }
223      await setFlag($, target, patch)
224
225      return { text: `${target}:已${verb === 'on' ? '开启' : verb === 'off' ? '关闭' : verb === 'hide' ? '隐藏界面' : '显示界面'}` }
226    }
227
228    await scan($)
229    await $.ui.open({
230      id: PANE,
231      title: 'Switchboard',
232      focus: true,
233      closeOnEscape: true,
234    })
235
236    return { text: 'Switchboard 总控台已打开。' }
237  })
238
239  // The one steady line above the prompt: quota, cache clock, task progress.
240  // Anything that needs you (a reminder, a question) draws its own extra row.
241  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
242    const below = await next(e)
243    const all = await read($, flags)
244    const mods = await read($, registry)
245    const mode = await read($, view)
246    const shows = (name: string) => {
247      const flag = all[name] ?? DEFAULT_FLAG
248
249      return flag.isOn && !flag.isHidden
250    }
251    const gauge = shows('usage-gauge') ? ((await $.state.get(GAUGE)).value ?? null) : null
252    const end = shows('cache-clock') ? ((await $.state.get(CACHE_END)).value ?? null) : null
253    const clock = (await $.state.get(CACHE_NOW)).value ?? 0
254    const isWarm = (await $.state.get(CACHE_WARM)).value ?? false
255    const steps = shows('helper-desk') ? ((await $.state.get(PLAN)).value ?? []) : []
256    const isSharing = (await $.state.get(SHARE)).value ?? false
257    const hasEye = shows('screen-share') && mods.some(one => one.name === 'screen-share')
258    if (gauge === null && end === null && steps.length === 0 && !hasEye) return below
259
260    const table = $.ui.resolve(e)
261    const { Box, Text, Button } = table
262    const Svg = 'Svg' in table ? table.Svg : undefined
263    const left = end === null ? 0 : end - (clock || end)
264    const done = steps.filter(one => one.status === 'completed').length
265    const cacheColor = left <= 0 ? '#9b9b9b' : left < 30_000 ? RED : left < 120_000 ? YELLOW : GREEN
266    // A thin bar where the surface can draw one, small blocks where it cannot.
267    const track = (percent: number, color: string) => {
268      if (Svg !== undefined) {
269        return <Svg source={barSvg(percent, color)} alt={`${Math.round(percent)}%`} width={BAR_W} height={4} />
270      }
271      const b = bar(percent)
272
273      return (
274        <Text color={color}>
275          {b.on}
276          <Text dimColor>{b.off}</Text>
277        </Text>
278      )
279    }
280    const metric = (label: string, percent: number, extra: string) => (
281      <Box key={label} gap={1} alignItems="center">
282        <Text dimColor>{label}</Text>
283        <Text bold color={colorOf(percent)}>{Math.round(percent)}%</Text>
284        {track(percent, colorOf(percent))}
285        {extra !== '' && <Text dimColor size="footnote">{extra}</Text>}
286      </Box>
287    )
288
289    if (mode === 'off') {
290      return (
291        <Box flexDirection="column">
292          {below}
293          <Box>
294            <Button key="show" plain onPress={() => setView($, 'full')}>📊 展开</Button>
295          </Box>
296        </Box>
297      )
298    }
299    if (mode === 'mini') {
300      return (
301        <Box flexDirection="column">
302          {below}
303          <Box columnGap={2} flexWrap="wrap" alignItems="center">
304            <Text>📊</Text>
305            {gauge !== null && gauge.contextPercent !== null && (
306              <Text bold color={colorOf(gauge.contextPercent)}>{Math.round(gauge.contextPercent)}%</Text>
307            )}
308            {gauge !== null &&
309              gauge.limits.map(one => (
310                <Text key={one.kind} bold color={colorOf(one.percentUsed)}>{Math.round(one.percentUsed)}%</Text>
311              ))}
312            {end !== null && <Text color={cacheColor}>⏱ {left <= 0 ? '已冷' : mmss(left)}</Text>}
313            {steps.length > 0 && <Text dimColor>{done}/{steps.length}</Text>}
314            <Button key="open" plain onPress={() => setView($, 'full')}>展开</Button>
315            <Button key="hide" plain onPress={() => setView($, 'off')}>隐藏</Button>
316          </Box>
317        </Box>
318      )
319    }
320
321    return (
322      <Box flexDirection="column">
323        {below}
324        <Box columnGap={3} flexWrap="wrap" alignItems="center">
325          {gauge !== null && gauge.contextPercent !== null && metric('对话', gauge.contextPercent, '')}
326          {gauge !== null &&
327            gauge.limits.map(one => metric(nameOf(one.kind), one.percentUsed, resetLabel(one.resetsAt, gauge.at)))}
328          {gauge !== null && gauge.usd !== null && <Text dimColor>${gauge.usd.toFixed(2)}</Text>}
329          {end !== null && (
330            <Box key="cache" gap={1} alignItems="center">
331              <Text color={cacheColor}>⏱ {left <= 0 ? '已冷' : mmss(left)}</Text>
332              <Button
333                key="warm"
334                plain
335                variant={isWarm ? 'primary' : 'secondary'}
336                onPress={() => request($, 'keep-warm')}
337              >
338                {isWarm ? 'Keep Warm · 开' : 'Keep Warm'}
339              </Button>
340            </Box>
341          )}
342          {steps.length > 0 && (
343            <Box key="plan" gap={1} alignItems="center">
344              <Text dimColor>任务</Text>
345              <Text bold>{done}/{steps.length}</Text>
346              {track((done / steps.length) * 100, PURPLE)}
347            </Box>
348          )}
349          {hasEye && (
350            <Button
351              key="eye"
352              plain
353              variant={isSharing ? 'primary' : 'secondary'}
354              onPress={() => request($, 'screen-share')}
355            >
356              {isSharing ? '👁 打码中' : '👁'}
357            </Button>
358          )}
359          <Button key="fold" plain onPress={() => setView($, 'mini')}>收起</Button>
360        </Box>
361      </Box>
362    )
363  })
364
365  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
366    const { Box, Text, Button } = $.ui.resolve(e)
367    const mods = await read($, registry)
368    const all = await read($, flags)
369
370    return (
371      <Box flexDirection="column" gap={1}>
372        <Text bold>Switchboard · 已发现 {mods.length} 个 Mod</Text>
373        {mods.length === 0 && <Text dimColor>还没有发现任何 Mod。</Text>}
374        {mods.map(mod => {
375          const flag = all[mod.name] ?? DEFAULT_FLAG
376          const light = !flag.isOn ? '○' : flag.isHidden ? '◐' : '●'
377          const color = !flag.isOn ? 'gray' : flag.isHidden ? 'yellow' : 'green'
378          const status = !flag.isOn
379            ? '已关闭'
380            : flag.isHidden
381              ? '运行中 · 界面隐藏'
382              : '运行中'
383
384          return (
385            <Box key={mod.name} flexDirection="column">
386              <Box gap={1}>
387                <Text color={color}>{light}</Text>
388                <Text bold>{mod.name}</Text>
389                <Text dimColor>
390                  {mod.version === undefined ? '' : `v${mod.version} · `}
391                  {status}
392                </Text>
393              </Box>
394              <Text dimColor>{mod.description}</Text>
395              <Box gap={1}>
396                <Button
397                  key={`${mod.name}:power`}
398                  label={flag.isOn ? 'Off' : 'On'}
399                  variant={flag.isOn ? 'secondary' : 'primary'}
400                  onPress={() => setFlag($, mod.name, { isOn: !flag.isOn })}
401                />
402                <Button
403                  key={`${mod.name}:hide`}
404                  label={flag.isHidden ? 'Show UI Panel' : 'Hide UI Panel'}
405                  onPress={() =>
406                    setFlag($, mod.name, { isHidden: !flag.isHidden })
407                  }
408                />
409              </Box>
410            </Box>
411          )
412        })}
413        <Text dimColor>
414          Off = 该 Mod 立即停手;Hide = 后台照跑,只收起它的面板。立即生效,不用重启。
415        </Text>
416      </Box>
417    )
418  })
419}
420
hooks/strip.ts 58 lines
1// Pure: how a number becomes a short bar, a color and a reset time.
2
3const WIDTH = 5
4const DAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
5
6export const bar = (percent: number) => {
7  const on = Math.max(0, Math.min(WIDTH, Math.round((percent / 100) * WIDTH)))
8
9  return { on: '▰'.repeat(on), off: '▱'.repeat(WIDTH - on) }
10}
11
12export const GREEN = '#6fbf7d'
13export const YELLOW = '#e0b84f'
14export const RED = '#e5675f'
15export const PURPLE = '#9b7bf0'
16export const BAR_W = 36
17
18export const colorOf = (percent: number) =>
19  percent >= 85 ? RED : percent >= 60 ? YELLOW : GREEN
20
21// A thin rounded bar, 36 x 4 px: the track and the filled part.
22export const barSvg = (percent: number, color: string): string => {
23  const fill = percent <= 0 ? 0 : Math.max(4, Math.round((BAR_W * Math.min(100, percent)) / 100))
24
25  return (
26    `<svg xmlns="http://www.w3.org/2000/svg" width="${BAR_W}" height="4" viewBox="0 0 ${BAR_W} 4">` +
27    `<rect width="${BAR_W}" height="4" rx="2" fill="#3a3a3a"/>` +
28    (fill > 0 ? `<rect width="${fill}" height="4" rx="2" fill="${color}"/>` : '') +
29    '</svg>'
30  )
31}
32
33const two = (n: number) => String(n).padStart(2, '0')
34
35export const mmss = (ms: number) => {
36  const total = Math.max(0, Math.round(ms / 1000))
37
38  return `${Math.floor(total / 60)}:${two(total % 60)}`
39}
40
41// "↻00:40" when the reset is today, "↻周六 17:00" otherwise.
42export const resetLabel = (iso: string | undefined, nowMs: number): string => {
43  if (iso === undefined) return ''
44  const at = new Date(iso)
45  if (Number.isNaN(at.getTime())) return ''
46  const today = new Date(nowMs)
47  const isToday =
48    at.getFullYear() === today.getFullYear() &&
49    at.getMonth() === today.getMonth() &&
50    at.getDate() === today.getDate()
51  const time = `${two(at.getHours())}:${two(at.getMinutes())}`
52
53  return isToday ? `↻${time}` : `↻${DAYS[at.getDay()]} ${time}`
54}
55
56export const nameOf = (kind: string) =>
57  kind === 'five_hour' ? '5 小时' : kind === 'seven_day' ? '本周' : kind === 'spend_limit' ? '预算' : kind
58
types/index.d.ts 38 lines
1export type ModFlag = { isOn: boolean; isHidden: boolean }
2export type ModEntry = {
3  name: string
4  description: string
5  version?: string
6  root: string
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    switchboard: {
12      flags: Record<string, ModFlag>
13      registry: ModEntry[]
14      // A press on the strip bumps a counter; the mod that owns the switch
15      // sees the change and flips it (only an owner may write its own state).
16      toggles: Record<string, number>
17      // how much of the strip shows
18      view: 'full' | 'mini' | 'off'
19    }
20    // Read-only views of other mods' values; each owner alone writes its own.
21    'usage-gauge': {
22      snap: {
23        contextTokens: number | null
24        contextWindow: number
25        contextPercent: number | null
26        limits: { kind: string; percentUsed: number; resetsAt?: string }[]
27        usd: number | null
28        at: number
29      } | null
30    }
31    'cache-clock': { expiresAt: number | null; now: number; isKeepWarm: boolean }
32    'helper-desk': {
33      plan: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]
34    }
35    'screen-share': { isOn: boolean }
36  }
37}
38