---
format: typebulb/v1
name: Is It True?
---

**code.tsx**

```tsx
import React, { useState, useEffect, useCallback } from "react";
import { createRoot } from "react-dom/client";

type TbModel = { provider: string; name: string; friendlyName: string; providerName: string };
type Verdict = "true" | "false" | "unclear";
type Result = {
  model: TbModel;
  status: "pending" | "done" | "error";
  verdict?: Verdict;
  reasoning?: string;
  error?: string;
};

const SYSTEM = `Assess whether the following claim is true, false, or unclear. If it involves recent events or specific facts, search the web to verify.

Start your response with one of these exact lines:
**Verdict: true**
**Verdict: false**
**Verdict: unclear**

Then explain your reasoning in 2-3 sentences.`;

const modelLabel = (m: TbModel) => m.providerName ? `${m.providerName}: ${m.friendlyName}` : m.friendlyName;

function parseResponse(text: string): { verdict: Verdict; reasoning: string } {
  const s = text.trim();
  const match = s.match(/\*\*Verdict:\s*(true|false|unclear)\*\*/i);
  const verdict: Verdict = match ? match[1].toLowerCase() as Verdict : "unclear";
  const reasoning = match ? s.slice(match.index! + match[0].length).trim() : s;
  return { verdict, reasoning };
}

function summaryText(results: Result[]): string | null {
  if (results.some(r => r.status === "pending")) return null;
  const done = results.filter(r => r.status === "done" && r.verdict);
  const errCount = results.filter(r => r.status === "error").length;
  if (!done.length) return errCount ? "All models errored." : null;

  const counts: Record<Verdict, number> = { true: 0, false: 0, unclear: 0 };
  for (const r of done) counts[r.verdict!]++;
  const n = done.length;
  const errNote = errCount ? ` (${errCount} errored)` : "";

  if (n === 1) return `${done[0].verdict}.${errNote}`;

  for (const [v, c] of Object.entries(counts)) {
    if (c === n) return `Unanimous: ${n} of ${n} say it's ${v}.${errNote}`;
  }

  const parts = Object.entries(counts)
    .filter(([, c]) => c > 0)
    .sort((a, b) => b[1] - a[1])
    .map(([v, c]) => `${c} say${c === 1 ? "s" : ""} ${v}`);
  return `Split: ${parts.join(", ")}.${errNote}`;
}

// ── App ──────────────────────────────────────────────────────────

