回覆後在輸入列上方顯示可點的小框:檔案與網址點了開啟、指令點了複製

Claude Code mods(hooks plugins)。
grill-choices:/grill-with-docs 的提問改成 AskUserQuestion 選擇題,可自行輸入。ship:/ship [補充說明],驗證 → 獨立 sonnet code-review → 確認 commit 訊息 → commit → push。action-chips:每次回覆後在輸入框上方排出小框框——回覆中的檔案與網址點了開啟(可執行檔只在 Finder 顯示)、指令點了複製;標籤由 haiku 寫成「這是什麼」(如 web demo、部署指令),依回覆順序由左至右、過長換行。system1:hook,派 sub-agent(Agent 工具)前用 System One 模型(目前後端為 TypeSafe Jev)自動決定 model。需環境變數 TYPESAFE_API_KEY(沒有就不動);名稱含 grill 或 plan 的 skill(如 /grilling、/grill-with-docs)收尾時,會問是否把結論拆成原子規格條目,由 system1:planner(opus)寫入 context/specs/<id>.json,經程式與 Jev 檢查、使用者確認後生效。可用 /plugin disable system1@cch 關閉、/plugin enable system1@cch 開啟。隱私:每次派 sub-agent,description 與 prompt 前 6000 字會送到 api.typesafe.ai。/plugin marketplace add cch-agent/claude-mods
/plugin install grill-choices@cch
/plugin install ship@cch
/plugin install action-chips@cch
/plugin install system1@cch
/reload-plugins
marketplace 名稱為
cch(見.claude-plugin/marketplace.json),安裝與解除安裝都用@cch。從舊名my-mods遷移:先/plugin uninstall <mod>@my-mods、/plugin marketplace remove my-mods,再依上方步驟重裝。
hooks/register.tsx 136 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Chip } from '../types'
5import { extract, fallbackLabel, parseLabels, type Candidate } from './extract'
6
7const chips = atom({ plugin: 'action-chips', key: 'chips' } as const, [] as Chip[])
8
9const MAX_CHIPS = 12
10
11// 下層真的有畫東西才算;空 Box 或沒東西都視為無(與 context-buddy 相同判斷)
12const isTree = (v: unknown): v is { type: string } => {
13 if (typeof v !== 'object' || v === null) return false
14 const { type, children } = v as { type?: unknown; children?: unknown }
15 if (typeof type !== 'string') return false
16 return type !== 'Box' || (Array.isArray(children) && children.length > 0)
17}
18const EXEC_RE = /\.(app|command|sh|bash|zsh|tool|terminal|workflow|scpt|applescript|pkg|dmg)\/?$/i
19
20async function resolvePath($: EngineInterface, p: string, cwd: string): Promise<string | undefined> {
21 let abs = p
22 if (p.startsWith('~/')) {
23 const home = await $.env.get('HOME')
24 if (!home) return undefined
25 abs = home + p.slice(1)
26 }
27 else if (!p.startsWith('/')) abs = cwd.replace(/\/$/, '') + '/' + p.replace(/^\.\//, '')
28 try {
29 const st = await $.fs.stat(abs)
30 return st.kind === 'other' ? undefined : abs
31 } catch {
32 return undefined
33 }
34}
35
36async function label($: EngineInterface, answer: string, items: Chip[]): Promise<string[] | undefined> {
37 const list = items
38 .map((c, i) => `${i + 1}. ${c.kind === 'copy' ? '指令' : '開啟'}:${c.target.slice(0, 300)}`)
39 .join('\n')
40 const r = await $.model.complete({
41 model: 'haiku',
42 maxTokens: 400,
43 timeoutMs: 4000, // 等標籤時 turn 尚未結束,逾時就用備用標籤
44 effort: 'low',
45 system:
46 '你替對話中的檔案、網址與指令取極短的按鈕標籤,說明它「是什麼/做什麼」,不要寫檔名或路徑。' +
47 '語言跟隨回覆(中文回覆就用繁體中文),每個標籤 2–8 個字或 1–3 個英文字,例如:web demo、mobile demo、配色比較頁、部署指令、進度紀錄。' +
48 '只輸出一個 JSON 字串陣列,順序與數量和清單相同。',
49 prompt: `回覆內容:\n${answer.slice(0, 6000)}\n\n清單:\n${list}`,
50 })
51 return r.isAnswered ? parseLabels(r.text, items.length) : undefined
52}
53
54export const register: Register = on => {
55 on('turn.complete', async ($, e, next) => {
56 const result = await next(e)
57 if (e.agentId !== undefined) return result
58 if (e.isAborted) {
59 await update($, chips, () => [])
60 return result
61 }
62
63 const cwd = await $.session.cwd()
64 const found: Chip[] = []
65 for (const c of extract(e.answer) as Candidate[]) {
66 if (found.length >= MAX_CHIPS) break
67 let target = c.target
68 if (c.isPath) {
69 const abs = await resolvePath($, c.target, cwd)
70 if (!abs) continue
71 target = abs
72 }
73 if (found.some(f => f.kind === c.kind && f.target === target)) continue
74 found.push({ kind: c.kind, target, label: fallbackLabel({ kind: c.kind, target }) })
75 }
76
77 // 先換上備用標籤(或清空),再請 haiku 補上描述性標籤
78 await update($, chips, () => found)
79 if (found.length > 0) {
80 const labels = await label($, e.answer, found)
81 if (labels) {
82 await update($, chips, cur =>
83 cur === undefined || cur.length !== found.length || cur.some((c, i) => c.target !== found[i]?.target)
84 ? (cur ?? [])
85 : cur.map((c, i) => ({ ...c, label: labels[i] || c.label })),
86 )
87 }
88 }
89 return result
90 })
91
92 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
93 if (e.props.hasSurvey) return next(e)
94 // 下層(例如 context-buddy)照樣畫,小框框疊在它上面
95 const below = await next(e)
96 const list = e.props.isWorking ? [] : await read($, chips)
97 if (list.length === 0) return below
98
99 const { Box, Button, Text } = $.ui.resolve(e)
100 const row = (
101 <Box key="action-chips" flexDirection="row" flexWrap="wrap" columnGap={1}>
102 {list.map((c, i) => (
103 <Button
104 key={`chip-${i}`}
105 onPress={async press => {
106 if (c.kind === 'copy') {
107 const r = await $.ui.copy({ text: c.target, surface: press.surface })
108 $.ui.toast(r.isCopied ? `已複製:${c.label}` : '複製失敗')
109 } else {
110 // 可執行的檔案只在 Finder 顯示,不直接執行
111 const argv = !c.target.startsWith('http') && EXEC_RE.test(c.target) ? ['open', '-R', c.target] : ['open', c.target]
112 try {
113 const r = await $.process.run(argv)
114 if (r.exitCode !== 0) $.ui.toast(`無法開啟:${c.label}`)
115 } catch {
116 $.ui.toast(`無法開啟:${c.label}`)
117 }
118 }
119 }}
120 >
121 <Text dimColor>{c.kind === 'copy' ? '⧉ ' : '↗ '}</Text>
122 {c.label}
123 </Button>
124 ))}
125 </Box>
126 )
127 if (!isTree(below)) return row
128 return (
129 <Box flexDirection="column">
130 {row}
131 {below}
132 </Box>
133 )
134 })
135}
136hooks/extract.ts 117 lines1import type { Chip } from '../types'
2
3/** 抽出的候選:path 型的還要確認檔案存在才算數。 */
4export type Candidate = { kind: 'open' | 'copy'; target: string; isPath: boolean }
5
6const SHELL_LANGS = new Set(['sh', 'bash', 'zsh', 'shell', 'console'])
7// 沒標語言的 code fence,第一行要像指令才算
8const CMD_HEAD = /^(!|\/[a-z][\w:-]*|cd|pnpm|npm|npx|yarn|bun|git|gh|claude|node|python3?|pip|curl|brew|wrangler|make|ls|cat|open|docker|kubectl)(\s|$)/
9// slash command:`/plugin install x`(斜線開頭、後面有空白參數;單純的絕對路徑沒有空白)
10const SLASH_CMD = /^\/[a-z][\w:-]*\s+\S/
11// 網址只收 ASCII,遇到中文或全形標點就停
12const URL_RE = /https?:\/\/[\x21-\x7e]+/g
13
14/** 去掉網址結尾的標點與不成對的右括號。 */
15export function trimUrl(u: string): string {
16 let s = u.replace(/[.,:;!?'"`>\]]+$/, '')
17 while (s.endsWith(')') && (s.match(/\(/g) ?? []).length < (s.match(/\)/g) ?? []).length) {
18 s = s.slice(0, -1).replace(/[.,:;!?'"`]+$/, '')
19 }
20 return s
21}
22
23function looksLikePath(s: string): boolean {
24 if (/\s/.test(s) || s.startsWith('-') || s.includes('://')) return false
25 if (/^[\w.-]+\(\)$/.test(s)) return false // 函式名
26 return s.includes('/') || /\.[A-Za-z0-9]{1,6}$/.test(s)
27}
28
29/** 去掉 `file.ts:12` 這類行號。 */
30export function stripLine(s: string): string {
31 return s.replace(/(?::\d+(?:[-:]\d+)*|#L?\d+.*|#[\w-]+)$/, '')
32}
33
34/** 依出現順序抽出回覆中的指令、檔案路徑與網址,重複的只留第一個。 */
35export function extract(text: string): Candidate[] {
36 const found: { at: number; c: Candidate }[] = []
37 let rest = text
38
39 // 1. code fence:shell 類的整塊當一個指令
40 rest = rest.replace(/```([\w-]*)[^\n]*\n([\s\S]*?)```/g, (m, lang: string, body: string, at: number) => {
41 const l = lang.toLowerCase()
42 let lines = body.split('\n').map(x => x.trimEnd())
43 if (l === 'console') lines = lines.filter(x => x.startsWith('$ '))
44 const cmd = lines
45 .map(x => x.replace(/^\$ /, ''))
46 .filter(x => x.trim() !== '' && !x.trimStart().startsWith('#'))
47 .join('\n')
48 if (cmd && (SHELL_LANGS.has(l) || (l === '' && CMD_HEAD.test(cmd)))) {
49 found.push({ at, c: { kind: 'copy', target: cmd, isPath: false } })
50 }
51 return ' '.repeat(m.length)
52 })
53
54 // 2. markdown 連結
55 rest = rest.replace(/\[[^\]\n]*\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g, (m, href: string, at: number) => {
56 if (/^https?:\/\//.test(href)) found.push({ at, c: { kind: 'open', target: href, isPath: false } })
57 else if (looksLikePath(stripLine(href))) found.push({ at, c: { kind: 'open', target: stripLine(href), isPath: true } })
58 return ' '.repeat(m.length)
59 })
60
61 // 3. inline code:`! cmd` 是指令,像路徑的是檔案
62 rest = rest.replace(/`([^`\n]+)`/g, (m, code: string, at: number) => {
63 const s = code.trim()
64 if (/^!\s*\S/.test(s) || SLASH_CMD.test(s)) found.push({ at, c: { kind: 'copy', target: s, isPath: false } })
65 else if (/^https?:\/\//.test(s)) found.push({ at, c: { kind: 'open', target: s, isPath: false } })
66 else if (looksLikePath(stripLine(s))) found.push({ at, c: { kind: 'open', target: stripLine(s), isPath: true } })
67 return ' '.repeat(m.length)
68 })
69
70 // 4. 裸網址
71 for (const m of rest.matchAll(URL_RE)) {
72 found.push({ at: m.index ?? 0, c: { kind: 'open', target: trimUrl(m[0]), isPath: false } })
73 }
74
75 found.sort((a, b) => a.at - b.at)
76 const seen = new Set<string>()
77 const out: Candidate[] = []
78 for (const { c } of found) {
79 const key = c.kind + '\0' + c.target
80 if (seen.has(key)) continue
81 seen.add(key)
82 out.push(c)
83 }
84 return out
85}
86
87/** 模型沒給標籤時的備用:檔名、網址的主機與路徑、指令的開頭。 */
88export function fallbackLabel(c: Pick<Chip, 'kind' | 'target'>): string {
89 if (c.kind === 'copy') {
90 const first = (c.target.split('\n')[0] ?? '').replace(/^!\s*/, '')
91 return first.length > 28 ? first.slice(0, 27) + '…' : first
92 }
93 if (/^https?:\/\//.test(c.target)) {
94 try {
95 const u = new URL(c.target)
96 const p = u.pathname.split('/').filter(Boolean).at(-1)
97 return p ? `${u.hostname.split('.')[0]}/${p.slice(0, 12)}` : u.hostname
98 } catch {
99 return c.target.slice(0, 28)
100 }
101 }
102 return c.target.split('/').filter(Boolean).at(-1) ?? c.target
103}
104
105/** 從模型回覆中取出 JSON 字串陣列;取不到回 undefined。 */
106export function parseLabels(reply: string, n: number): string[] | undefined {
107 const m = reply.match(/\[[\s\S]*\]/)
108 if (!m) return undefined
109 try {
110 const arr = JSON.parse(m[0])
111 if (!Array.isArray(arr) || arr.length !== n) return undefined
112 return arr.map(x => (typeof x === 'string' ? x.trim().slice(0, 24) : ''))
113 } catch {
114 return undefined
115 }
116}
117types/index.d.ts 9 lines1/** 一個小框:open 開啟檔案或網址,copy 複製指令。 */
2export type Chip = { kind: 'open' | 'copy'; target: string; label: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'action-chips': { chips: Chip[] }
7 }
8}
9