A pane of Mubit task stats, with a drill-in Requests view and feedback, from the mubit CLI

A pane beside the chat that shows your Mubit at a glance: tasks with their stage, call counts and a dotted shadow-promotion meter, and a Requests view you can drill into to read a call and leave ▲/▼ feedback. It reads live data through the mubit CLI and refreshes on a timer.

An earlier version of the pane, before the Projects tab; the views are described below.
The mubit CLI, signed in with mubit login. The mod runs it for every refresh. mubit-hack-26 ships it for macOS Apple Silicon as bin/mubit-darwin-arm64 (usage). Link it onto your PATH as mubit, or set the mod's command option to its absolute path.
Straight from GitHub, in Claude Code (v2.1.287 or later):
/plugin install mubit --marketplace username255/mubit-mod
Or from your shell:
claude plugin install mubit --marketplace username255/mubit-mod
Either one adds this repository as the mubit-mod marketplace and installs the mod from it. Append #v0.1.0 to the source to pin a release. Update with claude plugin update mubit@mubit-mod, and set options with claude plugin configure mubit@mubit-mod. To try it for one session without installing, clone the repository and run claude --plugin-dir ./mubit-mod.
Open the pane with /mubit-ui (it also opens on its own on a wide terminal).
◆ shadow, ● serving, · collecting), calls, +up / −down ratings, and a dotted meter of the shadow pass rate against the bar it must clear (orange below, green once it clears), with pairs-to-go (13/50 pairs).◆ marks a silent-test reply), a dotted latency bar, status and rating (▲ green for up, ▼ soft red for down). Show all / traffic / shadow picks which calls to list. Older › and ‹ Newer page through them; Older fetches the next page from Mubit when it runs out (needs a mubit CLI with traces --with-cursor; an older CLI shows one page). Click a row to open the call: its request and reply, any existing feedback, and ▲/▼ buttons with a note field to add your own.Loads are tolerant of a brief 503 from the engine (one retry, last-good data kept).
| Option | Default | Meaning |
|---|---|---|
command | mubit | The CLI command (name on PATH or an absolute path) |
project | (console's current) | Project slug to show |
refreshSeconds | 60 | How often to refresh |
requestLimit | 20 | How many recent calls the Requests view loads |
git clone https://github.com/username255/mubit-mod.git
claude plugin validate mubit-mod
claude plugin test mubit-mod
hooks/register.tsx 714 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Feedback, Meta, Notice, ProjectRow, RequestFilter, Shadow, Summary, TaskRow, TraceRow, View } from '../types'
5
6const PANE = 'mubit'
7
8// Palette drawn from the reference: warm coral-orange as the one accent (Mubit's
9// brand), with green/red/amber for pass, fail and attention. Hex reads on both
10// dark and light terminals; dim gray for everything secondary.
11const ORANGE = '#E8835A'
12const GREEN = '#6FBF73'
13const RED = '#E0564C'
14const SOFT_RED = '#B0544C' // a down rating: duller than RED, since it isn't an error
15const AMBER = '#D9A441'
16const MUTED = '#7d7d7d' // a neutral fill for bars that aren't the accent
17const INK = '#14110f' // text that sits on an orange fill
18
19// Change a tag when the stored shape changes; it discards what an older version wrote.
20const view = atom({ plugin: 'mubit', key: 'view' } as const, 'tasks' as View, { shape: 'view-1' })
21const tasks = atom({ plugin: 'mubit', key: 'tasks' } as const, [] as TaskRow[], { shape: 'tasks-2' })
22const traces = atom({ plugin: 'mubit', key: 'traces' } as const, [] as TraceRow[], { shape: 'traces-2' })
23const meta = atom(
24 { plugin: 'mubit', key: 'meta' } as const,
25 { loadedAt: null, loading: false, error: null, signedOut: false, transient: false } as Meta,
26 { shape: 'meta-2' },
27)
28const openTrace = atom({ plugin: 'mubit', key: 'openTrace' } as const, null as string | null, { shape: 'open-1' })
29const draft = atom({ plugin: 'mubit', key: 'draft' } as const, '', { shape: 'draft-1' })
30const notice = atom({ plugin: 'mubit', key: 'notice' } as const, null as Notice, { shape: 'notice-1' })
31const activeProject = atom({ plugin: 'mubit', key: 'activeProject' } as const, null as string | null, { shape: 'proj-1' })
32const projects = atom({ plugin: 'mubit', key: 'projects' } as const, [] as ProjectRow[], { shape: 'projects-1' })
33const summary = atom({ plugin: 'mubit', key: 'summary' } as const, null as Summary, { shape: 'summary-1' })
34const newProject = atom({ plugin: 'mubit', key: 'newProject' } as const, '', { shape: 'newproj-1' })
35const requestFilter = atom({ plugin: 'mubit', key: 'requestFilter' } as const, 'all' as RequestFilter, { shape: 'filter-1' })
36const traceCursor = atom({ plugin: 'mubit', key: 'traceCursor' } as const, null as string | null, { shape: 'cursor-1' })
37const tracePages = atom({ plugin: 'mubit', key: 'tracePages' } as const, 1, { shape: 'pages-1' })
38const requestStart = atom({ plugin: 'mubit', key: 'requestStart' } as const, 0, { shape: 'start-1' })
39
40type Config = { command: string; project: string; refreshSeconds: number; requestLimit: number }
41
42function readConfig(options: unknown): Config {
43 const o = (options ?? {}) as Partial<Config>
44 return {
45 command: o.command?.trim() || 'mubit',
46 project: o.project?.trim() || '',
47 refreshSeconds: Math.max(10, Number(o.refreshSeconds) || 60),
48 requestLimit: Math.max(1, Math.min(100, Number(o.requestLimit) || 20)),
49 }
50}
51
52const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
53const str = (v: unknown): string => (typeof v === 'string' ? v : '')
54
55// The CLI prints these when the saved sign-in has lapsed, or when the engine is briefly down.
56const signedOutRe = /sign ?in|clerk|session_(required|expired)|not signed in/i
57const transientRe = /\b5\d\d\b|engine_unreachable|unreachable|upstream_(failed|timeout|unreachable)|timed out|timeout/i
58
59function firstLine(text: string): string {
60 return (text.split('\n').find(l => l.trim()) ?? '').trim().slice(0, 240)
61}
62
63function messageText(content: unknown): string {
64 if (typeof content === 'string') return content
65 if (Array.isArray(content)) return content.map(p => str((p as Record<string, unknown>)?.text)).join(' ').trim()
66 return ''
67}
68
69function toTaskRow(l: Record<string, unknown>): TaskRow {
70 const counts = (l.counts ?? {}) as Record<string, unknown>
71 const live = l.live as Record<string, unknown> | null | undefined
72 const shadow: Shadow | null = live
73 ? {
74 passes: num(live.passes),
75 pairs: num(live.pairs),
76 rate: Math.round(num(live.rate) * 100),
77 need: Math.round(num(live.bar) * 100),
78 }
79 : null
80 return {
81 id: str(l.id),
82 name: str(l.name) || str(l.label) || str(l.id),
83 stage: str(l.stage),
84 calls: num(counts.traces),
85 up: num(counts.rated_up),
86 down: num(counts.rated_down),
87 unrated: num(counts.unrated),
88 adapter: str(l.adapter_id),
89 shadow,
90 }
91}
92
93function toTraceRow(t: Record<string, unknown>): TraceRow {
94 const up = (t.upstream ?? {}) as Record<string, unknown>
95 const routing = (t.routing ?? {}) as Record<string, unknown>
96 const resp = (t.response ?? {}) as Record<string, unknown>
97 const req = (t.request ?? {}) as Record<string, unknown>
98 const choices = Array.isArray(resp.choices) ? (resp.choices as Record<string, unknown>[]) : []
99 const reply = messageText((choices[0]?.message as Record<string, unknown>)?.content)
100 const msgs = Array.isArray(req.messages) ? (req.messages as Record<string, unknown>[]) : []
101 const lastUser = [...msgs].reverse().find(mm => str(mm.role) === 'user')
102 const feedback = Array.isArray(t.feedback) ? (t.feedback as Record<string, unknown>[]) : []
103 const fb: Feedback[] = feedback.map(f => ({
104 rating: str(f.rating),
105 comment: str(f.comment),
106 source: str(f.source),
107 at: str(f.at),
108 }))
109 return {
110 id: str(t.trace_id),
111 time: str(t.created_at).slice(11, 19),
112 purpose: str(t.purpose) || str(routing.decision),
113 answeredBy: [str(up.vendor), str(up.model)].filter(Boolean).join(' '),
114 model: str(resp.model) || str(up.model),
115 ms: typeof up.latency_ms === 'number' ? Math.round(up.latency_ms as number) : null,
116 status: typeof t.http_status === 'number' ? (t.http_status as number) : null,
117 rating: str(fb.at(-1)?.rating),
118 req: messageText(lastUser?.content),
119 reply,
120 fb,
121 }
122}
123
124type RunResult = { exitCode: number; stdout: string; stderr: string }
125
126// One CLI call for the chosen project, retried once on a transient 5xx/unreachable.
127async function runCli($: EngineInterface, cfg: Config, project: string, args: string[]): Promise<RunResult> {
128 const argv = [cfg.command, ...args, ...(project ? ['--project', project] : [])]
129 let res = (await $.process.run(argv, { timeoutMs: 25_000 })) as RunResult
130 if (res.exitCode !== 0 && transientRe.test(`${res.stderr}${res.stdout}`)) {
131 await $.clock.sleep(1500)
132 res = (await $.process.run(argv, { timeoutMs: 25_000 })) as RunResult
133 }
134 return res
135}
136
137const parse = (res: RunResult): any => {
138 try {
139 return JSON.parse(res.stdout || 'null')
140 } catch {
141 return null
142 }
143}
144
145type TracePage = { rows: TraceRow[]; next: string | null }
146
147// One page of calls through `traces --with-cursor`, from the start or from a cursor.
148// A CLI from before paging knows neither flag: show its one page and offer no more.
149async function tracePage($: EngineInterface, cfg: Config, project: string, filter: RequestFilter, cursor: string | null): Promise<TracePage | RunResult> {
150 const base = ['traces', '--limit', String(cfg.requestLimit), '--json']
151 let res = await runCli($, cfg, project, [...base, '--with-cursor', ...(filter === 'all' ? [] : ['--purpose', filter]), ...(cursor ? ['--cursor', cursor] : [])])
152 if (res.exitCode !== 0 && /unknown option/i.test(res.stderr)) {
153 if (cursor) return { rows: [], next: null }
154 res = await runCli($, cfg, project, [...base, ...(filter === 'shadow' ? ['--shadow'] : [])])
155 }
156 if (res.exitCode !== 0) return res
157 const out = parse(res)
158 const list = Array.isArray(out) ? out : Array.isArray(out?.traces) ? out.traces : []
159 return { rows: list.map(toTraceRow), next: Array.isArray(out) ? null : str(out?.next_cursor) || null }
160}
161
162// Fetch the page after the last one loaded and append it.
163async function loadMore($: EngineInterface, cfg: Config): Promise<void> {
164 const cursor = await read($, traceCursor)
165 if (!cursor) return
166 const project = (await read($, activeProject)) || cfg.project
167 await update($, meta, m => ({ ...m, loading: true }))
168 const page = await tracePage($, cfg, project, await read($, requestFilter), cursor)
169 if ('exitCode' in page) {
170 const message = firstLine(page.stderr) || firstLine(page.stdout) || `mubit exited ${page.exitCode}`
171 await update($, meta, m => ({ ...m, loading: false, error: message, signedOut: signedOutRe.test(message), transient: transientRe.test(message) }))
172 return
173 }
174 await update($, traces, old => {
175 const ids = new Set(old.map(r => r.id))
176 return [...old, ...page.rows.filter(r => !ids.has(r.id))]
177 })
178 await update($, traceCursor, () => page.next)
179 await update($, tracePages, p => p + 1)
180 await update($, meta, m => ({ ...m, loading: false, error: null }))
181}
182
183// Start the Requests list over: first page, top of the list.
184async function resetRequests($: EngineInterface): Promise<void> {
185 await update($, traces, () => [])
186 await update($, traceCursor, () => null)
187 await update($, tracePages, () => 1)
188 await update($, requestStart, () => 0)
189}
190
191async function setFilter($: EngineInterface, cfg: Config, filter: RequestFilter): Promise<void> {
192 await update($, requestFilter, () => filter)
193 await update($, openTrace, () => null)
194 await resetRequests($)
195 await load($, cfg)
196}
197
198// Load what the current view needs. Keeps the last good data on a blip.
199// Top-level so the module validator sees $ handed to a declared function.
200async function load($: EngineInterface, cfg: Config): Promise<void> {
201 const want = await read($, view)
202 const project = (await read($, activeProject)) || cfg.project
203 await update($, meta, m => ({ ...m, loading: true }))
204
205 const fail = async (res: RunResult) => {
206 const message = firstLine(res.stderr) || firstLine(res.stdout) || `mubit exited ${res.exitCode}`
207 const transient = transientRe.test(message)
208 await update($, meta, m => ({ ...m, loading: false, error: message, signedOut: signedOutRe.test(message), transient }))
209 }
210
211 try {
212 if (want === 'projects') {
213 const pr = await runCli($, cfg, '', ['projects', '--json'])
214 if (pr.exitCode !== 0) return await fail(pr)
215 const list = (parse(pr) ?? {}).projects ?? parse(pr) ?? []
216 await update($, projects, () =>
217 (Array.isArray(list) ? list : []).map((p: Record<string, unknown>) => ({
218 slug: str(p.slug),
219 name: str(p.name),
220 status: str(p.status),
221 })),
222 )
223 await update($, meta, () => ({ loadedAt: Date.now(), loading: false, error: null, signedOut: false, transient: false }))
224 return
225 }
226
227 const tasksRes = await runCli($, cfg, project, ['tasks', '--json'])
228 if (tasksRes.exitCode !== 0) return await fail(tasksRes)
229 const lanes = parse(tasksRes)
230 await update($, tasks, () => (Array.isArray(lanes) ? lanes.map(toTaskRow) : []))
231
232 if (want === 'tasks') {
233 // Best-effort extras for the summary: 24h traffic and the gate thresholds.
234 const [mx, pol] = await Promise.all([
235 runCli($, cfg, project, ['metrics', '--json']),
236 runCli($, cfg, project, ['policy', '--json']),
237 ])
238 const t = (parse(mx) ?? {}).totals ?? {}
239 const promo = ((parse(pol) ?? {}).effective ?? {}).promotion ?? {}
240 await update($, summary, () => ({
241 calls: num(t.calls),
242 errors: num(t.errors),
243 p50: Math.round(num(t.p50_ms)),
244 minPairs: num(promo.min_pairs) || 50,
245 bar: typeof promo.bar === 'number' ? promo.bar : 0.9,
246 }))
247 }
248
249 if (want === 'requests') {
250 const page = await tracePage($, cfg, project, await read($, requestFilter), null)
251 if ('exitCode' in page) return await fail(page)
252 if ((await read($, tracePages)) > 1) {
253 // Older pages stay loaded: the newest page goes in front, the rest keep their place.
254 const ids = new Set(page.rows.map(r => r.id))
255 await update($, traces, old => [...page.rows, ...old.filter(r => !ids.has(r.id))])
256 } else {
257 await update($, traces, () => page.rows)
258 await update($, traceCursor, () => page.next)
259 }
260 }
261 await update($, meta, () => ({ loadedAt: Date.now(), loading: false, error: null, signedOut: false, transient: false }))
262 } catch (err) {
263 const message = firstLine(err instanceof Error ? err.message : String(err))
264 await update($, meta, m => ({ ...m, loading: false, error: message, signedOut: signedOutRe.test(message), transient: transientRe.test(message) }))
265 }
266}
267
268// Create a project, then refresh the list and switch to it.
269async function createProject($: EngineInterface, cfg: Config, name: string): Promise<void> {
270 if (!name.trim()) return
271 await update($, notice, () => ({ text: 'Creating…', kind: 'ok' }))
272 const res = (await $.process.run([cfg.command, 'project-create', name.trim(), '--json'], { timeoutMs: 25_000 })) as RunResult
273 if (res.exitCode === 0) {
274 const slug = str(((parse(res) ?? {}).project ?? {}).slug)
275 await update($, newProject, () => '')
276 await update($, notice, () => ({ text: `Created ${slug || name}.`, kind: 'ok' }))
277 await load($, cfg)
278 } else {
279 await update($, notice, () => ({ text: firstLine(res.stderr) || `exited ${res.exitCode}`, kind: 'err' }))
280 }
281}
282
283// Switch which project the pane shows, then go back to Tasks and reload.
284async function pickProject($: EngineInterface, cfg: Config, slug: string | null): Promise<void> {
285 await update($, activeProject, () => slug)
286 await update($, view, () => 'tasks')
287 await update($, notice, () => null)
288 await resetRequests($)
289 await load($, cfg)
290}
291
292// Submit a rating on one call, then refresh so its feedback shows.
293async function rate($: EngineInterface, cfg: Config, id: string, rating: 'up' | 'down', comment: string): Promise<void> {
294 await update($, notice, () => ({ text: 'Saving…', kind: 'ok' }))
295 const args = ['rate', id, rating, ...(comment.trim() ? ['--comment', comment.trim()] : []), '--json']
296 const res = (await $.process.run([cfg.command, ...args], { timeoutMs: 25_000 })) as RunResult
297 if (res.exitCode === 0) {
298 await update($, draft, () => '')
299 await update($, notice, () => ({ text: `Saved: rated ${rating}.`, kind: 'ok' }))
300 await load($, cfg)
301 } else {
302 const message = firstLine(res.stderr) || firstLine(res.stdout) || `rate exited ${res.exitCode}`
303 await update($, notice, () => ({ text: message, kind: 'err' }))
304 }
305}
306
307export const register: Register = (on, options) => {
308 const cfg = readConfig(options)
309
310 on('session.start', async ($, e, next) => {
311 // `/mubit` belongs to the Mubit skill, so the pane's command is `/mubit-ui`.
312 try {
313 await $.command.register({ name: 'mubit-ui', description: 'Open the Mubit task-stats pane' })
314 } catch (err) {
315 $.ui.log(`mubit: command.register skipped: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
316 }
317 void $.ui.open({ id: PANE, title: 'Mubit' })
318 void load($, cfg)
319 $.clock.every(cfg.refreshSeconds * 1000, () => void load($, cfg))
320 return next(e)
321 })
322
323 on('command.run', { command: 'mubit-ui' }, async $ => {
324 await $.ui.open({ id: PANE, title: 'Mubit' })
325 void load($, cfg)
326 return { text: 'Mubit pane opened.' }
327 })
328
329 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
330 const resolved = $.ui.resolve(e)
331 const { Box, Text, Button } = resolved
332 // Input is on the terminal/desktop tables but not every surface's; take it loosely
333 // and guard its use, so the pane still renders where it's absent.
334 const Input = (resolved as unknown as { Input?: any }).Input
335 const [current, taskRows, traceRows, m, openId, draftText, note, active, projectRows, sum, newProj, filter, cursor, startAt] = await Promise.all([
336 read($, view),
337 read($, tasks),
338 read($, traces),
339 read($, meta),
340 read($, openTrace),
341 read($, draft),
342 read($, notice),
343 read($, activeProject),
344 read($, projects),
345 read($, summary),
346 read($, newProject),
347 read($, requestFilter),
348 read($, traceCursor),
349 read($, requestStart),
350 ])
351 const height = Math.max(6, e.props?.scroll?.bodyRows ?? e.viewport?.rows ?? 20)
352 // Every view sits inside a one-column margin on each side; `cols` is the width within it.
353 const cols = Math.max(24, (e.props?.bodyColumns ?? e.viewport?.columns ?? 80) - 2)
354 const bodyRows = Math.max(1, height - 4) // title, rule, content, rule, footer => 4 chrome rows
355
356 const clip = (s: string, n: number) => (n <= 1 ? '' : s.length > n ? `${s.slice(0, n - 1)}…` : s)
357 const padR = (s: string, n: number) => clip(s, n).padEnd(n)
358 const padL = (s: string, n: number) => clip(s, n).padStart(n)
359
360 const switchTo = (v: View) => async () => {
361 await update($, openTrace, () => null)
362 await update($, notice, () => null)
363 await update($, view, () => v)
364 void load($, cfg)
365 }
366
367 const freshness = (): string => {
368 const blip = m.transient ? ' · retrying' : ''
369 if (m.loadedAt === null) return m.loading ? 'loading…' : ''
370 const ago = Math.round((Date.now() - m.loadedAt) / 1000)
371 return `${ago < 1 ? 'just now' : `${ago}s ago`}${m.loading ? ' · refreshing' : blip}`
372 }
373
374 // A dotted meter in the reference's style: a sparse `·` track with a textured
375 // `▒…█` fill on top, the head brighter. Two spans so the fill alone is coloured.
376 const dottedBar = (pct: number, w: number, color: string) => {
377 const filled = Math.max(0, Math.min(w, Math.round((pct / 100) * w)))
378 const fill = filled > 0 ? `${'▒'.repeat(Math.max(0, filled - 1))}█` : ''
379 const track = '·'.repeat(w - filled)
380 return (
381 <Text>
382 <Text color={color}>{fill}</Text>
383 <Text dimColor>{track}</Text>
384 </Text>
385 )
386 }
387
388 const minPairs = sum?.minPairs ?? 50
389 const shadowBlock = (s: Shadow) => {
390 // Nothing graded yet reads as a 0% meter, which looks like failing; say so instead.
391 if (s.pairs === 0) return <Text dimColor>shadow · no graded pairs yet</Text>
392 const ok = s.rate >= s.need
393 const c = ok ? GREEN : ORANGE
394 return (
395 <Box gap={1}>
396 <Text dimColor>shadow {s.passes}/{s.pairs}</Text>
397 {dottedBar(s.rate, Math.min(16, Math.max(8, cols - 44)), c)}
398 <Text color={c} bold>{s.rate}%</Text>
399 <Text dimColor>→ {s.need}%</Text>
400 <Text dimColor>· {s.pairs}/{minPairs} pairs</Text>
401 </Box>
402 )
403 }
404
405 // A status glyph + colour per stage, echoing the reference's "+ running / ✓ done".
406 const stageStyle = (stage: string): { g: string; c?: string } =>
407 stage === 'serving' || stage === 'canary'
408 ? { g: '●', c: GREEN }
409 : stage === 'shadow'
410 ? { g: '◆', c: ORANGE }
411 : { g: '·', c: undefined }
412
413 // A rating as a triangle that takes colour (an emoji can't): green up, soft red down.
414 const ratingStyle = (rating: string): { g: string; c?: string } =>
415 rating === 'up' ? { g: '▲', c: GREEN } : rating === 'down' ? { g: '▼', c: SOFT_RED } : { g: ' ', c: undefined }
416
417 const tasksView = () => {
418 if (taskRows.length === 0) return <Text dimColor>{m.loading ? 'Loading…' : 'No tasks yet.'}</Text>
419 const shadowCount = taskRows.filter(t => t.shadow).length
420 const totalCalls = taskRows.reduce((a, t) => a + t.calls, 0)
421 return (
422 <Box flexDirection="column" gap={1}>
423 <Box flexDirection="column">
424 <Text dimColor>
425 {taskRows.length} tasks · {totalCalls} calls{shadowCount ? ` · ` : ''}
426 {shadowCount > 0 && <Text color={ORANGE}>◆ {shadowCount} shadow</Text>}
427 </Text>
428 {sum && (
429 <Text dimColor>
430 24h: {sum.calls} calls · {sum.errors > 0 ? <Text color={RED}>{sum.errors} errors</Text> : '0 errors'} · p50 {sum.p50}ms
431 </Text>
432 )}
433 </Box>
434 {taskRows.slice(0, Math.max(1, Math.floor((bodyRows - 1) / 4))).map(t => {
435 const { g, c } = stageStyle(t.stage)
436 return (
437 <Box key={t.id} flexDirection="column">
438 <Box justifyContent="space-between">
439 <Text bold>{clip(t.name, cols - 14)}</Text>
440 <Text color={c}>{g} {t.stage}</Text>
441 </Box>
442 <Box gap={2}>
443 <Text dimColor>{t.calls} calls</Text>
444 <Text>
445 <Text color={GREEN}>+{t.up}</Text>
446 <Text dimColor> / </Text>
447 <Text color={RED}>−{t.down}</Text>
448 </Text>
449 {t.unrated > 0 && <Text dimColor>{t.unrated} unrated</Text>}
450 </Box>
451 {t.shadow && shadowBlock(t.shadow)}
452 </Box>
453 )
454 })}
455 </Box>
456 )
457 }
458
459 // Which calls to list, as chips: the active one orange, the rest dim.
460 const filterChips = () => (
461 <Box gap={1}>
462 <Text dimColor>Show</Text>
463 {(['all', 'traffic', 'shadow'] as RequestFilter[]).map(f => (
464 <Button key={`filter-${f}`} plain onPress={() => void setFilter($, cfg, f)}>
465 {f === filter ? <Text bold color={ORANGE}>[{f}]</Text> : <Text dimColor>{f}</Text>}
466 </Button>
467 ))}
468 </Box>
469 )
470
471 const requestsList = () => {
472 if (traceRows.length === 0) {
473 return (
474 <Box flexDirection="column" gap={1}>
475 {filterChips()}
476 <Text dimColor>{m.loading ? 'Loading…' : filter === 'shadow' ? 'No silent-test calls found.' : 'No recent calls.'}</Text>
477 </Box>
478 )
479 }
480 const barW = 7
481 const nameW = Math.max(12, cols - 9 - barW - 1 - 7 - 4 - 3)
482 const head = `${padR('TIME', 9)}${padR('ANSWERED BY', nameW)}${padR('LATENCY', barW + 1 + 7)}${padL('ST', 4)}`
483 // Chips, column heads, the pager and the blank rows around the table take five
484 // rows; the calls get the rest.
485 const visible = Math.max(1, bodyRows - 5)
486 const start = Math.min(startAt, Math.max(0, traceRows.length - 1))
487 const shown = traceRows.slice(start, start + visible)
488 const maxMs = Math.max(1, ...shown.map(r => r.ms ?? 0))
489 const older = async () => {
490 // Near the end of what is loaded, fetch the next page first.
491 if (start + 2 * visible > traceRows.length && cursor) await loadMore($, cfg)
492 const total = (await read($, traces)).length
493 if (start + visible < total) await update($, requestStart, () => start + visible)
494 }
495 const hasOlder = start + visible < traceRows.length || cursor !== null
496 // A blank row after the chips and before the pager, as the Projects view spaces its list.
497 return (
498 <Box flexDirection="column" gap={1}>
499 {filterChips()}
500 <Box flexDirection="column">
501 <Text dimColor bold>
502 {head}
503 </Text>
504 {shown.map((r, i) => {
505 const adapter = /vllm|qwen|adapter/i.test(r.answeredBy)
506 const mark = ratingStyle(r.rating)
507 const who = `${r.purpose === 'shadow' ? '◆ ' : ''}${r.answeredBy || r.purpose}`
508 return (
509 <Button key={`row-${r.id || start + i}`} plain onPress={() => void update($, openTrace, () => r.id)}>
510 <Text dimColor>{padR(r.time, 9)}</Text>
511 <Text color={adapter ? ORANGE : undefined}>{padR(who, nameW)}</Text>
512 {dottedBar(((r.ms ?? 0) / maxMs) * 100, barW, adapter ? ORANGE : MUTED)}
513 <Text dimColor> {padL(r.ms === null ? '' : `${r.ms}ms`, 7)}</Text>
514 <Text color={r.status === 200 ? GREEN : r.status ? RED : undefined}> {padL(String(r.status ?? ''), 3)}</Text>
515 <Text color={mark.c}> {mark.g}</Text>
516 </Button>
517 )
518 })}
519 </Box>
520 <Box gap={2}>
521 <Text dimColor>
522 {start + 1}–{start + shown.length} of {traceRows.length}{cursor ? '+' : ''}
523 </Text>
524 {start > 0 && (
525 <Button key="newer" plain onPress={() => void update($, requestStart, () => Math.max(0, start - visible))}>
526 <Text color={ORANGE}>‹ Newer</Text>
527 </Button>
528 )}
529 {hasOlder && (
530 <Button key="older" plain onPress={() => void older()}>
531 <Text color={ORANGE}>Older ›</Text>
532 </Button>
533 )}
534 </Box>
535 </Box>
536 )
537 }
538
539 const requestDetail = (r: TraceRow) => {
540 const w = cols - 2
541 return (
542 <Box flexDirection="column">
543 <Button key="back" plain onPress={() => void update($, openTrace, () => null)}>
544 <Text color={ORANGE}>← Back</Text>
545 </Button>
546 <Box gap={2}>
547 <Text dimColor>{r.time}</Text>
548 <Text color={/vllm|qwen|adapter/i.test(r.answeredBy) ? ORANGE : undefined}>{r.model || r.answeredBy}</Text>
549 <Text color={r.status === 200 ? GREEN : RED}>{r.status ?? ''}</Text>
550 {r.ms !== null && <Text dimColor>{r.ms}ms</Text>}
551 </Box>
552 <Text> </Text>
553 <Text dimColor bold>REQUEST</Text>
554 <Text wrap="wrap">{clip(r.req || '(none)', w * 2)}</Text>
555 <Text dimColor bold>REPLY</Text>
556 <Text wrap="wrap">{clip(r.reply || '(none)', w * 2)}</Text>
557 <Text> </Text>
558 {r.fb.length > 0 ? (
559 <Box flexDirection="column">
560 <Text dimColor bold>FEEDBACK</Text>
561 {r.fb.slice(-3).map((f, i) => (
562 <Text key={String(i)}>
563 <Text color={ratingStyle(f.rating).c}>{ratingStyle(f.rating).g}</Text> <Text dimColor>{clip(f.comment || '(no note)', w - 12)} · {f.source}</Text>
564 </Text>
565 ))}
566 </Box>
567 ) : (
568 <Text dimColor>No feedback yet — add one below.</Text>
569 )}
570 {note && (
571 <Text color={note.kind === "ok" ? GREEN : RED}>{note.text}</Text>
572 )}
573 <Text> </Text>
574 {Input && (
575 <Box width={Math.min(60, w)}>
576 <Input
577 key="comment"
578 label="Note "
579 placeholder="why (optional)"
580 value={draftText}
581 onInput={(v: string) => void update($, draft, () => v)}
582 onSubmit={() => void rate($, cfg, r.id, 'up', draftText)}
583 />
584 </Box>
585 )}
586 <Box gap={2}>
587 <Button key="up" plain onPress={() => void rate($, cfg, r.id, 'up', draftText)}>
588 <Text color={GREEN}>▲ Rate up</Text>
589 </Button>
590 <Button key="down" plain onPress={() => void rate($, cfg, r.id, 'down', draftText)}>
591 <Text color={SOFT_RED}>▼ Rate down</Text>
592 </Button>
593 </Box>
594 </Box>
595 )
596 }
597
598 const projectsView = () => (
599 <Box flexDirection="column" gap={1}>
600 <Text dimColor>Pick the project the pane shows:</Text>
601 <Box flexDirection="column">
602 <Button key="proj-default" plain onPress={() => void pickProject($, cfg, null)}>
603 <Text color={active === null ? ORANGE : undefined} bold={active === null}>
604 {active === null ? '● ' : ' '}console default
605 </Text>
606 </Button>
607 {projectRows.slice(0, Math.max(1, bodyRows - 5)).map(p => (
608 <Button key={`proj-${p.slug}`} plain onPress={() => void pickProject($, cfg, p.slug)}>
609 <Text color={active === p.slug ? ORANGE : undefined} bold={active === p.slug}>
610 {active === p.slug ? '● ' : ' '}
611 {padR(p.name, Math.min(26, cols - 26))}
612 </Text>
613 <Text dimColor> {p.slug} {p.status}</Text>
614 </Button>
615 ))}
616 </Box>
617 {note && <Text color={note.kind === 'ok' ? GREEN : RED}>{note.text}</Text>}
618 {Input && (
619 <Box gap={1}>
620 <Text dimColor>+ new</Text>
621 <Box width={Math.min(34, cols - 14)}>
622 <Input
623 key="newproj"
624 placeholder="project name"
625 value={newProj}
626 onInput={(v: string) => void update($, newProject, () => v)}
627 onSubmit={(v: string) => void createProject($, cfg, v)}
628 />
629 </Box>
630 <Button key="create" plain onPress={() => void createProject($, cfg, newProj)}>
631 <Text color={GREEN}>Create</Text>
632 </Button>
633 </Box>
634 )}
635 </Box>
636 )
637
638 const body = () => {
639 if (m.signedOut) {
640 return (
641 <Box flexDirection="column" gap={1}>
642 <Text color={AMBER}>Signed out.</Text>
643 <Text dimColor>Run `mubit login` in a terminal, then Refresh.</Text>
644 </Box>
645 )
646 }
647 if (m.error && m.loadedAt === null) {
648 return (
649 <Box flexDirection="column" gap={1}>
650 <Text color={m.transient ? AMBER : RED}>
651 {m.transient ? 'Mubit is briefly unreachable — retrying.' : "Couldn't reach Mubit."}
652 </Text>
653 <Text dimColor wrap="wrap">{m.error}</Text>
654 </Box>
655 )
656 }
657 if (current === 'projects') return projectsView()
658 if (current === 'tasks') return tasksView()
659 if (openId) {
660 const row = traceRows.find(r => r.id === openId)
661 return row ? requestDetail(row) : <Text dimColor>That call is no longer in the list. ← Back with Refresh.</Text>
662 }
663 return requestsList()
664 }
665
666 // Tabs as `[ label ]`: the active one a solid orange chip with ink text,
667 // the rest dim — straight from the reference's nav.
668 const tab = (v: View, label: string) =>
669 current === v ? (
670 <Button key={v} plain onPress={switchTo(v)}>
671 <Text bold backgroundColor={ORANGE} color={INK}>{` ${label} `}</Text>
672 </Button>
673 ) : (
674 <Button key={v} plain onPress={switchTo(v)}>
675 <Text dimColor>[ {label} ]</Text>
676 </Button>
677 )
678
679 return (
680 <Box flexDirection="column" height={height} paddingX={1}>
681 <Box justifyContent="space-between">
682 <Text>
683 <Text bold color={ORANGE}>◈ MUBIT</Text>
684 <Text dimColor> · {current === 'projects' ? 'Projects' : current === 'tasks' ? 'Tasks' : openId ? 'Call' : 'Requests'}</Text>
685 </Text>
686 <Text dimColor>{active ? (projectRows.find(p => p.slug === active)?.name ?? active) : 'default project'}</Text>
687 </Box>
688 <Text dimColor>{'─'.repeat(cols)}</Text>
689
690 <Box flexDirection="column" flexGrow={1}>
691 {body()}
692 </Box>
693
694 {/* Controls: tab selection on the left; Refresh pinned right, when it last ran just
695 before it, so the changing time never moves the button. */}
696 <Text dimColor>{'─'.repeat(cols)}</Text>
697 <Box justifyContent="space-between">
698 <Box gap={2}>
699 {tab('tasks', 'Tasks')}
700 {tab('requests', 'Requests')}
701 {tab('projects', 'Projects')}
702 </Box>
703 <Box gap={1}>
704 {freshness() && <Text dimColor>{freshness()} ·</Text>}
705 <Button key="refresh" plain onPress={() => void load($, cfg)}>
706 <Text dimColor>↻ Refresh</Text>
707 </Button>
708 </Box>
709 </Box>
710 </Box>
711 )
712 })
713}
714types/index.d.ts 78 lines1export type View = 'tasks' | 'requests' | 'projects'
2
3// Which calls the Requests view lists: every call, client calls only, or silent-test calls.
4export type RequestFilter = 'all' | 'traffic' | 'shadow'
5
6export type Shadow = { passes: number; pairs: number; rate: number; need: number }
7
8export type TaskRow = {
9 id: string
10 name: string
11 stage: string
12 calls: number
13 up: number
14 down: number
15 unrated: number
16 adapter: string
17 shadow: Shadow | null
18}
19
20export type Feedback = { rating: string; comment: string; source: string; at: string }
21
22export type TraceRow = {
23 id: string
24 time: string
25 purpose: string
26 answeredBy: string
27 model: string
28 ms: number | null
29 status: number | null
30 rating: string
31 req: string
32 reply: string
33 fb: Feedback[]
34}
35
36export type ProjectRow = { slug: string; name: string; status: string }
37
38// 24h traffic totals plus the gate thresholds, for the Tasks summary line.
39export type Summary = {
40 calls: number
41 errors: number
42 p50: number
43 minPairs: number
44 bar: number // 0..1
45} | null
46
47export type Meta = {
48 loadedAt: number | null
49 loading: boolean
50 error: string | null
51 signedOut: boolean
52 transient: boolean
53}
54
55export type Notice = { text: string; kind: 'ok' | 'err' } | null
56
57declare module 'claude-code' {
58 interface PluginState {
59 mubit: {
60 view: Shaped<View>
61 tasks: Shaped<TaskRow[]>
62 traces: Shaped<TraceRow[]>
63 meta: Shaped<Meta>
64 openTrace: Shaped<string | null>
65 draft: Shaped<string>
66 notice: Shaped<Notice>
67 activeProject: Shaped<string | null> // project the pane shows; null = console default
68 projects: Shaped<ProjectRow[]>
69 summary: Shaped<Summary>
70 newProject: Shaped<string> // the new-project name being typed
71 requestFilter: Shaped<RequestFilter>
72 traceCursor: Shaped<string | null> // the CLI's cursor for the next page of calls; null = no more
73 tracePages: Shaped<number> // pages of calls loaded beyond a refresh's first one, plus one
74 requestStart: Shaped<number> // first loaded call the Requests view shows
75 }
76 }
77}
78