SLOPSHOPPER

agent-panel

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

newpaneguardcommandtoastprompt
v0.1.0MITupdated 2026-10-09StalicJi/my-mods/agent-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-panel
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ 這個 session 還沒有派出子代理 │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /agents │ ⎿ agent-panel: 已開啟子代理面板。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
這個 session 還沒有派出子代理
README

my-mods

個人使用的 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-sync)

項目設定方式沒設定時
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 所屬平台的新動態與通知暫停)
GitHubgh 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
  • 清單本身就是私人資訊,只放在每台電腦,不進 repo。清單檔不存在、或裡面有寫錯的正規表示式時,一律擋下推送。
  • 確定是誤判時用 git push --no-verify。

授權

clean-view 與 gitlab-sync 採用根目錄的 MIT License。where-am-i(MIT)與 next-steps(Apache 2.0)是 fork,依各自資料夾裡的 LICENSE。

Source 5 files
hooks/register.tsx 285 lines
1// 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}
285
hooks/batch.ts 129 lines
1// 這一回合派出的子代理:開新一批、記錄每次請求的用量、工具呼叫、完成與失敗。
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}
129
hooks/layout.ts 225 lines
1// 面板的版面:把一個子代理變成每一列的文字、顏色與進度條格子,以及時間、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}
225
hooks/pricing.ts 94 lines
1// 模型 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}
94
types/index.d.ts 51 lines
1// 一次請求的 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