SLOPSHOPPER

quick-commands

在模型名右边加一个 ⚡ 按钮,点开面板用点选代替手打斜杠命令,并可直接点选修改 /config 设置;支持中英文界面

newpanespinnertoastmodel
★ 1v0.1.1MITupdated 2026-10-06crebot51/jack-mods/plugins/quick-commands
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · quick-commands
│ ┃ quick-commands ✕ › fix the failing auth test and add an audit log call │ ┃ [ ● Commands ] [ Settings ] [ 🌐 Auto · Engl │ ┃ ⏺ Read(src/auth.ts) │ ┃ Search 0 commands, Enter runs the first ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Common built-ins run right away (⋯ to add ⎿ Added 2 lines, removed 1 line │ ┃ arguments); the rest just fill the input box ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Favorites │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ No matching commands │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⟨Claude Code's own drawing⟩⚡

Draws

Pane · quick-commands
[ ● Commands ] [ Settings ] [ 🌐 Auto · English ] Search 0 commands, Enter runs the first Common built-ins run right away (⋯ to add arguments); the rest just fill the input box Favorites No matching commands
README

jack-mods

English · Chinese (Simplified)

My own Claude Code mods.

token-band

A live token-usage band above the prompt box:

Expanded

Click the collapse button and the whole band disappears; only a 🪙 is left at the right of the toolbar below the prompt box. Click it to expand again:

Collapsed

271.1k/1.00M (27%) · this turn 3.3k · 113 tok/s · total in 7.92M · total out 38.4k · cache 98% · $8.58 · 5h 99% · 7d 70% ✅   [collapse]

The band's labels in the screenshots are currently Chinese; the line above shows the same fields in English, in the same order.

ItemMeaning
271.1k/1.00M (27%)Current context size / model window
this turnOutput tokens generated so far this turn, ticking up while the reply streams
tok/sAverage output speed: total output ÷ total generation time (first to last token, excluding time to first token)
total in / total outSession totals, including cache reads/writes and subagents
cacheShare of total input served from cache; higher is cheaper
$Session cost
5h / 7dShare of the 5-hour / 7-day subscription limit used (hidden on API billing)
✅ / ❌Model check: turns ❌ and shows a toast with the reason when the main thread was switched to another model, the model returned by the API differs from the one requested, or thinking effort was silently lowered
  • On narrow windows the band wraps to two lines, with the collapse button pinned to the right of the first line.
  • You can also type /tokens to toggle between expanded and collapsed.

quick-commands

Adds a ⚡ button to the right of the model name. It opens a side panel so you don't have to type /xxx by hand:

  • Commands tab: lists every slash command in the current session (built-in, your own, plugins and skills, MCP), grouped and searchable.
  • Common built-ins (/compact, /clear, /context, /cost, /resume, …) run with one click.
  • Everything else (skills, plugin and MCP commands) only fills /name into the prompt box so you can add arguments and send it yourself. Nothing fires by accident.
  • The ⋯ next to a built-in lets you type arguments; when a command's output lists "Available: a, b, c" the mod remembers it and shows those as buttons next time.
  • Settings tab: the /config settings as click targets. Toggles get On / Off, pick-one settings get one button per option, text and number settings get an input box. The current value has a blue background.
  • Chinese / English: the UI follows language in settings.json or the system language by default; the 🌐 button switches between Auto / Chinese / English by hand. In Chinese, the English command and setting descriptions are translated with haiku and cached. In English, the engine's own descriptions are shown untouched.
  • Click ⚡ to open; click it again (now ✕) or press Esc to close.

The ⚡ button, to the right of the model name:

Button

The panel in English (descriptions and setting names are the engine's originals, unchanged):

Commands tab (English) Settings tab (English)

Install

One line (paste into a terminal):

claude plugin marketplace add crebot51/jack-mods && claude plugin install token-band@jack-mods

To install quick-commands, swap the last name for quick-commands@jack-mods (no need to add the marketplace again if you already have).

Then start a new session. token-band's band shows up on desktop only after the first message; quick-commands' ⚡ sits to the right of the model name.

Update: claude plugin update <name> Uninstall: claude plugin uninstall <name>

The mod API is an early preview and may need updates across Claude Code versions.

Source 2 files
hooks/register.tsx 684 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Lang, LangPref, QuickCommand, RunMode, SettingRow, Tab } from '../types'
5
6const isOpen = atom({ plugin: 'quick-commands', key: 'isOpen' } as const, false)
7const commands = atom({ plugin: 'quick-commands', key: 'commands' } as const, [] as QuickCommand[])
8const mode = atom({ plugin: 'quick-commands', key: 'mode' } as const, 'run' as RunMode)
9const query = atom({ plugin: 'quick-commands', key: 'query' } as const, '')
10const tab = atom({ plugin: 'quick-commands', key: 'tab' } as const, 'commands' as Tab)
11const detail = atom({ plugin: 'quick-commands', key: 'detail' } as const, null as string | null)
12const zh = atom({ plugin: 'quick-commands', key: 'zh' } as const, {} as Record<string, string>)
13const langPref = atom({ plugin: 'quick-commands', key: 'langPref' } as const, 'auto' as LangPref)
14const langAuto = atom({ plugin: 'quick-commands', key: 'langAuto' } as const, 'zh' as Lang)
15const learned = atom({ plugin: 'quick-commands', key: 'learned' } as const, {} as Record<string, string[]>)
16const settings = atom({ plugin: 'quick-commands', key: 'settings' } as const, [] as SettingRow[])
17const model = atom({ plugin: 'quick-commands', key: 'model' } as const, '')
18const notice = atom({ plugin: 'quick-commands', key: 'notice' } as const, '')
19
20/** 命令面板的 id */
21export const PANE = 'quick-commands'
22
23/** 面板顶部的常用命令,按这个顺序;当前会话没有的自动跳过 */
24export const FAVORITES = ['compact', 'clear', 'context', 'cost', 'resume', 'review', 'init']
25
26/** 内置命令的参数可选值;其余命令从执行输出里的「Available: a, b, c」学 */
27export const KNOWN_OPTIONS: Record<string, string[]> = {}
28
29/** 桌面端自己的选择器能改的,不再做参数页,也不去学 */
30const SKIP_LEARN = new Set(['model', 'effort'])
31
32/** 选中项的底色:Claude 设置页里开关的蓝 */
33const BLUE = '#3d73d4'
34
35const CHIP_HEIGHT = 23
36const CHIP_PAD = 17
37
38/** 画一个和原生按钮一样大的蓝底圆角标签:高 23,左右各约 8.5 的内边距,字号 13 */
39export function chipSvg(label: string): { source: string; width: number; height: number } {
40  let text = 0
41  for (const ch of label) text += /[\u2e80-\u9fff\uff00-\uffef]/.test(ch) ? 13 : 6.6
42  const textWidth = Math.ceil(text)
43  const width = textWidth + CHIP_PAD
44  const safe = label.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
45  const source =
46    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${CHIP_HEIGHT}" viewBox="0 0 ${width} ${CHIP_HEIGHT}">` +
47    `<rect width="${width}" height="${CHIP_HEIGHT}" rx="6" fill="${BLUE}"/>` +
48    `<text x="${width / 2}" y="${CHIP_HEIGHT / 2 + 0.5}" text-anchor="middle" dominant-baseline="central" ` +
49    `font-family="-apple-system,BlinkMacSystemFont,'PingFang SC','Helvetica Neue',sans-serif" font-size="13" fill="#ffffff" ` +
50    `textLength="${textWidth}" lengthAdjust="spacingAndGlyphs">${safe}</text></svg>`
51  return { source, width, height: CHIP_HEIGHT }
52}
53
54/** 点一下就该直接执行的内置命令(不需要再打参数) */
55export const DIRECT = new Set([
56  'compact', 'clear', 'context', 'cost', 'status', 'usage', 'help', 'doctor', 'resume', 'export', 'copy',
57  'rewind', 'mcp', 'agents', 'hooks', 'permissions', 'config', 'memory', 'login', 'logout', 'vim', 'theme',
58])
59
60/** 界面文字的中英对照;{0}、{1} 是占位 */
61const STRINGS: Record<Lang, Record<string, string>> = {
62  zh: {
63    'group.builtin': '内置', 'group.user': '我的命令', 'group.plugin': '插件 / Skill', 'group.mcp': 'MCP',
64    on: '开', off: '关',
65    'pane.title': '快捷命令',
66    'toast.openFail': '快捷命令面板没能打开:{0}',
67    'toast.slow': '/{0} 还在执行,完成后会显示结果',
68    'toast.translateFail': '翻译没成功:{0}',
69    'toast.translateError': '翻译出错:{0}',
70    'chip.selected': '(已选中)',
71    'tab.commands': '命令', 'tab.settings': '设置',
72    'notice.failed': '「{0}」没改成:{1}', 'notice.changed': '已把「{0}」改成 {1}',
73    save: '保存', 'settings.none': '没有可改的设置',
74    back: '← 返回', 'args.placeholder': '其他参数,回车执行', run: '执行', 'run.bare': '不带参数执行 /{0}',
75    'search.placeholder': '搜索 {0} 个命令,回车执行第一个',
76    hint: '常用内置命令直接执行(⋯ 手动填参数);其余点一下只填进输入框',
77    favorites: '常用', 'commands.none': '没有匹配的命令',
78    'lang.auto': '自动 · {0}', 'lang.zh': '中文', 'lang.en': 'English',
79  },
80  en: {
81    'group.builtin': 'Built-in', 'group.user': 'My commands', 'group.plugin': 'Plugins & skills', 'group.mcp': 'MCP',
82    on: 'On', off: 'Off',
83    'pane.title': 'Quick commands',
84    'toast.openFail': "Couldn't open the Quick commands panel: {0}",
85    'toast.slow': '/{0} is still running; the result will show when it finishes',
86    'toast.translateFail': 'Translation failed: {0}',
87    'toast.translateError': 'Translation error: {0}',
88    'chip.selected': ' (selected)',
89    'tab.commands': 'Commands', 'tab.settings': 'Settings',
90    'notice.failed': 'Couldn\'t change "{0}": {1}', 'notice.changed': 'Changed "{0}" to {1}',
91    save: 'Save', 'settings.none': 'No settings to change',
92    back: '← Back', 'args.placeholder': 'Other arguments, Enter to run', run: 'Run', 'run.bare': 'Run /{0} without arguments',
93    'search.placeholder': 'Search {0} commands, Enter runs the first',
94    hint: 'Common built-ins run right away (⋯ to add arguments); the rest just fill the input box',
95    favorites: 'Favorites', 'commands.none': 'No matching commands',
96    'lang.auto': 'Auto · {0}', 'lang.zh': '中文', 'lang.en': 'English',
97  },
98}
99
100/** 取一条界面文字,把 {0}、{1} 换成参数 */
101export function t(lang: Lang, key: string, ...args: (string | number)[]): string {
102  const text = STRINGS[lang][key] ?? STRINGS.en[key] ?? key
103  return text.replace(/\{(\d)\}/g, (_, i: string) => String(args[Number(i)] ?? ''))
104}
105
106/** 从 settings.json 的 language(Claude 回复语言)或系统语言猜界面语言;都没有就当中文 */
107export function guessLang(setting: string | undefined, locale: string | undefined): Lang {
108  const s = (setting ?? '').trim()
109  if (s) return /chin|中文|汉|漢|^zh/i.test(s) ? 'zh' : 'en'
110  const l = (locale ?? '').trim()
111  if (l && l !== 'C' && l !== 'POSIX') return /^zh/i.test(l) ? 'zh' : 'en'
112  return 'zh'
113}
114
115/** 当前界面语言:手动选的优先,否则用自动判断的 */
116async function currentLang($: EngineInterface): Promise<Lang> {
117  const pref = await read($, langPref)
118  return pref === 'auto' ? await read($, langAuto) : pref
119}
120
121/** 分组顺序和名字 */
122const GROUPS: { source: string; title: string }[] = [
123  { source: 'builtin', title: 'group.builtin' },
124  { source: 'user', title: 'group.user' },
125  { source: 'plugin', title: 'group.plugin' },
126  { source: 'mcp', title: 'group.mcp' },
127]
128
129/** 常用命令(按 FAVORITES 顺序) */
130export function favorites(all: readonly QuickCommand[]): QuickCommand[] {
131  const byName = new Map(all.map(c => [c.name, c]))
132  return FAVORITES.flatMap(n => byName.get(n) ?? [])
133}
134
135/** 按关键字筛选:命令名或说明里包含即可,不分大小写;命令名开头匹配的排前面 */
136export function filter(all: readonly QuickCommand[], q: string): QuickCommand[] {
137  const k = q.trim().replace(/^\//, '').toLowerCase()
138  if (!k) return [...all]
139  const hits = all.filter(
140    c => c.name.toLowerCase().includes(k) || c.description.toLowerCase().includes(k),
141  )
142  const rank = (c: QuickCommand) =>
143    c.name.toLowerCase().startsWith(k) ? 0 : c.name.toLowerCase().includes(k) ? 1 : 2
144  return hits.sort((a, b) => rank(a) - rank(b))
145}
146
147/** 把说明截到 n 个字以内 */
148export function clip(text: string, n: number): string {
149  if (n <= 1) return ''
150  return text.length > n ? `${text.slice(0, n - 1)}…` : text
151}
152
153/**
154 * 从命令输出里找参数可选值,例如
155 * 「Usage: /model <name>. Available: sonnet, opus, haiku, or a full model ID.」
156 * 只留不带空格的值(去掉「or a full model ID」这类说明)
157 */
158export function parseOptions(text: string | undefined): string[] {
159  const m = text?.match(/Available(?: values| options)?:\s*([^\n]+)/i)
160  if (!m?.[1]) return []
161  return m[1]
162    .replace(/\.\s*$/, '')
163    .split(/,\s*/)
164    .map(s => s.trim().replace(/^or\s+/, '').replace(/[`'"]/g, ''))
165    .filter(s => s.length > 0 && !/\s/.test(s))
166}
167
168/** 当前模型是否就是这个选项(模型 id 里含有选项名即算) */
169export function isCurrentModel(current: string, option: string): boolean {
170  if (!current) return false
171  const base = option.replace(/\[1m\]$/, '')
172  const has1m = option.endsWith('[1m]')
173  return current.includes(base) && current.includes('[1m]') === has1m
174}
175
176/** 文字占几个字符格:中文、全角算 2 */
177export function cells(text: string): number {
178  let n = 0
179  for (const ch of text) n += /[\u2e80-\u9fff\uff00-\uffef]/.test(ch) ? 2 : 1
180  return n
181}
182
183/** 名字和一排选项能不能放进同一行(每个选项按文字宽 + 左右内边距和间距估 4 格) */
184export function fitsOneLine(label: string, options: readonly string[], columns: number): boolean {
185  const controls = options.reduce((sum, o) => sum + cells(o) + 4, 0)
186  return cells(label) + 2 + controls <= columns - 2
187}
188
189/** 设置项的值显示成文字 */
190export function showValue(v: SettingRow['value'], lang: Lang = 'zh'): string {
191  if (typeof v === 'boolean') return t(lang, v ? 'on' : 'off')
192  if (Array.isArray(v)) return v.join(', ')
193  return String(v)
194}
195
196/** 重新拉取当前会话可用的斜杠命令和当前模型 */
197async function refresh($: EngineInterface) {
198  const list = await $.command.list()
199  await update($, commands, () =>
200    list.map(c => ({ name: c.name, description: c.description, source: c.source })),
201  )
202  const current = await $.session.model()
203  await update($, model, () => current)
204  const conf = (await $.settings.read()) as { language?: string }
205  const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LANG'))
206  const guessed = guessLang(conf.language, locale)
207  await update($, langAuto, () => guessed)
208}
209
210const hasCJK = (text: string) => /[\u4e00-\u9fff]/.test(text)
211
212/** 译文缓存的 key:原文变了就重新翻 */
213export const zhKey = (id: string, text: string) => `${id}|${text}`
214
215/** 从模型回复里取出 {"0":"译文",...} */
216export function parseTranslations(text: string): Record<string, string> {
217  const m = text.match(/\{[\s\S]*\}/)
218  if (!m) return {}
219  try {
220    const obj = JSON.parse(m[0]) as Record<string, unknown>
221    const out: Record<string, string> = {}
222    for (const [k, v] of Object.entries(obj)) if (typeof v === 'string' && v.trim()) out[k] = v.trim()
223    return out
224  } catch {
225    return {}
226  }
227}
228
229let translating = false
230
231/** 把还没有中文的命令说明、设置名和说明批量翻成中文,存起来;失败就静默,界面继续显示英文 */
232async function translate($: EngineInterface) {
233  if (translating || (await currentLang($)) !== 'zh') return
234  translating = true
235  try {
236    let next = await read($, zh)
237    // 模型偶尔漏回几条,所以缺的接着再翻,最多 3 轮,一轮没有进展就停
238    for (let round = 0; round < 3; round++) {
239      const have = next
240      const items: { id: string; text: string }[] = []
241      for (const c of await read($, commands)) {
242        if (c.description && !hasCJK(c.description)) items.push({ id: `cmd:${c.name}`, text: c.description })
243      }
244      for (const r of await read($, settings)) {
245        if (r.label && !hasCJK(r.label)) items.push({ id: `label:${r.key}`, text: r.label })
246        if (r.description && !hasCJK(r.description)) items.push({ id: `desc:${r.key}`, text: r.description })
247      }
248      const todo = items.filter(i => !have[zhKey(i.id, i.text)])
249      for (let i = 0; i < todo.length; i += 20) {
250        const chunk = todo.slice(i, i + 20)
251        const prompt =
252          '把下面 JSON 数组里每条说明翻译成简体中文:简短通顺,不超过 40 字,命令名、代码、专有名词保持原样。' +
253          '只输出一个 JSON 对象,键是序号,值是译文,不要别的文字。\n' +
254          JSON.stringify(chunk.map((c, n) => ({ n: String(n), text: c.text.slice(0, 400) })))
255        const r = await $.model.complete({ model: 'haiku', prompt, maxTokens: 4096 })
256        if (!r.isAnswered) {
257          $.ui.toast(t(await currentLang($), 'toast.translateFail', r.reason))
258          break
259        }
260        const got = parseTranslations(r.text)
261        const added: Record<string, string> = {}
262        chunk.forEach((c, n) => {
263          const t = got[String(n)]
264          if (t) added[zhKey(c.id, c.text)] = t
265        })
266        next = { ...next, ...added }
267        await update($, zh, () => next)
268        await $.store.set('zh', next)
269      }
270      if (todo.length === 0 || Object.keys(next).length === Object.keys(have).length) break
271    }
272  } catch (err) {
273    // 翻译只是锦上添花,只提示一下,不影响使用
274    $.ui.toast(t(await currentLang($), 'toast.translateError', err instanceof Error ? err.message : String(err)))
275  } finally {
276    translating = false
277  }
278}
279
280/** 重新拉取 /config 的设置项 */
281async function refreshSettings($: EngineInterface) {
282  const rows = await $.config.list()
283  await update($, settings, () =>
284    rows.map(r => ({
285      key: r.key,
286      label: r.label,
287      description: r.description,
288      kind: r.kind,
289      value: r.value,
290      options: r.options,
291      isLocked: r.isLocked,
292    })),
293  )
294}
295
296/** 打开面板;detailName 给了就直接进那个命令的参数页 */
297async function openPane($: EngineInterface, detailName: string | null) {
298  await update($, detail, () => detailName)
299  const opened = await $.ui.open({ id: PANE, title: t(await currentLang($), 'pane.title'), focus: true, closeOnEscape: true, rows: 14 })
300  if (opened.isPlaced) {
301    await update($, isOpen, () => true)
302  } else {
303    $.ui.toast(t(await currentLang($), 'toast.openFail', opened.reason))
304  }
305}
306
307/** 执行 /name args;执行后发现命令列出了可选值,就记下来并打开它的参数页 */
308async function runCommand($: EngineInterface, name: string, args: string) {
309  const text = args ? `/${name} ${args}` : `/${name}`
310  await $.ui.close({ id: PANE })
311  if ((await read($, mode)) === 'fill') {
312    await $.prompt.fill({ text: args ? text : `${text} ` })
313    return
314  }
315  let output: string | undefined
316  try {
317    // 像 /compact 这种要跑很久的命令,不等它跑完,免得超过 hook 的 10 秒预算
318    const slow = Symbol('slow')
319    const ran = await Promise.race([
320      $.command.run({ command: name, args }),
321      $.clock.sleep(6000).then(() => slow, () => new Promise<never>(() => {})),
322    ])
323    if (ran === slow) {
324      $.ui.toast(t(await currentLang($), 'toast.slow', name))
325      return
326    }
327    output = (ran as { text: string }).text
328  } catch {
329    // 有些命令不能从插件直接执行,退回到填进输入框
330    await $.prompt.fill({ text: `${text} ` })
331    return
332  }
333  const options = parseOptions(output)
334  if (!args && options.length > 0 && !SKIP_LEARN.has(name)) {
335    const next = { ...(await read($, learned)), [name]: options }
336    await update($, learned, () => next)
337    await $.store.set('learned', next)
338    await openPane($, name)
339  }
340}
341
342export const register: Register = on => {
343  on('session.start', async ($, e, next) => {
344    const result = await next(e)
345    await update($, isOpen, () => false)
346    await update($, detail, () => null)
347    const saved = await $.store.get('learned')
348    if (saved && typeof saved === 'object') {
349      await update($, learned, () => saved as Record<string, string[]>)
350    }
351    const savedLang = await $.store.get('langPref')
352    if (savedLang === 'zh' || savedLang === 'en' || savedLang === 'auto') {
353      await update($, langPref, () => savedLang)
354    }
355    const savedZh = await $.store.get('zh')
356    if (savedZh && typeof savedZh === 'object') {
357      await update($, zh, () => savedZh as Record<string, string>)
358    }
359    await refresh($)
360    void translate($)
361    return result
362  })
363
364  // 面板被关掉(点面板的关闭、按 Esc、或我们自己关)时同步按钮状态
365  on('ui.close', { id: PANE }, async ($, e, next) => {
366    const result = await next(e)
367    await update($, isOpen, () => false)
368    // 桌面端手动关掉面板时,底部按钮不一定跟着重画,主动要一次
369    $.ui.invalidate('ui.render')
370    return result
371  })
372
373  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
374    const engine = await next(e)
375    const { Box, Button } = $.ui.resolve(e)
376    // 以引擎里面板的实际状态为准,自己记的状态只用来触发重画
377    const open = (await read($, isOpen)) && (await $.ui.panes()).some(p => p.id === PANE)
378
379    return (
380      <Box>
381        {engine}
382        <Button
383          key="toggle"
384          label={open ? '✕' : '⚡'}
385          plain
386          dimColor={!open}
387          onPress={async () => {
388            const isUp = (await $.ui.panes()).some(p => p.id === PANE)
389            if (open || isUp) {
390              if (isUp) await $.ui.close({ id: PANE })
391              await update($, isOpen, () => false)
392              return
393            }
394            await refresh($)
395            await update($, query, () => '')
396            await update($, notice, () => '')
397            if ((await read($, tab)) === 'settings') await refreshSettings($)
398            await openPane($, null)
399            void translate($)
400          }}
401        />
402      </Box>
403    )
404  })
405
406  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
407    const els = $.ui.resolve(e)
408    const { Box, Button, Text } = els
409    // 手机端没有输入框
410    const Input = 'Input' in els ? els.Input : undefined
411    const Svg = 'Svg' in els ? els.Svg : undefined
412    const width = e.props.bodyColumns
413    const pref = await read($, langPref)
414    const lang = await currentLang($)
415    const tt = (key: string, ...args: (string | number)[]) => t(lang, key, ...args)
416    const all = await read($, commands)
417    const currentTab = await read($, tab)
418    const detailName = await read($, detail)
419    const learnedOptions = await read($, learned)
420    const zhMap = await read($, zh)
421    const zhOf = (id: string, text: string) => (lang === 'zh' ? zhMap[zhKey(id, text)] : undefined) ?? text
422    const optionsOf = (name: string) => learnedOptions[name] ?? KNOWN_OPTIONS[name] ?? []
423
424    // 选中的项画成蓝底白字(本来就不用再点),尺寸、圆角和旁边的原生按钮一样;没选中的才是按钮。
425    // 桌面端没有能点的蓝色元素,所以用 Svg 画;没有 Svg 的界面(终端)退回浅灰按钮
426    const chip = (key: string, label: string, selected: boolean, onPress: () => unknown) => {
427      if (!selected) return <Button key={key} label={label} onPress={onPress} />
428      if (!Svg) return <Button key={key} label={`● ${label}`} variant="secondary" onPress={onPress} />
429      const { source, width: chipW, height: chipH } = chipSvg(label)
430      return (
431        <Box key={key}>
432          <Svg source={source} alt={`${label}${tt('chip.selected')}`} width={chipW} height={chipH} />
433        </Box>
434      )
435    }
436
437    const tabs = (
438      <Box flexDirection="row" columnGap={1}>
439        {chip('tab:commands', tt('tab.commands'), currentTab === 'commands', async () => {
440          await update($, tab, () => 'commands')
441          await update($, detail, () => null)
442        })}
443        {chip('tab:settings', tt('tab.settings'), currentTab === 'settings', async () => {
444          await refreshSettings($)
445          void translate($)
446          await update($, notice, () => '')
447          await update($, tab, () => 'settings')
448        })}
449        <Button
450          key="lang"
451          label={`🌐 ${pref === 'auto' ? tt('lang.auto', tt(`lang.${lang}`)) : tt(`lang.${pref}`)}`}
452          dimColor
453          onPress={async () => {
454            const nextPref: LangPref = pref === 'auto' ? 'zh' : pref === 'zh' ? 'en' : 'auto'
455            await update($, langPref, () => nextPref)
456            await $.store.set('langPref', nextPref)
457            void translate($)
458          }}
459        />
460      </Box>
461    )
462
463    // ── 设置页 ──
464    if (currentTab === 'settings') {
465      const rows = await read($, settings)
466      const msg = await read($, notice)
467      const set = async (row: SettingRow, value: SettingRow['value']) => {
468        const result = await $.config.set({ key: row.key, value })
469        const name = zhOf(`label:${row.key}`, row.label)
470        await update($, notice, () =>
471          'deny' in result && result.deny
472            ? tt('notice.failed', name, String(result.deny))
473            : tt('notice.changed', name, showValue(value, lang)),
474        )
475        await refreshSettings($)
476        void translate($)
477      }
478
479      // 每个设置一行:名字在左,选项在右;选项太多放不下才折到名字下面
480      const settingRow = (row: SettingRow) => {
481        const label = zhOf(`label:${row.key}`, row.label)
482        const title = <Text bold wrap="truncate-end">{label}</Text>
483        const desc = row.description ? (
484          <Text dimColor wrap="truncate-end">{clip(zhOf(`desc:${row.key}`, row.description), width - 2)}</Text>
485        ) : null
486        let controls: RenderChildren = null
487        let options: readonly string[] = []
488        if (row.isLocked) {
489          controls = <Text dimColor>🔒 {showValue(row.value, lang)}</Text>
490          options = [showValue(row.value, lang)]
491        } else if (row.kind === 'boolean') {
492          controls = (
493            <Box flexDirection="row" columnGap={1}>
494              {chip(`set:${row.key}:on`, tt('on'), row.value === true, () => set(row, true))}
495              {chip(`set:${row.key}:off`, tt('off'), row.value === false, () => set(row, false))}
496            </Box>
497          )
498          options = [tt('on'), tt('off')]
499        } else if (row.kind === 'choice' && row.options) {
500          controls = (
501            <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={0}>
502              {row.options.map(opt => chip(`set:${row.key}:${opt}`, opt, row.value === opt, () => set(row, opt)))}
503            </Box>
504          )
505          options = row.options
506        } else if (Input) {
507          // 文字、数字:名字下面一个输入框
508          return (
509            <Box key={`setting:${row.key}`} flexDirection="column">
510              {title}
511              {desc}
512              <Input
513                key={`set:${row.key}:input`}
514                value={showValue(row.value)}
515                submitLabel={tt('save')}
516                onSubmit={(v: string) => {
517                  void set(row, row.kind === 'number' ? Number(v) : v)
518                }}
519              />
520            </Box>
521          )
522        } else {
523          controls = <Text dimColor>{showValue(row.value, lang)}</Text>
524          options = [showValue(row.value, lang)]
525        }
526        const oneLine = fitsOneLine(label, options, width)
527        return (
528          <Box key={`setting:${row.key}`} flexDirection="column">
529            {oneLine ? (
530              <Box flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={1}>
531                <Box flexShrink={1}>{title}</Box>
532                <Box flexShrink={0}>{controls}</Box>
533              </Box>
534            ) : (
535              <Box flexDirection="column">
536                {title}
537                {controls}
538              </Box>
539            )}
540            {desc}
541          </Box>
542        )
543      }
544
545      return (
546        <Box flexDirection="column" rowGap={1}>
547          {tabs}
548          {msg ? <Text dimColor>{msg}</Text> : null}
549          {rows.length === 0 ? <Text dimColor>{tt('settings.none')}</Text> : null}
550          <Box flexDirection="column">{rows.map(settingRow)}</Box>
551        </Box>
552      )
553    }
554
555    // ── 命令参数页 ──
556    if (detailName) {
557      const info = all.find(c => c.name === detailName)
558      const options = optionsOf(detailName)
559      const current = await read($, model)
560      return (
561        <Box flexDirection="column" rowGap={1}>
562          {tabs}
563          <Box flexDirection="row" columnGap={1}>
564            <Button key="back" label={tt('back')} dimColor onPress={() => update($, detail, () => null)} />
565            <Text bold>/{detailName}</Text>
566          </Box>
567          {info?.description ? <Text dimColor>{zhOf(`cmd:${info.name}`, info.description)}</Text> : null}
568          {options.length > 0 ? (
569            <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1}>
570              {options.map(opt => {
571                const isCurrent = detailName === 'model' && isCurrentModel(current, opt)
572                return chip(`opt:${opt}`, opt, isCurrent, () => runCommand($, detailName, opt))
573              })}
574            </Box>
575          ) : null}
576          {Input ? (
577            <Input
578              key="args"
579              placeholder={tt('args.placeholder')}
580              submitLabel={tt('run')}
581              onSubmit={(v: string) => {
582                void runCommand($, detailName, v.trim())
583              }}
584            />
585          ) : null}
586          <Button
587            key="run-bare"
588            label={tt('run.bare', detailName)}
589            dimColor
590            onPress={() => runCommand($, detailName, '')}
591          />
592        </Box>
593      )
594    }
595
596    // ── 命令列表 ──
597    const q = await read($, query)
598
599    // 有可选值的命令点了进参数页,其余直接执行
600    // 技能 / 插件 / MCP 命令几乎都要接自由文本,点一下只把 /名字 填进输入框,不执行
601    // 打包进来的技能也可能被标成 builtin,所以只认下面这份「不用参数」的名单,其余都填输入框
602    const isBuiltin = (name: string) =>
603      DIRECT.has(name) && (all.find(c => c.name === name)?.source ?? 'builtin') === 'builtin'
604    const press = async (name: string) => {
605      if (!isBuiltin(name)) {
606        await $.ui.close({ id: PANE })
607        await $.prompt.fill({ text: `/${name} ` })
608        return
609      }
610      if (optionsOf(name).length > 0) await update($, detail, () => name)
611      else await runCommand($, name, '')
612    }
613
614    // 常用里的命令在分组里还会出现一次,key 用前缀区分
615    const row = (prefix: string) => (c: QuickCommand) => (
616      <Box key={`row:${prefix}:${c.name}`} flexDirection="row" columnGap={1}>
617        <Box flexShrink={0} flexDirection="row">
618          <Button
619            key={`${prefix}:${c.name}`}
620            label={optionsOf(c.name).length > 0 ? `/${c.name} ›` : `/${c.name}`}
621            plain
622            onPress={() => press(c.name)}
623          />
624          {isBuiltin(c.name) ? (
625            <Button
626              key={`args:${prefix}:${c.name}`}
627              label="⋯"
628              plain
629              dimColor
630              onPress={() => update($, detail, () => c.name)}
631            />
632          ) : null}
633        </Box>
634        <Text dimColor wrap="truncate-end">
635          {clip(zhOf(`cmd:${c.name}`, c.description), width - c.name.length - 6)}
636        </Text>
637      </Box>
638    )
639
640    const hits = filter(all, q)
641    const groups = GROUPS.map(g => ({
642      ...g,
643      items: hits.filter(c => (c.source ?? 'builtin') === g.source),
644    })).filter(g => g.items.length > 0)
645
646    return (
647      <Box flexDirection="column" rowGap={1}>
648        {tabs}
649        {Input ? (
650          <Input
651            key="search"
652            placeholder={tt('search.placeholder', all.length)}
653            value={q}
654            autoFocus
655            onInput={(v: string) => void update($, query, () => v)}
656            onSubmit={(v: string) => {
657              const first = filter(all, v)[0]
658              if (first) void press(first.name)
659            }}
660          />
661        ) : null}
662
663        <Text dimColor>{tt('hint')}</Text>
664
665        {!q.trim() ? (
666          <Box flexDirection="column">
667            <Text bold>{tt('favorites')}</Text>
668            {favorites(all).map(row('fav'))}
669          </Box>
670        ) : null}
671
672        {groups.map(g => (
673          <Box key={`group:${g.source}`} flexDirection="column">
674            <Text bold>{tt(g.title)}</Text>
675            {g.items.map(row('cmd'))}
676          </Box>
677        ))}
678
679        {hits.length === 0 ? <Text dimColor>{tt('commands.none')}</Text> : null}
680      </Box>
681    )
682  })
683}
684
types/index.d.ts 59 lines
1/** 面板里列出的一条命令 */
2export type QuickCommand = {
3  /** 命令名,不带斜杠 */
4  name: string
5  description: string
6  /** 来源:builtin / plugin / user / mcp。旧状态里可能没有 */
7  source?: string
8}
9
10/** 界面语言:中文或英文 */
11export type Lang = 'zh' | 'en'
12
13/** 语言偏好:auto 跟着 settings.json 的 language / 系统语言,或手动指定 */
14export type LangPref = 'auto' | Lang
15
16/** 点命令后:直接执行,或填进输入框等补参数 */
17export type RunMode = 'run' | 'fill'
18
19/** 面板当前显示哪一页 */
20export type Tab = 'commands' | 'settings'
21
22/** /config 里的一行设置,只留面板要用的字段 */
23export type SettingRow = {
24  key: string
25  label: string
26  description?: string
27  kind: 'boolean' | 'choice' | 'text' | 'number'
28  value: boolean | string | number | readonly string[]
29  options?: readonly string[]
30  isLocked: boolean
31}
32
33declare module 'claude-code' {
34  interface PluginState {
35    'quick-commands': {
36      isOpen: boolean
37      commands: QuickCommand[]
38      mode: RunMode
39      query: string
40      tab: Tab
41      /** 正在看哪个命令的参数页;null 表示在列表 */
42      detail: string | null
43      /** 学到的命令参数可选值:命令名 -> 可选值 */
44      learned: Record<string, string[]>
45      settings: SettingRow[]
46      /** 手动选的界面语言,auto 表示自动判断 */
47      langPref: LangPref
48      /** 自动判断出的界面语言 */
49      langAuto: Lang
50      /** 译文缓存:`id|原文` -> 中文 */
51      zh: Record<string, string>
52      /** 当前模型,用来在 /model 的选项里高亮 */
53      model: string
54      /** 设置页上一次操作的结果提示 */
55      notice: string
56    }
57  }
58}
59