verbose live UI + tool-level SSE events + Groq default + regression tests

This commit is contained in:
2026-06-03 05:04:29 -03:00
parent 131f4d9b86
commit e124a8a7d9
69 changed files with 3030 additions and 137 deletions

View File

@@ -0,0 +1,267 @@
import { ref, onUnmounted, computed } from 'vue'
import type { LogEntry } from 'soleprint-ui'
import { apiUrl } from '../config'
/* Mirror of the backend factories in api/runtime/events.py */
export type RunStatus = 'idle' | 'connecting' | 'streaming' | 'done' | 'error'
export interface PlanStep {
analysis: string
args: Record<string, any>
why: string
}
export interface GraphNode {
id: string // e.g. "plan", "direct_answer", "synthesize"
kind: 'plan' | 'analysis' | 'synthesize'
label: string
status: 'pending' | 'running' | 'done' | 'error'
detail?: string
}
export interface ToolCall {
tool: string
input?: any
output?: any
error?: string | null
/** ms since the run started */
t_started: number
t_ended?: number
}
export function useRunStream() {
const status = ref<RunStatus>('idle')
const runId = ref<string | null>(null)
const question = ref<string>('')
const rationale = ref<string>('')
const planSteps = ref<PlanStep[]>([])
const graphNodes = ref<GraphNode[]>([])
const log = ref<LogEntry[]>([])
const toolCalls = ref<ToolCall[]>([])
const answer = ref<string>('')
const error = ref<string | null>(null)
let es: EventSource | null = null
let t0 = 0
const elapsed = (): number => Math.round(performance.now() - t0)
function reset() {
runId.value = null
question.value = ''
rationale.value = ''
planSteps.value = []
graphNodes.value = []
log.value = []
toolCalls.value = []
answer.value = ''
error.value = null
status.value = 'idle'
}
function push(level: string, stage: string, msg: string) {
log.value.push({
level,
stage,
msg,
ts: new Date().toISOString().split('T')[1]!.split('.')[0]!,
})
}
function nodeBy(id: string): GraphNode | undefined {
return graphNodes.value.find(n => n.id === id)
}
function setNodeStatus(id: string, s: GraphNode['status'], detail?: string) {
const n = nodeBy(id)
if (n) {
n.status = s
if (detail !== undefined) n.detail = detail
}
}
async function ask(q: string): Promise<string | null> {
reset()
status.value = 'connecting'
question.value = q
t0 = performance.now()
let res: Response
try {
res = await fetch(apiUrl('/ask'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: q }),
})
} catch (e: any) {
status.value = 'error'
error.value = String(e)
push('error', 'system', `network: ${e}`)
return null
}
if (!res.ok) {
status.value = 'error'
error.value = `${res.status} ${res.statusText}`
push('error', 'system', `submit: ${error.value}`)
return null
}
const { run_id } = await res.json()
runId.value = run_id
// Seed graph with the meta-nodes we know about; analysis nodes are added
// once the plan arrives.
graphNodes.value = [
{ id: 'plan', kind: 'plan', label: 'Plan', status: 'running' },
{ id: 'synthesize', kind: 'synthesize', label: 'Synthesize', status: 'pending' },
]
push('info', 'system', `ask submitted → run ${run_id}`)
openStream(run_id)
return run_id
}
function openStream(id: string) {
if (es) es.close()
es = new EventSource(apiUrl(`/runs/${id}/stream`))
status.value = 'streaming'
es.addEventListener('run_start', (e: MessageEvent) => {
const d = JSON.parse(e.data)
push('info', 'run', `start: ${d.question}`)
})
es.addEventListener('node_update', (e: MessageEvent) => {
const d = JSON.parse(e.data)
push('debug', 'graph', `${d.node}${d.keys?.join(', ')}`)
})
es.addEventListener('plan_ready', (e: MessageEvent) => {
const d = JSON.parse(e.data) as { rationale: string; steps: PlanStep[] }
rationale.value = d.rationale
planSteps.value = d.steps
// Replace placeholder analysis nodes with real ones from the plan.
const planNode = nodeBy('plan')!
planNode.status = 'done'
planNode.detail = d.rationale
const synth = graphNodes.value.find(n => n.id === 'synthesize')
graphNodes.value = [
planNode,
...d.steps.map((s, i): GraphNode => ({
id: `${s.analysis}#${i}`,
kind: 'analysis',
label: s.analysis,
status: 'pending',
detail: s.why,
})),
synth || { id: 'synthesize', kind: 'synthesize', label: 'Synthesize', status: 'pending' },
]
push('info', 'plan', `${d.steps.length} step(s): ${d.steps.map(s => s.analysis).join(' → ')}`)
})
es.addEventListener('analysis_start', (e: MessageEvent) => {
const d = JSON.parse(e.data) as { analysis: string; args: any; why: string }
// Mark the first matching pending analysis node as running.
const n = graphNodes.value.find(g => g.kind === 'analysis' && g.label === d.analysis && g.status === 'pending')
if (n) n.status = 'running'
push('info', d.analysis, `start: ${d.why || JSON.stringify(d.args)}`)
})
es.addEventListener('analysis_end', (e: MessageEvent) => {
const d = JSON.parse(e.data) as { analysis: string; summary: string; error: string | null; row_count: number }
const n = graphNodes.value.find(g => g.kind === 'analysis' && g.label === d.analysis && g.status === 'running')
if (n) {
n.status = d.error ? 'error' : 'done'
n.detail = d.error || d.summary
}
const level = d.error ? 'error' : 'info'
const msg = d.error ? `error: ${d.error}` : `done — ${d.row_count} rows · ${d.summary}`
push(level, d.analysis, msg)
})
es.addEventListener('tool_call_start', (e: MessageEvent) => {
const d = JSON.parse(e.data) as { tool: string; input: any }
toolCalls.value.push({ tool: d.tool, input: d.input, t_started: elapsed() })
const detail = d.tool === 'execute_sql' ? (d.input?.sql || '') :
d.tool === 'text_to_sql' ? (d.input?.question || '') :
JSON.stringify(d.input)
push('debug', d.tool, `${detail}`)
})
es.addEventListener('tool_call_end', (e: MessageEvent) => {
const d = JSON.parse(e.data) as { tool: string; output: any; error: string | null }
const call = [...toolCalls.value].reverse().find(c => c.tool === d.tool && c.t_ended === undefined)
if (call) {
call.output = d.output
call.error = d.error
call.t_ended = elapsed()
}
if (d.error) {
push('error', d.tool, `${d.error}`)
return
}
if (d.tool === 'text_to_sql' && d.output?.sql) {
push('info', d.tool, `${d.output.sql}`)
} else if (d.tool === 'execute_sql') {
const label = d.output?.label ? `${d.output.label}: ` : ''
push('info', d.tool, `${label}${d.output?.row_count} rows`)
} else if (d.tool === 'generate_pair') {
push('info', d.tool, `✓ a=${d.output?.a?.label} · b=${d.output?.b?.label}`)
} else {
push('info', d.tool, '✓')
}
})
es.addEventListener('llm_call', (e: MessageEvent) => {
const d = JSON.parse(e.data) as { label: string; system_chars: number; user_chars: number }
push('debug', d.label, `llm ← sys:${d.system_chars} usr:${d.user_chars}`)
})
es.addEventListener('synth_start', () => {
setNodeStatus('synthesize', 'running')
push('info', 'synthesize', '→ composing final answer')
})
es.addEventListener('run_end', (e: MessageEvent) => {
const d = JSON.parse(e.data) as { answer: string; error: string | null }
setNodeStatus('synthesize', d.error ? 'error' : 'done')
answer.value = d.answer || ''
if (d.error) {
error.value = d.error
push('error', 'run', `end: ${d.error}`)
} else {
push('info', 'run', `end · ${elapsed()}ms`)
}
})
es.addEventListener('end', () => {
status.value = error.value ? 'error' : 'done'
es?.close()
es = null
})
es.addEventListener('error', (e: MessageEvent) => {
// The browser also fires a generic `error` event on disconnect; only
// surface it when the run hasn't already ended.
if (status.value === 'streaming') {
status.value = 'error'
const msg = e?.data ? JSON.parse(e.data)?.message : 'stream error'
push('error', 'stream', msg)
}
})
}
function disconnect() {
if (es) { es.close(); es = null }
}
onUnmounted(disconnect)
const isRunning = computed(() => status.value === 'connecting' || status.value === 'streaming')
return {
status, runId, question, rationale, planSteps, graphNodes,
log, toolCalls, answer, error, isRunning,
ask, disconnect,
}
}

