派出子代理時跳出面板,顯示每個子代理的模型、effort、工具次數、正在做什麼、ctx、token、估算費用與時間;/agents 開關

個人使用的 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 285 lines1// Agent Panel:派出子代理時跳出面板,顯示這一回合每個子代理的模型、用量、估算費用與時間;/agents 開關
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register, Timer } from 'claude-code'
4
5import type { AgentRow, Batch, TokenUsage } from '../types'
6import { addAgent, batchTotals, finishAgent, hasRunning, recordReported, recordStep, recordToolCall, seedRunning } from './batch'
7import { agentCard, colorRuns, describeTool, formatCost, formatElapsed, formatTokens, splitSections, summaryMode } from './layout'
8
9const PANE_ID = 'agent-panel'
10const PANE_TITLE = 'Agents'
11// 停靠在右邊時要求的寬度;放在輸入框上方時 Claude Code 會忽略它
12const PANE_COLUMNS = 42
13const NARROW_HINT = '子代理面板放不下:打 /agents 開啟'
14const EMPTY_HINT = '這個 session 還沒有派出子代理'
15// 彗星移動與時間跳動:每 0.2 秒一拍
16const ANIMATION_MS = 200
17const BAR_CELL = '▆'
18const SEPARATOR = '─'
19
20// 狀態由 host 保存,熱重載後仍在
21const batchAtom = atom({ plugin: 'agent-panel', key: 'batch' } as const, null as Batch | null)
22// 動畫計數器,只有面板讀,所以動畫只會讓面板重畫;不用 $.ui.invalidate,那會讓整個對話紀錄重跑
23const tickAtom = atom({ plugin: 'agent-panel', key: 'tick' } as const, 0)
24
25// 下面三個是模組自己的變數,熱重載後重來
26// 目前主回合的 turnId;歸零時沿用現有批次
27let currentTurnId: string | null = null
28// 窗格放不下的提示每個 session 最多一次
29let hasWarnedNarrow = false
30let timer: Timer | undefined
31
32type SpawnFacts = { id: string; description: string; isNested: boolean }
33type FinishReason = 'answer' | 'aborted' | 'error' | 'refusal'
34
35// mod 自己開的窗格要終端機夠寬才放得下(使用者親手開過後門檻較低);放不下時窗格在背景等,提示一次怎麼開
36export async function openPanel($: EngineInterface): Promise<void> {
37 const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE, columns: PANE_COLUMNS })
38 if (!opened.isPlaced && !hasWarnedNarrow) {
39 hasWarnedNarrow = true
40 $.ui.toast(NARROW_HINT)
41 }
42 await syncTimer($)
43}
44
45// 由 mod 關閉,不會清掉「使用者親手開過」的紀錄(使用者按 ✕ 才會)
46async function closePanel($: EngineInterface) {
47 await $.ui.close({ id: PANE_ID }).catch(() => {})
48 await syncTimer($)
49}
50
51// 只在面板已放上畫面、而且有子代理在跑時計時;全部完成或面板關掉就停,不在背景空轉
52export async function syncTimer($: EngineInterface): Promise<void> {
53 try {
54 const [batch, panes] = await Promise.all([read($, batchAtom), $.ui.panes()])
55 const isShown = panes.some(pane => pane.id === PANE_ID && pane.isPlaced)
56 if (!isShown || !hasRunning(batch)) {
57 stopTimer()
58 return
59 }
60 if (timer === undefined) timer = $.clock.every(ANIMATION_MS, () => void update($, tickAtom, frame => frame + 1).catch(() => {}))
61 } catch {
62 // 略過:下一次寫入時再同步
63 }
64}
65
66function stopTimer() {
67 timer?.cancel()
68 timer = undefined
69}
70
71// 只有使用者本人送出的一般訊息才關面板:/ 開頭的指令、背景任務的通知、其他 session 或外掛送的都不算
72function isOwnMessage(originKind: string, text: string) {
73 return (originKind === 'composer' || originKind === 'bridge') && !text.trimStart().startsWith('/')
74}
75
76// 下面的記錄都只觀察:出錯就略過這次,不影響子代理本身
77async function recordSpawn($: EngineInterface, spawn: SpawnFacts) {
78 try {
79 const startedAt = await $.clock.now()
80 // 開了新的一批才跳出面板;同一批再派不重開,使用者手動關掉後也不會一直彈回來。
81 // 在更新函式裡判斷:平行派出時 update 會在版本衝突後重試,最後一次看到的才是真正寫入當下的批次
82 let isNewBatch = false
83 await update($, batchAtom, batch => {
84 const next = addAgent(batch, currentTurnId, { ...spawn, startedAt })
85 isNewBatch = batch === null || batch.turnId !== next.turnId
86 return next
87 })
88 if (isNewBatch) await openPanel($)
89 else await syncTimer($)
90 } catch {
91 // 略過
92 }
93}
94
95async function recordStepUsage($: EngineInterface, agentId: string, step: { model: string; effort?: string | number; usage: TokenUsage | null }) {
96 try {
97 await update($, batchAtom, batch => (batch === null ? batch : recordStep(batch, agentId, step)))
98 } catch {
99 // 略過
100 }
101}
102
103async function recordTool($: EngineInterface, agentId: string, activity: string) {
104 try {
105 await update($, batchAtom, batch => (batch === null ? batch : recordToolCall(batch, agentId, activity)))
106 } catch {
107 // 略過
108 }
109}
110
111// 前景子代理完成時,主迴圈 Agent 工具的結果帶有 Claude Code 自己算的總計
112async function recordAgentResult($: EngineInterface, record: unknown) {
113 if (typeof record !== 'object' || record === null) return
114 const reported = record as { agentId?: unknown; totalTokens?: unknown; totalToolUseCount?: unknown }
115 if (typeof reported.agentId !== 'string') return
116 const agentId = reported.agentId
117 try {
118 await update($, batchAtom, batch => (batch === null ? batch : recordReported(batch, agentId, reported)))
119 } catch {
120 // 略過
121 }
122}
123
124async function recordFinish($: EngineInterface, agentId: string, reason: FinishReason) {
125 try {
126 const endedAt = await $.clock.now()
127 await update($, batchAtom, batch => (batch === null ? batch : finishAgent(batch, agentId, reason, endedAt)))
128 await syncTimer($)
129 } catch {
130 // 略過
131 }
132}
133
134// 熱重載或面板開啟前就在跑的子代理:從 $.agent.list() 補上
135async function seedFromAgentList($: EngineInterface) {
136 try {
137 const listed = await $.agent.list()
138 const now = await $.clock.now()
139 await update($, batchAtom, batch => seedRunning(batch, listed, now))
140 } catch {
141 // 略過
142 }
143}
144
145export const register: Register = on => {
146 on('session.start', async ($, e, next) => {
147 const started = await next(e)
148 // 名稱已被佔用時會被拒絕,不影響其他功能
149 await $.command.register({ name: 'agents', description: '開關子代理面板' }).catch(() => {})
150 await seedFromAgentList($)
151 await syncTimer($) // 熱重載時把動畫接回來
152 return started
153 })
154
155 on('session.end', async ($, e, next) => {
156 stopTimer()
157 if (e.reason === 'clear') await update($, batchAtom, () => null).catch(() => {})
158 return next(e)
159 })
160
161 // subagent 的執行不會發 turn.start,只有主迴圈會
162 on('turn.start', async ($, e, next) => {
163 currentTurnId = e.turnId
164 return next(e)
165 })
166
167 on('prompt.submit', async ($, e, next) => {
168 if (isOwnMessage(e.origin.kind, e.text)) await closePanel($)
169 return next(e)
170 })
171
172 on('command.run', { command: 'agents' }, async $ => {
173 const panes = await $.ui.panes().catch(() => [])
174 if (panes.some(pane => pane.id === PANE_ID && pane.isPlaced)) {
175 await closePanel($)
176 return { text: '已關閉子代理面板。' }
177 }
178 // 使用者打指令開的窗格任何寬度都放得下,也讓之後 mod 自己開時門檻降到 110 欄
179 await $.ui.open({ id: PANE_ID, title: PANE_TITLE, columns: PANE_COLUMNS })
180 await syncTimer($)
181 return { text: '已開啟子代理面板。' }
182 })
183
184 on('agent.spawn', async ($, e, next) => {
185 const result = await next(e)
186 // 被擋下、沒有本機執行迴圈(workflow 的遠端子代理)、在其他窗格跑的 teammate:追蹤不到,不列入
187 if (result.deny !== undefined || result.agentId === undefined || result.teammateId !== undefined) return result
188 await recordSpawn($, { id: result.agentId, description: e.description, isNested: e.parentAgentId !== undefined })
189 return result
190 })
191
192 on('turn.step', async function* ($, e, next) {
193 const result = yield* next(e)
194 if (e.agentId !== undefined) await recordStepUsage($, e.agentId, { model: e.model, effort: e.effort, usage: result.usage })
195 return result
196 })
197
198 on('tool.call', async ($, e, next) => {
199 if (e.agentId !== undefined) {
200 await recordTool($, e.agentId, describeTool(e.tool, e))
201 return next(e)
202 }
203 if (e.tool !== 'Agent') return next(e)
204 const result = await next(e)
205 await recordAgentResult($, (result as { result?: unknown }).result)
206 return result
207 })
208
209 on('turn.complete', async ($, e, next) => {
210 if (e.agentId !== undefined) await recordFinish($, e.agentId, e.reason)
211 return next(e)
212 })
213
214 // 面板:上方是這一批的總計,下面 Running、Finished 兩組卡片;版面規則都在 layout.ts
215 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
216 const { Box, Text } = $.ui.resolve(e)
217 const [batch, frame] = await Promise.all([read($, batchAtom), read($, tickAtom)])
218 if (batch === null) return <Text dimColor>{EMPTY_HINT}</Text>
219
220 const columns = e.props.bodyColumns
221 const now = await $.clock.now()
222 const totals = batchTotals(batch, now)
223 const cost = formatCost(totals.costUsd, totals.hasUnpriced)
224 const tokens = formatTokens(totals.tokens)
225 const time = formatElapsed(totals.elapsedMs)
226 const { running, finished } = splitSections(batch)
227 const lastId = [...running, ...finished].at(-1)?.id
228 const tileWidth = Math.floor(columns / 3)
229
230 const section = (title: string, agents: AgentRow[]) =>
231 agents.length > 0 && (
232 <Box flexDirection="column">
233 <Text dimColor>{`${title} · ${agents.length}`}</Text>
234 {agents.map(agent => {
235 const card = agentCard(agent, { columns, now, frame })
236 return (
237 <Box flexDirection="column">
238 {card.lines.map(line => (
239 <Text wrap="truncate-end">
240 {line.map(span => (
241 <Text color={span.color} dimColor={span.isDim} bold={span.isBold}>
242 {span.text}
243 </Text>
244 ))}
245 </Text>
246 ))}
247 <Text>
248 {' '}
249 {colorRuns(card.bar).map(run => (
250 <Text color={run.color}>{BAR_CELL.repeat(run.count)}</Text>
251 ))}
252 </Text>
253 {agent.id !== lastId && <Text dimColor>{SEPARATOR.repeat(columns)}</Text>}
254 </Box>
255 )
256 })}
257 </Box>
258 )
259
260 return (
261 <Box flexDirection="column">
262 <Text bold>{PANE_TITLE}</Text>
263 {summaryMode(columns) === 'tiles' ? (
264 <Box flexDirection="row">
265 {[
266 ['Cost', cost],
267 ['Tokens', tokens],
268 ['Time', time],
269 ].map(([label, value]) => (
270 <Box flexDirection="column" borderStyle="round" width={tileWidth} paddingX={1}>
271 <Text dimColor>{label}</Text>
272 <Text bold>{value}</Text>
273 </Box>
274 ))}
275 </Box>
276 ) : (
277 <Text>{`${cost} · ${tokens} · ${time}`}</Text>
278 )}
279 {section('Running', running)}
280 {section('Finished', finished)}
281 </Box>
282 )
283 })
284}
285hooks/batch.ts 129 lines1// 這一回合派出的子代理:開新一批、記錄每次請求的用量、工具呼叫、完成與失敗。
2// 全部是純函式,回傳新物件、不改傳入的 batch;找不到子代理時原樣回傳同一個物件
3import type { AgentRow, Batch, TokenUsage } from '../types'
4import { requestCostUsd, totalTokens } from './pricing'
5
6type SpawnFacts = { id: string; description: string; isNested: boolean; startedAt: number }
7type FinishReason = 'answer' | 'aborted' | 'error' | 'refusal'
8type ListedAgent = { id: string; description: string; status: string; parentId?: string }
9
10const FAILURE_REASONS: Record<Exclude<FinishReason, 'answer'>, string> = {
11 aborted: '已中斷',
12 error: 'API 錯誤',
13 refusal: '模型拒絕',
14}
15
16function newRow(spawn: SpawnFacts): AgentRow {
17 return {
18 ...spawn,
19 status: 'running',
20 endedAt: null,
21 failureReason: null,
22 model: null,
23 effort: null,
24 toolCount: 0,
25 activity: '',
26 lastUsage: null,
27 reportedTokens: null,
28 costUsd: 0,
29 hasUnpricedUsage: false,
30 }
31}
32
33// 新回合開新一批,帶過還在跑的(背景子代理可能跨回合);turnId 不明(剛熱重載,模組變數歸零)時沿用現有批次
34function batchForTurn(batch: Batch | null, currentTurnId: string | null): Batch {
35 if (batch === null) return { turnId: currentTurnId ?? 'unknown', agents: [] }
36 if (currentTurnId === null || batch.turnId === currentTurnId) return batch
37 return { turnId: currentTurnId, agents: batch.agents.filter(agent => agent.status === 'running') }
38}
39
40function updateAgent(batch: Batch, agentId: string, change: (row: AgentRow) => AgentRow): Batch {
41 const index = batch.agents.findIndex(agent => agent.id === agentId)
42 if (index < 0) return batch
43 const agents = batch.agents.slice()
44 agents[index] = change(agents[index]!)
45 return { ...batch, agents }
46}
47
48const finiteNumber = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : null)
49
50export function addAgent(batch: Batch | null, currentTurnId: string | null, spawn: SpawnFacts): Batch {
51 const current = batchForTurn(batch, currentTurnId)
52 if (current.agents.some(agent => agent.id === spawn.id)) return current
53 return { ...current, agents: [...current.agents, newRow(spawn)] }
54}
55
56export function recordStep(
57 batch: Batch,
58 agentId: string,
59 step: { model: string; effort?: string | number; usage: TokenUsage | null },
60): Batch {
61 return updateAgent(batch, agentId, row => {
62 const withModel = { ...row, model: step.model, effort: step.effort ?? row.effort }
63 // 請求失敗或中斷時沒有 usage:不累加,也不當成 0
64 if (step.usage === null) return withModel
65 const cost = requestCostUsd(step.model, step.usage)
66 return {
67 ...withModel,
68 lastUsage: step.usage,
69 costUsd: row.costUsd + (cost ?? 0),
70 hasUnpricedUsage: row.hasUnpricedUsage || cost === null,
71 }
72 })
73}
74
75export function recordToolCall(batch: Batch, agentId: string, activity: string): Batch {
76 return updateAgent(batch, agentId, row => ({ ...row, toolCount: row.toolCount + 1, activity }))
77}
78
79// 前景子代理完成時 Agent 工具結果帶的總計;背景子代理的結果沒有這兩個欄位,維持即時累計
80export function recordReported(batch: Batch, agentId: string, reported: { totalTokens?: unknown; totalToolUseCount?: unknown }): Batch {
81 const tokens = finiteNumber(reported.totalTokens)
82 const toolUses = finiteNumber(reported.totalToolUseCount)
83 if (tokens === null && toolUses === null) return batch
84 return updateAgent(batch, agentId, row => ({
85 ...row,
86 reportedTokens: tokens ?? row.reportedTokens,
87 toolCount: toolUses ?? row.toolCount,
88 }))
89}
90
91export function finishAgent(batch: Batch, agentId: string, reason: FinishReason, endedAt: number): Batch {
92 return updateAgent(batch, agentId, row =>
93 reason === 'answer'
94 ? { ...row, status: 'done', endedAt, failureReason: null }
95 : { ...row, status: 'failed', endedAt, failureReason: FAILURE_REASONS[reason] },
96 )
97}
98
99// 熱重載或面板開啟前就在跑的子代理:用 $.agent.list() 補上,用量從之後的請求開始累計
100export function seedRunning(batch: Batch | null, listed: readonly ListedAgent[], now: number): Batch | null {
101 if (batch === null) return null
102 const known = new Set(batch.agents.map(agent => agent.id))
103 const added = listed
104 .filter(agent => agent.status === 'running' && !known.has(agent.id))
105 .map(agent => newRow({ id: agent.id, description: agent.description, isNested: agent.parentId !== undefined, startedAt: now }))
106 return added.length === 0 ? batch : { ...batch, agents: [...batch.agents, ...added] }
107}
108
109export function hasRunning(batch: Batch | null): boolean {
110 return batch !== null && batch.agents.some(agent => agent.status === 'running')
111}
112
113export function agentTokens(row: AgentRow): number {
114 if (row.reportedTokens !== null) return row.reportedTokens
115 return row.lastUsage === null ? 0 : totalTokens(row.lastUsage)
116}
117
118// 時間從最早派出到最晚結束;還有在跑就算到現在
119export function batchTotals(batch: Batch, now: number): { costUsd: number; hasUnpriced: boolean; tokens: number; elapsedMs: number } {
120 const { agents } = batch
121 const costUsd = agents.reduce((sum, agent) => sum + agent.costUsd, 0)
122 const hasUnpriced = agents.some(agent => agent.hasUnpricedUsage)
123 const tokens = agents.reduce((sum, agent) => sum + agentTokens(agent), 0)
124 if (agents.length === 0) return { costUsd, hasUnpriced, tokens, elapsedMs: 0 }
125 const startedAt = Math.min(...agents.map(agent => agent.startedAt))
126 const endedAt = hasRunning(batch) ? now : Math.max(...agents.map(agent => agent.endedAt ?? now))
127 return { costUsd, hasUnpriced, tokens, elapsedMs: Math.max(0, endedAt - startedAt) }
128}
129hooks/layout.ts 225 lines1// 面板的版面:把一個子代理變成每一列的文字、顏色與進度條格子,以及時間、token、費用的格式。
2// displayWidth、fitToWidth、colorRuns、cometColor、describeTool 照搬自 clean-view/hooks/register.tsx
3// (mod 之間不能共用程式碼),改那邊的用字或配色時這裡要一起改
4import type { AgentRow, Batch } from '../types'
5import { agentTokens } from './batch'
6import { contextPercent, modelInfo } from './pricing'
7import type { ModelFamily } from './pricing'
8
9export type Span = { text: string; color?: string; isDim?: boolean; isBold?: boolean }
10
11// lines 是進度條以外的每一列;bar 是進度條每一格的顏色
12export type Card = { lines: Span[][]; bar: string[] }
13
14const ORANGE = '#f79a4f'
15const PINK = '#ec4f8f'
16const PURPLE = '#b45ce6'
17const BLUE = '#6f7df2'
18const GREEN = '#46b06e'
19const GRAY = '#8a8a94'
20const TRACK_COLOR = '#4a4a52'
21const ACCENT_COLORS = [ORANGE, PINK, PURPLE, BLUE]
22const FAMILY_COLORS: Record<ModelFamily, string> = { opus: ORANGE, sonnet: BLUE, haiku: GREEN, fable: PURPLE, unknown: GRAY }
23
24const INDENT = ' '
25// 彗星:頭最亮,後面四格一格比一格淡,融進底色
26const COMET_FADE = [1, 0.7, 0.45, 0.25, 0.1]
27const COMET_COLOR_HALF_PERIOD_FRAMES = 15 // 從橘變到藍約 3 秒(每拍 0.2 秒)
28// 面板內寬窄於這個欄數時,三格統計放不下,改成一行
29const TILES_MIN_COLUMNS = 36
30
31export function formatElapsed(ms: number): string {
32 const totalSeconds = Math.max(0, Math.floor(ms / 1000))
33 const hours = Math.floor(totalSeconds / 3600)
34 const minutes = Math.floor((totalSeconds % 3600) / 60)
35 const seconds = String(totalSeconds % 60).padStart(2, '0')
36 return hours > 0 ? `${hours}:${String(minutes).padStart(2, '0')}:${seconds}` : `${minutes}:${seconds}`
37}
38
39export function formatTokens(count: number): string {
40 if (count < 1000) return String(Math.round(count))
41 const thousands = Math.round(count / 1000)
42 if (thousands < 1000) return `${thousands}k`
43 return `${(count / 1_000_000).toFixed(1)}M`
44}
45
46// 費用一律是估算,標「≈」;有請求的模型不在價目表時補「?」
47export function formatCost(costUsd: number, hasUnpriced: boolean): string {
48 if (hasUnpriced && costUsd === 0) return '≈?'
49 return `≈$${costUsd.toFixed(2)}${hasUnpriced ? '+?' : ''}`
50}
51
52export function summaryMode(columns: number): 'tiles' | 'line' {
53 return columns < TILES_MIN_COLUMNS ? 'line' : 'tiles'
54}
55
56// 失敗的也放在 Finished;各組維持派出順序
57export function splitSections(batch: Batch): { running: AgentRow[]; finished: AgentRow[] } {
58 return {
59 running: batch.agents.filter(agent => agent.status === 'running'),
60 finished: batch.agents.filter(agent => agent.status !== 'running'),
61 }
62}
63
64export function agentCard(row: AgentRow, options: { columns: number; now: number; frame: number }): Card {
65 const { columns, now, frame } = options
66 const barWidth = Math.max(0, columns - INDENT.length)
67 const lines = [titleLine(row, frame), modelLine(row)]
68 if (row.status === 'running') lines.push([{ text: INDENT }, { text: row.activity || '思考中', isDim: true }])
69 if (row.status === 'failed') lines.push([{ text: INDENT }, { text: row.failureReason ?? '', color: 'error' }])
70 lines.push(usageLine(row, now))
71 return { lines: lines.map(line => fitLine(line, columns)), bar: cardBar(row, barWidth, frame) }
72}
73
74function titleLine(row: AgentRow, frame: number): Span[] {
75 const isRunning = row.status === 'running'
76 const mark: Span =
77 row.status === 'running'
78 ? { text: '● ', color: cometColor(frame), isBold: true }
79 : row.status === 'done'
80 ? { text: '✓ ', color: 'success' }
81 : { text: '✗ ', color: 'error' }
82 return [mark, { text: `${row.isNested ? '↳ ' : ''}${row.description}`, isBold: isRunning }]
83}
84
85function modelLine(row: AgentRow): Span[] {
86 const model = row.model === null ? null : modelInfo(row.model)
87 const name: Span = model === null ? { text: 'starting', isDim: true } : { text: model.name, color: FAMILY_COLORS[model.family] }
88 const effort = row.effort === null ? '' : ` · ${row.effort}`
89 return [{ text: INDENT }, name, { text: `${effort} · ${row.toolCount} tools`, isDim: true }]
90}
91
92function usageLine(row: AgentRow, now: number): Span[] {
93 const percent = row.model !== null && row.lastUsage !== null ? contextPercent(row.model, row.lastUsage) : null
94 const parts = [
95 ...(percent === null ? [] : [`ctx ${percent}%`]),
96 formatTokens(agentTokens(row)),
97 formatCost(row.costUsd, row.hasUnpricedUsage),
98 formatElapsed((row.endedAt ?? now) - row.startedAt),
99 ]
100 return [{ text: INDENT }, { text: parts.join(' · '), isDim: true }]
101}
102
103function cardBar(row: AgentRow, width: number, frame: number): string[] {
104 const positions = Array.from({ length: width }, (_, index) => index)
105 if (row.status === 'failed') return positions.map(() => GRAY)
106 if (row.status === 'done') {
107 const family = row.model === null ? 'unknown' : modelInfo(row.model).family
108 return positions.map(() => FAMILY_COLORS[family])
109 }
110 // 執行中:一顆彗星從左往右走,尾巴也離開右邊後再從左邊進來,frame 每拍加一
111 const head = frame % (width + COMET_FADE.length)
112 const headColor = cometColor(frame)
113 return positions.map(index => {
114 const intensity = COMET_FADE[head - index]
115 return intensity === undefined ? TRACK_COLOR : mixColor(TRACK_COLOR, headColor, intensity)
116 })
117}
118
119// 一列超過寬度時從尾端截斷:名稱、正在做什麼可能很長,模型與用量列在很窄的面板也可能放不下
120function fitLine(spans: Span[], columns: number): Span[] {
121 const fitted: Span[] = []
122 let used = 0
123 for (const span of spans) {
124 const width = displayWidth(span.text)
125 if (used + width <= columns) {
126 fitted.push(span)
127 used += width
128 continue
129 }
130 const text = fitToWidth(span.text, columns - used)
131 if (text) fitted.push({ ...span, text })
132 break
133 }
134 return fitted
135}
136
137// 彗星此刻的顏色:沿著 ACCENT_COLORS 來回走,一個來回是兩個半週期
138export function cometColor(frame: number): string {
139 const phase = frame % (COMET_COLOR_HALF_PERIOD_FRAMES * 2)
140 const progress = (phase <= COMET_COLOR_HALF_PERIOD_FRAMES ? phase : COMET_COLOR_HALF_PERIOD_FRAMES * 2 - phase) / COMET_COLOR_HALF_PERIOD_FRAMES
141 return paletteAt(ACCENT_COLORS, progress)
142}
143
144// 沿著一串顏色取色:progress 0 是第一個、1 是最後一個,中間在相鄰兩色之間內插
145function paletteAt(colors: readonly string[], progress: number) {
146 const position = progress * (colors.length - 1)
147 const lower = Math.min(Math.floor(position), colors.length - 2)
148 return mixColor(colors[lower]!, colors[lower + 1]!, position - lower)
149}
150
151// 兩個顏色之間取比例:0 是 from、1 是 to
152function mixColor(from: string, to: string, ratio: number) {
153 const target = hexChannels(to)
154 const mixed = hexChannels(from).map((channel, i) => Math.round(channel + (target[i]! - channel) * ratio))
155 return `#${mixed.map(channel => channel.toString(16).padStart(2, '0')).join('')}`
156}
157
158function hexChannels(hex: string) {
159 return [1, 3, 5].map(start => parseInt(hex.slice(start, start + 2), 16))
160}
161
162// 連續同色的格子合併成一段,少畫很多元素
163export function colorRuns(cells: readonly string[]): { color: string; count: number }[] {
164 const runs: { color: string; count: number }[] = []
165 for (const color of cells) {
166 const last = runs[runs.length - 1]
167 if (last?.color === color) last.count += 1
168 else runs.push({ color, count: 1 })
169 }
170 return runs
171}
172
173// 東亞寬字元、全形符號與 emoji 在終端機佔兩格;範圍用 \u 跳脫,避免存檔時字元被正規化成別的碼位
174const 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
175
176function charWidth(char: string) {
177 return WIDE_CHAR.test(char) ? 2 : 1
178}
179
180export function displayWidth(text: string): number {
181 let width = 0
182 for (const char of text) width += charWidth(char)
183 return width
184}
185
186// 超過寬度時依顯示寬度截斷,補「…」;中文一個字算兩格
187export function fitToWidth(text: string, maxColumns: number): string {
188 if (maxColumns <= 0) return ''
189 if (displayWidth(text) <= maxColumns) return text
190 let kept = ''
191 let width = 0
192 for (const char of text) {
193 if (width + charWidth(char) > maxColumns - 1) break
194 kept += char
195 width += charWidth(char)
196 }
197 return `${kept}…`
198}
199
200// 一句話說出子代理在做什麼;用字跟 clean-view 的 describe 一致
201export function describeTool(tool: string, input: unknown): string {
202 const args = (typeof input === 'object' && input !== null ? input : {}) as Record<string, unknown>
203 const text = (key: string) => (typeof args[key] === 'string' ? (args[key] as string) : '')
204 const file = (path: string) => path.split('/').slice(-2).join('/')
205 if (tool === 'Bash') return `執行:${text('description') || text('command').slice(0, 80)}`
206 if (tool === 'Read') return `讀取 ${file(text('file_path'))}`
207 if (tool === 'Write') return `寫入 ${file(text('file_path'))}`
208 if (tool === 'Edit') return `編輯 ${file(text('file_path'))}`
209 if (tool === 'NotebookEdit') return `編輯 ${file(text('notebook_path'))}`
210 if (tool === 'Grep' || tool === 'Glob') return `搜尋 "${text('pattern').slice(0, 40)}"`
211 if (tool === 'WebSearch') return `搜尋網路「${text('query').slice(0, 40)}」`
212 if (tool === 'WebFetch') return `讀取網頁 ${hostOf(text('url'))}`
213 if (tool === 'Agent') return `委派 agent:${text('description')}`
214 if (tool === 'Skill') return `使用 skill:${text('skill')}`
215 if (tool === 'AskUserQuestion') return '詢問你問題'
216 if (tool === 'ToolSearch') return '載入工具'
217 if (tool.startsWith('mcp__')) return `使用 ${tool.split('__').slice(1).join(' ')}`
218 return `使用 ${tool}`
219}
220
221function hostOf(url: string) {
222 const match = /^[a-z]+:\/\/([^/?#]+)/i.exec(url)
223 return match?.[1] ?? url.slice(0, 40)
224}
225hooks/pricing.ts 94 lines1// 模型 id 轉顯示名稱、單價與 context 上限,估算一次請求的費用與 ctx 百分比
2import type { TokenUsage } from '../types'
3
4export type ModelFamily = 'opus' | 'sonnet' | 'haiku' | 'fable' | 'unknown'
5
6export type ModelInfo = { key: string | null; name: string; family: ModelFamily; contextWindow: number | null }
7
8// 每百萬 token 的美元單價
9type Rates = { input: number; output: number; cacheWrite: number; cacheRead: number }
10
11type PriceEntry = { contextWindow: number; ratesFor: (promptTokenCount: number) => Rates }
12
13const MILLION = 1_000_000
14
15// Haiku 5.5 依提示長度有兩種價位:提示超過 100,000 token 改用高價
16const HAIKU_5_5_SMALL_PROMPT_LIMIT = 100_000
17
18const flat = (input: number, output: number, cacheWrite: number, cacheRead: number, contextWindow = MILLION): PriceEntry => ({
19 contextWindow,
20 ratesFor: () => ({ input, output, cacheWrite, cacheRead }),
21})
22
23// 單價來源:claude-api skill,資料日期 2026-10-06。
24// usage 分不出 cache 存 5 分鐘還是 1 小時,cache 寫入一律用 5 分鐘的單價,所以畫面上的費用都標「≈」
25const PRICES: Record<string, PriceEntry> = {
26 'fable-5-1': flat(10, 50, 12.5, 0.25),
27 'mythos-5-1': flat(10, 50, 12.5, 0.25),
28 'fable-5': flat(10, 50, 12.5, 1),
29 'mythos-5': flat(10, 50, 12.5, 1),
30 'opus-5-5': flat(4, 20, 5, 0.2),
31 'opus-5': flat(5, 25, 6.25, 0.5),
32 'opus-4-8': flat(5, 25, 6.25, 0.5),
33 'opus-4-7': flat(5, 25, 6.25, 0.5),
34 'opus-4-6': flat(5, 25, 6.25, 0.5),
35 'sonnet-5-5': flat(2, 10, 2.5, 0.2),
36 'sonnet-5': flat(2, 10, 2.5, 0.2),
37 'sonnet-4-6': flat(3, 15, 3.75, 0.3),
38 'haiku-5-5': {
39 contextWindow: MILLION,
40 ratesFor: promptTokenCount =>
41 promptTokenCount > HAIKU_5_5_SMALL_PROMPT_LIMIT
42 ? { input: 0.5, output: 2.5, cacheWrite: 0.625, cacheRead: 0.05 }
43 : { input: 0.1, output: 0.5, cacheWrite: 0.125, cacheRead: 0.01 },
44 },
45 'haiku-4-5': flat(1, 5, 1.25, 0.1, 200_000),
46}
47
48// 系列、主版本、次版本(一到兩位數),後面可能接 8 位數的日期
49const MODEL_ID = /^claude-(opus|sonnet|haiku|fable|mythos)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$/
50
51// 同一個模型有好幾種寫法:Bedrock 的 anthropic. 前綴、[1m] 這類後綴、Vertex 的 @ 日期後綴
52function bareModelId(modelId: string) {
53 return modelId.replace(/^anthropic\./, '').replace(/\[[^\]]*\]$/, '').replace(/@.*$/, '')
54}
55
56export function modelInfo(modelId: string): ModelInfo {
57 const match = MODEL_ID.exec(bareModelId(modelId))
58 if (!match) return { key: null, name: modelId, family: 'unknown', contextWindow: null }
59 const [, series = '', major = '', minor] = match
60 const version = minor === undefined ? major : `${major}.${minor}`
61 const name = `${series.charAt(0).toUpperCase()}${series.slice(1)} ${version}`
62 const family = (series === 'mythos' ? 'fable' : series) as ModelFamily
63 const candidateKey = minor === undefined ? `${series}-${major}` : `${series}-${major}-${minor}`
64 const entry = PRICES[candidateKey]
65 return { key: entry ? candidateKey : null, name, family, contextWindow: entry?.contextWindow ?? null }
66}
67
68export function promptTokens(usage: TokenUsage): number {
69 return usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
70}
71
72export function totalTokens(usage: TokenUsage): number {
73 return promptTokens(usage) + usage.output_tokens
74}
75
76export function requestCostUsd(modelId: string, usage: TokenUsage): number | null {
77 const { key } = modelInfo(modelId)
78 const entry = key === null ? undefined : PRICES[key]
79 if (!entry) return null
80 const rates = entry.ratesFor(promptTokens(usage))
81 const dollarsPerMillion =
82 usage.input_tokens * rates.input +
83 usage.output_tokens * rates.output +
84 usage.cache_creation_input_tokens * rates.cacheWrite +
85 usage.cache_read_input_tokens * rates.cacheRead
86 return dollarsPerMillion / MILLION
87}
88
89export function contextPercent(modelId: string, usage: TokenUsage): number | null {
90 const { contextWindow } = modelInfo(modelId)
91 if (contextWindow === null) return null
92 return Math.round((promptTokens(usage) / contextWindow) * 100)
93}
94types/index.d.ts 51 lines1// 一次請求的 token 用量,形狀同 claude-code 的 ModelUsage。
2// 合約檔不能 import 'claude-code' 的型別(scripts/check-contracts.sh 的替身模組只有 PluginState),所以自己定義
3export type TokenUsage = {
4 input_tokens: number
5 output_tokens: number
6 cache_read_input_tokens: number
7 cache_creation_input_tokens: number
8}
9
10export type AgentStatus = 'running' | 'done' | 'failed'
11
12export type AgentRow = {
13 id: string
14 description: string
15 // 由子代理再派出的
16 isNested: boolean
17 status: AgentStatus
18 startedAt: number
19 endedAt: number | null
20 // 已中斷、API 錯誤、模型拒絕
21 failureReason: string | null
22 // 最後一次請求的模型 id 與 effort
23 model: string | null
24 effort: string | number | null
25 toolCount: number
26 // 正在做什麼,例如「讀取 src/app.ts」
27 activity: string
28 // 最後一次請求的 4 種 token 數
29 lastUsage: TokenUsage | null
30 // 前景子代理完成時 Agent 工具結果的 totalTokens;有值時以它為準
31 reportedTokens: number | null
32 // 已知單價的請求累計
33 costUsd: number
34 // 有請求的模型不在價目表
35 hasUnpricedUsage: boolean
36}
37
38// 這一回合派出的子代理
39export type Batch = { turnId: string; agents: AgentRow[] }
40
41declare module 'claude-code' {
42 interface PluginState {
43 'agent-panel': {
44 // 最近一批
45 batch: Batch | null
46 // 動畫計數器,只有面板讀
47 tick: number
48 }
49 }
50}
51