. We do
+ // NOT pass `children` (the inner element) through, because the
+ // `code` component below can't tell block code from inline code (a
+ // no-language fence's carries no className) and would render it
+ // as orange inline text on a slate-900 box — the "black box, yellow
+ // text" artifact. Handling all block code here keeps `code` purely for
+ // inline spans.
pre({ children }) {
const child = Array.isArray(children) ? children[0] : children
- if (
- child &&
- typeof child === "object" &&
- "props" in child &&
- (child as { props: { className?: string } }).props.className === "language-chart"
- ) {
- const raw = (child as { props: { children?: unknown } }).props.children
- const text = (Array.isArray(raw) ? raw.join("") : String(raw ?? "")).trim()
- const spec = tryParseChartSpec(text)
+ const isElement =
+ !!child && typeof child === "object" && "props" in child
+ const className = isElement
+ ? (child as { props: { className?: string } }).props.className
+ : undefined
+ const rawChildren = isElement
+ ? (child as { props: { children?: unknown } }).props.children
+ : children
+ const text = Array.isArray(rawChildren)
+ ? rawChildren.join("")
+ : String(rawChildren ?? "")
+
+ if (hasLanguageClass(className, "chart")) {
+ const trimmed = text.trim()
+ const spec = tryParseChartSpec(trimmed)
if (spec) return
// Don't show the red parse-failed box mid-stream — ReactMarkdown
// re-renders on every token, so an unclosed chart fence flashes
// an error for every chart until streaming finishes. Only treat
// it as a real error once the JSON has finished arriving.
- if (chartSpecLooksIncomplete(text)) return
- return
+ if (chartSpecLooksIncomplete(trimmed)) return
+ return
}
+
return (
-
- {children}
+
+ {text}
)
},
- code({ className, children, ...props }) {
- const isInline = !className
- if (isInline) {
- return (
-
- {children}
-
- )
- }
+ // Inline code only — block code never reaches here (see `pre` above).
+ code({ children, ...props }) {
return (
-
+
{children}
)
diff --git a/portal-web/src/components/chat/PilotArea.tsx b/portal-web/src/components/chat/PilotArea.tsx
index 9dbd8e67..19d5f7bc 100644
--- a/portal-web/src/components/chat/PilotArea.tsx
+++ b/portal-web/src/components/chat/PilotArea.tsx
@@ -21,6 +21,7 @@ import {
} from "lucide-react"
import { cn } from "./cn"
import { Markdown } from "./Markdown"
+import { svgChartToPngDataUrl, tryParseChartSpec } from "./ChartRenderer"
import { InputArea } from "./InputArea"
import { SkillCard } from "./SkillCard"
import { ScheduleCard } from "./ScheduleCard"
@@ -310,7 +311,7 @@ export function PilotArea({
{visibleForCopy.length > 0 && (
-
+
)}
@@ -1189,20 +1190,25 @@ async function copyTextToClipboard(text: string): Promise {
// Tracks "copied" feedback state with a self-cancelling timer so back-to-back
// clicks don't leave a dangling timeout that clears the green check too early.
-function useCopyFeedback(): [boolean, (text: string) => Promise] {
+// `flash` lets callers that copy via a custom path (e.g. rich text/html with
+// rasterised charts) still surface the same green-check feedback.
+function useCopyFeedback(): [boolean, (text: string) => Promise, () => void] {
const [copied, setCopied] = useState(false)
const timerRef = useRef | null>(null)
useEffect(() => () => {
if (timerRef.current) clearTimeout(timerRef.current)
}, [])
- const copy = useCallback(async (text: string) => {
- const ok = await copyTextToClipboard(text)
- if (!ok) return
+ const flash = useCallback(() => {
setCopied(true)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => setCopied(false), 2000)
}, [])
- return [copied, copy]
+ const copy = useCallback(async (text: string) => {
+ const ok = await copyTextToClipboard(text)
+ if (!ok) return
+ flash()
+ }, [flash])
+ return [copied, copy, flash]
}
function serializeSessionToText(messages: PilotMessage[]): string {
@@ -1213,7 +1219,7 @@ function serializeSessionToText(messages: PilotMessage[]): string {
if (m.role === "user") {
lines.push(`You:\n${m.content.trim()}`)
} else if (m.role === "assistant") {
- const body = (m.content ?? "").trim()
+ const body = stripChartFences(m.content ?? "")
if (body) lines.push(`Assistant:\n${body}`)
} else if (m.role === "tool") {
const name = m.toolName ?? "tool"
@@ -1227,9 +1233,108 @@ function serializeSessionToText(messages: PilotMessage[]): string {
return lines.join("\n\n")
}
-function CopySessionButton({ messages }: { messages: PilotMessage[] }) {
- const [copied, copy] = useCopyFeedback()
- const handleCopy = () => {
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+}
+
+// Build a text/html rendition of the whole session with each ```chart fenced
+// block swapped for its rasterised PNG. The PNGs are passed in DOM order; an
+// assistant message's fences are mapped to them left-to-right, skipping fences
+// whose JSON failed to parse (those rendered no chart in the DOM, so they did
+// not consume a PNG).
+//
+// KNOWN LIMITATION — this PNG↔fence mapping is best-effort, not exact. It
+// assumes `tryParseChartSpec` succeeding here implies the fence rendered a real
+// `.chart-host svg` in the DOM. That can drift if a fence parsed but the chart
+// did not actually mount (e.g. it was still in the streaming/loading state, or
+// future render-gating changes), in which case later images attach to the
+// wrong message. Acceptable for a copy-to-clipboard convenience; if it ever
+// needs to be exact, walk per-message DOM nodes instead of re-parsing text.
+function serializeSessionToHtml(messages: PilotMessage[], chartUrls: string[]): string {
+ const fenceRe = /```chart\s*([\s\S]*?)```/g
+ let chartIdx = 0
+ const parts: string[] = []
+ for (const m of messages) {
+ if (m.hidden) continue
+ if (m.metadata?.kind === "delegation_status_notice") continue
+ if (m.role === "user") {
+ parts.push(`You:
${escapeHtml(m.content.trim()).replace(/\n/g, "
")}
`)
+ } else if (m.role === "assistant") {
+ const body = (m.content ?? "").trim()
+ if (!body) continue
+ let html = "Assistant:
"
+ let last = 0
+ let match: RegExpExecArray | null
+ fenceRe.lastIndex = 0
+ while ((match = fenceRe.exec(body)) !== null) {
+ const before = body.slice(last, match.index).trim()
+ if (before) html += `${escapeHtml(before).replace(/\n/g, "
")}
`
+ const parsed = tryParseChartSpec(match[1].trim())
+ if (parsed && chartIdx < chartUrls.length) {
+ html += `
`
+ } else {
+ html += "[chart]
"
+ }
+ last = match.index + match[0].length
+ }
+ const tail = body.slice(last).trim()
+ if (tail) html += `${escapeHtml(tail).replace(/\n/g, "
")}
`
+ parts.push(html)
+ } else if (m.role === "tool") {
+ const name = m.toolName ?? "tool"
+ const out = (m.content ?? "").trim()
+ parts.push(
+ `[${escapeHtml(name)}]
` +
+ (out ? `${escapeHtml(out)}` : ""),
+ )
+ } else if (m.role === "error") {
+ parts.push(`Error: ${escapeHtml((m.content ?? "").trim())}
`)
+ }
+ }
+ return `${parts.join("")}`
+}
+
+async function copySessionWithCharts(
+ container: HTMLElement,
+ messages: PilotMessage[],
+): Promise {
+ const svgs = Array.from(container.querySelectorAll('.chart-host svg[role="img"]'))
+ if (svgs.length === 0) return false
+ if (typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) return false
+ try {
+ const chartUrls = await Promise.all(svgs.map((svg) => svgChartToPngDataUrl(svg)))
+ const html = serializeSessionToHtml(messages, chartUrls)
+ const plain = serializeSessionToText(messages)
+ await navigator.clipboard.write([
+ new ClipboardItem({
+ "text/html": new Blob([html], { type: "text/html" }),
+ "text/plain": new Blob([plain], { type: "text/plain" }),
+ }),
+ ])
+ return true
+ } catch (err) {
+ console.warn("[copy] rich session copy failed, falling back to text:", err)
+ return false
+ }
+}
+
+function CopySessionButton({
+ messages,
+ containerRef,
+}: {
+ messages: PilotMessage[]
+ containerRef: React.RefObject
+}) {
+ const [copied, copy, flashCopied] = useCopyFeedback()
+ const handleCopy = async () => {
+ const container = containerRef.current
+ if (container && (await copySessionWithCharts(container, messages))) {
+ flashCopied()
+ return
+ }
void copy(serializeSessionToText(messages))
}
return (
@@ -1273,10 +1378,59 @@ function CopyIconButton({
)
}
-function CopyableMessage({ isUser, content }: { isUser: boolean; content: string }) {
- const [copied, copy] = useCopyFeedback()
+// Plain-text fallback for the clipboard: a ```chart fenced JSON block is noise
+// when pasted as text, so swap it for a readable placeholder.
+function stripChartFences(markdown: string): string {
+ return markdown.replace(/```chart\s*[\s\S]*?```/g, "[chart]").replace(/\n{3,}/g, "\n\n").trim()
+}
+
+// Copy a rendered message bubble as rich text/html with charts rasterised to
+// inline PNGs, plus a text/plain fallback. Returns false when there are no
+// charts or the browser can't do a rich clipboard write, so the caller can
+// fall back to a plain markdown copy. Without this, "copy answer" yielded the
+// raw chart JSON spec instead of the picture the user actually sees.
+async function copyBubbleWithCharts(bubble: HTMLElement, content: string): Promise {
+ const charts = Array.from(bubble.querySelectorAll('.chart-host svg[role="img"]'))
+ if (charts.length === 0) return false
+ if (typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) return false
+ try {
+ const dataUrls = await Promise.all(charts.map((svg) => svgChartToPngDataUrl(svg)))
+ const clone = bubble.cloneNode(true) as HTMLElement
+ const cloneHosts = Array.from(clone.querySelectorAll(".chart-host"))
+ cloneHosts.forEach((host, i) => {
+ const img = document.createElement("img")
+ img.src = dataUrls[i] ?? ""
+ img.style.maxWidth = "100%"
+ img.style.height = "auto"
+ host.replaceWith(img)
+ })
+ // Drop any leftover interactive controls (chart toolbars etc.).
+ clone.querySelectorAll("button").forEach((b) => b.remove())
+ const html = `${clone.innerHTML}`
+ const plain = stripChartFences(content)
+ await navigator.clipboard.write([
+ new ClipboardItem({
+ "text/html": new Blob([html], { type: "text/html" }),
+ "text/plain": new Blob([plain], { type: "text/plain" }),
+ }),
+ ])
+ return true
+ } catch (err) {
+ console.warn("[copy] rich chart copy failed, falling back to text:", err)
+ return false
+ }
+}
- const handleCopy = () => {
+function CopyableMessage({ isUser, content }: { isUser: boolean; content: string }) {
+ const [copied, copy, flashCopied] = useCopyFeedback()
+ const bubbleRef = useRef(null)
+
+ const handleCopy = async () => {
+ const bubble = bubbleRef.current
+ if (bubble && (await copyBubbleWithCharts(bubble, content))) {
+ flashCopied()
+ return
+ }
void copy(content)
}
@@ -1286,6 +1440,7 @@ function CopyableMessage({ isUser, content }: { isUser: boolean; content: string
return (
{
+ it("formats everyday integers with thousands separators, no scientific notation", () => {
+ expect(fmtNumber(0)).toBe("0")
+ expect(fmtNumber(42)).toBe("42")
+ expect(fmtNumber(1000)).toBe("1,000")
+ expect(fmtNumber(1005)).toBe("1,005")
+ expect(fmtNumber(1200)).toBe("1,200")
+ expect(fmtNumber(-1500)).toBe("-1,500")
+ })
+
+ it("uses SI suffixes for millions and above", () => {
+ expect(fmtNumber(1_000_000)).toBe("1M")
+ expect(fmtNumber(1_500_000)).toBe("1.5M")
+ expect(fmtNumber(2_000_000_000)).toBe("2G")
+ })
+
+ it("keeps significant digits for sub-1 values instead of rounding to 0 (regression)", () => {
+ // toFixed(2) used to turn 0.003 into "0.00" -> "0"; that hid real data.
+ expect(fmtNumber(0.003)).not.toBe("0")
+ expect(fmtNumber(0.003)).toBe("0.003")
+ expect(fmtNumber(0.5)).toBe("0.5")
+ expect(fmtNumber(0.12345)).toBe("0.12")
+ expect(fmtNumber(12.345)).toBe("12.35")
+ })
+
+ it("falls back to exponential only for values outside any sane chart range", () => {
+ expect(fmtNumber(1e-9)).toMatch(/e-9$/)
+ expect(fmtNumber(1e20)).toMatch(/e/)
+ })
+
+ it("passes non-finite values through", () => {
+ expect(fmtNumber(NaN)).toBe("NaN")
+ expect(fmtNumber(Infinity)).toBe("Infinity")
+ })
+})
+
+describe("niceAxis", () => {
+ it("produces a covering range with sorted ticks", () => {
+ const a = niceAxis(0, 1005)
+ expect(a.min).toBeLessThanOrEqual(0)
+ expect(a.max).toBeGreaterThanOrEqual(1005)
+ expect(a.ticks.length).toBeGreaterThanOrEqual(2)
+ expect([...a.ticks]).toEqual([...a.ticks].sort((x, y) => x - y))
+ })
+
+ it("does not collapse when min === max", () => {
+ const a = niceAxis(5, 5)
+ expect(a.max).toBeGreaterThan(a.min)
+ })
+
+ it("returns a safe default for non-finite input", () => {
+ expect(niceAxis(NaN, NaN)).toEqual({ min: 0, max: 1, ticks: [0, 0.5, 1] })
+ })
+})
+
+describe("logAxis", () => {
+ it("spans whole powers of ten with power-of-ten ticks", () => {
+ const a = logAxis(10, 1000)
+ expect(a.log).toBe(true)
+ expect(a.min).toBe(10)
+ expect(a.max).toBe(1000)
+ expect(a.ticks).toEqual([10, 100, 1000])
+ })
+})
+
+describe("axisFrac", () => {
+ it("maps linearly for a linear axis", () => {
+ const a = niceAxis(0, 100)
+ expect(axisFrac(a, a.min)).toBe(0)
+ expect(axisFrac(a, a.max)).toBe(1)
+ })
+
+ it("maps logarithmically for a log axis", () => {
+ const a = logAxis(10, 1000)
+ expect(axisFrac(a, 10)).toBeCloseTo(0)
+ expect(axisFrac(a, 100)).toBeCloseTo(0.5)
+ expect(axisFrac(a, 1000)).toBeCloseTo(1)
+ })
+})
+
+describe("logPossible", () => {
+ it("is true whenever every value is positive and there are >=2 of them", () => {
+ // Permissive on purpose: a modest spread still gets the toolbar toggle so
+ // line charts expose linear/log the same way bar charts do.
+ expect(logPossible([10, 20, 30])).toBe(true)
+ expect(logPossible([1, 1000])).toBe(true)
+ })
+
+ it("is false for a zero, a negative, or too few points", () => {
+ expect(logPossible([0, 1000])).toBe(false)
+ expect(logPossible([1000, -5])).toBe(false)
+ expect(logPossible([1000])).toBe(false)
+ })
+})
+
+describe("logBeneficial", () => {
+ it("is true when every value is positive and the spread is large", () => {
+ expect(logBeneficial([1, 1000])).toBe(true)
+ expect(logBeneficial([5, 17, 1005])).toBe(true)
+ })
+
+ it("is false for a small spread, a zero, a negative, or too few points", () => {
+ expect(logBeneficial([10, 20, 30])).toBe(false)
+ expect(logBeneficial([0, 1000])).toBe(false)
+ expect(logBeneficial([1000, -5])).toBe(false)
+ expect(logBeneficial([1000])).toBe(false)
+ })
+})
+
+const barSpec = (seriesCount: number, nameLen = 4): ChartSpec => ({
+ type: "bar",
+ data: {
+ categories: ["a", "b"],
+ series: Array.from({ length: seriesCount }, (_, i) => ({
+ name: `s${i}`.padEnd(nameLen, "x"),
+ values: [1, 2],
+ })),
+ },
+})
+
+const lineSpec = (seriesCount: number, nameLen = 16): ChartSpec => ({
+ type: "line",
+ data: {
+ series: Array.from({ length: seriesCount }, (_, i) => ({
+ name: `series-${i}`.padEnd(nameLen, "x"),
+ points: [{ x: 0, y: 1 }, { x: 1, y: 2 }],
+ })),
+ },
+})
+
+const pieSpec: ChartSpec = {
+ type: "pie",
+ data: { slices: [{ label: "a", value: 1 }, { label: "b", value: 2 }] },
+}
+
+describe("layoutLegendRows", () => {
+ it("keeps a short legend on one row", () => {
+ expect(layoutLegendRows(["aa", "bb", "cc"], 1000)).toHaveLength(1)
+ })
+
+ it("wraps a legend that does not fit the width", () => {
+ const names = Array.from({ length: 20 }, (_, i) => `long-series-name-${i}`)
+ expect(layoutLegendRows(names, 300).length).toBeGreaterThan(1)
+ })
+})
+
+describe("computePlot", () => {
+ it("yields a non-empty plot rectangle inside the canvas", () => {
+ const p = computePlot(900, 520, true, 1, true, false, true, true)
+ expect(p.left).toBeGreaterThan(0)
+ expect(p.right).toBeLessThan(900)
+ expect(p.top).toBeGreaterThan(0)
+ expect(p.bottom).toBeLessThan(520)
+ expect(p.w).toBeGreaterThan(0)
+ expect(p.h).toBeGreaterThan(0)
+ })
+
+ it("reserves more vertical space as legend rows grow", () => {
+ const one = computePlot(900, 520, true, 1, true, false, false, false)
+ const four = computePlot(900, 520, true, 4, true, false, false, false)
+ expect(four.top).toBeGreaterThan(one.top)
+ })
+})
+
+describe("barTickLayout", () => {
+ it("keeps short labels horizontal in a 26px band", () => {
+ const l = barTickLayout(["a", "b", "c"], 900)
+ expect(l.rotate).toBe(false)
+ expect(l.tickBandH).toBe(26)
+ })
+
+ it("rotates crowded labels and sizes the band to the overhang (no clip regression)", () => {
+ // Many long category names at a narrow width: labels must rotate, and the
+ // band must be tall enough to contain a -30deg label's vertical overhang —
+ // a fixed 50px band used to let long labels spill past the canvas bottom.
+ const cats = Array.from({ length: 10 }, (_, i) => `namespace-prefix-${i}-long`)
+ const l = barTickLayout(cats, 720)
+ expect(l.rotate).toBe(true)
+ expect(l.tickBandH).toBeGreaterThan(50)
+ })
+
+ it("makes chartCanvasSize grow the canvas to fit a tall rotated tick band", () => {
+ const cats = Array.from({ length: 10 }, (_, i) => `namespace-prefix-${i}-long`)
+ const spec: ChartSpec = {
+ type: "bar",
+ data: { categories: cats, series: [{ name: "s", values: cats.map(() => 1) }] },
+ }
+ const { height } = chartCanvasSize(spec, 720)
+ expect(height).toBeGreaterThan(520)
+ })
+})
+
+describe("legendRowsFor", () => {
+ it("returns 0 for a single-series chart and for pie", () => {
+ expect(legendRowsFor(barSpec(1), 900)).toBe(0)
+ expect(legendRowsFor(pieSpec, 900)).toBe(0)
+ })
+
+ it("returns >=1 for a multi-series bar/line chart", () => {
+ expect(legendRowsFor(barSpec(3), 900)).toBeGreaterThanOrEqual(1)
+ expect(legendRowsFor(lineSpec(3), 900)).toBeGreaterThanOrEqual(1)
+ })
+})
+
+describe("chartCanvasSize", () => {
+ it("falls back to the spec width before the container is measured", () => {
+ expect(chartCanvasSize(lineSpec(2), null).width).toBe(900)
+ })
+
+ it("clamps the measured width to [300, specWidth]", () => {
+ expect(chartCanvasSize(lineSpec(2), 100).width).toBe(300)
+ expect(chartCanvasSize(lineSpec(2), 2000).width).toBe(900)
+ expect(chartCanvasSize(lineSpec(2), 480).width).toBe(480)
+ })
+
+ it("never shrinks height below the spec height — the plot-collapse regression guard", () => {
+ // A narrow width forces the 10-series legend to wrap; height must GROW to
+ // keep the plot area, never scale down (which collapsed it to a sliver).
+ for (const w of [320, 400, 600, 900, null]) {
+ const { height } = chartCanvasSize(lineSpec(10), w)
+ expect(height).toBeGreaterThanOrEqual(520)
+ }
+ })
+
+ it("grows height when the legend wraps onto extra rows", () => {
+ const wide = chartCanvasSize(lineSpec(10), 900)
+ const narrow = chartCanvasSize(lineSpec(10), 320)
+ expect(narrow.legendRows).toBeGreaterThan(wide.legendRows)
+ expect(narrow.height).toBeGreaterThan(wide.height)
+ })
+
+ it("returns a legendRows count consistent with legendRowsFor at the resolved width", () => {
+ const { width, legendRows } = chartCanvasSize(lineSpec(8), 500)
+ expect(legendRows).toBe(legendRowsFor(lineSpec(8), width))
+ })
+})
+
+describe("collectChartValues", () => {
+ it("flattens bar values and line y-values, and is empty for pie", () => {
+ expect(collectChartValues(barSpec(2)).sort()).toEqual([1, 1, 2, 2])
+ expect(collectChartValues(lineSpec(1))).toEqual([1, 2])
+ expect(collectChartValues(pieSpec)).toEqual([])
+ })
+})
+
+describe("series style cycling", () => {
+ it("cycles dash patterns and marker shapes by index", () => {
+ expect(seriesDash(0)).toBe(seriesDash(5))
+ expect(seriesShape(0)).toBe(seriesShape(4))
+ expect(seriesShape(0)).not.toBe(seriesShape(1))
+ })
+})
diff --git a/portal-web/src/components/chat/chart-utils.ts b/portal-web/src/components/chat/chart-utils.ts
new file mode 100644
index 00000000..6eff5a80
--- /dev/null
+++ b/portal-web/src/components/chat/chart-utils.ts
@@ -0,0 +1,497 @@
+/**
+ * Pure (DOM-free) logic for the chart renderer: the ChartSpec contract, number
+ * / axis math, legend layout, plot geometry, canvas sizing, and the trust-
+ * boundary spec parser.
+ *
+ * This file deliberately holds NO JSX and NO browser APIs so every function
+ * here is unit-testable headlessly (see chart-utils.test.ts). ChartRenderer.tsx
+ * imports from here and keeps only the React components + DOM helpers.
+ */
+
+export type PieSlice = { label: string; value: number }
+export type BarSeries = { name: string; values: number[] }
+export type LinePoint = { x: number | string; y: number }
+export type LineSeries = { name: string; points: LinePoint[] }
+
+export const CHART_SPEC_VERSION = 1
+
+export interface CommonOpts {
+ schema_version?: typeof CHART_SPEC_VERSION
+ title?: string
+ x_label?: string
+ y_label?: string
+ width?: number
+ height?: number
+}
+
+export type ChartSpec =
+ | ({ type: "pie"; data: { slices: PieSlice[] } } & CommonOpts)
+ | ({ type: "bar"; data: { categories: string[]; series: BarSeries[] } } & CommonOpts)
+ | ({ type: "line"; data: { series: LineSeries[] } } & CommonOpts)
+
+// No grey in the categorical palette — grey is reserved for the "Others"
+// bucket so it reads as a residual rather than a real category. 11 distinct
+// hues cover the max 11 real slices a collapsed pie can have.
+export const PALETTE = [
+ "#4e79a7", "#f28e2b", "#e15759", "#76b7b2", "#59a14f",
+ "#edc948", "#b07aa1", "#ff9da7", "#9c755f", "#b6992d", "#d37295",
+]
+// Neutral grey for the collapsed "Others" slice — legible on both themes.
+export const OTHERS_COLOR = "#9aa0a6"
+
+export const TITLE_SIZE = 18
+export const LEGEND_SIZE = 13
+// Vertical advance between wrapped legend rows.
+export const LEGEND_LINE_H = LEGEND_SIZE + 9
+export const AXIS_LABEL_SIZE = 13
+export const TICK_SIZE = 12
+export const PIE_LABEL_SIZE = 12
+
+// Per-series line dash patterns + marker shapes. Colour alone is not enough to
+// tell series apart for colour-blind viewers or in greyscale prints, so each
+// line series also gets a distinct dash and a distinct point marker.
+export const LINE_DASHES = ["", "7 4", "2 4", "10 4 2 4", "1 5"]
+export type MarkerShapeKind = "circle" | "square" | "triangle" | "diamond"
+export const MARKER_SHAPES: MarkerShapeKind[] = ["circle", "square", "triangle", "diamond"]
+export function seriesDash(i: number): string {
+ return LINE_DASHES[i % LINE_DASHES.length]
+}
+export function seriesShape(i: number): MarkerShapeKind {
+ return MARKER_SHAPES[i % MARKER_SHAPES.length]
+}
+
+// High-cardinality pie data (e.g. a per-namespace Pod count with 20+ entries)
+// renders as a fan of unreadable slivers. Keep the largest slices and roll the
+// long tail into a single "Others" bucket so the chart stays legible.
+export const PIE_MAX_SLICES = 12
+
+// othersIndex is the index of the collapsed "Others" slice, or -1 when the
+// data was small enough to render as-is. The caller colours that slice grey.
+export function collapsePieSlices(
+ slices: PieSlice[],
+ max = PIE_MAX_SLICES,
+): { slices: PieSlice[]; othersIndex: number } {
+ if (slices.length <= max) return { slices, othersIndex: -1 }
+ const sorted = [...slices].sort((a, b) => b.value - a.value)
+ const head = sorted.slice(0, max - 1)
+ const tail = sorted.slice(max - 1)
+ const tailTotal = tail.reduce((a, s) => a + Math.max(0, s.value), 0)
+ return {
+ slices: [...head, { label: `Others (${tail.length})`, value: tailTotal }],
+ othersIndex: head.length,
+ }
+}
+
+export function pieSliceColor(index: number, othersIndex: number): string {
+ return index === othersIndex ? OTHERS_COLOR : PALETTE[index % PALETTE.length]
+}
+
+export function approxTextWidth(text: string, fontSize: number): number {
+ let w = 0
+ for (const ch of text) {
+ // CJK ideographs + fullwidth forms render roughly square (one em wide).
+ if (/[\u4e00-\u9fff\uff00-\uffef]/.test(ch)) w += fontSize
+ else if (/[A-Z0-9]/.test(ch)) w += fontSize * 0.62
+ else w += fontSize * 0.52
+ }
+ return w
+}
+
+// Compact, human-readable number formatting for axis ticks and tooltips.
+// Everyday magnitudes must NOT use scientific notation — a Pod count of 1200
+// should read "1,200", not "1.20e+3". SI suffixes cover the genuinely large;
+// values below 1 keep significant-digit precision (so 0.003 stays "0.003" and
+// is not rounded down to "0" by a fixed 2-decimal format); exponential is the
+// last resort for values outside any sane chart range.
+export function fmtNumber(n: number): string {
+ if (!Number.isFinite(n)) return String(n)
+ if (n === 0) return "0"
+ const abs = Math.abs(n)
+ if (abs >= 1e15 || abs < 1e-4) return n.toExponential(1).replace("e+", "e")
+ if (abs >= 1e6) {
+ const units: Array<[string, number]> = [["T", 1e12], ["G", 1e9], ["M", 1e6]]
+ for (const [u, d] of units) {
+ if (abs >= d) {
+ const v = n / d
+ return (Number.isInteger(v) ? String(v) : v.toFixed(1)) + u
+ }
+ }
+ }
+ if (abs >= 1000) return Math.round(n).toLocaleString("en-US")
+ if (Number.isInteger(n)) return String(n)
+ if (abs >= 1) return String(Number(n.toFixed(2)))
+ // abs in [1e-4, 1): two significant digits, no scientific notation.
+ return String(Number(n.toPrecision(2)))
+}
+
+function niceNum(range: number, round: boolean): number {
+ const exp = Math.floor(Math.log10(range || 1))
+ const f = range / Math.pow(10, exp)
+ let nf: number
+ if (round) {
+ if (f < 1.5) nf = 1
+ else if (f < 3) nf = 2
+ else if (f < 7) nf = 5
+ else nf = 10
+ } else if (f <= 1) nf = 1
+ else if (f <= 2) nf = 2
+ else if (f <= 5) nf = 5
+ else nf = 10
+ return nf * Math.pow(10, exp)
+}
+
+export interface Axis { min: number; max: number; ticks: number[]; log?: boolean }
+
+export function niceAxis(min: number, max: number, count = 5): Axis {
+ if (!Number.isFinite(min) || !Number.isFinite(max)) {
+ return { min: 0, max: 1, ticks: [0, 0.5, 1] }
+ }
+ if (min === max) {
+ const pad = Math.abs(min) > 0 ? Math.abs(min) * 0.1 : 1
+ min -= pad; max += pad
+ }
+ const range = niceNum(max - min, false)
+ const step = niceNum(range / (count - 1), true)
+ const niceMin = Math.floor(min / step) * step
+ const niceMax = Math.ceil(max / step) * step
+ const ticks: number[] = []
+ for (let v = niceMin; v <= niceMax + step / 2; v += step) {
+ ticks.push(Number(v.toFixed(10)))
+ }
+ return { min: niceMin, max: niceMax, ticks }
+}
+
+// Log-scale axis spanning whole powers of ten. Used when a linear axis would
+// flatten small series into an unreadable band along the baseline.
+export function logAxis(dataMin: number, dataMax: number): Axis {
+ const loExp = Math.floor(Math.log10(dataMin))
+ const hiExp = Math.max(Math.ceil(Math.log10(dataMax)), loExp + 1)
+ const ticks: number[] = []
+ for (let e = loExp; e <= hiExp + 1e-9; e++) ticks.push(Math.pow(10, e))
+ return { min: Math.pow(10, loExp), max: Math.pow(10, hiExp), ticks, log: true }
+}
+
+// Fraction (0..1) of a value's position along its axis, log-aware.
+export function axisFrac(axis: Axis, v: number): number {
+ if (axis.log) {
+ const lmin = Math.log10(axis.min)
+ const lmax = Math.log10(axis.max)
+ const lv = Math.log10(Math.max(v, axis.min))
+ return lmax > lmin ? (lv - lmin) / (lmax - lmin) : 0
+ }
+ return axis.max > axis.min ? (v - axis.min) / (axis.max - axis.min) : 0
+}
+
+// A log scale is *possible* — and the toolbar toggle worth offering — whenever
+// every value is positive (log of <=0 is undefined) and there are at least two
+// values to span. This is intentionally permissive: the user can switch to log
+// even on a modest spread, the same way bar and line charts both expose it.
+export function logPossible(values: number[]): boolean {
+ const finite = values.filter((v) => Number.isFinite(v))
+ const pos = finite.filter((v) => v > 0)
+ return pos.length >= 2 && pos.length === finite.length
+}
+
+// A log scale is *beneficial* — worth auto-selecting in "auto" mode — only when
+// it's possible AND the spread is large enough that a linear axis would flatten
+// small values into an unreadable band along the baseline.
+export function logBeneficial(values: number[]): boolean {
+ if (!logPossible(values)) return false
+ const pos = values.filter((v) => Number.isFinite(v) && v > 0)
+ return Math.max(...pos) / Math.min(...pos) >= 50
+}
+
+export function collectChartValues(spec: ChartSpec): number[] {
+ if (spec.type === "bar") return spec.data.series.flatMap((s) => s.values)
+ if (spec.type === "line") return spec.data.series.flatMap((s) => s.points.map((p) => p.y))
+ return []
+}
+
+// Legend swatch/spacing geometry, shared by layout + render so wrapping math
+// and drawn positions never drift.
+export const LEGEND_SWATCH = 12
+export const LEGEND_GAP = 6
+export const LEGEND_BETWEEN = 18
+export const LEGEND_MARGIN = 16
+
+// Pack legend entries into rows that each fit within the chart width. A single
+// long row used to overflow the viewBox and silently clip every entry past the
+// right edge — wrapping keeps all series visible.
+export function layoutLegendRows(names: string[], width: number): string[][] {
+ const maxW = width - LEGEND_MARGIN * 2
+ const itemW = (n: string) => LEGEND_SWATCH + LEGEND_GAP + approxTextWidth(n, LEGEND_SIZE)
+ const rows: string[][] = []
+ let cur: string[] = []
+ let curW = 0
+ for (const n of names) {
+ const w = itemW(n)
+ const add = cur.length ? LEGEND_BETWEEN + w : w
+ if (cur.length && curW + add > maxW) {
+ rows.push(cur)
+ cur = []
+ curW = 0
+ }
+ cur.push(n)
+ curW += cur.length === 1 ? w : LEGEND_BETWEEN + w
+ }
+ if (cur.length) rows.push(cur)
+ return rows
+}
+
+// Number of legend rows the chart will need at a given width. Single source of
+// truth: both the canvas-height calculation and computePlot must agree on this,
+// otherwise the legend eats space the plot thinks it has and the plot collapses
+// to a sliver. Pie charts use a vertical side-legend, so they need 0 rows here.
+export function legendRowsFor(spec: ChartSpec, width: number): number {
+ if (spec.type === "bar" || spec.type === "line") {
+ const names = spec.data.series.map((s) => s.name)
+ return names.length > 1 ? layoutLegendRows(names, width).length : 0
+ }
+ return 0
+}
+
+export interface Plot { left: number; right: number; top: number; bottom: number; w: number; h: number }
+// `rotatedTickH` is the height of the x-tick band when ticks are rotated; the
+// caller measures it from the actual longest label (see barTickLayout) so a
+// long category label never overhangs past the canvas bottom. Defaults to the
+// historic fixed value for callers that don't rotate.
+export function computePlot(
+ width: number, height: number,
+ hasTitle: boolean, legendRows: number,
+ hasXLabels: boolean, rotateXTicks: boolean,
+ hasYAxisLabel: boolean, hasXAxisLabel: boolean,
+ rotatedTickH = 50,
+): Plot {
+ const titleH = hasTitle ? 30 : 6
+ const legendH = legendRows > 0 ? legendRows * LEGEND_LINE_H + 6 : 0
+ const xTickH = hasXLabels ? (rotateXTicks ? rotatedTickH : 26) : 8
+ const xLabelH = hasXAxisLabel ? 22 : 0
+ const yLabelW = hasYAxisLabel ? 22 : 0
+ const top = titleH + legendH + 8
+ const bottom = height - xTickH - xLabelH - 8
+ const left = 60 + yLabelW
+ const right = width - 24
+ return { left, right, top, bottom, w: right - left, h: bottom - top }
+}
+
+// Bar x-tick geometry — single source of truth shared by chartCanvasSize (which
+// grows the canvas to fit) and renderBar (which draws + reserves the band).
+// Category labels rotate to -30° when they'd otherwise overlap; a rotated label
+// overhangs the axis by labelWidth*sin(30°), so the band must be tall enough to
+// contain that overhang or the labels get clipped at the canvas bottom.
+export const BAR_TICK_ROTATE_DEG = 30
+// Gap from the plot bottom to the rotated label's anchor (matches renderBar).
+const BAR_TICK_GAP = 16
+// Historic fixed rotated-tick band height; canvas growth is measured against it.
+export const BAR_TICK_BASE_H = 50
+
+export function barTickLayout(
+ categories: string[], width: number,
+): { rotate: boolean; tickBandH: number } {
+ const longest = categories.reduce(
+ (m, c) => Math.max(m, approxTextWidth(c, TICK_SIZE)),
+ 0,
+ )
+ const approxGroupW = categories.length ? (width - 80) / categories.length : width
+ const rotate = longest + 4 > approxGroupW
+ if (!rotate) return { rotate: false, tickBandH: 26 }
+ const overhang = longest * Math.sin((BAR_TICK_ROTATE_DEG * Math.PI) / 180)
+ return { rotate: true, tickBandH: Math.ceil(BAR_TICK_GAP + overhang + 8) }
+}
+
+export function defaultSize(type: ChartSpec["type"]): { width: number; height: number } {
+ if (type === "pie") return { width: 760, height: 480 }
+ return { width: 900, height: 520 }
+}
+
+export function describeChart(spec: ChartSpec): string {
+ if (spec.type === "pie") return `Pie chart with ${spec.data.slices.length} segment(s).`
+ if (spec.type === "bar") {
+ return `Bar chart with ${spec.data.categories.length} categories and ${spec.data.series.length} series.`
+ }
+ return `Line chart with ${spec.data.series.length} series.`
+}
+
+// Single source of truth for chart canvas dimensions.
+//
+// width: the container's measured width, clamped to [300, specWidth] so the
+// chart fills a narrow chat bubble without ever upscaling past its
+// ideal size. Falls back to specWidth before the first measurement.
+// height: the spec height is a FLOOR, never scaled down. A narrow width forces
+// the legend to wrap onto more rows; shrinking the canvas at the same
+// time would collapse the plot to a sliver. Instead the canvas GROWS
+// by one line per extra wrapped legend row.
+// legendRows: returned so renderBar/renderLine reuse the exact same count via
+// computePlot — they must not recompute it independently.
+export function chartCanvasSize(
+ spec: ChartSpec,
+ measuredW: number | null,
+): { width: number; height: number; legendRows: number } {
+ const def = defaultSize(spec.type)
+ const specWidth = spec.width ?? def.width
+ const specHeight = spec.height ?? def.height
+ const width = measuredW
+ ? Math.round(Math.min(Math.max(measuredW, 300), specWidth))
+ : specWidth
+ const legendRows = legendRowsFor(spec, width)
+ let height = specHeight + Math.max(0, legendRows - 1) * LEGEND_LINE_H
+ // Grow the canvas when bar category labels rotate and overhang past the
+ // historic fixed tick band — same "canvas GROWS, plot never collapses"
+ // philosophy as the legend-row growth above.
+ if (spec.type === "bar") {
+ const { tickBandH } = barTickLayout(spec.data.categories, width)
+ height += Math.max(0, tickBandH - BAR_TICK_BASE_H)
+ }
+ return { width, height, legendRows }
+}
+
+// ---- Trust-boundary spec parser -------------------------------------------
+
+// Heuristic: does the text look like a fully-streamed JSON object, or are we
+// still mid-stream? During streaming ReactMarkdown re-parses on every token,
+// so the chart fence is rendered before the closing `}` arrives — without
+// this check tryParseChartSpec would return null and Markdown.tsx would flash
+// the red "parse failed" box for every chart until streaming completes.
+// Returns true when the text is empty, doesn't end with `}`, or has more
+// opening braces than closing ones (ignoring braces inside JSON strings).
+export function chartSpecLooksIncomplete(raw: string): boolean {
+ const text = raw.trim()
+ if (!text || !text.endsWith("}")) return true
+ let depth = 0
+ let inStr = false
+ let esc = false
+ for (let i = 0; i < text.length; i++) {
+ const ch = text[i]
+ if (esc) { esc = false; continue }
+ if (inStr) {
+ if (ch === "\\") esc = true
+ else if (ch === '"') inStr = false
+ continue
+ }
+ if (ch === '"') inStr = true
+ else if (ch === "{") depth++
+ else if (ch === "}") depth--
+ }
+ return depth !== 0 || inStr
+}
+
+// The chart spec round-trips through the LLM as plain text in the final reply.
+// On a small fraction of generations the model double-escapes non-ASCII —
+// emits a literal backslash-u-XXXX sequence instead of the character (or a
+// single \u escape JSON.parse would decode). JSON.parse then yields the
+// literal 6-char string. Decode such stray escapes so CJK titles/labels render.
+function decodeStrayUnicodeEscapes(s: string): string {
+ if (!s.includes("\\u")) return s
+ return s.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
+}
+
+// String coercion for model-emitted leaf text: the renderer iterates these
+// with `for..of` (would throw on a number) and draws them verbatim (would
+// show stray unicode escapes). Both hazards are normalised here.
+function toCleanString(v: unknown): string {
+ return decodeStrayUnicodeEscapes(String(v))
+}
+
+function pickCommonOpts(obj: Record): CommonOpts | null {
+ const rawVersion = obj.schema_version
+ if (rawVersion !== undefined && rawVersion !== CHART_SPEC_VERSION) return null
+ const out: CommonOpts = { schema_version: CHART_SPEC_VERSION }
+ for (const k of ["title", "x_label", "y_label"] as const) {
+ if (typeof obj[k] === "string") out[k] = decodeStrayUnicodeEscapes(obj[k] as string)
+ }
+ for (const k of ["width", "height"] as const) {
+ if (typeof obj[k] === "number" && Number.isFinite(obj[k])) out[k] = obj[k] as number
+ }
+ return out
+}
+
+// Coerce a leaf value to a finite number the way the backend handler does
+// (accepts numeric strings), or return null when it can't — JSON.stringify
+// would otherwise serialise a NaN as `null` and the renderer would silently
+// draw wrong data.
+function toFiniteNumber(v: unknown): number | null {
+ const n = typeof v === "number" ? v : Number(v)
+ return Number.isFinite(n) ? n : null
+}
+
+// Deep validation + normalisation mirroring backend handler.ts `validate()`.
+// The chart JSON is re-emitted by the LLM in its final reply (not the original
+// tool response), so it crosses a trust boundary even though the MCP tool
+// itself validated input — the model can rewrite, truncate, or corrupt fields
+// during paste. Beyond shape checks this also normalises leaf values: category
+// / series-name fields are coerced to strings (the renderer iterates them with
+// `for..of` and would throw on a number) and numeric fields are coerced to
+// finite numbers (a non-finite value rejects the whole spec). Returning null
+// lets Markdown.tsx fall back to instead of crashing the
+// chat bubble inside the chart renderers.
+export function tryParseChartSpec(raw: string): ChartSpec | null {
+ try {
+ const obj = JSON.parse(raw) as Record
+ if (!obj || typeof obj !== "object") return null
+ const data = obj.data as Record | undefined
+ if (!data || typeof data !== "object") return null
+ const common = pickCommonOpts(obj)
+ if (!common) return null
+
+ if (obj.type === "pie") {
+ const rawSlices = data.slices
+ if (!Array.isArray(rawSlices) || !rawSlices.length) return null
+ const slices: PieSlice[] = []
+ for (let i = 0; i < rawSlices.length; i++) {
+ const s = rawSlices[i] as { label?: unknown; value?: unknown }
+ if (!s || typeof s !== "object") return null
+ const value = toFiniteNumber(s.value)
+ if (value === null) return null
+ slices.push({ label: toCleanString(s.label ?? `slice ${i}`), value })
+ }
+ return { type: "pie", data: { slices }, ...common }
+ }
+
+ if (obj.type === "bar") {
+ const rawCats = data.categories
+ const rawSeries = data.series
+ if (!Array.isArray(rawCats) || !rawCats.length) return null
+ if (!Array.isArray(rawSeries) || !rawSeries.length) return null
+ const categories = rawCats.map(toCleanString)
+ const series: BarSeries[] = []
+ for (let i = 0; i < rawSeries.length; i++) {
+ const s = rawSeries[i] as { name?: unknown; values?: unknown }
+ if (!s || typeof s !== "object" || !Array.isArray(s.values)) return null
+ if (s.values.length !== categories.length) return null
+ const values: number[] = []
+ for (const v of s.values) {
+ const n = toFiniteNumber(v)
+ if (n === null) return null
+ values.push(n)
+ }
+ series.push({ name: toCleanString(s.name ?? `series ${i}`), values })
+ }
+ return { type: "bar", data: { categories, series }, ...common }
+ }
+
+ if (obj.type === "line") {
+ const rawSeries = data.series
+ if (!Array.isArray(rawSeries) || !rawSeries.length) return null
+ const series: LineSeries[] = []
+ for (let i = 0; i < rawSeries.length; i++) {
+ const s = rawSeries[i] as { name?: unknown; points?: unknown }
+ if (!s || typeof s !== "object" || !Array.isArray(s.points) || !s.points.length) return null
+ const points: LinePoint[] = []
+ for (const p of s.points) {
+ const pt = p as { x?: unknown; y?: unknown }
+ if (!pt || typeof pt !== "object") return null
+ const y = toFiniteNumber(pt.y)
+ if (y === null) return null
+ const x = typeof pt.x === "number" ? pt.x : toCleanString(pt.x)
+ points.push({ x, y })
+ }
+ series.push({ name: toCleanString(s.name ?? `series ${i}`), points })
+ }
+ return { type: "line", data: { series }, ...common }
+ }
+
+ return null
+ } catch {
+ return null
+ }
+}