markform 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/README.md +45 -55
  2. package/dist/ai-sdk.d.mts +1 -1
  3. package/dist/ai-sdk.mjs +2 -2
  4. package/dist/{apply-BUU2QcJ2.mjs → apply-BTFCHpYV.mjs} +402 -341
  5. package/dist/bin.mjs +1 -1
  6. package/dist/{cli-BZh25bvy.mjs → cli-BZa4fYR9.mjs} +518 -65
  7. package/dist/cli.mjs +1 -1
  8. package/dist/{coreTypes-DJtu8OOp.mjs → coreTypes-B1oI7qvV.mjs} +36 -3
  9. package/dist/{coreTypes-BSPJ9H27.d.mts → coreTypes-JCPm418M.d.mts} +215 -9
  10. package/dist/index.d.mts +180 -17
  11. package/dist/index.mjs +5 -5
  12. package/dist/{session-DSTNiHza.mjs → session-CzCh6JeY.mjs} +1 -1
  13. package/dist/{session-CmHdAPyg.mjs → session-Dxqwt0RC.mjs} +3 -3
  14. package/dist/{shared-C9yW5FLZ.mjs → shared-CNqwaxUt.mjs} +1 -1
  15. package/dist/{shared-DQ6y3Ggc.mjs → shared-D3dNi-Gn.mjs} +1 -1
  16. package/dist/{src-kUggXhN1.mjs → src-CYnyLwBe.mjs} +297 -128
  17. package/docs/markform-spec.md +104 -53
  18. package/examples/movie-research/movie-deep-research-mock-filled.form.md +601 -0
  19. package/examples/movie-research/{movie-research-deep.form.md → movie-deep-research.form.md} +2 -2
  20. package/examples/rejection-test/rejection-test.session.yaml +446 -0
  21. package/examples/simple/simple-with-skips.session.yaml +1966 -18
  22. package/examples/simple/simple.session.yaml +1979 -18
  23. package/examples/startup-deep-research/startup-deep-research.form.md +21 -36
  24. package/package.json +1 -1
  25. package/examples/earnings-analysis/earnings-analysis.form.md +0 -159
  26. package/examples/earnings-analysis/earnings-analysis.raw.md +0 -801
  27. package/examples/earnings-analysis/earnings-analysis.valid.ts +0 -198
  28. package/examples/movie-research/movie-research-basic.form.md +0 -169
@@ -1,9 +1,10 @@
1
1
 
2
- import { L as PatchSchema } from "./coreTypes-DJtu8OOp.mjs";
3
- import { A as deriveSchemaPath, D as USER_ROLE, E as REPORT_EXTENSION, F as formatSuggestedLlms, L as hasWebSearchSupport, M as parseRolesFlag, N as SUGGESTED_LLMS, O as deriveExportPath, P as WEB_SEARCH_CONFIG, R as parseModelIdForDisplay, S as DEFAULT_RESEARCH_MAX_PATCHES_PER_TURN, T as MAX_FORMS_IN_MENU, _ as DEFAULT_MAX_PATCHES_PER_TURN, d as serialize, f as serializeRawMarkdown, g as DEFAULT_MAX_ISSUES_PER_TURN, h as DEFAULT_FORMS_DIR, i as inspect, j as detectFileType, k as deriveReportPath, m as AGENT_ROLE, n as getAllFields, p as serializeReportMarkdown, t as applyPatches, v as DEFAULT_MAX_TURNS, x as DEFAULT_RESEARCH_MAX_ISSUES_PER_TURN, y as DEFAULT_PORT } from "./apply-BUU2QcJ2.mjs";
4
- import { E as parseForm, T as formToJsonSchema, a as resolveHarnessConfig, c as getProviderNames, f as createMockAgent, i as runResearch, l as resolveModel, m as createHarness, n as isResearchForm, o as fillForm, s as getProviderInfo, t as VERSION, u as createLiveAgent } from "./src-kUggXhN1.mjs";
5
- import { n as serializeSession } from "./session-CmHdAPyg.mjs";
6
- import { a as formatPath, c as logError, d as logTiming, f as logVerbose, g as writeFile, i as formatOutput, l as logInfo, m as readFile$1, n as createSpinner, o as getCommandContext, p as logWarn, r as ensureFormsDir, s as logDryRun, t as OUTPUT_FORMATS, u as logSuccess } from "./shared-DQ6y3Ggc.mjs";
2
+ import { L as PatchSchema } from "./coreTypes-B1oI7qvV.mjs";
3
+ import { A as deriveReportPath, C as DEFAULT_RESEARCH_MAX_PATCHES_PER_TURN, D as REPORT_EXTENSION, E as MAX_FORMS_IN_MENU, F as WEB_SEARCH_CONFIG, I as formatSuggestedLlms, M as detectFileType, N as parseRolesFlag, O as USER_ROLE, P as SUGGESTED_LLMS, R as hasWebSearchSupport, S as DEFAULT_RESEARCH_MAX_ISSUES_PER_TURN, _ as DEFAULT_MAX_ISSUES_PER_TURN, b as DEFAULT_PORT, d as serialize, f as serializeRawMarkdown, g as DEFAULT_FORMS_DIR, h as ALL_EXTENSIONS, i as inspect, j as deriveSchemaPath, k as deriveExportPath, m as AGENT_ROLE, n as getAllFields, p as serializeReportMarkdown, t as applyPatches, v as DEFAULT_MAX_PATCHES_PER_TURN, y as DEFAULT_MAX_TURNS, z as parseModelIdForDisplay } from "./apply-BTFCHpYV.mjs";
4
+ import { D as parseForm, E as formToJsonSchema, a as resolveHarnessConfig, c as getProviderNames, d as createLiveAgent, h as createHarness, i as runResearch, l as resolveModel, n as isResearchForm, o as fillForm, p as createMockAgent, s as getProviderInfo, t as VERSION, u as buildMockWireFormat } from "./src-CYnyLwBe.mjs";
5
+ import { n as serializeSession } from "./session-Dxqwt0RC.mjs";
6
+ import { a as formatPath, c as logError, d as logTiming, f as logVerbose, g as writeFile, i as formatOutput, l as logInfo, m as readFile$1, n as createSpinner, o as getCommandContext, p as logWarn, r as ensureFormsDir, s as logDryRun, t as OUTPUT_FORMATS, u as logSuccess } from "./shared-D3dNi-Gn.mjs";
7
+ import Markdoc from "@markdoc/markdoc";
7
8
  import YAML from "yaml";
8
9
  import { Command } from "commander";
9
10
  import pc from "picocolors";
@@ -942,7 +943,16 @@ function formatFieldResponse(response, useColors) {
942
943
  }
