From 922351472c34f4e5501d21443abcb9b8cf3abe07 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Sat, 4 Jul 2026 02:07:23 +0000 Subject: [PATCH] feat(core+cli): guide agents to the right --type (did-you-mean hint + rich types in help) Motivated by the agent-compat journey tests: after #219 made `push` reject unknown --type values, agents self-corrected to a VALID but often WRONG type (e.g. falling back to `mermaid`) because the error listed the valid set but not which fits the intent. Two nudges close that gap: - validateContent: map common wrong --type values (Mermaid keywords like "flowchart"/"erDiagram", semantic names like "comparison"/"line") to the termchart type that renders that intent, surfaced as `Did you mean "X"?` plus a one-line intent->type guide in the reject message. - CLI --help: a "Push --type" block listing the rich viewer types (component / flow / vegalite / panes / calltree / datatable / markdown) so agents stop anchoring on the tool's Mermaid heritage. Measured (scripts/experiments/agent-compat): journey activation 0/5->4/5 (Claude), 2/5->3/5 (Gemini). +1 unit test. --- packages/cli/src/cli.ts | 10 +++++++- packages/core/src/validate.ts | 34 +++++++++++++++++++++++++-- packages/viewer/test/validate.test.ts | 17 ++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2b721c3..9627f73 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -30,7 +30,7 @@ Usage: termchart lint [file] Check source is within the renderable subset termchart serve [flags] Start a local viewer + print its URL and env exports (--port --token --wsid --bucket) termchart begin [flags] Show a scope as "composing" instantly — placeholder + focus + loader in one call (--project --agent --description) - termchart push [flags] Push a diagram to the remote viewer (--project --agent --type --description --focus [--schema to make it template-able]) + termchart push [flags] Push a rich diagram to the remote viewer (--project --agent --type --description --focus [--schema to make it template-able]) termchart status [flags] Push a status update — toast/progress/loader (--message --progress --loader --id --done) termchart clear [flags] Clear viewer scopes (--project --agent for one, or --all) termchart pull [flags] Fetch a stored view's spec to iterate on (--project --agent [--json]) @@ -45,6 +45,14 @@ Usage: termchart run Refresh a scheduled board now — enforce lifecycle, assemble its prompt + run its agent, which pushes the board (--timeout , default 600) termchart --version +Push --type (rich viewer types — these are NOT Mermaid diagram keywords): + component comparisons / product cards / status boards / dashboards (Mantine UI tree) + flow architecture / sequence / ER / class / state machine / pipeline (nodes + edges) + vegalite charts — metrics / trends / distributions (a Vega-Lite spec) + panes a grid or rows of several of the above (multi-tile dashboards) + calltree call hierarchies datatable large tabular data + markdown prose / notes / report mermaid quick throwaway flowchart (ASCII fallback) + Render flags: --ascii Pure ASCII (+,-,|) instead of Unicode box-drawing --safe, --portable Alias for --ascii — guaranteed alignment in any terminal diff --git a/packages/core/src/validate.ts b/packages/core/src/validate.ts index 4bcf1fd..255c5bb 100644 --- a/packages/core/src/validate.ts +++ b/packages/core/src/validate.ts @@ -28,6 +28,32 @@ export type KnownPushType = (typeof KNOWN_PUSH_TYPES)[number]; const KNOWN_TYPE_SET = new Set(KNOWN_PUSH_TYPES); const KNOWN_PUSH_TYPES_LIST = KNOWN_PUSH_TYPES.join(", "); +// Common wrong `--type` values agents reach for — Mermaid diagram keywords ("flowchart", +// "erDiagram") and semantic names ("comparison", "architecture", "line") — mapped to the +// termchart type that actually renders that intent. Surfaced as a "did you mean" in the reject +// message so a pushing agent self-corrects to the RIGHT journey, not merely a renderable type. +const TYPE_ALIASES: Record = { + comparison: "component", compare: "component", cards: "component", card: "component", + status: "component", statusboard: "component", stats: "component", scorecard: "component", + dashboard: "panes", grid: "panes", + flowchart: "flow", graph: "flow", digraph: "flow", architecture: "flow", diagram: "flow", + sequence: "flow", sequencediagram: "flow", state: "flow", statemachine: "flow", statediagram: "flow", + erd: "flow", er: "flow", erdiagram: "flow", schema: "flow", sql: "flow", class: "flow", + classdiagram: "flow", pipeline: "flow", tree: "flow", network: "flow", + line: "vegalite", bar: "vegalite", chart: "vegalite", plot: "vegalite", scatter: "vegalite", + histogram: "vegalite", area: "vegalite", pie: "vegalite", timeseries: "vegalite", + metric: "vegalite", metrics: "vegalite", trend: "vegalite", + callgraph: "calltree", callhierarchy: "calltree", + table: "datatable", datatabel: "datatable", spreadsheet: "datatable", + md: "markdown", doc: "markdown", document: "markdown", report: "markdown", notes: "markdown", text: "markdown", +}; +// One-line intent -> type guide appended to the reject message (kept ASCII). +const TYPE_INTENT_GUIDE = + "pick by intent: comparisons / product cards / status -> component; dashboards of peer tiles -> panes; " + + "architecture / flow / sequence / ER / class / state / pipeline -> flow; " + + "charts / metrics / trends / distributions -> vegalite; call hierarchies -> calltree; " + + "large tabular data -> datatable; prose / notes -> markdown"; + /** * The client component-resolver's whitelist (component-resolver.tsx REGISTRY). A node with any * other `type` renders as a visible "[unknown component: X]" marker — observed in the wild: @@ -300,11 +326,15 @@ const STRUCTURED_TYPES = new Set(["component", "panes", "flow", "vegalite", "cal export function validateContent(type: string, content: string, depth = 0): string | null { // The viewer can only display types it has a renderer for — anything else would be stored, // 204'd, and then break in the browser with no feedback to the pusher. - if (!KNOWN_TYPE_SET.has(type)) + if (!KNOWN_TYPE_SET.has(type)) { + const guess = TYPE_ALIASES[type.toLowerCase().replace(/[^a-z]/g, "")]; return ( `unknown type "${type}" — the viewer has no renderer for it and would show a ` + - `"[unsupported type]" block. Supported types: ${KNOWN_PUSH_TYPES_LIST}` + `"[unsupported type]" block.` + + (guess ? ` Did you mean "${guess}"?` : "") + + ` Supported types: ${KNOWN_PUSH_TYPES_LIST}. To ${TYPE_INTENT_GUIDE}` ); + } // mermaid is NOT freeform — validate against the renderable subset (header + supported constructs). if (type === "mermaid") return mermaidErrors(content)[0] ?? null; if (!STRUCTURED_TYPES.has(type)) return null; // text/ansi/markdown are genuinely freeform diff --git a/packages/viewer/test/validate.test.ts b/packages/viewer/test/validate.test.ts index 1867a52..0680836 100644 --- a/packages/viewer/test/validate.test.ts +++ b/packages/viewer/test/validate.test.ts @@ -58,6 +58,23 @@ describe("validateContent", () => { expect(validateContent("flow", JSON.stringify({ nodes: [], edges: [] }))).toBeNull(); }); + it("maps a mistaken --type to the right one (did-you-mean) so an agent self-corrects to the correct journey", () => { + // Mermaid keywords + semantic names agents reach for -> the type that renders that intent. + const cases: Array<[string, string]> = [ + ["comparison", "component"], ["erDiagram", "flow"], ["flowchart", "flow"], ["sql", "flow"], + ["line", "vegalite"], ["metrics", "vegalite"], ["status", "component"], ["dashboard", "panes"], + ["table", "datatable"], + ]; + for (const [bad, want] of cases) { + const msg = validateContent(bad, "{}") ?? ""; + expect(msg).toContain(`unknown type "${bad}"`); + expect(msg).toContain(`Did you mean "${want}"`); + expect(msg).toContain("pick by intent"); // the intent guide is always appended + } + // a truly unrecognizable type still errors, just without a guess + expect(validateContent("zzzzz", "{}")).toContain('unknown type "zzzzz"'); + }); + it("rejects a null/primitive flow node or edge with a path (would otherwise 500 in the renderer)", () => { expect(validateContent("flow", JSON.stringify({ nodes: [null] }))).toBe("flow.nodes[0] must be an object"); expect(validateContent("flow", JSON.stringify({ nodes: [{ id: "a" }, "x"] }))).toBe("flow.nodes[1] must be an object");