+ Preview
+
+ The exact .gittensory.yml this configuration would
+ produce. Nothing is saved until you copy it into your repo.
+
+
+
+
+
+ );
+}
diff --git a/apps/gittensory-ui/src/lib/config-generator-yaml.test.ts b/apps/gittensory-ui/src/lib/config-generator-yaml.test.ts
new file mode 100644
index 0000000000..5e3583d314
--- /dev/null
+++ b/apps/gittensory-ui/src/lib/config-generator-yaml.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from "vitest";
+
+import { formStateToYaml, type GeneratorFormState } from "@/lib/config-generator-yaml";
+
+const HEADER = "# .gittensory.yml — generated by the config generator";
+
+describe("formStateToYaml", () => {
+ it("serializes a full gate.aiReview config with every field set", () => {
+ const state: GeneratorFormState = {
+ gate: {
+ aiReview: {
+ combine: "consensus",
+ provider: "anthropic",
+ model: "claude-3-5-sonnet-latest",
+ },
+ },
+ };
+ expect(formStateToYaml(state)).toBe(
+ [
+ HEADER,
+ "gate:",
+ " aiReview:",
+ " combine: consensus",
+ " provider: anthropic",
+ " model: claude-3-5-sonnet-latest",
+ ].join("\n"),
+ );
+ });
+
+ it("omits unset keys for a partial config (only provider set)", () => {
+ const state: GeneratorFormState = { gate: { aiReview: { provider: "openai" } } };
+ expect(formStateToYaml(state)).toBe(
+ [HEADER, "gate:", " aiReview:", " provider: openai"].join("\n"),
+ );
+ });
+
+ it("omits unset keys for a partial config (only combine and model set)", () => {
+ const state: GeneratorFormState = { gate: { aiReview: { combine: "single", model: "gpt-5" } } };
+ expect(formStateToYaml(state)).toBe(
+ [HEADER, "gate:", " aiReview:", " combine: single", " model: gpt-5"].join("\n"),
+ );
+ });
+
+ it("emits only the header for a fully empty state", () => {
+ expect(formStateToYaml({})).toBe(HEADER);
+ });
+
+ it("emits only the header when gate is present but aiReview is unset", () => {
+ expect(formStateToYaml({ gate: {} })).toBe(HEADER);
+ });
+
+ it("treats null fields the same as unset (omitted, not emitted as `null`)", () => {
+ const state: GeneratorFormState = {
+ gate: { aiReview: { combine: null, provider: null, model: null } },
+ };
+ expect(formStateToYaml(state)).toBe(HEADER);
+ });
+
+ it("treats an empty-string model as unset rather than emitting a blank value", () => {
+ const state: GeneratorFormState = { gate: { aiReview: { provider: "anthropic", model: "" } } };
+ expect(formStateToYaml(state)).toBe(
+ [HEADER, "gate:", " aiReview:", " provider: anthropic"].join("\n"),
+ );
+ });
+
+ it("quotes a model value containing YAML-special characters", () => {
+ const state: GeneratorFormState = { gate: { aiReview: { model: "claude: sonnet" } } };
+ expect(formStateToYaml(state)).toBe(
+ [HEADER, "gate:", " aiReview:", ` model: ${JSON.stringify("claude: sonnet")}`].join("\n"),
+ );
+ });
+
+ it("quotes a model value with leading/trailing whitespace", () => {
+ const state: GeneratorFormState = { gate: { aiReview: { model: " claude " } } };
+ expect(formStateToYaml(state)).toBe(
+ [HEADER, "gate:", " aiReview:", ` model: ${JSON.stringify(" claude ")}`].join("\n"),
+ );
+ });
+});
diff --git a/apps/gittensory-ui/src/lib/config-generator-yaml.ts b/apps/gittensory-ui/src/lib/config-generator-yaml.ts
new file mode 100644
index 0000000000..a8269ef85f
--- /dev/null
+++ b/apps/gittensory-ui/src/lib/config-generator-yaml.ts
@@ -0,0 +1,52 @@
+// Config-generator YAML preview serializer (#2210, part of #1683). Pure form-state -> `.gittensory.yml`
+// text builder — the reviewable-output half of the config generator; field-group panels (#2208 and
+// siblings) own collecting GeneratorFormState, this module only turns it into text. Key names and
+// nesting match the real manifest schema (packages/gittensory-engine/src/focus-manifest.ts's
+// gateConfigToJson `gate.aiReview` shape, the same shape documented in this repo's own root
+// .gittensory.yml under the commented-out `aiReview:` example) — no parallel schema. Every field is
+// optional so a fresh/partial form never produces invalid output; unset keys are omitted entirely.
+
+export type AiCombineStrategy = "single" | "consensus" | "synthesis";
+export type AiProvider = "anthropic" | "openai";
+
+export type GeneratorGateAiReviewState = {
+ combine?: AiCombineStrategy | null;
+ provider?: AiProvider | null;
+ model?: string | null;
+};
+
+export type GeneratorFormState = {
+ gate?: {
+ aiReview?: GeneratorGateAiReviewState;
+ };
+};
+
+const YAML_HEADER = "# .gittensory.yml — generated by the config generator";
+
+/** Plain-scalar YAML value needs quoting when it would otherwise be ambiguous (a YAML special
+ * character, leading/trailing whitespace, or the empty string). Model names are free text, so this
+ * is a real correctness need, not defensive-for-its-own-sake. */
+function yamlScalar(value: string): string {
+ const needsQuote = value === "" || value.trim() !== value || /[:#[\]{}&*!|>'"%@`,]/.test(value);
+ return needsQuote ? JSON.stringify(value) : value;
+}
+
+function yamlLine(indent: number, key: string, value: string): string {
+ return `${" ".repeat(indent)}${key}: ${yamlScalar(value)}`;
+}
+
+/** Serializes the `gate.aiReview` slice of GeneratorFormState into `.gittensory.yml` text, omitting
+ * every unset field/block so the preview is always valid YAML (including the empty-state case). */
+export function formStateToYaml(state: GeneratorFormState): string {
+ const aiReview = state.gate?.aiReview;
+ const aiReviewLines: string[] = [];
+ if (aiReview?.combine) aiReviewLines.push(yamlLine(2, "combine", aiReview.combine));
+ if (aiReview?.provider) aiReviewLines.push(yamlLine(2, "provider", aiReview.provider));
+ if (aiReview?.model) aiReviewLines.push(yamlLine(2, "model", aiReview.model));
+
+ const lines = [YAML_HEADER];
+ if (aiReviewLines.length > 0) {
+ lines.push("gate:", " aiReview:", ...aiReviewLines);
+ }
+ return lines.join("\n");
+}