943
944
  case "url": return value.value ? green(`"${value.value}"`) : dim("(empty)");
944
945
  case "url_list": return value.items.length > 0 ? green(`[${value.items.join(", ")}]`) : dim("(empty)");
945
- default: return dim("(unknown)");
946
+ case "date": return value.value ? green(value.value) : dim("(empty)");
947
+ case "year": return value.value !== null ? green(String(value.value)) : dim("(empty)");
948
+ case "table": {
949
+ const rowCount = value.rows?.length ?? 0;
950
+ return rowCount > 0 ? green(`(${rowCount} rows)`) : dim("(empty)");
951
+ }
952
+ default: {
953
+ const _exhaustive = value;
954
+ throw new Error(`Unhandled field value kind: ${_exhaustive.kind}`);
955
+ }
946
956
  }
947
957
  }
948
958
  /**
@@ -1014,15 +1024,9 @@ const EXAMPLE_DEFINITIONS = [
1014
1024
  type: "fill"
1015
1025
  },
1016
1026
  {
1017
- id: "movie-research-basic",
1018
- filename: "movie-research-basic.form.md",
1019
- path: "movie-research/movie-research-basic.form.md",
1020
- type: "research"
1021
- },
1022
- {
1023
- id: "movie-research-deep",
1024
- filename: "movie-research-deep.form.md",
1025
- path: "movie-research/movie-research-deep.form.md",
1027
+ id: "movie-deep-research",
1028
+ filename: "movie-deep-research.form.md",
1029
+ path: "movie-research/movie-deep-research.form.md",
1026
1030
  type: "research"
1027
1031
  },
1028
1032
  {
@@ -1030,12 +1034,6 @@ const EXAMPLE_DEFINITIONS = [
1030
1034
  filename: "startup-deep-research.form.md",
1031
1035
  path: "startup-deep-research/startup-deep-research.form.md",
1032
1036
  type: "research"
1033
- },
1034
- {
1035
- id: "earnings-analysis",
1036
- filename: "earnings-analysis.form.md",
1037
- path: "earnings-analysis/earnings-analysis.form.md",
1038
- type: "research"
1039
1037
  }
1040
1038
  ];
1041
1039
  /** Default example ID for menus (movie-research-demo, index 0) */
@@ -1059,7 +1057,7 @@ function getExamplesDir() {
1059
1057
  }
1060
1058
  /**
1061
1059
  * Load the content of an example form.
1062
- * @param exampleId - The example ID (e.g., 'simple', 'movie-research-deep')
1060
+ * @param exampleId - The example ID (e.g., 'simple', 'movie-deep-research')
1063
1061
  * @returns The form content as a string
1064
1062
  * @throws Error if the example is not found
1065
1063
  */
