SLOPSHOPPER

router-view

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

newpanecommandprocesstimeragents
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · router-view
│ ┃ Model router ✕ › fix the failing auth test and add an audit log call │ ┃ Router stats failed: SyntaxError: JSON Parse │ ┃ error: Unexpected EOF ⏺ 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 │ │ › /router │ ⎿ router-view: Model router pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Model router
Router stats failed: SyntaxError: JSON Parse error: Unexpected EOF
Source 2 files
hooks/register.tsx 181 lines
1import 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}
181
hooks/view.ts 106 lines
1// 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