"use client"; import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from "react"; import { ink, pct, split, swap, uncertain, type Alt, type Placed, type Token } from "./ink"; import "./confidence-ink.css"; export type { Alt, Token } from "./ink"; export type ConfidenceInkProps = { tokens: Token[]; /** Tokens below this probability are drawn in lighter ink; the reader can move it. */ defaultThreshold?: number; label?: string; onChange?: (text: string, tokens: Token[]) => void; theme?: "light" | "dark"; className?: string; }; /** * Confidence Ink * Generated text printed with the model's own certainty: words it was unsure * of come out in lighter ink with a dotted underline, and opening one shows * what else it nearly wrote, with the odds, so you can swap it in. */ export function ConfidenceInk({ tokens: initial, defaultThreshold = 0.6, label = "Answer", onChange, theme = "light", className = "" }: ConfidenceInkProps) { const id = useId(); const [tokens, setTokens] = useState(initial); const [threshold, setThreshold] = useState(defaultThreshold); const [open, setOpen] = useState(null); const [pos, setPos] = useState<{ x: number; y: number } | null>(null); const [message, setMessage] = useState(""); const root = useRef(null); const triggers = useRef(new Map()); const menu = useRef(null); const originals = useRef(initial.map((t) => t.text)); const low = new Set(uncertain(tokens, threshold)); const edited = tokens.filter((t) => t.edited).length; // Place the menu under its word, kept inside the panel. useLayoutEffect(() => { if (open === null) return setPos(null); const b = triggers.current.get(open), box = root.current; if (!b || !box) return; const r = b.getBoundingClientRect(), o = box.getBoundingClientRect(); const w = menu.current?.offsetWidth ?? 240; setPos({ x: Math.max(8, Math.min(o.width - w - 8, r.left - o.left)), y: r.bottom - o.top + 6 }); }, [open, tokens]); // Focus the current choice once the menu is placed and visible. const placed = pos !== null; useEffect(() => { if (placed) menu.current?.querySelector("[aria-checked='true']")?.focus(); }, [placed, open]); useEffect(() => { if (open === null) return; const away = (e: PointerEvent) => { if (!menu.current?.contains(e.target as Node) && !triggers.current.get(open)?.contains(e.target as Node)) setOpen(null); }; document.addEventListener("pointerdown", away); return () => document.removeEventListener("pointerdown", away); }, [open]); const close = (refocus = true) => { const i = open; setOpen(null); if (refocus && i !== null) triggers.current.get(i)?.focus(); }; const pick = (i: number, alt: Alt) => { const next = swap(tokens, i, alt, originals.current[i]); setTokens(next); onChange?.(next.map((t) => t.text).join(""), next); setMessage(`Replaced with “${alt.text.trim()}”, ${pct(alt.p)}.`); close(); }; const onMenuKey = (e: KeyboardEvent) => { const items = [...(menu.current?.querySelectorAll("[role='menuitemradio']") ?? [])]; const k = items.indexOf(document.activeElement as HTMLElement); if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); items[(k + (e.key === "ArrowDown" ? 1 : items.length - 1)) % items.length]?.focus(); } else if (e.key === "Home") { e.preventDefault(); items[0]?.focus(); } else if (e.key === "End") { e.preventDefault(); items[items.length - 1]?.focus(); } else if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); close(); } }; const current = open !== null ? tokens[open] : null; const options = current ? [{ text: current.text, p: current.p }, ...(current.alternatives ?? [])].sort((a, b) => b.p - a.p) : []; return (
{label}

{tokens.map((t, i) => { const [space, word] = split(t.text); if (!low.has(i) && !t.edited) return {t.text}; const alts = (t.alternatives?.length ?? 0) > 0; return ( {space} ); })}

{current && (