@@ -1070,7 +1068,7 @@ function loadExampleContent(exampleId) {
1070
1068
  try {
1071
1069
  return readFileSync(filePath, "utf-8");
1072
1070
  } catch (error) {
1073
- throw new Error(`Failed to load example '${exampleId}' from ${filePath}: ${error}`);
1071
+ throw new Error(`Failed to load example '${exampleId}' from ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
1074
1072
  }
1075
1073
  }
1076
1074
  /**
@@ -1081,7 +1079,7 @@ function getExampleById(id) {
1081
1079
  }
1082
1080
  /**
1083
1081
  * Get the absolute path to an example's source file.
1084
- * @param exampleId - The example ID (e.g., 'simple', 'movie-research-deep')
1082
+ * @param exampleId - The example ID (e.g., 'simple', 'movie-deep-research')
1085
1083
  * @returns The absolute path to the example form file
1086
1084
  * @throws Error if the example is not found
1087
1085
  */
@@ -1091,27 +1089,27 @@ function getExamplePath(exampleId) {
1091
1089
  return join(getExamplesDir(), example.path);
1092
1090
  }
1093
1091
  /**
1094
- * Extract YAML frontmatter from a markdown file content.
1092
+ * Extract YAML frontmatter from markdown content using Markdoc's native support.
1095
1093
  * @param content - The markdown file content
1096
1094
  * @returns The parsed frontmatter object or null if no frontmatter found
1097
1095
  */
1098
1096
  function extractFrontmatter(content) {
1099
- const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
1100
- if (!frontmatterMatch || !frontmatterMatch[1]) return null;
1097
+ const rawFrontmatter = Markdoc.parse(content).attributes.frontmatter;
1098
+ if (!rawFrontmatter) return null;
1101
1099
  try {
1102
- return YAML.parse(frontmatterMatch[1]);
1100
+ return YAML.parse(rawFrontmatter);
1103
1101
  } catch {
1104
1102
  return null;
1105
1103
  }
1106
1104
  }
1107
1105
  /**
1108
1106
  * Load metadata (title, description) from an example's YAML frontmatter.
1109
- * @param exampleId - The example ID (e.g., 'simple', 'movie-research-deep')
1107
+ * @param exampleId - The example ID (e.g., 'simple', 'movie-deep-research')
1110
1108
  * @returns Object with title and description from frontmatter
1111
1109
  */
1112
1110
  function loadExampleMetadata(exampleId) {
1113
1111
  const frontmatter = extractFrontmatter(loadExampleContent(exampleId));
1114
- if (!frontmatter || !frontmatter.markform) return {};
1112
+ if (!frontmatter?.markform) return {};
1115
1113
  const markform = frontmatter.markform;
1116
1114
  return {
1117
1115
  title: typeof markform.title === "string" ? markform.title : void 0,
@@ -1147,7 +1145,7 @@ function issueReasonToStatus(reason) {
1147
1145
  case "validation_error": return "invalid";
1148
1146
  case "checkbox_incomplete": return "incomplete";
1149
1147
  case "min_items_not_met": return "too-few";
1150
- case "optional_empty": return "empty";
1148
+ case "optional_unanswered": return "unanswered";
1151
1149
  default: return "issue";
1152
1150
  }
1153
1151
  }
@@ -1170,7 +1168,7 @@ function formatTurnIssues(issues, maxShow = 5) {
1170
1168
  /**
1171
1169
  * Format form info for menu label display.
1172
1170
  * Format: "filename - Title [runMode]"
1173
- * Example: "movie-research-deep.form.md - Movie Research (Deep) [research]"
1171
+ * Example: "movie-deep-research.form.md - Movie Deep Research [research]"
1174
1172
  */
1175
1173
  function formatFormLabel(info) {
1176
1174
  const titlePart = info.title ? ` - ${info.title}` : "";
@@ -1187,7 +1185,7 @@ function formatFormHint(info) {
1187
1185
  /**
1188
1186
  * Format form info for log line (e.g., after copying).
1189
1187
  * Format: "filename - Title" (dimmed title)
1190
- * Example: "✓ movie-research-deep.form.md - Movie Research (Deep)"
1188
+ * Example: "✓ movie-deep-research.form.md - Movie Deep Research"
1191
1189
  */
1192
1190
  function formatFormLogLine(info, prefix) {
1193
1191
  const titlePart = info.title ? ` - ${info.title}` : "";
@@ -1955,7 +1953,10 @@ function formatPatchValue(patch) {
1955
1953
  case "set_url_list": return patch.items.length > 0 ? truncate(`[${patch.items.join(", ")}]`) : "(empty)";
1956
1954
  case "set_date": return patch.value ? truncate(`"${patch.value}"`) : "(empty)";
1957
1955
  case "set_year": return patch.value !== null ? String(patch.value) : "(empty)";
1958
- case "set_table": return patch.rows.length > 0 ? truncate(`[${patch.rows.length} rows]`) : "(empty)";
1956
+ case "set_table": {
1957
+ const rowCount = patch.rows?.length ?? 0;
1958
+ return rowCount > 0 ? truncate(`[${rowCount} rows]`) : "(empty)";
1959
+ }
1959
1960
  case "add_note": return truncate(`note: ${patch.text}`);
1960
1961
  case "remove_note": return `(remove note ${patch.noteId})`;
1961
1962
  }
@@ -2258,6 +2259,7 @@ async function runAgentFillWorkflow(form, modelId, formsDir, filePath, isResearc
2258
2259
  targetRoles: [AGENT_ROLE],
2259
2260
  fillMode: overwrite ? "overwrite" : "continue",
2260
2261
  enableWebSearch: isResearch,
2262
+ captureWireFormat: false,
2261
2263
  callbacks
2262
2264
  });
2263
2265
  if (result.status.ok) p.log.success(pc.green(`Form completed in ${result.turns} turn(s)`));
@@ -2851,6 +2853,7 @@ function registerFillCommand(program) {
2851
2853
  let mockPath;
2852
2854
  let agentProvider;
2853
2855
  let agentModelName;
2856
+ let targetRole = AGENT_ROLE;
2854
2857
  let currentSpinner = null;
2855
2858
  if (options.mock) {
2856
2859
  mockPath = resolve(options.mockSource);
@@ -2878,12 +2881,12 @@ function registerFillCommand(program) {
2878
2881
  error: (msg) => currentSpinner?.error(msg),
2879
2882
  getElapsedMs: () => currentSpinner?.getElapsedMs() ?? 0
2880
2883
  }, ctx);
2881
- const primaryRole = targetRoles[0] === "*" ? AGENT_ROLE : targetRoles[0];
2884
+ targetRole = targetRoles[0] === "*" ? AGENT_ROLE : targetRoles[0] ?? AGENT_ROLE;
2882
2885
  const liveAgent = createLiveAgent({
2883
2886
  model,
2884
2887
  provider,
2885
2888
  systemPromptAddition: systemPrompt,
2886
- targetRole: primaryRole,
2889
+ targetRole,
2887
2890
  enableWebSearch: true,
2888
2891
  callbacks
2889
2892
  });
@@ -2899,6 +2902,7 @@ function registerFillCommand(program) {
2899
2902
  logVerbose(ctx, `Target roles: ${targetRoles.includes("*") ? "*" : targetRoles.join(", ")}`);
2900
2903
  logVerbose(ctx, `Fill mode: ${fillMode}`);
2901
2904
  let stepResult = harness.step();
2905
+ let previousRejections;
2902
2906
  logInfo(ctx, `${pc.bold(`Turn ${stepResult.turnNumber}:`)} ${formatTurnIssues(stepResult.issues)}`);
2903
2907
  while (!stepResult.isComplete && !harness.hasReachedMaxTurns()) {
2904
2908
  let spinner = null;
@@ -2913,7 +2917,7 @@ function registerFillCommand(program) {
2913
2917
  }
2914
2918
  let response;
2915
2919
  try {
2916
- response = await agent.generatePatches(stepResult.issues, harness.getForm(), harnessConfig.maxPatchesPerTurn);
2920
+ response = await agent.fillFormTool(stepResult.issues, harness.getForm(), harnessConfig.maxPatchesPerTurn);
2917
2921
  spinner?.stop();
2918
2922
  currentSpinner = null;
2919
2923
  } catch (error) {
@@ -2933,7 +2937,7 @@ function registerFillCommand(program) {
2933
2937
  }
2934
2938
  if (stats) {
2935
2939
  logVerbose(ctx, ` Stats: tokens ↓${stats.inputTokens ?? 0} ↑${stats.outputTokens ?? 0}`);
2936
- if (stats.toolCalls.length > 0) logVerbose(ctx, ` Tools: ${stats.toolCalls.map((t) => `${t.name}(${t.count})`).join(", ")}`);
2940
+ if (stats.toolCalls && stats.toolCalls.length > 0) logVerbose(ctx, ` Tools: ${stats.toolCalls.map((t) => `${t.name}(${t.count})`).join(", ")}`);
2937
2941
  if (stats.prompts) {
2938
2942
  logVerbose(ctx, ``);
2939
2943
  logVerbose(ctx, pc.dim(` ─── System Prompt ───`));
@@ -2945,12 +2949,28 @@ function registerFillCommand(program) {
2945
2949
  }
2946
2950
  }
2947
2951
  let llmStats;
2948
- if (stats) llmStats = {
2949
- inputTokens: stats.inputTokens,
2950
- outputTokens: stats.outputTokens,
2951
- toolCalls: stats.toolCalls.length > 0 ? stats.toolCalls : void 0
2952
- };
2953
- stepResult = harness.apply(patches, stepResult.issues, llmStats);
2952
+ let context;
2953
+ let wire;
2954
+ if (stats) {
2955
+ llmStats = {
2956
+ inputTokens: stats.inputTokens,
2957
+ outputTokens: stats.outputTokens,
2958
+ toolCalls: stats.toolCalls && stats.toolCalls.length > 0 ? stats.toolCalls : void 0
2959
+ };
2960
+ if (stats.prompts) context = {
2961
+ systemPrompt: stats.prompts.system,
2962
+ contextPrompt: stats.prompts.context
2963
+ };
2964
+ wire = stats.wire;
2965
+ } else if (options.mock) {
2966
+ wire = buildMockWireFormat(harness.getForm(), stepResult.issues, patches, harnessConfig.maxPatchesPerTurn, targetRole, previousRejections);
2967
+ context = {
2968
+ systemPrompt: wire.request.system,
2969
+ contextPrompt: wire.request.prompt
2970
+ };
2971
+ }
2972
+ stepResult = harness.apply(patches, stepResult.issues, llmStats, context, wire);
2973
+ previousRejections = stepResult.rejectedPatches;
2954
2974
  if (stepResult.isComplete) logInfo(ctx, pc.green(` ✓ Complete`));
2955
2975
  else if (!harness.hasReachedMaxTurns()) {
2956
2976
  stepResult = harness.step();
@@ -3091,7 +3111,16 @@ function formatFieldValue(value, useColors) {
3091
3111
  }
3092
3112
  case "url": return value.value ? green(`"${value.value}"`) : dim("(empty)");
3093
3113
  case "url_list": return value.items.length > 0 ? green(`[${value.items.map((i) => `"${i}"`).join(", ")}]`) : dim("(empty)");
3094
- default: return dim("(unknown)");
3114
+ case "date": return value.value ? green(value.value) : dim("(empty)");
3115
+ case "year": return value.value !== null ? green(String(value.value)) : dim("(empty)");
3116
+ case "table": {
3117
+ const rowCount = value.rows?.length ?? 0;
3118
+ return rowCount > 0 ? green(`(${rowCount} rows)`) : dim("(empty)");
3119
+ }
3120
+ default: {
3121
+ const _exhaustive = value;
3122
+ throw new Error(`Unhandled field value kind: ${_exhaustive.kind}`);
3123
+ }
3095
3124
  }
3096
3125
  }
3097
3126
  /**
@@ -3504,6 +3533,57 @@ function openBrowser(url) {
3504
3533
  });
3505
3534
  }
3506
3535
  /**
3536
+ * Get the base path by stripping any known markform extension.
3537
+ */
3538
+ function getBasePath(filePath) {
3539
+ for (const ext of Object.values(ALL_EXTENSIONS)) if (filePath.endsWith(ext)) return filePath.slice(0, -ext.length);
3540
+ if (filePath.endsWith(".md")) return filePath.slice(0, -3);
3541
+ return filePath;
3542
+ }
3543
+ /**
3544
+ * Find related files for a form file.
3545
+ * Checks for .report.md, .yml, and .schema.json files with the same base name.
3546
+ */
3547
+ function findRelatedFiles(formPath) {
3548
+ const base = getBasePath(formPath);
3549
+ const reportPath = base + ALL_EXTENSIONS.report;
3550
+ const valuesPath = base + ALL_EXTENSIONS.yaml;
3551
+ const schemaPath = base + ALL_EXTENSIONS.schema;
3552
+ return {
3553
+ form: formPath,
3554
+ report: existsSync(reportPath) ? reportPath : null,
3555
+ values: existsSync(valuesPath) ? valuesPath : null,
3556
+ schema: existsSync(schemaPath) ? schemaPath : null
3557
+ };
3558
+ }
3559
+ /**
3560
+ * Build tabs for tabbed navigation.
3561
+ */
3562
+ function buildTabs(relatedFiles) {
3563
+ const tabs = [];
3564
+ tabs.push({
3565
+ id: "form",
3566
+ label: "Markform",
3567
+ path: relatedFiles.form
3568
+ });
3569
+ if (relatedFiles.report) tabs.push({
3570
+ id: "report",
3571
+ label: "Report",
3572
+ path: relatedFiles.report
3573
+ });
3574
+ if (relatedFiles.values) tabs.push({
3575
+ id: "values",
3576
+ label: "Values",
3577
+ path: relatedFiles.values
3578
+ });
3579
+ if (relatedFiles.schema) tabs.push({
3580
+ id: "schema",
3581
+ label: "Schema",
3582
+ path: relatedFiles.schema
3583
+ });
3584
+ return tabs;
3585
+ }
3586
+ /**
3507
3587
  * Register the serve command.
3508
3588
  */
3509
3589
  function registerServeCommand(program) {
@@ -3517,8 +3597,10 @@ function registerServeCommand(program) {
3517
3597
  const content = await readFile$1(filePath);
3518
3598
  let form = null;
3519
3599
  if (fileType === "form") form = parseForm(content);
3600
+ const relatedFiles = fileType === "form" ? findRelatedFiles(filePath) : null;
3601
+ const tabs = relatedFiles ? buildTabs(relatedFiles) : null;
3520
3602
  const server = createServer((req, res) => {
3521
- handleRequest(req, res, filePath, fileType, form, ctx, (updatedForm) => {
3603
+ handleRequest(req, res, filePath, fileType, form, ctx, tabs, (updatedForm) => {
3522
3604
  form = updatedForm;
3523
3605
  }).catch((err) => {
3524
3606
  console.error("Request error:", err);
@@ -3548,10 +3630,29 @@ function registerServeCommand(program) {
3548
3630
  * Handle HTTP requests.
3549
3631
  * Dispatches to appropriate renderer based on file type.
3550
3632
  */
3551
- async function handleRequest(req, res, filePath, fileType, form, ctx, updateForm) {
3633
+ async function handleRequest(req, res, filePath, fileType, form, ctx, tabs, updateForm) {
3552
3634
  const url = req.url ?? "/";
3635
+ if (req.method === "GET" && url.startsWith("/tab/") && tabs && tabs.length > 1) {
3636
+ const tabId = url.slice(5);
3637
+ const tab = tabs.find((t) => t.id === tabId);
3638
+ if (tab?.path) {
3639
+ const content = await readFile(tab.path, "utf-8");
3640
+ const tabFileType = detectFileType(tab.path);
3641
+ let html;
3642
+ if (tabFileType === "report" || tabFileType === "raw") html = renderMarkdownContent(content);
3643
+ else if (tabFileType === "yaml") html = renderYamlContent(content);
3644
+ else if (tabFileType === "json" || tabFileType === "schema") html = renderJsonContent(content);
3645
+ else html = `<pre>${escapeHtml(content)}</pre>`;
3646
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
3647
+ res.end(html);
3648
+ return;
3649
+ }
3650
+ res.writeHead(404);
3651
+ res.end("Tab not found");
3652
+ return;
3653
+ }
3553
3654
  if (req.method === "GET" && url === "/") if (fileType === "form" && form) {
3554
- const html = renderFormHtml(form);
3655
+ const html = renderFormHtml(form, tabs);
3555
3656
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
3556
3657
  res.end(html);
3557
3658
  } else if (fileType === "raw" || fileType === "report") {
@@ -3562,7 +3663,7 @@ async function handleRequest(req, res, filePath, fileType, form, ctx, updateForm
3562
3663
  const html = renderYamlHtml(await readFile(filePath, "utf-8"), basename(filePath));
3563
3664
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
3564
3665
  res.end(html);
3565
- } else if (fileType === "json") {
3666
+ } else if (fileType === "json" || fileType === "schema") {
3566
3667
  const html = renderJsonHtml(await readFile(filePath, "utf-8"), basename(filePath));
3567
3668
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
3568
3669
  res.end(html);
@@ -3769,6 +3870,11 @@ function formDataToPatches(formData, form) {
3769
3870
  });
3770
3871
  break;
3771
3872
  }
3873
+ case "table": break;
3874
+ default: {
3875
+ const _exhaustive = field;
3876
+ throw new Error(`Unhandled field kind: ${_exhaustive.kind}`);
3877
+ }
3772
3878
  }
3773
3879
  }
3774
3880
  return patches;
@@ -3814,10 +3920,14 @@ async function handleSave(req, res, form, filePath, ctx, updateForm) {
3814
3920
  * Render the form as HTML.
3815
3921
  * @public Exported for testing.
3816
3922
  */
3817
- function renderFormHtml(form) {
3923
+ function renderFormHtml(form, tabs) {
3818
3924
  const { schema, responsesByFieldId } = form;
3819
3925
  const formTitle = schema.title ?? schema.id;
3820
3926
  const groupsHtml = schema.groups.map((group) => renderGroup(group, responsesByFieldId)).join("\n");
3927
+ const showTabs = tabs && tabs.length > 1;
3928
+ const tabBarHtml = showTabs ? `<div class="tab-bar">
3929
+ ${tabs.map((tab, i) => `<button class="tab-btn${i === 0 ? " active" : ""}" data-tab="${tab.id}">${escapeHtml(tab.label)}</button>`).join("\n ")}
3930
+ </div>` : "";
3821
3931
  return `<!DOCTYPE html>
3822
3932
  <html lang="en">
3823
3933
  <head>
@@ -3835,7 +3945,74 @@ function renderFormHtml(form) {
3835
3945
  background: #f8f9fa;
3836
3946
  color: #212529;
3837
3947
  }
3838
- h1 { color: #495057; border-bottom: 2px solid #dee2e6; padding-bottom: 0.5rem; }
3948
+ h1 { color: #495057; border-bottom: none; padding-bottom: 0.5rem; }
3949
+ /* Tab bar styles */
3950
+ .tab-bar {
3951
+ display: flex;
3952
+ gap: 0.25rem;
3953
+ margin-bottom: 1.5rem;
3954
+ border-bottom: 2px solid #dee2e6;
3955
+ padding-bottom: 0;
3956
+ }
3957
+ .tab-btn {
3958
+ padding: 0.5rem 1rem;
3959
+ border: none;
3960
+ background: transparent;
3961
+ color: #6c757d;
3962
+ font-size: 0.95rem;
3963
+ cursor: pointer;
3964
+ border-bottom: 2px solid transparent;
3965
+ margin-bottom: -2px;
3966
+ transition: all 0.15s;
3967
+ }
3968
+ .tab-btn:hover {
3969
+ color: #495057;
3970
+ }
3971
+ .tab-btn.active {
3972
+ color: #0d6efd;
3973
+ border-bottom-color: #0d6efd;
3974
+ font-weight: 500;
3975
+ }
3976
+ .tab-content {
3977
+ display: none;
3978
+ }
3979
+ .tab-content.active {
3980
+ display: block;
3981
+ }
3982
+ /* Light theme syntax highlighting for tab content */
3983
+ .tab-content pre {
3984
+ background: #f8f9fa;
3985
+ color: #24292e;
3986
+ padding: 1rem;
3987
+ border-radius: 6px;
3988
+ border: 1px solid #e1e4e8;
3989
+ overflow-x: auto;
3990
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
3991
+ font-size: 0.9rem;
3992
+ line-height: 1.5;
3993
+ }
3994
+ .syn-key { color: #005cc5; }
3995
+ .syn-string { color: #22863a; }
3996
+ .syn-number { color: #005cc5; }
3997
+ .syn-bool { color: #d73a49; }
3998
+ .syn-null { color: #d73a49; }
3999
+ .syn-comment { color: #6a737d; font-style: italic; }
4000
+ /* Markdown content styles */
4001
+ .markdown-content { padding: 0.5rem 0; }
4002
+ .markdown-content h2 { font-size: 1.4rem; color: #24292e; margin: 1.5rem 0 0.75rem; }
4003
+ .markdown-content h3 { font-size: 1.2rem; color: #24292e; margin: 1.25rem 0 0.5rem; }
4004
+ .markdown-content h4 { font-size: 1.1rem; color: #24292e; margin: 1rem 0 0.5rem; }
4005
+ .markdown-content h5 { font-size: 1rem; color: #24292e; margin: 0.75rem 0 0.5rem; }
4006
+ .markdown-content p { margin: 0.75rem 0; line-height: 1.6; }
4007
+ .markdown-content li { margin: 0.25rem 0; margin-left: 1.5rem; line-height: 1.6; }
4008
+ .markdown-content code { background: #f1f3f5; padding: 0.2em 0.4em; border-radius: 3px; font-size: 0.9em; }
4009
+ .markdown-content pre { background: #f8f9fa; padding: 1rem; border-radius: 6px; border: 1px solid #e1e4e8; overflow-x: auto; }
4010
+ .markdown-content pre code { background: none; padding: 0; }
4011
+ .markdown-content a { color: #0366d6; text-decoration: none; }
4012
+ .markdown-content a:hover { text-decoration: underline; }
4013
+ .markdown-content strong { font-weight: 600; }
4014
+ .loading { text-align: center; padding: 2rem; color: #6c757d; }
4015
+ .error { text-align: center; padding: 2rem; color: #dc3545; }
3839
4016
  h2 { color: #6c757d; font-size: 1.25rem; }
3840
4017
  .group {
3841
4018
  background: white;
@@ -3982,16 +4159,42 @@ function renderFormHtml(form) {
3982
4159
  border-radius: 3px;
3983
4160
  margin-left: 0.5rem;
3984
4161
  }
4162
+ .table-container {
4163
+ overflow-x: auto;
4164
+ }
4165
+ .data-table {
4166
+ width: 100%;
4167
+ border-collapse: collapse;
4168
+ font-size: 0.9rem;
4169
+ }
4170
+ .data-table th,
4171
+ .data-table td {
4172
+ padding: 0.5rem 0.75rem;
4173
+ text-align: left;
4174
+ border: 1px solid #dee2e6;
4175
+ }
4176
+ .data-table th {
4177
+ background: #f8f9fa;
4178
+ font-weight: 600;
4179
+ color: #495057;
4180
+ }
4181
+ .data-table tbody tr:hover {
4182
+ background: #f8f9fa;
4183
+ }
3985
4184
  </style>
3986
4185
  </head>
3987
4186
  <body>
3988
4187
  <h1>${escapeHtml(formTitle)}</h1>
3989
- <form method="POST" action="/save" id="markform">
3990
- ${groupsHtml}
3991
- <div class="toolbar">
3992
- <button type="submit" class="btn btn-primary">Save</button>
3993
- </div>
3994
- </form>
4188
+ ${tabBarHtml}
4189
+ <div id="tab-form" class="tab-content active">
4190
+ <form method="POST" action="/save" id="markform">
4191
+ ${groupsHtml}
4192
+ <div class="toolbar">
4193
+ <button type="submit" class="btn btn-primary">Save</button>
4194
+ </div>
4195
+ </form>
4196
+ </div>
4197
+ ${showTabs ? "<div id=\"tab-other\" class=\"tab-content\"><div class=\"loading\">Loading...</div></div>" : ""}
3995
4198
  <script>
3996
4199
  // Track fields marked for skip
3997
4200
  const skippedFields = new Set();
@@ -4057,6 +4260,50 @@ function renderFormHtml(form) {
4057
4260
  alert('Save failed: ' + err.message);
4058
4261
  }
4059
4262
  });
4263
+
4264
+ // Tab switching logic
4265
+ const tabButtons = document.querySelectorAll('.tab-btn');
4266
+ const tabFormContent = document.getElementById('tab-form');
4267
+ const tabOtherContent = document.getElementById('tab-other');
4268
+ const tabCache = {};
4269
+
4270
+ tabButtons.forEach(btn => {
4271
+ btn.addEventListener('click', async () => {
4272
+ const tabId = btn.dataset.tab;
4273
+
4274
+ // Update active button
4275
+ tabButtons.forEach(b => b.classList.remove('active'));
4276
+ btn.classList.add('active');
4277
+
4278
+ if (tabId === 'form') {
4279
+ // Show form tab
4280
+ if (tabFormContent) tabFormContent.classList.add('active');
4281
+ if (tabOtherContent) tabOtherContent.classList.remove('active');
4282
+ } else {
4283
+ // Show other tab content
4284
+ if (tabFormContent) tabFormContent.classList.remove('active');
4285
+ if (tabOtherContent) {
4286
+ tabOtherContent.classList.add('active');
4287
+
4288
+ // Fetch content if not cached
4289
+ if (!tabCache[tabId]) {
4290
+ tabOtherContent.innerHTML = '<div class="loading">Loading...</div>';
4291
+ try {
4292
+ const response = await fetch('/tab/' + tabId);
4293
+ if (response.ok) {
4294
+ tabCache[tabId] = await response.text();
4295
+ } else {
4296
+ tabCache[tabId] = '<div class="error">Failed to load content</div>';
4297
+ }
4298
+ } catch (err) {
4299
+ tabCache[tabId] = '<div class="error">Failed to load content</div>';
4300
+ }
4301
+ }
4302
+ tabOtherContent.innerHTML = tabCache[tabId];
4303
+ }
4304
+ }
4305
+ });
4306
+ });
4060
4307
  <\/script>
4061
4308
  </body>
4062
4309
  </html>`;
@@ -4119,7 +4366,13 @@ function renderFieldHtml(field, value, isSkipped) {
4119
4366
  case "year":
4120
4367
  inputHtml = renderYearInput(field, value, disabledAttr);
4121
4368
  break;
4122
- default: inputHtml = "<div class=\"field-help\">(unknown field kind)</div>";
4369
+ case "table":
4370
+ inputHtml = renderTableInput(field, value, disabledAttr);
4371
+ break;
4372
+ default: {
4373
+ const _exhaustive = field;
4374
+ throw new Error(`Unhandled field kind: ${_exhaustive.kind}`);
4375
+ }
4123
4376
  }
4124
4377
  const skipButton = !field.required && !skipped ? `<div class="field-actions">
4125
4378
  <button type="button" class="btn-skip" data-skip-field="${field.id}">Skip</button>
@@ -4284,6 +4537,34 @@ function renderCheckboxesInput(field, value, disabledAttr) {
4284
4537
  </div>`;
4285
4538
  }
4286
4539
  /**
4540
+ * Render a table field as an HTML table.
4541
+ * Currently read-only display; editing requires more complex UI.
4542
+ */
4543
+ function renderTableInput(field, value, _disabledAttr) {
4544
+ const rows = value?.rows ?? [];
4545
+ if (rows.length === 0) return "<div class=\"field-help\">(no data)</div>";
4546
+ return `<div class="table-container">
4547
+ <table class="data-table">
4548
+ <thead>
4549
+ <tr>${field.columns.map((col) => `<th>${escapeHtml(col.label)}</th>`).join("")}</tr>
4550
+ </thead>
4551
+ <tbody>
4552
+ ${rows.map((row) => {
4553
+ return `<tr>${field.columns.map((col) => {
4554
+ const cell = row[col.id];
4555
+ let cellValue = "";
4556
+ if (cell?.state === "answered" && cell.value !== void 0 && cell.value !== null) cellValue = String(cell.value);
4557
+ else if (cell?.state === "skipped") cellValue = cell.reason ? `[skipped: ${cell.reason}]` : "[skipped]";
4558
+ else if (cell?.state === "aborted") cellValue = cell.reason ? `[aborted: ${cell.reason}]` : "[aborted]";
4559
+ return `<td>${escapeHtml(cellValue)}</td>`;
4560
+ }).join("")}</tr>`;
4561
+ }).join("\n ")}
4562
+ </tbody>
4563
+ </table>
4564
+ <div class="field-help">(table fields are currently read-only in the web UI)</div>
4565
+ </div>`;
4566
+ }
4567
+ /**
4287
4568
  * Escape HTML special characters.
4288
4569
  * @public Exported for testing.
4289
4570
  */
@@ -4310,15 +4591,23 @@ const READ_ONLY_STYLES = `
4310
4591
  box-shadow: 0 1px 3px rgba(0,0,0,0.1);
4311
4592
  }
4312
4593
  pre {
4313
- background: #1e1e1e;
4314
- color: #d4d4d4;
4594
+ background: #f8f9fa;
4595
+ color: #24292e;
4315
4596
  padding: 1rem;
4316
4597
  border-radius: 6px;
4598
+ border: 1px solid #e1e4e8;
4317
4599
  overflow-x: auto;
4318
4600
  font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
4319
4601
  font-size: 0.9rem;
4320
4602
  line-height: 1.5;
4321
4603
  }
4604
+ /* Light theme syntax highlighting */
4605
+ .syn-key { color: #005cc5; }
4606
+ .syn-string { color: #22863a; }
4607
+ .syn-number { color: #005cc5; }
4608
+ .syn-bool { color: #d73a49; }
4609
+ .syn-null { color: #d73a49; }
4610
+ .syn-comment { color: #6a737d; font-style: italic; }
4322
4611
  .badge {
4323
4612
  font-size: 0.75rem;
4324
4613
  padding: 0.2rem 0.5rem;
@@ -4397,9 +4686,23 @@ function renderMarkdownHtml(content, filename) {
4397
4686
  */
4398
4687
  function renderYamlHtml(content, filename) {
4399
4688
  const highlighted = content.split("\n").map((line) => {
4689
+ if (line.trim().startsWith("#")) return `<span class="syn-comment">${escapeHtml(line)}</span>`;
4400
4690
  const colonIndex = line.indexOf(":");
4401
- if (colonIndex > 0 && !line.trim().startsWith("#") && !line.trim().startsWith("-")) return `<span style="color:#9cdcfe">${escapeHtml(line.slice(0, colonIndex))}</span>${escapeHtml(line.slice(colonIndex))}`;
4402
- if (line.trim().startsWith("#")) return `<span style="color:#6a9955">${escapeHtml(line)}</span>`;
4691
+ if (colonIndex > 0 && !line.trim().startsWith("-")) {
4692
+ const key = escapeHtml(line.slice(0, colonIndex));
4693
+ const afterColon = line.slice(colonIndex + 1).trim();
4694
+ const colonAndSpace = escapeHtml(line.slice(colonIndex, colonIndex + 1));
4695
+ if (afterColon === "") return `<span class="syn-key">${key}</span>${colonAndSpace}`;
4696
+ const valueStart = line.indexOf(afterColon, colonIndex);
4697
+ return `<span class="syn-key">${key}</span>${escapeHtml(line.slice(colonIndex, valueStart))}${highlightYamlValue(afterColon)}`;
4698
+ }
4699
+ if (line.trim().startsWith("-")) {
4700
+ const dashIndex = line.indexOf("-");
4701
+ const beforeDash = escapeHtml(line.slice(0, dashIndex));
4702
+ const afterDash = line.slice(dashIndex + 1).trim();
4703
+ if (afterDash === "") return `${beforeDash}-`;
4704
+ return `${beforeDash}- ${highlightYamlValue(afterDash)}`;
4705
+ }
4403
4706
  return escapeHtml(line);
4404
4707
  }).join("\n");
4405
4708
  return `<!DOCTYPE html>
@@ -4419,6 +4722,17 @@ function renderYamlHtml(content, filename) {
4419
4722
  </html>`;
4420
4723
  }
4421
4724
  /**
4725
+ * Highlight a YAML value based on its type.
4726
+ */
4727
+ function highlightYamlValue(value) {
4728
+ const trimmed = value.trim();
4729
+ if (trimmed === "true" || trimmed === "false") return `<span class="syn-bool">${escapeHtml(value)}</span>`;
4730
+ if (trimmed === "null" || trimmed === "~") return `<span class="syn-null">${escapeHtml(value)}</span>`;
4731
+ if (/^-?\d+\.?\d*$/.test(trimmed)) return `<span class="syn-number">${escapeHtml(value)}</span>`;
4732
+ if (trimmed.startsWith("\"") && trimmed.endsWith("\"") || trimmed.startsWith("'") && trimmed.endsWith("'")) return `<span class="syn-string">${escapeHtml(value)}</span>`;
4733
+ return `<span class="syn-string">${escapeHtml(value)}</span>`;
4734
+ }
4735
+ /**
4422
4736
  * Render JSON content with syntax highlighting and formatting.
4423
4737
  */
4424
4738
  function renderJsonHtml(content, filename) {
@@ -4429,7 +4743,7 @@ function renderJsonHtml(content, filename) {
4429
4743
  } catch {
4430
4744
  formatted = content;
4431
4745
  }
4432
- const highlighted = formatted.replace(/"([^"]+)":/g, "<span style=\"color:#9cdcfe\">\"$1\"</span>:").replace(/: "([^"]+)"/g, ": <span style=\"color:#ce9178\">\"$1\"</span>").replace(/: (\d+\.?\d*)/g, ": <span style=\"color:#b5cea8\">$1</span>").replace(/: (true|false|null)/g, ": <span style=\"color:#569cd6\">$1</span>");
4746
+ const highlighted = formatted.replace(/"([^"]+)":/g, "<span class=\"syn-key\">\"$1\"</span>:").replace(/: "([^"]*)"/g, ": <span class=\"syn-string\">\"$1\"</span>").replace(/: (-?\d+\.?\d*)/g, ": <span class=\"syn-number\">$1</span>").replace(/: (true|false)/g, ": <span class=\"syn-bool\">$1</span>").replace(/: (null)/g, ": <span class=\"syn-null\">$1</span>");
4433
4747
  return `<!DOCTYPE html>
4434
4748
  <html lang="en">
4435
4749
  <head>
@@ -4466,6 +4780,144 @@ function renderPlainTextHtml(content, filename) {
4466
4780
  </body>
4467
4781
  </html>`;
4468
4782
  }
4783
+ /**
4784
+ * Render markdown content (content only, no page wrapper).
4785
+ * Used for tab content.
4786
+ * @public Exported for testing.
4787
+ */
4788
+ function renderMarkdownContent(content) {
4789
+ const lines = content.split("\n");
4790
+ let html = "<div class=\"markdown-content\">";
4791
+ let inParagraph = false;
4792
+ let inCodeBlock = false;
4793
+ let codeBlockContent = "";
4794
+ for (const line of lines) {
4795
+ const trimmed = line.trim();
4796
+ if (trimmed.startsWith("```")) {
4797
+ if (inCodeBlock) {
4798
+ html += `<pre><code>${escapeHtml(codeBlockContent.trim())}</code></pre>`;
4799
+ codeBlockContent = "";
4800
+ inCodeBlock = false;
4801
+ } else {
4802
+ if (inParagraph) {
4803
+ html += "</p>";
4804
+ inParagraph = false;
4805
+ }
4806
+ inCodeBlock = true;
4807
+ }
4808
+ continue;
4809
+ }
4810
+ if (inCodeBlock) {
4811
+ codeBlockContent += line + "\n";
4812
+ continue;
4813
+ }
4814
+ if (trimmed.startsWith("# ")) {
4815
+ if (inParagraph) {
4816
+ html += "</p>";
4817
+ inParagraph = false;
4818
+ }
4819
+ html += `<h2>${escapeHtml(trimmed.slice(2))}</h2>`;
4820
+ } else if (trimmed.startsWith("## ")) {
4821
+ if (inParagraph) {
4822
+ html += "</p>";
4823
+ inParagraph = false;
4824
+ }
4825
+ html += `<h3>${escapeHtml(trimmed.slice(3))}</h3>`;
4826
+ } else if (trimmed.startsWith("### ")) {
4827
+ if (inParagraph) {
4828
+ html += "</p>";
4829
+ inParagraph = false;
4830
+ }
4831
+ html += `<h4>${escapeHtml(trimmed.slice(4))}</h4>`;
4832
+ } else if (trimmed.startsWith("#### ")) {
4833
+ if (inParagraph) {
4834
+ html += "</p>";
4835
+ inParagraph = false;
4836
+ }
4837
+ html += `<h5>${escapeHtml(trimmed.slice(5))}</h5>`;
4838
+ } else if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) {
4839
+ if (inParagraph) {
4840
+ html += "</p>";
4841
+ inParagraph = false;
4842
+ }
4843
+ html += `<li>${formatInlineMarkdown(trimmed.slice(2))}</li>`;
4844
+ } else if (/^\d+\.\s/.test(trimmed)) {
4845
+ if (inParagraph) {
4846
+ html += "</p>";
4847
+ inParagraph = false;
4848
+ }
4849
+ const text = trimmed.replace(/^\d+\.\s/, "");
4850
+ html += `<li>${formatInlineMarkdown(text)}</li>`;
4851
+ } else if (trimmed === "") {
4852
+ if (inParagraph) {
4853
+ html += "</p>";
4854
+ inParagraph = false;
4855
+ }
4856
+ } else {
4857
+ if (!inParagraph) {
4858
+ html += "<p>";
4859
+ inParagraph = true;
4860
+ } else html += "<br>";
4861
+ html += formatInlineMarkdown(trimmed);
4862
+ }
4863
+ }
4864
+ if (inParagraph) html += "</p>";
4865
+ html += "</div>";
4866
+ return html;
4867
+ }
4868
+ /**
4869
+ * Format inline markdown (bold, italic, code, links).
4870
+ */
4871
+ function formatInlineMarkdown(text) {
4872
+ let result = escapeHtml(text);
4873
+ result = result.replace(/`([^`]+)`/g, "<code>$1</code>");
4874
+ result = result.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
4875
+ result = result.replace(/\*([^*]+)\*/g, "<em>$1</em>");
4876
+ result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, linkText, url) => `<a href="${url.replace(/&amp;/g, "&")}" target="_blank">${linkText}</a>`);
4877
+ return result;
4878
+ }
4879
+ /**
4880
+ * Render YAML content (content only, no page wrapper).
4881
+ * Used for tab content.
4882
+ * @public Exported for testing.
4883
+ */
4884
+ function renderYamlContent(content) {
4885
+ return `<pre>${content.split("\n").map((line) => {
4886
+ if (line.trim().startsWith("#")) return `<span class="syn-comment">${escapeHtml(line)}</span>`;
4887
+ const colonIndex = line.indexOf(":");
4888
+ if (colonIndex > 0 && !line.trim().startsWith("-")) {
4889
+ const key = escapeHtml(line.slice(0, colonIndex));
4890
+ const afterColon = line.slice(colonIndex + 1).trim();
4891
+ const colonAndSpace = escapeHtml(line.slice(colonIndex, colonIndex + 1));
4892
+ if (afterColon === "") return `<span class="syn-key">${key}</span>${colonAndSpace}`;
4893
+ const valueStart = line.indexOf(afterColon, colonIndex);
4894
+ return `<span class="syn-key">${key}</span>${escapeHtml(line.slice(colonIndex, valueStart))}${highlightYamlValue(afterColon)}`;
4895
+ }
4896
+ if (line.trim().startsWith("-")) {
4897
+ const dashIndex = line.indexOf("-");
4898
+ const beforeDash = escapeHtml(line.slice(0, dashIndex));
4899
+ const afterDash = line.slice(dashIndex + 1).trim();
4900
+ if (afterDash === "") return `${beforeDash}-`;
4901
+ return `${beforeDash}- ${highlightYamlValue(afterDash)}`;
4902
+ }
4903
+ return escapeHtml(line);
4904
+ }).join("\n")}</pre>`;
4905
+ }
4906
+ /**
4907
+ * Render JSON content (content only, no page wrapper).
4908
+ * Used for tab content.
4909
+ * @public Exported for testing.
4910
+ */
4911
+ function renderJsonContent(content) {
4912
+ let formatted;
4913
+ try {
4914
+ const parsed = JSON.parse(content);
4915
+ formatted = JSON.stringify(parsed, null, 2);
4916
+ } catch {
4917
+ formatted = content;
4918
+ }
4919
+ return `<pre>${formatted.replace(/"([^"]+)":/g, "<span class=\"syn-key\">\"$1\"</span>:").replace(/: "([^"]*)"/g, ": <span class=\"syn-string\">\"$1\"</span>").replace(/: (-?\d+\.?\d*)/g, ": <span class=\"syn-number\">$1</span>").replace(/: (true|false)/g, ": <span class=\"syn-bool\">$1</span>").replace(/: (null)/g, ": <span class=\"syn-null\">$1</span>")}</pre>`;
4920
+ }
4469
4921
 
4470
4922
  //#endregion
4471
4923
  //#region src/cli/commands/render.ts
@@ -4636,6 +5088,7 @@ function registerResearchCommand(program) {
4636
5088
  result = await runResearch(form, {
4637
5089
  model: modelId,
4638
5090
  enableWebSearch: true,
5091
+ captureWireFormat: false,
4639
5092
  maxTurns,
4640
5093
  maxPatchesPerTurn,
4641
5094
  maxIssuesPerTurn,
@@ -4658,9 +5111,9 @@ function registerResearchCommand(program) {
4658
5111
  console.log(` ${formPath} ${pc.dim("(filled markform source)")}`);
4659
5112
  console.log(` ${schemaPath} ${pc.dim("(JSON Schema)")}`);
4660
5113
  if (options.transcript && result.transcript) {
4661
- const { serializeSession: serializeSession$1 } = await import("./session-DSTNiHza.mjs");
5114
+ const { serializeSession: serializeSession$1 } = await import("./session-CzCh6JeY.mjs");
4662
5115
  const transcriptPath = outputPath.replace(/\.form\.md$/, ".session.yaml");
4663
- const { writeFile: writeFile$1 } = await import("./shared-C9yW5FLZ.mjs");
5116
+ const { writeFile: writeFile$1 } = await import("./shared-CNqwaxUt.mjs");
4664
5117
  await writeFile$1(transcriptPath, serializeSession$1(result.transcript));
4665
5118
  logInfo(ctx, `Transcript: ${transcriptPath}`);
4666
5119
  }