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

English · Chinese (Simplified)
My own Claude Code mods.
A live token-usage band above the prompt box:

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:

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.
| Item | Meaning |
|---|---|
271.1k/1.00M (27%) | Current context size / model window |
| this turn | Output tokens generated so far this turn, ticking up while the reply streams |
tok/s | Average output speed: total output ÷ total generation time (first to last token, excluding time to first token) |
| total in / total out | Session totals, including cache reads/writes and subagents |
| cache | Share of total input served from cache; higher is cheaper |
$ | Session cost |
5h / 7d | Share 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 |
/tokens to toggle between expanded and collapsed.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:
/compact, /clear, /context, /cost, /resume, …) run with one click./name into the prompt box so you can add arguments and send it yourself. Nothing fires by accident.⋯ 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./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.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.The ⚡ button, to the right of the model name:

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

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.
hooks/register.tsx 684 lines1import { 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, '&').replace(/</g, '<').replace(/>/g, '>')
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}
684types/index.d.ts 59 lines1/** 面板里列出的一条命令 */
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