繁體中文版(fork 自 hamzafer/claude-code-mods 0.1.4):輸入框上方的進度摘要(goal、now、waiting、next),/where 顯示較長的回顧

個人使用的 Claude Code mod marketplace。這裡只放通用的程式碼,網址、token 這類私人設定由每台電腦各自設定,不進版控。
| mod | 用途 |
|---|---|
clean-view | 工具呼叫收成一行淡色摘要(Edit、Write、Bash 與錯誤訊息照常顯示),計畫清單顯示在輸入框上方;/clean 切換 |
where-am-i | 輸入框上方的進度摘要(繁體中文版,fork 自 hamzafer/claude-code-mods,MIT) |
next-steps | 回合結束後建議下一步(繁體中文版,fork 自 anthropics/claude-plugins-community,Apache 2.0) |
gitlab-sync | 分支跟遠端的同步狀態與還開著的張數(GitLab 的 Task、Issue 分開計數);GitLab 與 GitHub 上自己的 issue/MR(PR)有新動態時通知,/gitlab、/github 查看 |
agent-panel | 派出子代理時跳出面板(右側或輸入框上方),顯示每個子代理的模型、用量、估算費用與時間;/agents 開關 |
clone 到固定的資料夾,再把那個資料夾加成 marketplace。這樣 Claude Code 會直接讀資料夾裡的程式,git pull 之後執行 /reload-plugins 就是新版:
git clone <這個 repo 的網址> ~/.claude/mods-marketplaces/my-mods
claude plugin marketplace add ~/.claude/mods-marketplaces/my-mods
claude plugin install clean-view@my-mods
claude plugin install where-am-i@my-mods
claude plugin install next-steps@my-mods
claude plugin install gitlab-sync@my-mods
claude plugin install agent-panel@my-mods
| 項目 | 設定方式 | 沒設定時 | |
|---|---|---|---|
| GitLab 網址 | 在 Claude Code 執行 /plugin configure gitlab-sync,填入 gitlabUrl(例如 https://gitlab.example.com,不含 /api/v4);也可以執行 `echo '{"gitlabUrl":"https://gitlab.example.com"}' \ | claude plugin configure gitlab-sync@my-mods --values-stdin` | 不檢查 GitLab |
| GitLab token | 環境變數 GITLAB_TOKEN;或存進 macOS 鑰匙圈:security add-generic-password -U -a "$USER" -s gitlab-token -w。環境變數優先,是空的或被拒絕(401)時改讀鑰匙圈 | 輸入框上方顯示「GitLab 通知暫停」(在 GitHub repo 裡不顯示:band 只顯示目前 repo 所屬平台的新動態與通知暫停) | |
| GitHub | gh auth login,token 跟 gh CLI 共用 | 不檢查 GitHub |
設定值存在這台電腦的 Claude Code 設定裡,不在這個 repo。
改完任一個 mod:
claude plugin test <mod 資料夾>
claude plugin validate <mod 資料夾>
bash scripts/check-contracts.sh # 改過任何 types/index.d.ts 後執行
可能有好幾個 Claude Code session 同時在改這裡的檔案,動手前先看 git status。
scripts/git-hooks/pre-push 會在 git push 前掃描要送出去的 commit:新增的行、檔名、作者與提交者、commit 訊息。找到私有網段 IP,或私人關鍵字清單裡的字,就擋下推送。每個 clone 要做兩件事:
git config core.hooksPath scripts/git-hooks
# 這台電腦的私人關鍵字清單:一行一個延伸正規表示式,不分大小寫,# 開頭是註解
$EDITOR ~/.config/git/private-patterns
git push --no-verify。clean-view 與 gitlab-sync 採用根目錄的 MIT License。where-am-i(MIT)與 next-steps(Apache 2.0)是 fork,依各自資料夾裡的 LICENSE。
hooks/register.tsx 275 lines1// Where Am I: a live recap above the prompt (goal, now, waiting on you, next), plus /where.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register } from 'claude-code'
4
5import type { Recap } from '../types'
6
7const MODEL = 'haiku'
8const MAX_LOG = 20
9
10// 摘要區塊的外框,讓它在 band 裡一眼就能認出來;只框自己畫的部分,
11// 其他 mod 的內容(rest)留在框外,因為串接順序無法保證這個 mod 在最外層
12const BORDER_STYLE = 'round'
13const BORDER_COLOR = 'suggestion'
14// 外框左右各一格線、一格內距;框內每一列可用的寬度是 bodyColumns 扣掉這些,再扣掉標籤
15const BOX_CHROME_COLUMNS = 4
16const LABELS = { goal: '◆ Goal: ', now: ' Now: ', next: ' Next: ', wait: ' Wait: ' }
17// 存進 recap 前的安全上限:真正的截斷在畫框時依寬度做,這裡只防模型偶爾寫出一大段
18const MAX_STORED_COLUMNS = 300
19
20// Held by the host, so the recap survives a hot reload of this file.
21const recap = atom({ plugin: 'where-am-i', key: 'recap' } as const, null as Recap | null)
22const live = atom({ plugin: 'where-am-i', key: 'live' } as const, '')
23// True while the next-steps mod shows its list of next prompts: this band leaves out its own next meanwhile.
24const nextStepsActive = { plugin: 'next-steps', key: 'active' } as const
25// clean-view 的合併框模式(clean-view/hooks/register.tsx 的 combinedBox):
26// 合併框顯示時,Goal/Now/Wait 會跟計畫畫在同一個框裡,這裡就讓出位置。改名要兩邊一起改
27const cleanViewCombinedBox = { plugin: 'clean-view', key: 'combinedBox' } as const
28// clean-view 的計畫工具(clean-view/hooks/register.tsx 的 PLAN_TOOL):只是在回報計畫,不算正在做的事;
29// 也拿來判斷 clean-view 是否還載入著(見 isCleanViewLoaded)
30const CLEAN_VIEW_PLAN_TOOL = 'mcp__clean-view__update_plan'
31// 確認 clean-view 是否載入的結果留用多久
32const CLEAN_VIEW_RECHECK_MS = 2000
33
34// lastCheck 是最近一次確認的結果;isRecheckScheduled 表示已排了一次到期後的重新確認
35type CleanViewProbe = {
36 lastCheck: { isLoaded: boolean; checkedAt: number } | null
37 isRecheckScheduled: boolean
38}
39
40export const register: Register = on => {
41 let prompt = ''
42 let log: string[] = []
43 const cleanViewProbe: CleanViewProbe = { lastCheck: null, isRecheckScheduled: false }
44
45 on('session.start', async ($, e, next) => {
46 const r = await next(e)
47 await $.command.register({ name: 'where', description: 'Recap the session so far in a few bullets' }).catch(() => {}) // a name Claude Code already has is refused: start anyway
48 return r
49 })
50
51 on('prompt.submit', async ($, e, next) => {
52 prompt = e.text.slice(0, 1500)
53 log = []
54 await update($, live, () => '正在讀你的訊息')
55 return next(e)
56 })
57
58 // Observe only: note what is happening, then let the call run untouched.
59 // Only Claude's own calls count: one another mod makes in the background (`$.tool.call`,
60 // `$.mcp.call`) is raised by that plugin, and `next.origin` names it instead of the engine.
61 on('tool.call', async ($, e, next) => {
62 if (next.origin.plugin !== 'engine' || e.tool === CLEAN_VIEW_PLAN_TOOL) return next(e)
63 const line = describe(e as unknown as Record<string, unknown>)
64 log = [...log, e.agentId ? `(agent) ${line}` : line].slice(-MAX_LOG)
65 if (!e.agentId) await update($, live, () => line)
66 const r = await next(e)
67 return r
68 })
69
70 on('turn.complete', async ($, e, next) => {
71 const r = await next(e)
72 if (!e.agentId) {
73 await update($, live, () => '')
74 void summarize($, prompt, log, e.answer).catch(() => {}) // in the background, so the turn ends at once
75 }
76 return r
77 })
78
79 on('command.run', { command: 'where' }, async $ => ({ text: await longRecap($, prompt, log) }))
80
81 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
82 const rest = await next(e) // what other mods and Claude Code draw here stays
83 const r = await read($, recap)
84 if (e.props.hasSurvey || !r) return rest
85 const { value: combinedBoxMode = 'hidden' } = await $.state.get(cleanViewCombinedBox)
86 // 跟 clean-view 的 isCombinedBoxShown 同一個規則(兩個 mod 不能共用程式碼),改規則要兩邊一起改
87 const isCombinedBoxShown = combinedBoxMode === 'always' || (combinedBoxMode === 'whileWorking' && e.props.isWorking)
88 if (isCombinedBoxShown && (await isCleanViewLoaded($, cleanViewProbe))) return rest
89
90 const { Box, Text } = $.ui.resolve(e)
91 const { value: hasNextSteps = false } = await $.state.get(nextStepsActive)
92 const nextStep = hasNextSteps ? '' : r.next
93 // 依框的實際寬度截斷;wrap="truncate-end" 留著當保險,寬度算錯時也不會折成兩行
94 const innerColumns = e.props.bodyColumns - BOX_CHROME_COLUMNS
95 const fit = (label: string, text: string) => clip(text, innerColumns - displayWidth(label))
96
97 return (
98 <Box flexDirection="column">
99 <Box flexDirection="column" paddingX={1} borderStyle={BORDER_STYLE} borderColor={BORDER_COLOR}>
100 <Text wrap="truncate-end">
101 <Text color="cyan" bold>{LABELS.goal}</Text>
102 <Text>{fit(LABELS.goal, r.goal)}</Text>
103 </Text>
104 <Text wrap="truncate-end">
105 <Text dimColor>{LABELS.now}</Text>
106 <Text>{fit(LABELS.now, (await read($, live)) || r.now)}</Text>
107 </Text>
108 {nextStep !== '' && (
109 <Text wrap="truncate-end">
110 <Text dimColor>{LABELS.next}</Text>
111 <Text>{fit(LABELS.next, nextStep)}</Text>
112 </Text>
113 )}
114 {r.waiting !== '' && (
115 <Text color="yellow" wrap="truncate-end">{`${LABELS.wait}${fit(LABELS.wait, r.waiting)}`}</Text>
116 )}
117 </Box>
118 {rest}
119 </Box>
120 )
121 })
122}
123
124// clean-view 停用或載入失敗時,host 不會清掉它寫過的 state(只在 session 結束時整批清),
125// combinedBox 可能一直停在 always,摘要框就永遠讓位。host 卸載 plugin 時會一併移除它註冊的工具,
126// 所以改看計畫工具還在不在。列工具要算每個工具的描述,不適合每次重畫都做:結果留用一段時間,
127// 期間的重畫改排一次到期後的重新確認,結果變了就重畫,停用後不必等別的事件來觸發重畫
128async function isCleanViewLoaded($: EngineInterface, probe: CleanViewProbe): Promise<boolean> {
129 const now = await $.clock.now()
130 const last = probe.lastCheck
131 if (last === null || now - last.checkedAt >= CLEAN_VIEW_RECHECK_MS) {
132 const isLoaded = await listsCleanViewTool($)
133 probe.lastCheck = { isLoaded, checkedAt: now }
134 return isLoaded
135 }
136 if (!probe.isRecheckScheduled) {
137 probe.isRecheckScheduled = true
138 $.clock.after(last.checkedAt + CLEAN_VIEW_RECHECK_MS - now, async () => {
139 probe.isRecheckScheduled = false
140 const isLoaded = await listsCleanViewTool($)
141 const hasChanged = isLoaded !== probe.lastCheck?.isLoaded
142 probe.lastCheck = { isLoaded, checkedAt: await $.clock.now() }
143 if (hasChanged) $.ui.invalidate('ui.render')
144 })
145 }
146 return last.isLoaded
147}
148
149// 列不出工具時當作還載入著,維持照 combinedBox 讓位的原行為,避免兩個框同時出現
150async function listsCleanViewTool($: EngineInterface): Promise<boolean> {
151 const tools = await $.tool.list().catch(() => null)
152 return tools === null || tools.some(tool => tool.name === CLEAN_VIEW_PLAN_TOOL)
153}
154
155// A short label for one tool call: what a person would say Claude is doing.
156// 用字跟 clean-view 的 describe 一致,同一個畫面上 transcript 與 Now 才不會兩套說法
157export function describe(e: Record<string, unknown>): string {
158 const tool = String(e.tool)
159 const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
160 const file = (p: string) => p.split('/').slice(-2).join('/')
161 if (tool === 'Bash') return `執行:${s('description') || s('command').slice(0, 60)}`
162 if (tool === 'Read') return `讀取 ${file(s('file_path'))}`
163 if (tool === 'Write') return `寫入 ${file(s('file_path'))}`
164 if (tool === 'Edit') return `編輯 ${file(s('file_path'))}`
165 if (tool === 'NotebookEdit') return `編輯 ${file(s('notebook_path'))}`
166 if (tool === 'Grep' || tool === 'Glob') return `搜尋 "${s('pattern').slice(0, 40)}"`
167 if (tool === 'WebSearch') return `搜尋網路「${s('query').slice(0, 40)}」`
168 if (tool === 'WebFetch') return `讀取網頁 ${s('url').replace(/^[a-z]+:\/\//i, '').split(/[/?#]/)[0]}`
169 if (tool === 'Agent') return `委派 agent:${s('description')}`
170 if (tool === 'Skill') return `使用 skill:${s('skill')}`
171 if (tool === 'AskUserQuestion') return '詢問你問題'
172 if (tool === 'ToolSearch') return '載入工具'
173 if (tool.startsWith('mcp__')) return `使用 ${tool.split('__').slice(1).join(' ')}`
174 return `使用 ${tool}`
175}
176
177async function summarize($: EngineInterface, prompt: string, log: string[], answer: string) {
178 const before = await read($, recap)
179 const r = await $.model.complete({
180 model: MODEL,
181 maxTokens: 300,
182 system:
183 'You keep a one-glance recap of a coding session for someone with ADHD. ' +
184 'Write every field in Traditional Chinese as used in Taiwan (繁體中文,台灣用語); keep code identifiers, ' +
185 'file names and commands as they are. Plain words, no em dashes, ' +
186 'each field at most 30 Chinese characters. Reply with JSON only: {"goal","now","waiting","next"}. ' +
187 '"goal": the overall aim of the session (keep the previous goal unless it clearly changed). ' +
188 '"now": what was just done. "waiting": what the assistant is waiting on from the person, or "". ' +
189 '"next": the next step.',
190 prompt: [
191 `Previous recap: ${before ? JSON.stringify(before) : 'none'}`,
192 `The person's latest message: ${prompt}`,
193 `Tools used this turn: ${log.join('; ') || 'none'}`,
194 `The assistant's reply: ${answer.slice(0, 2500)}`,
195 ].join('\n\n'),
196 })
197 if (!r.isAnswered) return
198 const parsed = parseRecap(r.text)
199 if (parsed) await update($, recap, () => parsed)
200}
201
202async function longRecap($: EngineInterface, prompt: string, log: string[]) {
203 const messages = (await $.session.messages()).slice(-12)
204 const r = await $.model.complete({
205 model: MODEL,
206 // 中文每個字耗用的 token 比英文多,原本的 500 寫 6 點條列可能被截斷
207 maxTokens: 700,
208 system:
209 'Write a recap of this coding session for someone who lost track, in Traditional Chinese as used in Taiwan ' +
210 '(繁體中文,台灣用語); keep code identifiers, file names and commands as they are. Plain words, no em dashes. ' +
211 'At most 6 short bullets: the goal, what is done, what is happening now, what is waiting on them, the next step.',
212 prompt: [
213 `<transcript>\n${messages.filter(m => m.text.trim() !== '').map(m => `[${m.role === 'user' ? 'person' : 'assistant'}] ${m.text.slice(0, 800)}`).join('\n')}\n</transcript>`,
214 `Latest message: ${prompt}`,
215 `Recent tool calls: ${log.join('; ') || 'none'}`,
216 'Write the recap of the transcript above now: the bullets only, not a reply to it.',
217 ].join('\n\n'),
218 })
219 return r.isAnswered ? r.text : '目前無法產生摘要。'
220}
221
222// 東亞寬字元、全形符號與 emoji 在終端機佔兩格;範圍用 \u 跳脫,避免存檔時字元被正規化成別的碼位
223const WIDE_CHAR = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
224
225// 併成一行、去掉結尾的句號(刪節號不算),再以顯示寬度截到 maxColumns 格以內,放不下補「…」。
226// 以前只留第一句,模型多寫的句子會整句消失又沒有「…」提示,現在每一句都留著,放不下才截。
227// 原版用空格找斷點,中文沒有空格會切錯位置,所以改成依顯示寬度計算
228export function clip(text: string, maxColumns: number) {
229 const oneLine = text.replace(/\s+/g, ' ').trim().replace(/(?<![.。])[.。]$/, '')
230 if (displayWidth(oneLine) <= maxColumns) return oneLine
231 if (maxColumns <= 0) return ''
232 return `${trimToWordBoundary(takeWidth(oneLine, maxColumns - 1))}…`
233}
234
235function charWidth(char: string) {
236 return WIDE_CHAR.test(char) ? 2 : 1
237}
238
239function displayWidth(text: string) {
240 let width = 0
241 for (const char of text) width += charWidth(char)
242 return width
243}
244
245function takeWidth(text: string, maxWidth: number) {
246 let width = 0
247 let kept = ''
248 for (const char of text) {
249 width += charWidth(char)
250 if (width > maxWidth) break
251 kept += char
252 }
253 return kept
254}
255
256// 結尾是英文單字時退回最後一個空格,避免切在單字中間;中文字與字之間本來就能斷
257function trimToWordBoundary(kept: string) {
258 const lastSpace = kept.lastIndexOf(' ')
259 const tail = kept.slice(lastSpace + 1)
260 return lastSpace > 0 && !WIDE_CHAR.test(tail) ? kept.slice(0, lastSpace) : kept
261}
262
263// The model's JSON, tolerating a code fence around it.
264export function parseRecap(text: string): Recap | null {
265 const body = text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)
266 try {
267 const o = JSON.parse(body) as Record<string, unknown>
268 const field = (k: string) => (typeof o[k] === 'string' ? clip((o[k] as string).replace(/\s*—\s*/g, ', '), MAX_STORED_COLUMNS) : '')
269 if (!field('goal')) return null
270 return { goal: field('goal'), now: field('now'), waiting: field('waiting'), next: field('next') }
271 } catch {
272 return null
273 }
274}
275types/index.d.ts 20 lines1export type Recap = { goal: string; now: string; waiting: string; next: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'where-am-i': { recap: Recap | null; live: string }
6 // next-steps 擁有,這裡只讀 active:清單顯示時讓出摘要框裡的「Next:」。
7 // 形狀要跟 next-steps/types/index.d.ts 一致,../scripts/check-contracts.sh 會抓出不一致
8 'next-steps': { active: boolean }
9 // clean-view 擁有,這裡只讀 combinedBox。形狀要跟 clean-view/types/index.d.ts 一致,
10 // ../scripts/check-contracts.sh 會抓出不一致
11 'clean-view': {
12 isEnabled: boolean
13 plan: { title: string; status: 'pending' | 'in_progress' | 'completed' }[]
14 tick: number
15 turnClock: { startedAt: number; endedAt: number | null } | null
16 combinedBox: 'hidden' | 'whileWorking' | 'always'
17 }
18 }
19}
20