21
ui/app/src/config.ts Normal file
View File

@@ -0,0 +1,21 @@
/* API URL resolver.
*
* The SPA is served from `nvi.local.ar` (or `nivii.mcrn.ar` in AWS, or
* `localhost:5173` in `vite dev`). The API lives at the `api.` subdomain in
* production-style deploys; in dev the vite proxy forwards relative paths.
*/
export function apiUrl(path: string): string {
const { protocol, hostname } = window.location
if (hostname.endsWith('.local.ar')) {
return `${protocol}//api.${hostname}${path}`
}
if (hostname === 'nivii.mcrn.ar' || hostname === 'docs.nivii.mcrn.ar') {
return `https://api.nivii.mcrn.ar${path}`
}
// localhost (vite dev) or unrecognised host — rely on same-origin / proxy.
return path
}

View File

@@ -3,13 +3,12 @@ import { createRouter, createWebHistory } from 'vue-router'
import 'soleprint-ui/src/tokens.css'
import App from './App.vue'
import Ask from './pages/Ask.vue'
import RunInspector from './pages/RunInspector.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: Ask },
{ path: '/runs/:id', component: RunInspector, props: true },
{ path: '/:catchAll(.*)', redirect: '/' },
],
})

View File

@@ -1,80 +1,477 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { nextTick, ref, watch } from 'vue'
import { Panel } from 'soleprint-ui'
import { useRunStream } from '../composables/useRunStream'
const question = ref('Which districts had the sharpest rise in loan defaults?')
const submitting = ref(false)
const error = ref<string | null>(null)
const router = useRouter()
const question = ref('How many loans are currently in debt (status D)?')
const examples = [
'How many loans are currently in debt (status D)?',
'Which districts have the highest average loan amount?',
'Compare loan default rates between 1995 and 1996.',
'What share of accounts have at least one credit card?',
]
const {
status, runId, rationale, planSteps, graphNodes, log,
toolCalls, answer, error, isRunning, ask,
} = useRunStream()
const wrapLog = ref(true)
const logEl = ref<HTMLElement | null>(null)
const expanded = ref<Set<string>>(new Set())
function toggleNode(id: string) {
const s = new Set(expanded.value)
if (s.has(id)) s.delete(id)
else s.add(id)
expanded.value = s
}
watch(() => log.value.length, async () => {
await nextTick()
if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight
})
async function submit() {
if (!question.value.trim()) return
submitting.value = true
error.value = null
try {
const res = await fetch('/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: question.value }),
})
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
const { run_id } = await res.json()
router.push(`/runs/${run_id}`)
} catch (e: any) {
error.value = e.message ?? String(e)
} finally {
submitting.value = false
}
if (!question.value.trim() || isRunning.value) return
await ask(question.value)
}
function pick(q: string) {
question.value = q
}
function statusOf(s: string): 'idle' | 'processing' | 'live' | 'error' {
if (s === 'streaming' || s === 'connecting') return 'processing'
if (s === 'done') return 'live'
if (s === 'error') return 'error'
return 'idle'
}
</script>
<template>
<div class="ask">
<h2>Ask a question</h2>
<textarea v-model="question" rows="3" :disabled="submitting" />
<div class="actions">
<button @click="submit" :disabled="submitting || !question.trim()">
{{ submitting ? 'Submitting…' : 'Ask' }}
</button>
<div class="ask-layout">
<!-- Left / top: ask + answer (the user's surface) -->
<div class="ask-pane">
<Panel title="Ask" :status="statusOf(status)">
<div class="ask-form">
<textarea v-model="question" rows="3" :disabled="isRunning"
placeholder="Ask a question about the financial warehouse…" />
<div class="ask-actions">
<span class="hint">{{ isRunning ? `running… ${runId}` : 'Examples:' }}</span>
<button class="run-btn" @click="submit" :disabled="isRunning || !question.trim()">
{{ isRunning ? 'Running' : 'Ask' }}
</button>
</div>
<div v-if="!isRunning" class="examples">
<button v-for="e in examples" :key="e" class="example" @click="pick(e)">
{{ e }}
</button>
</div>
</div>
</Panel>
<Panel title="Answer" :status="answer ? 'live' : (error ? 'error' : 'idle')">
<div v-if="error" class="error-block">{{ error }}</div>
<div v-else-if="answer" class="answer-block">{{ answer }}</div>
<div v-else-if="isRunning" class="muted">Composing…</div>
<div v-else class="muted">Ask a question to see the answer here.</div>
</Panel>
</div>
<!-- Right / bottom: live trace (the demo's surface) -->
<div class="trace-pane">
<Panel title="Plan" :status="statusOf(status)">
<div v-if="rationale" class="rationale">{{ rationale }}</div>
<div v-else class="muted small">Waiting for the planner</div>
<div class="graph">
<div v-for="(n, idx) in graphNodes" :key="n.id" class="graph-row">
<span class="connector" v-if="idx > 0"></span>
<div
:class="['node', n.kind, n.status, {
expanded: expanded.has(n.id),
expandable: !!n.detail,
}]"
@click="n.detail && toggleNode(n.id)"
:role="n.detail ? 'button' : undefined"
:tabindex="n.detail ? 0 : undefined"
@keydown.enter="n.detail && toggleNode(n.id)"
@keydown.space.prevent="n.detail && toggleNode(n.id)"
>
<div class="node-head">
<span class="chev" v-if="n.detail">{{ expanded.has(n.id) ? '' : '' }}</span>
<span class="dot" />
<span class="label">{{ n.label }}</span>
<span v-if="n.detail && !expanded.has(n.id)" class="detail-preview">{{ n.detail }}</span>
</div>
<div v-if="n.detail && expanded.has(n.id)" class="detail-full">{{ n.detail }}</div>
</div>
</div>
</div>
</Panel>
<Panel title="Tool calls" class="tools-panel">
<div v-if="toolCalls.length === 0" class="muted small">No tool calls yet.</div>
<div v-for="(t, i) in toolCalls" :key="i" :class="['tool', t.error ? 'err' : (t.t_ended ? 'done' : 'pending')]">
<div class="tool-head">
<span class="tool-name">{{ t.tool }}</span>
<span class="tool-time">{{ t.t_ended ? `${t.t_ended - t.t_started}ms` : '…' }}</span>
</div>
<pre v-if="t.tool === 'text_to_sql' && t.output?.sql" class="tool-body sql">{{ t.output.sql }}</pre>
<pre v-else-if="t.tool === 'execute_sql' && t.input?.sql" class="tool-body sql">{{ t.input.sql }}</pre>
<pre v-else-if="t.tool === 'generate_pair' && t.output" class="tool-body sql">A ({{ t.output.a?.label }}): {{ t.output.a?.sql }}
B ({{ t.output.b?.label }}): {{ t.output.b?.sql }}</pre>
<pre v-if="t.output?.preview && t.output.preview.length" class="tool-body rows">{{ JSON.stringify(t.output.preview, null, 2) }}</pre>
<div v-if="t.error" class="tool-error">{{ t.error }}</div>
</div>
</Panel>
<Panel title="Event log" class="log-panel">
<template #actions>
<label class="wrap-toggle">
<input type="checkbox" v-model="wrapLog" />
wrap
</label>
</template>
<div ref="logEl" :class="['log', { wrap: wrapLog }]">
<div v-for="(e, i) in log" :key="i" :class="['log-row', e.level]">
<span class="log-ts">{{ e.ts }}</span>
<span class="log-level">{{ e.level }}</span>
<span class="log-stage">{{ e.stage }}</span>
<span class="log-msg">{{ e.msg }}</span>
</div>
<div v-if="log.length === 0" class="muted small">No events yet.</div>
</div>
</Panel>
</div>
<p v-if="error" class="error">{{ error }}</p>
</div>
</template>
<style scoped>
.ask {
max-width: 720px;
.ask-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
height: calc(100vh - 80px);
min-height: 0;
}
.ask-pane,
.trace-pane {
display: flex;
flex-direction: column;
gap: 12px;
overflow: auto;
min-height: 0;
}
.trace-pane {
border-left: var(--panel-border);
padding-left: 16px;
}
.trace-pane > .tools-panel {
flex: 1;
min-height: 0;
}
.trace-pane > .log-panel {
flex: 1;
min-height: 200px;
}
/* ── Ask form ── */
.ask-form {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
textarea {
width: 100%;
background: var(--surface-2);
color: var(--text-primary);
border: var(--panel-border);
padding: 12px;
padding: 10px;
font-family: var(--font-mono);
font-size: 14px;
font-size: 13px;
resize: vertical;
}
.actions {
.ask-actions {
display: flex;
justify-content: flex-end;
align-items: center;
justify-content: space-between;
gap: 12px;
}
button {
padding: 8px 20px;
.hint {
font-family: var(--font-mono);
background: var(--accent-dim);
color: var(--text-primary);
border: var(--panel-border);
font-size: 11px;
color: var(--text-dim);
}
.run-btn {
background: var(--accent);
color: white;
border: none;
padding: 6px 18px;
font-family: var(--font-mono);
font-size: 12px;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
.run-btn:hover { background: var(--accent-dim); }
.run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.examples {
display: flex;
flex-direction: column;
gap: 4px;
}
.error {
.example {
text-align: left;
background: transparent;
border: none;
border-left: 2px solid var(--surface-3);
padding: 4px 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
}
.example:hover {
color: var(--text-primary);
border-left-color: var(--accent);
}
/* ── Answer block ── */
.answer-block {
padding: 16px;
font-size: 14px;
color: var(--text-primary);
line-height: 1.6;
}
.error-block {
padding: 16px;
color: var(--status-error, #f55);
font-family: var(--font-mono);
font-size: 12px;
}
.muted {
padding: 16px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 12px;
}
.muted.small { padding: 8px 12px; font-size: 11px; }
/* ── Plan + graph ── */
.rationale {
padding: 10px 12px;
font-size: 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--surface-3);
font-style: italic;
}
.graph {
display: flex;
flex-direction: column;
padding: 10px 12px;
gap: 2px;
}
.graph-row {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.connector {
font-family: var(--font-mono);
color: var(--text-dim);
font-size: 10px;
margin-left: 6px;
}
.node {
display: flex;
flex-direction: column;
padding: 6px 10px;
background: var(--surface-2);
border: var(--panel-border);
font-family: var(--font-mono);
font-size: 12px;
width: 100%;
box-sizing: border-box;
}
.node.expandable { cursor: pointer; }
.node.expandable:hover { background: var(--surface-3, #1a2340); }
.node.expandable:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
.node-head {
display: flex;
align-items: center;
gap: 10px;
}
.node .chev {
color: var(--text-dim);
font-size: 10px;
width: 10px;
flex-shrink: 0;
}
.node .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--status-idle, #555);
flex-shrink: 0;
}
.node.running .dot {
background: var(--status-processing, #ffc107);
box-shadow: 0 0 8px var(--status-processing, #ffc107);
animation: pulse 1s ease-in-out infinite;
}
.node.done .dot { background: var(--status-live, #00c853); }
.node.error .dot { background: var(--status-error, #ef4444); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.node .label { font-weight: 500; }
.node .detail-preview {
margin-left: auto;
color: var(--text-dim);
font-size: 11px;
text-align: right;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node .detail-full {
margin-top: 6px;
padding: 6px 0 2px 20px;
color: var(--text-secondary);
font-size: 11px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
border-top: 1px dashed var(--surface-3);
}
/* ── Tool call cards ── */
.tool {
margin: 8px;
border: var(--panel-border);
border-left-width: 3px;
background: var(--surface-2);
}
.tool.done { border-left-color: var(--status-live, #00c853); }
.tool.pending { border-left-color: var(--status-processing, #ffc107); }
.tool.err { border-left-color: var(--status-error, #ef4444); }
.tool-head {
display: flex;
justify-content: space-between;
padding: 6px 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
border-bottom: 1px solid var(--surface-3);
}
.tool-name { font-weight: 600; }
.tool-time { color: var(--text-dim); }
.tool-body {
margin: 0;
padding: 8px 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow: auto;
}
.tool-body.sql { color: #7ab0ff; }
.tool-body.rows { color: var(--text-dim); }
.tool-error {
padding: 8px 10px;
color: var(--status-error, #f55);
font-family: var(--font-mono);
font-size: 11px;
}
/* ── Event log ── */
.wrap-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
user-select: none;
}
.wrap-toggle input { margin: 0; }
.log {
height: 100%;
overflow: auto;
padding: 4px 0;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.5;
}
.log-row {
display: grid;
grid-template-columns: 64px 52px 100px 1fr;
gap: 8px;
padding: 2px 10px;
align-items: baseline;
}
.log:not(.wrap) .log-msg {
white-space: pre;
overflow-x: auto;
}
.log.wrap .log-msg {
white-space: pre-wrap;
word-break: break-word;
}
.log-ts { color: var(--text-dim); }
.log-level {
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.5px;
}
.log-row.info .log-level { color: var(--text-secondary); }
.log-row.debug .log-level { color: var(--text-dim); }
.log-row.error .log-level { color: var(--status-error, #ef4444); }
.log-row.error .log-msg { color: var(--status-error, #ef4444); }
.log-row.warn .log-level { color: var(--status-processing, #ffc107); }
.log-stage { color: #7ab0ff; }
.log-msg { color: var(--text-primary); }
/* ── Mobile: stack vertically ── */
@media (max-width: 900px) {
.ask-layout {
grid-template-columns: 1fr;
height: auto;
}
.trace-pane {
border-left: none;
border-top: var(--panel-border);
padding-left: 0;
padding-top: 16px;
}
}
</style>

View File

@@ -1,47 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const props = defineProps<{ id: string }>()
const state = ref<any>(null)
const error = ref<string | null>(null)
onMounted(async () => {
try {
const res = await fetch(`/runs/${props.id}`)
if (!res.ok) throw new Error(`${res.status}`)
state.value = await res.json()
} catch (e: any) {
error.value = e.message ?? String(e)
}
})
</script>
<template>
<div class="inspector">
<h2>Run {{ id }}</h2>
<p v-if="error" class="error">{{ error }}</p>
<pre v-else-if="state">{{ JSON.stringify(state, null, 2) }}</pre>
<p v-else>Loading</p>
</div>
</template>
<style scoped>
.inspector {
display: flex;
flex-direction: column;
gap: 12px;
}
pre {
background: var(--surface-2);
color: var(--text-primary);
border: var(--panel-border);
padding: 12px;
font-family: var(--font-mono);
font-size: 12px;
overflow: auto;
}
.error {
color: var(--status-error, #f55);
}
</style>