Painel /router com as estatísticas de roteamento de modelos (claude-model-router --stats).

hooks/register.tsx 181 lines1import type { Register } from 'claude-code'
2
3import { agentRows, bar, clip, effortLine, modelTag, percent, type Summary } from './view'
4
5const PANE = 'router-view'
6const PANE_OPEN = { id: PANE, title: 'Model router', columns: 36 }
7const REFRESH_MS = 5000
8const STATS_ARGV = ['claude-model-router', '--stats-json', 'today']
9const DOUBTS_SHOWN = 5
10const PROMPT_WIDTH = 30
11const PANE_COLUMNS = 36
12const STATUS_LOOK: Record<string, { glyph: string; color?: string }> = {
13 running: { glyph: '●', color: 'success' },
14 waiting: { glyph: '◐', color: 'warning' },
15 pending: { glyph: '○' },
16 idle: { glyph: '◌' },
17}
18const TIER_COLOR: Record<string, string> = {
19 haiku: 'success',
20 sonnet: 'suggestion',
21 opus: 'claude',
22}
23
24// Each agent's model, by agent id, set when it spawns. A reload clears it, so
25// an agent started before that shows '?'.
26const modelOf = new Map<string, string>()
27
28// Which sections show their body. Both start folded; a reload folds them again.
29let expanded = { agents: false, jev: false }
30
31export const register: Register = on => {
32 on('agent.spawn', async ($, e, next) => {
33 const started = await next(e)
34 if (started.deny === undefined && started.agentId) {
35 modelOf.set(started.agentId, started.model)
36 }
37
38 return started
39 })
40
41 on('session.start', async ($, e, next) => {
42 await $.command.register({
43 name: 'router',
44 description: 'Show how the model router sent the turns (Haiku, Sonnet, Opus)',
45 })
46 // Unasked, the dock opens only from 144 terminal columns. /router opens it at any width.
47 void $.ui.open(PANE_OPEN)
48 $.clock.every(REFRESH_MS, () => $.ui.invalidate('ui.render'))
49
50 return next(e)
51 })
52
53 on('command.run', { command: 'router' }, async $ => {
54 await $.ui.open(PANE_OPEN)
55
56 return { text: 'Model router pane opened.' }
57 })
58
59 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
60 const { Box, Text, Button } = $.ui.resolve(e)
61 const toggle = (section: keyof typeof expanded) => () => {
62 expanded = { ...expanded, [section]: !expanded[section] }
63 $.ui.invalidate('ui.render')
64 }
65 const arrow = (isOpen: boolean) => (isOpen ? '▾' : '▸')
66 const ran = await $.process
67 .run(STATS_ARGV, { timeoutMs: 10_000 })
68 .catch(error => ({ exitCode: -1, stdout: '', stderr: String(error) }))
69 let s: Summary
70 try {
71 if (ran.exitCode !== 0) throw new Error(ran.stderr)
72 s = JSON.parse(ran.stdout)
73 } catch (error) {
74 return <Text color="error">Router stats failed: {clip(String(error), 200)}</Text>
75 }
76
77 const rows = agentRows(await $.agent.list().catch(() => []))
78 const tiers = Object.keys(s.final)
79 const doubts = s.doubts.slice(-DOUBTS_SHOWN).reverse()
80
81 return (
82 <Box flexDirection="column">
83 <Button
84 plain
85 label={`${arrow(expanded.agents)} Agentes ativos (${rows.length})`}
86 onPress={toggle('agents')}
87 />
88 {expanded.agents && <Text dimColor>main</Text>}
89 {expanded.agents && rows.length === 0 && <Text dimColor>└─ nenhum subagente</Text>}
90 {expanded.agents && rows.map(({ prefix, agent }) => {
91 const look = STATUS_LOOK[agent.status] ?? { glyph: '·' }
92 const tag = `[${modelTag(modelOf.get(agent.id))}] `
93 const room = PANE_COLUMNS - prefix.length - 3 - tag.length
94 return (
95 <Text>
96 <Text dimColor>{prefix} </Text>
97 <Text color={look.color}>{look.glyph} </Text>
98 <Text color={TIER_COLOR[modelTag(modelOf.get(agent.id))]}>{tag}</Text>
99 <Text>{clip(`${agent.type}: ${agent.description || agent.name || ''}`, room)}</Text>
100 </Text>
101 )
102 })}
103 <Text> </Text>
104 <Text dimColor>{'─'.repeat(PANE_COLUMNS - 4)}</Text>
105 <Text> </Text>
106
107 <Button
108 plain
109 label={`${arrow(expanded.jev)} Jev hoje · ${s.total} turnos`}
110 onPress={toggle('jev')}
111 />
112 {expanded.jev && (
113 <Box flexDirection="column">
114 <Text> </Text>
115 {tiers.map(tier => {
116 const [full, empty] = bar(s.final[tier] ?? 0, s.total)
117 return (
118 <Text>
119 <Text color={TIER_COLOR[tier]}>{tier.padEnd(7)}</Text>
120 <Text color={TIER_COLOR[tier]}>{full}</Text>
121 <Text dimColor>{empty}</Text>
122 <Text> {String(s.final[tier]).padStart(3)}</Text>
123 <Text dimColor>{percent(s.final[tier] ?? 0, s.total)}</Text>
124 </Text>
125 )
126 })}
127 <Text dimColor>
128 Jev pediu: {tiers.map(t => `${t.charAt(0).toUpperCase()} ${s.jev[t]}`).join(' · ')}
129 </Text>
130 <Text dimColor>Esforço: {effortLine(s.effort, s.effort_default)}</Text>
131 <Text> </Text>
132
133 <Text bold>Dúvidas e falhas</Text>
134 <Text>
135 <Text dimColor>{'dúvida'.padEnd(9)}</Text>
136 <Text color={s.doubts.length ? 'warning' : undefined}>{s.doubts.length}</Text>
137 <Text dimColor> (conf {'<'} {s.confidence_min})</Text>
138 </Text>
139 <Text dimColor>
140 {' →'.padEnd(9)}
141 {s.doubt_tiers
142 ? tiers.map(t => `${t.charAt(0).toUpperCase()} ${s.doubt_tiers?.[t] ?? 0}`).join(' · ')
143 : s.fallback}
144 </Text>
145 <Text>
146 <Text dimColor>{'falha'.padEnd(9)}</Text>
147 <Text color={s.failed ? 'error' : undefined}>{s.failed}</Text>
148 <Text dimColor> (→ {s.fallback})</Text>
149 </Text>
150 <Text>
151 <Text dimColor>{'400'.padEnd(9)}</Text>
152 <Text color={s.rejects ? 'error' : undefined}>{s.rejects}</Text>
153 </Text>
154 {s.ms_median !== null && (
155 <Text>
156 <Text dimColor>{'latência'.padEnd(9)}</Text>
157 <Text>{s.ms_median} ms</Text>
158 <Text dimColor> (máx {s.ms_max})</Text>
159 </Text>
160 )}
161
162 {doubts.length > 0 && <Text> </Text>}
163 {doubts.length > 0 && <Text bold>Dúvidas recentes</Text>}
164 {doubts.map(d => (
165 <Box flexDirection="column">
166 <Text>
167 <Text dimColor>{d.ts.slice(-5)} </Text>
168 <Text color="warning">{d.confidence.toFixed(2)}</Text>
169 <Text dimColor> jev→</Text>
170 <Text color={TIER_COLOR[d.jev]}>{d.jev}</Text>
171 </Text>
172 <Text dimColor wrap="truncate-end"> {clip(d.prompt ?? '', PROMPT_WIDTH)}</Text>
173 </Box>
174 ))}
175 </Box>
176 )}
177 </Box>
178 )
179 })
180}
181hooks/view.ts 106 lines1// Pure helpers for the router pane, kept apart so the tests can reach them.
2export type Doubt = {
3 ts: string
4 confidence: number
5 jev: string
6 prompt?: string
7}
8
9export type Summary = {
10 since: string
11 total: number
12 final: Record<string, number>
13 jev: Record<string, number>
14 doubts: Doubt[]
15 failed: number
16 rejects: number
17 ms_median: number | null
18 ms_max: number | null
19 confidence_min: number
20 fallback: string
21 // Absent when the router predates the effort feature.
22 effort?: Record<string, number>
23 effort_default?: number
24 // Absent when the router predates the coverage rule.
25 doubt_tiers?: Record<string, number>
26 coverage?: number
27}
28
29export const BAR_WIDTH = 12
30
31// A bar of BAR_WIDTH cells: filled for n out of total, the rest light.
32export function bar(n: number, total: number): [string, string] {
33 const full = total > 0 ? Math.round((n * BAR_WIDTH) / total) : 0
34
35 return ['█'.repeat(full), '░'.repeat(BAR_WIDTH - full)]
36}
37
38export function percent(n: number, total: number): string {
39 return `${total > 0 ? Math.round((n * 100) / total) : 0}%`.padStart(4)
40}
41
42export function clip(text: string, width: number): string {
43 return text.length > width ? `${text.slice(0, width - 1)}…` : text
44}
45
46export type AgentNode = {
47 id: string
48 parentId?: string
49 type: string
50 description: string
51 name?: string
52 status: string
53}
54
55export type AgentRow = { prefix: string; agent: AgentNode }
56
57const ENDED = new Set(['completed', 'failed', 'killed'])
58
59// The active agents as tree rows. An ended agent stays only while an active one
60// hangs below it. An agent whose parent is gone becomes a root.
61export function agentRows(agents: readonly AgentNode[]): AgentRow[] {
62 const byId = new Map(agents.map(a => [a.id, a]))
63 const kids = new Map<string | undefined, AgentNode[]>()
64 for (const a of agents) {
65 const key = a.parentId !== undefined && byId.has(a.parentId) ? a.parentId : undefined
66 kids.set(key, [...(kids.get(key) ?? []), a])
67 }
68 const isLive = (a: AgentNode): boolean =>
69 !ENDED.has(a.status) || (kids.get(a.id) ?? []).some(isLive)
70
71 const rows: AgentRow[] = []
72 const walk = (parent: string | undefined, indent: string) => {
73 const list = (kids.get(parent) ?? []).filter(isLive)
74 list.forEach((agent, i) => {
75 const isLast = i === list.length - 1
76 rows.push({ prefix: `${indent}${isLast ? '└─' : '├─'}`, agent })
77 walk(agent.id, `${indent}${isLast ? ' ' : '│ '}`)
78 })
79 }
80 walk(undefined, '')
81
82 return rows
83}
84
85// The short name of the model an agent runs on: haiku, sonnet or opus. Any
86// other model shows its last part, so a new family still reads.
87export function modelTag(model: string | undefined): string {
88 if (!model) return '?'
89 const family = model.toLowerCase().match(/haiku|sonnet|opus/)?.[0]
90 return family ?? model.split('-').pop() ?? model
91}
92
93// The efforts used, as 'low 3 · high 10 · padrão (high) 2'. 'padrão' counts the turns
94// where Jev was not sure, so the default (high) was used. Efforts with no turn are left out.
95export function effortLine(
96 effort: Record<string, number> | undefined,
97 defaults: number | undefined,
98): string {
99 const parts = Object.entries(effort ?? {})
100 .filter(([, n]) => n > 0)
101 .map(([name, n]) => `${name} ${n}`)
102 if (defaults) parts.push(`padrão (high) ${defaults}`)
103
104 return parts.length > 0 ? parts.join(' · ') : 'nenhum ainda'
105}
106