工具呼叫收成一行淡色摘要(Edit、Write、Bash 與錯誤訊息照常顯示),計畫清單顯示在 prompt 上方;/clean 切換

個人使用的 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 699 lines1// Clean View:工具呼叫收成一行淡色摘要(Edit、Write、Bash 與錯誤訊息照常顯示),Claude 回報的計畫清單顯示在 prompt 上方;/clean 切換
2import { atom, read, update } from 'claude-code'
3import type { Color, EngineInterface, Register, ThemeKey, Timer } from 'claude-code'
4
5import type { CombinedBoxMode, PlanStatus, PlanStep, TurnClock } from '../types'
6
7const PLAN_TOOL_NAME = 'update_plan'
8// 外掛註冊的工具,模型看到的全名固定是 mcp__<plugin>__<name>;寫成字串常值,matcher 才讀得出來。
9// where-am-i/hooks/register.tsx 也用這個名稱略過計畫工具,改名要兩邊一起改
10const PLAN_TOOL = 'mcp__clean-view__update_plan'
11const MAX_GROUP_LINES = 5
12// 這幾個工具照 Claude Code 原本的樣子畫,看得到 diff、指令與輸出。
13// 唯讀的 Bash(ls、cat、grep…)會被 Claude Code 折進 ToolGroup,那裡照樣一行摘要
14const FULL_VIEW_TOOLS: readonly string[] = ['Edit', 'Write', 'Bash']
15const MAX_ERROR_LINES = 4
16
17// 計畫清單的排版
18const BAR_WIDTH = 12
19const BAR_COLUMNS = BAR_WIDTH + 2 // 進度條後面空兩格再接狀態字
20// 影片 1:53~1:56 的 Working 條是一顆彗星:頭最亮,後面四格一格比一格淡,融進灰色底
21const COMET_FADE = [1, 0.7, 0.45, 0.25, 0.1]
22// 影片的主色:橘、粉紅、紫、藍。彗星顏色隨時間在這幾個顏色之間來回變化
23const ORANGE = '#f79a4f'
24const PINK = '#ec4f8f'
25const ACCENT_COLORS = [ORANGE, PINK, '#b45ce6', '#6f7df2']
26const TITLE_MARK = '✳ '
27const TITLE_COLOR = ORANGE
28const ELAPSED_COLUMNS = 8 // 「59m 59s」寬 7,再空一格
29const COMET_COLOR_HALF_PERIOD_FRAMES = 15 // 從橘變到藍約 3 秒
30const MARK_COLUMNS = 4 // 「 ✓ 」
31const STATUS_COLUMNS = 8 // 「Working」寬 7
32const MIN_TITLE_COLUMNS = 12
33const BOX_CHROME_COLUMNS = 4 // 外框左右各一格線、一格內距
34const BOX_BORDER_ROWS = 2
35const PLAN_HEADER_ROWS = 2 // 整體進度列與「Plan:」標題列
36const PERCENT_COLUMNS = 5 // 「 100%」
37const MIN_OVERALL_BAR_WIDTH = 6
38// 影片 1:53~1:56:整體長條填滿的那段有一道較亮的光,約兩格寬,往右掃到填滿處尾端後停一下再從頭來。
39// 影片約 0.4 秒一格,長條一長要掃很久,依使用者要求加快成每拍(0.2 秒)一格
40const OVERALL_SHIMMER_WIDTH = 2
41const OVERALL_SHIMMER_PAUSE_CELLS = 4
42const OVERALL_SHIMMER_LIGHTEN = 0.35
43const MIN_STEP_ROWS = 3
44const MAX_STEP_ROWS = 7
45// 有彗星或亮光要動時每 0.2 秒一拍;只剩標題列的經過秒數要更新時,每秒一拍就夠
46const ANIMATION_MS = 200
47const CLOCK_ONLY_MS = 1000
48
49// ▆ 只佔格子下方四分之三:Ghostty 自己畫方塊字元並填滿整格高度,用滿格的方塊上下列會黏在一起,
50// ▆ 上方留空,列與列之間才分得開。完成的步驟是綠漸層,進行中的步驟是彗星(見 stepBar)
51const BAR_CELL = '▆'
52const DONE_GRADIENT = { from: '#2c9a52', to: '#9ee6b4' }
53// 整體長條已完成那段的填色:左邊橘、填滿處的尾端粉紅
54const OVERALL_FILL_GRADIENT = { from: ORANGE, to: PINK }
55// Working 狀態字與整體百分比的顏色
56const WORKING_COLOR = PINK
57// 空的部分是灰色底;暫停時彗星停住、改成淺灰
58const PAUSED_COLOR = '#8a8a94'
59const TRACK_COLOR = '#4a4a52'
60
61// 狀態由 host 保存,熱重載後仍在;每個 session 預設開啟
62const isEnabled = atom({ plugin: 'clean-view', key: 'isEnabled' } as const, true)
63const plan = atom({ plugin: 'clean-view', key: 'plan' } as const, [] as PlanStep[])
64// 只有 band 讀它,所以動畫只會讓 band 重畫;用 $.ui.invalidate 會連 transcript 每一列都重跑
65const tick = atom({ plugin: 'clean-view', key: 'tick' } as const, 0)
66// 主迴圈回合的起訖時間:動畫計時器看它判斷回合是否進行中,標題列用它算經過秒數;
67// 畫面要不要顯示一律看 band 自己的 e.props.isWorking
68const turnClock = atom({ plugin: 'clean-view', key: 'turnClock' } as const, null as TurnClock | null)
69// where-am-i 讀這個值決定要不要讓出位置(where-am-i/hooks/register.tsx 的 cleanViewCombinedBox),改名要兩邊一起改
70const combinedBox = atom({ plugin: 'clean-view', key: 'combinedBox' } as const, 'hidden' as CombinedBoxMode)
71// where-am-i 擁有的摘要,合併框讀來顯示 Goal/Now/Wait
72const whereAmIRecap = { plugin: 'where-am-i', key: 'recap' } as const
73const whereAmILive = { plugin: 'where-am-i', key: 'live' } as const
74
75const PLAN_SCHEMA = {
76 type: 'object',
77 properties: {
78 steps: {
79 type: 'array',
80 description: 'The whole plan, in order. It replaces the previous list.',
81 items: {
82 type: 'object',
83 properties: {
84 title: { type: 'string', description: 'A short phrase, in the language you reply in.' },
85 status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
86 },
87 required: ['title', 'status'],
88 },
89 },
90 },
91 required: ['steps'],
92}
93
94const PLAN_TOOL_DESCRIPTION =
95 'Show the person a checklist of your plan above their prompt; they see it instead of your tool calls. ' +
96 'Send the whole list every time: it replaces the previous one. Keep exactly one step in_progress while you work. ' +
97 'Step titles are short phrases (about 20 CJK or 40 Latin characters at most) in the language you reply in.'
98
99// 看畫面的人讀的語言;主對話的計畫指示與子代理的語言要求共用,換語言只改這裡
100const READER_LANGUAGE = 'Traditional Chinese (Taiwan usage)'
101
102// 這個環境的主對話沒有 TodoWrite/TaskCreate,計畫清單只能靠模型主動回報,所以要在 system prompt 交代。
103// 最後一行的語言要求:工具呼叫收成一行後,畫面上最顯眼的是工具之間的進度說明,
104// 而查對話紀錄發現英文幾乎都出在這裡(思考是英文或剛收到英文的系統提醒時特別容易)
105const PLAN_INSTRUCTIONS = [
106 '# Plan checklist',
107 `The person uses Clean View: each tool call shows as one short line, and a checklist from the ${PLAN_TOOL} tool shows above the prompt.`,
108 `For a task of three or more steps, call ${PLAN_TOOL} before starting, the first step in_progress and the rest pending.`,
109 'Call it again as each step finishes, moving in_progress to the next one, and mark every step completed when the work is done.',
110 'Skip it for questions you answer directly and for tasks of one or two steps.',
111 `Write the progress notes between tool calls, and any status update you give in reply to a system reminder, in ${READER_LANGUAGE} even when your thinking or the reminder is in English; keep code identifiers, commands and paths as they are.`,
112].join('\n')
113
114// 附在每個子代理任務尾端的語言要求;內容固定,同一句不論附幾次都一樣
115const SUBAGENT_LANGUAGE_NOTE = [
116 '# Language',
117 `The person watches this run on screen and reads ${READER_LANGUAGE}.`,
118 `Write your progress notes between tool calls, the description you give each tool call, and your final report in ${READER_LANGUAGE}.`,
119 'Keep code identifiers, commands, file names and paths as they are.',
120 'If the task above explicitly asks for another language or an exact output format, follow the task.',
121].join('\n')
122
123// 畫面上的步驟狀態:in_progress 依回合是否進行中分成 running 與 paused(例如 Esc 中斷後)
124type StepDisplay = 'completed' | 'running' | 'paused' | 'pending'
125
126type RowStyle = {
127 mark: string
128 markColor?: Color
129 isMarkDim: boolean
130 isTextBold: boolean
131 isTextDim: boolean
132 label: string
133 labelColor?: Color
134 isLabelBold: boolean
135}
136
137const ROW_STYLE: Record<StepDisplay, RowStyle> = {
138 completed: { mark: '✓', markColor: 'success', isMarkDim: false, isTextBold: false, isTextDim: true, label: 'Done', isLabelBold: false },
139 running: { mark: '▸', isMarkDim: false, isTextBold: true, isTextDim: false, label: 'Working', labelColor: WORKING_COLOR, isLabelBold: true },
140 paused: { mark: '▸', isMarkDim: true, isTextBold: false, isTextDim: false, label: 'Paused', isLabelBold: false },
141 pending: { mark: '○', isMarkDim: true, isTextBold: false, isTextDim: true, label: '—', isLabelBold: false },
142}
143
144type PlanRow = { text: string; display: StepDisplay }
145type RecapSummary = { goal: string; now: string; waiting: string }
146
147// 模組重新載入時 engine 會丟掉舊的 timer,這個變數也跟著重來
148let animation: { timer: Timer; intervalMs: number } | undefined
149
150// 計畫、開關或回合狀態變動後都呼叫這裡:決定合併框的顯示模式,以及計時器要不要跑、跑多快
151async function refreshPlanBand($: EngineInterface) {
152 const [enabled, steps, clock, currentMode] = await Promise.all([read($, isEnabled), read($, plan), read($, turnClock), read($, combinedBox)])
153 const mode = combinedBoxMode(enabled, steps)
154 // 沒變就不寫:寫入會讓讀它的 band(含 where-am-i)重畫
155 if (mode !== currentMode) await update($, combinedBox, () => mode)
156 const turnRunning = clock !== null && clock.endedAt === null
157 syncAnimation($, enabled && turnRunning ? animationIntervalMs(steps) : null)
158}
159
160function syncAnimation($: EngineInterface, intervalMs: number | null) {
161 if (animation?.intervalMs === intervalMs) return
162 stopAnimation()
163 if (intervalMs === null) return
164 animation = { intervalMs, timer: $.clock.every(intervalMs, () => void update($, tick, frame => frame + 1).catch(() => {})) }
165}
166
167function stopAnimation() {
168 animation?.timer.cancel()
169 animation = undefined
170}
171
172// 回合中有計畫才需要計時器:有進行中的步驟(彗星)或整體長條有亮光可掃時每 0.2 秒一拍,
173// 只剩標題列的經過秒數要更新時每秒一拍;沒有計畫就不跑
174export function animationIntervalMs(steps: readonly PlanStep[]): number | null {
175 if (steps.length === 0) return null
176 const hasComet = steps.some(step => step.status === 'in_progress')
177 const hasShimmer = countCompleted(steps) > 0 && !isPlanFinished(steps)
178 return hasComet || hasShimmer ? ANIMATION_MS : CLOCK_ONLY_MS
179}
180
181// where-am-i 沒裝或還沒有摘要時回傳 null,合併框就只畫計畫
182async function readRecapSummary($: EngineInterface): Promise<RecapSummary | null> {
183 const { value: recap } = await $.state.get(whereAmIRecap)
184 if (!recap) return null
185 const { value: live } = await $.state.get(whereAmILive)
186 return { goal: recap.goal, now: live || recap.now, waiting: recap.waiting }
187}
188
189export const register: Register = on => {
190 on('session.start', async ($, e, next) => {
191 const started = await next(e)
192 // 名稱已被 Claude Code 佔用時會被拒絕,不影響其他功能
193 await $.command
194 .register({ name: 'clean', description: '切換 Clean View:收起工具呼叫細節,只看計畫清單' })
195 .catch(() => {})
196 await $.tool.register({ name: PLAN_TOOL_NAME, description: PLAN_TOOL_DESCRIPTION, inputSchema: PLAN_SCHEMA })
197 await refreshPlanBand($) // 熱重載也會走到這裡,回合中重載時把動畫接回來
198 return started
199 })
200
201 on('command.run', { command: 'clean' }, async $ => {
202 const enabled = await update($, isEnabled, value => !value)
203 await refreshPlanBand($)
204 return { text: enabled ? 'Clean View 已開啟:工具呼叫收成一行摘要,Edit、Write、Bash 與錯誤訊息照常顯示。' : 'Clean View 已關閉:恢復完整顯示。' }
205 })
206
207 // 內容固定的 session 區段,不會讓 prompt cache 每次失效;沒有這個工具的請求(例如部分 subagent)不加
208 on('prompt.compose', async ($, e, next) => {
209 const composed = await next(e)
210 if (!e.tools.includes(PLAN_TOOL)) return composed
211 return { sections: [...composed.sections, { id: 'clean-view:plan', text: PLAN_INSTRUCTIONS, scope: 'session' }] }
212 })
213
214 // 子代理的 system prompt 由 agent 定義自己組成,不經過 prompt.compose,所以語言要求只能附在交給它的任務尾端。
215 // workflow 啟動的 agent 內容不能改寫(改了會被忽略並記一行失敗),原樣放行
216 on('agent.spawn', async ($, e, next) => {
217 if (e.workflow) return next(e)
218 return next({ ...e, prompt: withSubagentLanguageNote(e.prompt) })
219 })
220
221 // 自己回答,不呼叫 next:這個工具沒有實際執行的東西,也就不會跳權限確認
222 on('tool.call', { tool: PLAN_TOOL }, async ($, e) => {
223 if (e.agentId) return { result: 'Ignored: only the main conversation updates the plan.' }
224 const steps = parseSteps((e as { steps?: unknown }).steps)
225 if (steps === null) return { deny: 'steps must be an array of { title, status } with status pending, in_progress or completed.' }
226 await update($, plan, () => steps)
227 await refreshPlanBand($)
228 return { result: `Plan shown: ${countCompleted(steps)}/${steps.length} completed.` }
229 })
230
231 // 上一份計畫已全部完成才清掉;還沒做完的留著,Claude 可能下一輪接著做。
232 // prompt 被擋下也無妨:做完的計畫在閒置時本來就不顯示
233 on('prompt.submit', async ($, e, next) => {
234 await update($, plan, current => (isPlanFinished(current) ? [] : current))
235 await refreshPlanBand($)
236 return next(e)
237 })
238
239 // subagent 的執行不會發 turn.start,只有主迴圈會
240 on('turn.start', async ($, e, next) => {
241 const startedAt = await $.clock.now()
242 await update($, turnClock, () => ({ startedAt, endedAt: null }))
243 await refreshPlanBand($)
244 return next(e)
245 })
246
247 // 中斷(reason 為 aborted)也會發 turn.complete
248 on('turn.complete', async ($, e, next) => {
249 if (!e.agentId) {
250 const endedAt = await $.clock.now()
251 await update($, turnClock, clock => (clock ? { ...clock, endedAt } : clock))
252 await refreshPlanBand($)
253 }
254 return next(e)
255 })
256
257 on('session.end', async ($, e, next) => {
258 stopAnimation()
259 if (e.reason === 'clear') {
260 await update($, plan, () => [])
261 await refreshPlanBand($)
262 }
263 return next(e)
264 })
265
266 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
267 if (!(await read($, isEnabled)) || FULL_VIEW_TOOLS.includes(e.props.tool)) return next(e)
268 const { Box, Text } = $.ui.resolve(e)
269 if (e.props.tool === PLAN_TOOL) return <Box />
270 return (
271 <Box flexDirection="column">
272 {toolLines(e.props).map(line => (
273 <Text color={line.color} dimColor={line.color === undefined} wrap="truncate-end">
274 {line.text}
275 </Text>
276 ))}
277 </Box>
278 )
279 })
280
281 // 錯誤內容由上面 ToolUse 那一列接在摘要下面:群組裡的呼叫沒有 ToolResult,兩邊才畫得一致
282 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
283 if (!(await read($, isEnabled)) || FULL_VIEW_TOOLS.includes(e.props.tool)) return next(e)
284 const { Box } = $.ui.resolve(e)
285 return <Box />
286 })
287
288 // 連續的讀檔、搜尋會被折成一組;照樣一個動作一行(出錯的下面接錯誤內容),太多時只留最後幾個
289 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
290 if (!(await read($, isEnabled))) return next(e)
291 const { Box, Text } = $.ui.resolve(e)
292 const calls = e.props.calls.filter(call => call.tool !== PLAN_TOOL)
293 const shown = calls.slice(-MAX_GROUP_LINES)
294 const hiddenCount = calls.length - shown.length
295 return (
296 <Box flexDirection="column">
297 {hiddenCount > 0 && <Text dimColor>{` · 前面還有 ${hiddenCount} 個動作`}</Text>}
298 {shown.flatMap(toolLines).map(line => (
299 <Text color={line.color} dimColor={line.color === undefined} wrap="truncate-end">
300 {line.text}
301 </Text>
302 ))}
303 </Box>
304 )
305 })
306
307 // 計畫顯示時畫合併框:where-am-i 的 Goal/Now/Wait 加上計畫清單(Next 跟計畫重複所以不放)
308 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
309 const rest = await next(e) // 其他 mod 與 Claude Code 自己畫的內容保留在下面
310 const mode = await read($, combinedBox)
311 if (e.props.hasSurvey || !isCombinedBoxShown(mode, e.props.isWorking)) return rest
312
313 const { Box, Text } = $.ui.resolve(e)
314 const innerColumns = e.props.bodyColumns - BOX_CHROME_COLUMNS
315 const [summary, clock, steps, frame] = await Promise.all([readRecapSummary($), read($, turnClock), read($, plan), read($, tick)])
316 const title = titleRow(summary?.goal ?? '', steps, await elapsedLabel($, clock), innerColumns)
317 const view = planView(steps, {
318 frame,
319 isWorking: e.props.isWorking,
320 columns: innerColumns,
321 rows: e.props.maxRows - BOX_BORDER_ROWS - rowsAbovePlan(title, summary),
322 })
323 return (
324 <Box flexDirection="column">
325 <Box flexDirection="column" paddingX={1} borderStyle="round" borderColor="suggestion">
326 {/* 標題列(照影片):左邊是目標,右邊是這個回合經過的時間 */}
327 {title && (
328 <Box flexDirection="row" justifyContent="space-between">
329 <Text bold color={TITLE_COLOR} wrap="truncate-end">
330 {title.text}
331 </Text>
332 {title.elapsed !== null && <Text dimColor>{title.elapsed}</Text>}
333 </Box>
334 )}
335 {summary?.now && (
336 <Text wrap="truncate-end">
337 <Text dimColor>{' Now: '}</Text>
338 <Text>{summary.now}</Text>
339 </Text>
340 )}
341 {/* 整體進度列:進度條跟各步驟的進度條從同一欄開始,一路延伸到框的右邊 */}
342 <Box flexDirection="row">
343 <Box width={view.titleColumns}>
344 <Text dimColor wrap="truncate-end">{` ${view.overall.label}`}</Text>
345 </Box>
346 <Box width={view.overall.cells.length + 1}>
347 <Text>
348 {colorRuns(view.overall.cells).map(run => (
349 <Text color={run.color}>{BAR_CELL.repeat(run.count)}</Text>
350 ))}
351 </Text>
352 </Box>
353 <Text bold color={view.overall.isFinished ? 'success' : WORKING_COLOR}>
354 {view.overall.percent}
355 </Text>
356 </Box>
357 {/* 標籤跟 Now/Wait 一樣縮排;完成幾步看上面的 Step x of y,這裡不重複 */}
358 <Text dimColor>{' Plan:'}</Text>
359 {view.rows.map(row => (
360 <Box flexDirection="row">
361 <Box width={view.titleColumns}>
362 <Text wrap="truncate-end">
363 <Text color={row.style.markColor} dimColor={row.style.isMarkDim}>{` ${row.style.mark} `}</Text>
364 <Text bold={row.style.isTextBold} dimColor={row.style.isTextDim}>
365 {row.text}
366 </Text>
367 </Text>
368 </Box>
369 <Box width={BAR_COLUMNS}>
370 <Text>
371 {colorRuns(row.cells).map(run => (
372 <Text color={run.color}>{BAR_CELL.repeat(run.count)}</Text>
373 ))}
374 </Text>
375 </Box>
376 {view.isStatusShown && (
377 <Text bold={row.style.isLabelBold} color={row.style.labelColor} dimColor={row.style.labelColor === undefined}>
378 {row.style.label}
379 </Text>
380 )}
381 </Box>
382 ))}
383 {summary?.waiting && <Text color="yellow" wrap="truncate-end">{` Wait: ${summary.waiting}`}</Text>}
384 </Box>
385 {rest}
386 </Box>
387 )
388 })
389}
390
391// 已經附過(例如上層子代理把整段任務轉交下去)就不再重複
392function withSubagentLanguageNote(prompt: string) {
393 if (prompt.includes(SUBAGENT_LANGUAGE_NOTE)) return prompt
394 return `${prompt.trimEnd()}\n\n${SUBAGENT_LANGUAGE_NOTE}`
395}
396
397export function combinedBoxMode(enabled: boolean, steps: readonly PlanStep[]): CombinedBoxMode {
398 if (!enabled || steps.length === 0) return 'hidden'
399 return isPlanFinished(steps) ? 'whileWorking' : 'always'
400}
401
402// where-am-i/hooks/register.tsx 有同一行判斷(兩個 mod 不能共用程式碼),改規則要兩邊一起改
403export function isCombinedBoxShown(mode: CombinedBoxMode, isWorking: boolean) {
404 return mode === 'always' || (mode === 'whileWorking' && isWorking)
405}
406
407// 計畫區塊上方、框線以外的列數:標題列、Now、Wait;跟 JSX 裡畫哪些列要一致
408function rowsAbovePlan(title: TitleRow | null, summary: RecapSummary | null) {
409 return (title ? 1 : 0) + (summary?.now ? 1 : 0) + (summary?.waiting ? 1 : 0)
410}
411
412// 回合進行中算到現在(只有這時才需要讀時鐘),結束了就停在結束那一刻;還沒有回合就不顯示
413async function elapsedLabel($: EngineInterface, clock: TurnClock | null) {
414 if (!clock) return null
415 const end = clock.endedAt ?? (await $.clock.now())
416 return formatElapsed(end - clock.startedAt)
417}
418
419type TitleRow = { text: string; elapsed: string | null }
420
421// 標題列:左邊「✳ 目標」,where-am-i 還沒有摘要時改用目前步驟的標題;右邊是經過時間。兩邊都沒有就不畫
422export function titleRow(goal: string, steps: readonly PlanStep[], elapsed: string | null, innerColumns: number): TitleRow | null {
423 const subject = goal || (steps[currentStepIndex(steps)]?.title ?? '')
424 const fitted = fitToWidth(subject, innerColumns - displayWidth(TITLE_MARK) - ELAPSED_COLUMNS)
425 const text = fitted ? TITLE_MARK + fitted : ''
426 if (!text && elapsed === null) return null
427 return { text, elapsed }
428}
429
430export function formatElapsed(ms: number): string {
431 const totalSeconds = Math.max(0, Math.floor(ms / 1000))
432 const hours = Math.floor(totalSeconds / 3600)
433 const minutes = Math.floor((totalSeconds % 3600) / 60)
434 const seconds = totalSeconds % 60
435 if (hours > 0) return `${hours}h ${String(minutes).padStart(2, '0')}m`
436 if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, '0')}s`
437 return `${seconds}s`
438}
439
440// 超過寬度時依顯示寬度截斷,補「…」;中文一個字算兩格
441export function fitToWidth(text: string, maxColumns: number): string {
442 if (maxColumns <= 0) return ''
443 if (displayWidth(text) <= maxColumns) return text
444 let kept = ''
445 let width = 0
446 for (const char of text) {
447 if (width + charWidth(char) > maxColumns - 1) break
448 kept += char
449 width += charWidth(char)
450 }
451 return `${kept}…`
452}
453
454type PlanViewOptions = { frame: number; isWorking: boolean; columns: number; rows: number }
455
456// 合併框裡計畫區塊要畫的一切:進度字樣、欄寬、要不要顯示狀態字,以及每一列的樣式與方格顏色
457export function planView(steps: readonly PlanStep[], options: PlanViewOptions) {
458 const stepRows = Math.min(MAX_STEP_ROWS, Math.max(MIN_STEP_ROWS, options.rows - PLAN_HEADER_ROWS))
459 const rows = planRows(steps, stepRows, options.isWorking)
460 const layout = planLayout(rows, options.columns)
461 // 標籤欄也要放得下整體進度列的「 Step x of y」,不然標籤會換行、多出一列
462 const titleColumns = Math.max(layout.titleColumns, displayWidth(` ${overallLabel(steps)}`) + 1)
463 return {
464 ...layout,
465 titleColumns,
466 overall: overallProgress(steps, {
467 barWidth: Math.max(MIN_OVERALL_BAR_WIDTH, options.columns - titleColumns - PERCENT_COLUMNS),
468 frame: options.frame,
469 isAnimated: options.isWorking, // 閒置時計時器已停,亮光停在半路很怪,乾脆不畫
470 }),
471 rows: rows.map(row => ({ ...row, style: ROW_STYLE[row.display], cells: stepBar(row.display, options.frame) })),
472 }
473}
474
475// 影片的整體進度列:Step x of y、一條長進度條、百分比。x 是進行中的那一步,沒有進行中時是第一個還沒完成的步驟。
476// 百分比只算已完成的步驟(步驟內部沒有進度可量);全部完成時整條變綠
477type OverallBarOptions = { barWidth: number; frame: number; isAnimated: boolean }
478
479export function overallProgress(steps: readonly PlanStep[], options: OverallBarOptions) {
480 const { barWidth } = options
481 const completedCount = countCompleted(steps)
482 const ratio = steps.length === 0 ? 0 : completedCount / steps.length
483 const filledCells = Math.round(ratio * barWidth)
484 const isFinished = isPlanFinished(steps)
485 const positions = Array.from({ length: barWidth }, (_, index) => index)
486 if (isFinished) {
487 const cells = positions.map(index => gradientAt(DONE_GRADIENT, index, barWidth))
488 return { label: overallLabel(steps), percent: '100%', cells, isFinished }
489 }
490 // 漸層只鋪在已填滿的那段,左邊橘、填滿處的尾端粉紅,跟影片一樣;再疊上掃過的亮光
491 const isShimmer = options.isAnimated ? shimmerBand(options.frame, filledCells) : () => false
492 const cells = positions.map(index => {
493 if (index >= filledCells) return TRACK_COLOR
494 const color = gradientAt(OVERALL_FILL_GRADIENT, index, filledCells)
495 return isShimmer(index) ? lighten(color, OVERALL_SHIMMER_LIGHTEN) : color
496 })
497 return { label: overallLabel(steps), percent: `${Math.round(ratio * 100)}%`, cells, isFinished }
498}
499
500export function overallLabel(steps: readonly PlanStep[]) {
501 const current = currentStepIndex(steps)
502 return `Step ${(current >= 0 ? current : steps.length - 1) + 1} of ${steps.length}`
503}
504
505// 目前的步驟:進行中的那一步,沒有的話是第一個還沒完成的步驟;全部完成時是 -1
506function currentStepIndex(steps: readonly PlanStep[]) {
507 const runningIndex = steps.findIndex(step => step.status === 'in_progress')
508 return runningIndex >= 0 ? runningIndex : steps.findIndex(step => step.status !== 'completed')
509}
510
511// 連續同色的格子合併成一段,少畫很多元素(整體長條在寬螢幕上可能有一百多格)
512export function colorRuns(cells: readonly string[]): { color: string; count: number }[] {
513 const runs: { color: string; count: number }[] = []
514 for (const color of cells) {
515 const last = runs[runs.length - 1]
516 if (last?.color === color) last.count += 1
517 else runs.push({ color, count: 1 })
518 }
519 return runs
520}
521
522function shimmerBand(frame: number, filledCells: number) {
523 const head = frame % (filledCells + OVERALL_SHIMMER_PAUSE_CELLS)
524 return (index: number) => index <= head && index > head - OVERALL_SHIMMER_WIDTH
525}
526
527// band 的高度有限,還要跟其他 mod 共用:超過列數時先把已完成的收成一列,還放不下就把後段收成「還有 N 項」
528export function planRows(steps: readonly PlanStep[], maxRows: number, isWorking: boolean): PlanRow[] {
529 const rows = steps.map(step => ({ text: step.title, display: displayOf(step.status, isWorking) }))
530 if (rows.length <= maxRows) return rows
531
532 const completedCount = countCompleted(steps)
533 const folded: PlanRow[] = completedCount > 0 ? [{ text: `已完成 ${completedCount} 項`, display: 'completed' }] : []
534 const compact = [...folded, ...rows.filter(row => row.display !== 'completed')]
535 if (compact.length <= maxRows) return compact
536
537 const kept = compact.slice(0, maxRows - 1)
538 return [...kept, { text: `還有 ${compact.length - kept.length} 項`, display: 'pending' }]
539}
540
541function displayOf(status: PlanStatus, isWorking: boolean): StepDisplay {
542 if (status === 'in_progress') return isWorking ? 'running' : 'paused'
543 return status
544}
545
546// 標題欄寬取最長的標題,讓進度條對齊成一欄;太窄時先拿掉狀態字,再縮標題,進度條維持原寬
547export function planLayout(rows: readonly PlanRow[], columns: number) {
548 const longest = Math.max(0, ...rows.map(row => displayWidth(row.text)))
549 const wantedTitleColumns = MARK_COLUMNS + longest + 2
550 const roomWithStatus = columns - BAR_COLUMNS - STATUS_COLUMNS
551 if (roomWithStatus >= MIN_TITLE_COLUMNS) {
552 return { titleColumns: Math.min(wantedTitleColumns, roomWithStatus), isStatusShown: true }
553 }
554 const roomWithoutStatus = columns - BAR_COLUMNS
555 return { titleColumns: Math.max(MIN_TITLE_COLUMNS, Math.min(wantedTitleColumns, roomWithoutStatus)), isStatusShown: false }
556}
557
558// 每一格的顏色,由左到右:完成是綠漸層,未開始只有灰色底;進行中是一顆彗星從左往右走,
559// 尾巴也離開右邊後再從左邊進來,frame 每拍加一。暫停時停在左邊、整顆看得見的位置並改成淺灰,
560// 不沿用最後一拍的位置:那一拍可能剛好出界,看起來就跟未開始一樣
561const PAUSED_HEAD = COMET_FADE.length - 1
562
563export function stepBar(display: StepDisplay, frame: number): string[] {
564 const positions = Array.from({ length: BAR_WIDTH }, (_, index) => index)
565 if (display === 'completed') return positions.map(index => gradientAt(DONE_GRADIENT, index))
566 if (display === 'pending') return positions.map(() => TRACK_COLOR)
567 const head = display === 'paused' ? PAUSED_HEAD : frame % (BAR_WIDTH + COMET_FADE.length)
568 const headColor = display === 'running' ? cometColor(frame) : PAUSED_COLOR
569 return positions.map(index => {
570 const intensity = COMET_FADE[head - index]
571 return intensity === undefined ? TRACK_COLOR : mixColor(TRACK_COLOR, headColor, intensity)
572 })
573}
574
575// 彗星此刻的顏色:沿著 ACCENT_COLORS 來回走,一個來回是兩個半週期
576export function cometColor(frame: number): string {
577 const phase = frame % (COMET_COLOR_HALF_PERIOD_FRAMES * 2)
578 const progress = (phase <= COMET_COLOR_HALF_PERIOD_FRAMES ? phase : COMET_COLOR_HALF_PERIOD_FRAMES * 2 - phase) / COMET_COLOR_HALF_PERIOD_FRAMES
579 return paletteAt(ACCENT_COLORS, progress)
580}
581
582// 沿著一串顏色取色:progress 0 是第一個、1 是最後一個,中間在相鄰兩色之間內插
583function paletteAt(colors: readonly string[], progress: number) {
584 const position = progress * (colors.length - 1)
585 const lower = Math.min(Math.floor(position), colors.length - 2)
586 return mixColor(colors[lower]!, colors[lower + 1]!, position - lower)
587}
588
589function gradientAt(gradient: { from: string; to: string }, index: number, length = BAR_WIDTH) {
590 return mixColor(gradient.from, gradient.to, length > 1 ? index / (length - 1) : 0)
591}
592
593// 兩個顏色之間取比例:0 是 from、1 是 to
594function mixColor(from: string, to: string, ratio: number) {
595 const target = hexChannels(to)
596 const mixed = hexChannels(from).map((channel, i) => Math.round(channel + (target[i]! - channel) * ratio))
597 return `#${mixed.map(channel => channel.toString(16).padStart(2, '0')).join('')}`
598}
599
600function hexChannels(hex: string) {
601 return [1, 3, 5].map(start => parseInt(hex.slice(start, start + 2), 16))
602}
603
604function lighten(hex: string, ratio: number) {
605 return mixColor(hex, '#ffffff', ratio)
606}
607
608// 東亞寬字元、全形符號與 emoji 在終端機佔兩格;範圍用 \u 跳脫,避免存檔時字元被正規化成別的碼位
609const 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
610
611function charWidth(char: string) {
612 return WIDE_CHAR.test(char) ? 2 : 1
613}
614
615function displayWidth(text: string) {
616 let width = 0
617 for (const char of text) width += charWidth(char)
618 return width
619}
620
621type ToolCallView = { tool: string; input: unknown; isRunning: boolean; isErrored: boolean; isInterrupted: boolean; output?: unknown }
622type ToolLine = { text: string; color?: ThemeKey }
623
624// 一個工具呼叫在畫面上的所有列:一行摘要,出錯時下面接錯誤內容
625export function toolLines(call: ToolCallView): ToolLine[] {
626 const errors = errorLines(call)
627 const indented = errors.map((line, index) => ({ text: `${index === 0 ? ' ⎿ ' : ' '}${line}`, color: 'error' as const }))
628 return [toolLine(call), ...indented]
629}
630
631export function toolLine(call: ToolCallView): ToolLine {
632 const label = ` · ${describe(call.tool, call.input)}`
633 if (call.isInterrupted) return { text: `${label}(已中斷)`, color: 'warning' }
634 if (call.isErrored) return { text: `${label}(失敗)`, color: 'error' }
635 if (call.isRunning) return { text: `${label} …` }
636 return { text: label }
637}
638
639// 出錯時 output 是模型讀到的文字。中斷不算錯誤內容,摘要已標「(已中斷)」。
640// Bash 失敗是「Exit code N」後面接整段輸出,錯誤多半在最後,所以太長時留第一行與最後幾行
641export function errorLines(call: ToolCallView): string[] {
642 if (!call.isErrored || call.isInterrupted || typeof call.output !== 'string') return []
643 const text = call.output
644 .replace(/<\/?tool_use_error>/g, '')
645 .replace(/\x1b\[[0-9;]*m/g, '') // Python traceback 之類的輸出夾帶的 ANSI 色碼
646 const lines = text.split('\n').filter(line => line.trim() !== '')
647 if (lines.length <= MAX_ERROR_LINES) return lines
648 const tail = lines.slice(-(MAX_ERROR_LINES - 2))
649 return [lines[0]!, `…(省略 ${lines.length - 1 - tail.length} 行)`, ...tail]
650}
651
652// 一句話說出 Claude 在做什麼,取代原本的工具名稱與參數;where-am-i 的 describe 用同一套用字
653export function describe(tool: string, input: unknown): string {
654 const args = (typeof input === 'object' && input !== null ? input : {}) as Record<string, unknown>
655 const text = (key: string) => (typeof args[key] === 'string' ? (args[key] as string) : '')
656 const file = (path: string) => path.split('/').slice(-2).join('/')
657 if (tool === 'Bash') return `執行:${text('description') || text('command').slice(0, 80)}`
658 if (tool === 'Read') return `讀取 ${file(text('file_path'))}`
659 if (tool === 'Write') return `寫入 ${file(text('file_path'))}`
660 if (tool === 'Edit') return `編輯 ${file(text('file_path'))}`
661 if (tool === 'NotebookEdit') return `編輯 ${file(text('notebook_path'))}`
662 if (tool === 'Grep' || tool === 'Glob') return `搜尋 "${text('pattern').slice(0, 40)}"`
663 if (tool === 'WebSearch') return `搜尋網路「${text('query').slice(0, 40)}」`
664 if (tool === 'WebFetch') return `讀取網頁 ${hostOf(text('url'))}`
665 if (tool === 'Agent') return `委派 agent:${text('description')}`
666 if (tool === 'Skill') return `使用 skill:${text('skill')}`
667 if (tool === 'AskUserQuestion') return '詢問你問題'
668 if (tool === 'ToolSearch') return '載入工具'
669 if (tool.startsWith('mcp__')) return `使用 ${tool.split('__').slice(1).join(' ')}`
670 return `使用 ${tool}`
671}
672
673function hostOf(url: string) {
674 const match = /^[a-z]+:\/\/([^/?#]+)/i.exec(url)
675 return match?.[1] ?? url.slice(0, 40)
676}
677
678const PLAN_STATUSES: readonly string[] = ['pending', 'in_progress', 'completed'] satisfies PlanStatus[]
679
680export function parseSteps(value: unknown): PlanStep[] | null {
681 if (!Array.isArray(value)) return null
682 const steps = value.flatMap(item => (isPlanStep(item) ? [{ title: item.title.trim(), status: item.status }] : []))
683 return steps.length === value.length ? steps : null
684}
685
686function isPlanStep(item: unknown): item is PlanStep {
687 if (typeof item !== 'object' || item === null) return false
688 const { title, status } = item as Record<string, unknown>
689 return typeof title === 'string' && typeof status === 'string' && PLAN_STATUSES.includes(status)
690}
691
692function countCompleted(steps: readonly PlanStep[]) {
693 return steps.filter(step => step.status === 'completed').length
694}
695
696function isPlanFinished(steps: readonly PlanStep[]) {
697 return steps.length > 0 && countCompleted(steps) === steps.length
698}
699types/index.d.ts 25 lines1export type PlanStatus = 'pending' | 'in_progress' | 'completed'
2
3export type PlanStep = { title: string; status: PlanStatus }
4
5// 合併框何時顯示:hidden 不顯示、whileWorking 只在回合進行中(計畫已做完)、always 一直顯示(計畫還沒做完)
6export type CombinedBoxMode = 'hidden' | 'whileWorking' | 'always'
7
8// 主迴圈目前(或最近一次)回合的起訖時間;endedAt 是 null 表示回合還在跑
9export type TurnClock = { startedAt: number; endedAt: number | null }
10
11declare module 'claude-code' {
12 interface PluginState {
13 // where-am-i/types/index.d.ts 也宣告了同樣的形狀,改這裡要一起改;../scripts/check-contracts.sh 會抓出不一致
14 'clean-view': {
15 isEnabled: boolean
16 plan: PlanStep[]
17 tick: number
18 turnClock: TurnClock | null
19 combinedBox: CombinedBoxMode
20 }
21 // where-am-i 擁有,這裡只讀來放進合併框;形狀要跟它自己的合約一致
22 'where-am-i': { recap: { goal: string; now: string; waiting: string; next: string } | null; live: string }
23 }
24}
25