function App() {
  const [models, setModels] = useState<TbModel[]>([]);
  const [hasOwnKeys, setHasOwnKeys] = useState(true);
  const [selected, setSelected] = useState<Set<number>>(new Set());
  const [claim, setClaim] = useState("");
  const [results, setResults] = useState<Result[]>([]);
  const [running, setRunning] = useState(false);

  useEffect(() => {
    Promise.all([tb.models(), tb.hasOwnKeys()]).then(([m, has]) => {
      setModels(m);
      setHasOwnKeys(has);
      if (m.length === 1 && !has) setSelected(new Set([0]));
    });
  }, []);

  const toggle = (i: number) =>
    setSelected(prev => { const s = new Set(prev); s.has(i) ? s.delete(i) : s.add(i); return s; });

  const check = useCallback(async () => {
    const sel = [...selected].sort();
    if (!claim.trim() || !sel.length) return;

    const initial: Result[] = sel.map(i => ({ model: models[i], status: "pending" }));
    setResults(initial);
    setRunning(true);

    await Promise.all(sel.map(async (modelIdx, resultIdx) => {
      const model = models[modelIdx];
      try {
        const resp = await tb.ai({
          provider: model.provider, model: model.name,
          system: SYSTEM,
          messages: [{ role: "user", content: claim.trim() }],
        });
        const { verdict, reasoning } = parseResponse(resp.text);
        setResults(prev => prev.map((r, i) => i === resultIdx
          ? { ...r, status: "done", verdict, reasoning } : r));
      } catch (e: any) {
        setResults(prev => prev.map((r, i) => i === resultIdx
          ? { ...r, status: "error", error: e.message } : r));
      }
    }));

    setRunning(false);
  }, [claim, selected, models]);

  const singleDefault = models.length === 1 && !hasOwnKeys;
  const sum = summaryText(results);

  return (
    <div className="app">
      <h1>Is it true?</h1>

      <textarea
        value={claim}
        onChange={e => setClaim(e.target.value)}
        placeholder="Paste a claim to check…"
        rows={3}
        disabled={running}
      />

      {models.length > 0 && !singleDefault && (
        <div className="models">
          {Object.entries(models.reduce((acc, m, i) => {
            const g = m.providerName || "Default";
            (acc[g] ??= []).push({ m, i });
            return acc;
          }, {} as Record<string, { m: TbModel; i: number }[]>)).map(([provider, group]) => (
            <div key={provider} className="provider-group">
              <div className="provider-name">{provider}</div>
              <div className="chips">{group.map(({ m, i }) => (
                <button key={i} className={`chip${selected.has(i) ? " on" : ""}`}
                  onClick={() => !running && toggle(i)} disabled={running}>
                  {m.friendlyName}
                </button>
              ))}</div>
            </div>
          ))}
        </div>
      )}
      {singleDefault && <p className="hint">Register with your own API keys to call multiple models at once and with web search.</p>}

      <button className="go" onClick={check}
        disabled={running || !claim.trim() || !selected.size}>
        {running ? "Checking…" : "Check"}
      </button>

      {results.length > 0 && (
        <div className="results">
          {sum && <div className="summary">{sum}</div>}
          {results.map((r, i) => (
            <div key={i} className="card">
              <div className="card-head">
                <span className="card-model">{modelLabel(r.model)}</span>
                {r.status === "pending" && <span className="badge pending pending-anim">…</span>}
                {r.status === "done" && <span className={`badge ${r.verdict}`}>{r.verdict}</span>}
                {r.status === "error" && <span className="badge error">error</span>}
              </div>
              {r.reasoning && <p className="reasoning">{r.reasoning}</p>}
              {r.error && <p className="reasoning error-text">{r.error}</p>}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

createRoot(document.getElementById("root")!).render(<App />);
```
**styles.css**

```css
:root {
  --bg: #0a0a0a;
  --bg-card: #141414;
  --bg-input: #1a1a1a;
  --border: #333;
  --border-light: #222;
  --text: #e0e0e0;
  --text-muted: #999;
  --text-dim: #888;
  --accent: #4a9eff;
  --accent-bg: #111828;
  --green: #4ade80;
  --green-bg: #0a2a0a;
  --red: #f87171;
  --red-bg: #2a0a0a;
  --yellow: #fbbf24;
  --yellow-bg: #2a2a0a;
  font-family: system-ui, -apple-system, sans-serif;
}

html[data-theme="light"] {
  --bg: #f8f8f8;
  --bg-card: #fff;
  --bg-input: #fff;
  --border: #ddd;
  --border-light: #e8e8e8;
  --text: #1a1a1a;
  --text-muted: #666;
  --text-dim: #888;
  --accent: #2563eb;
  --accent-bg: #eff6ff;
  --green: #16a34a;
  --green-bg: #f0fdf4;
  --red: #dc2626;
  --red-bg: #fef2f2;
  --yellow: #92400e;
  --yellow-bg: #fefce8;
}

* { box-sizing: border-box; margin: 0; }
body { background: var(--bg); color: var(--text); padding: 24px; }
.app { max-width: 640px; margin: 0 auto; }
h1 { font-size: 1.5rem; margin-bottom: 16px; }

textarea {
  width: 100%; padding: 12px; border-radius: 8px; border: 1px solid var(--border);
  background: var(--bg-input); color: var(--text); font-size: 0.95rem; resize: vertical;
  font-family: inherit;
}
textarea:focus { outline: none; border-color: var(--accent); }

.models { margin: 12px 0; }
.provider-group { margin-bottom: 8px; }
.provider-group:last-child { margin-bottom: 0; }
.provider-name { font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; margin-bottom: 4px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
  padding: 6px 14px; border-radius: 16px; border: 1px solid var(--border);
  background: transparent; color: var(--text-dim); cursor: pointer; font-size: 0.85rem;
  transition: all 0.15s; user-select: none;
}
.chip:hover { border-color: var(--accent); color: var(--text); }
.chip.on { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); font-weight: 600; }
.chip:disabled { opacity: 0.4; cursor: not-allowed; }
.hint { font-size: 0.82rem; color: var(--yellow); font-weight: 600; margin: 12px 0; }

.go {
  padding: 10px 24px; border-radius: 8px; border: none;
  background: var(--accent); color: #fff; font-size: 0.95rem; cursor: pointer;
  font-weight: 500;
}
.go:disabled { opacity: 0.4; cursor: not-allowed; }

.results { margin-top: 20px; }

.summary {
  font-size: 1.1rem; font-weight: 600; padding: 12px 16px;
  border-radius: 8px; background: var(--accent-bg); margin-bottom: 12px;
  border-left: 3px solid var(--accent);
}

.card {
  padding: 12px 16px; border-radius: 8px; background: var(--bg-card);
  border: 1px solid var(--border-light); margin-bottom: 8px;
}
.card-head { display: flex; justify-content: space-between; align-items: center; }
.card-model { font-weight: 500; font-size: 0.9rem; }

.badge {
  padding: 2px 10px; border-radius: 12px; font-size: 0.8rem; font-weight: 600;
  text-transform: uppercase; letter-spacing: 0.03em;
}
.badge.true { background: var(--green-bg); color: var(--green); }
.badge.false { background: var(--red-bg); color: var(--red); }
.badge.unclear { background: var(--yellow-bg); color: var(--yellow); }
.badge.pending { color: var(--text-dim); }
.badge.error { background: var(--red-bg); color: var(--red); }

.reasoning { margin-top: 8px; font-size: 0.85rem; color: var(--text-muted); line-height: 1.5; }
.error-text { color: var(--red); }

@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.pending-anim { animation: pulse 1.2s ease-in-out infinite; }
```
**index.html**

```html
<div id="root"></div>
```
**config.json**

```json
{
  "dependencies": {
    "react": "^19.2.4",
    "react-dom": "^19.2.4"
  },
  "description": "Fact-check a claim across multiple AI models and see if they agree."
}
```