SLOPSHOPPER

action-chips

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

newbandtoastmodelprocess
v0.1.1MITupdated 2026-10-12cch-agent/claude-mods/action-chips
A shopper browsing a rack in a slop shop
README

cch

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,再依上方步驟重裝。

Source 3 files
hooks/register.tsx 136 lines
1import { 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}
136
hooks/extract.ts 117 lines
1import 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}
117
types/index.d.ts 9 lines
1/** 一個小框: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