@khanglvm/relay 0.13.6 → 0.13.8

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.
package/README.md CHANGED
@@ -76,6 +76,7 @@ unchanged; pick whichever surface fits.
76
76
  | ASCII architecture art | Mermaid, Graphviz, PlantUML — zoomable, full-screen, even user-editable |
77
77
  | Numbers buried in prose | Charts and sortable tables; screenshots and HTML prototypes in a sandbox |
78
78
  | "Here's the diff — paste it in your editor" | Side-by-side **diff** blocks, syntax-highlighted **code**, **video** walkthroughs, and **file paths you click to open** in the default app |
79
+ | "Which commits should I pick? Resolve this conflict manually." | `rly git pick` / `rly git cherry-pick` boards and color-coded `git-conflict` resolvers with ours/theirs/custom hunk choices |
79
80
  | "Type *done* when finished reviewing" | A Submit button; answers, notes, and inline comments returned as JSON |
80
81
  | Feedback = another wall of text | Click any chart point, diagram node, table cell, or sentence to comment — the agent replies and the thread grows on the board |
81
82
 
@@ -88,6 +89,8 @@ Node ≥ 18; Chart.js / Mermaid / Graphviz are vendored and lazy-loaded offline.
88
89
  | | |
89
90
  |---|---|
90
91
  | `rly help` | every command at a glance |
92
+ | `rly git pick` / `rly git cherry-pick` | choose commit actions and rank the order directly on a board |
93
+ | `rly git conflict [files…]` | auto-detect unmerged conflict files, or open specific local paths, and return resolved content in `result.blockEdits` |
91
94
  | `rly view <file.md> …` | open a quick read-only board that renders local markdown file(s), data files, or PDFs — library-free; great for plans, READMEs, reports, quotes |
92
95
  | `rly install --target <agent>` | write relay's rules into an agent's instruction file — `claude` `codex` `cursor` `copilot` `kiro` `windsurf` `cline` `gemini` `opencode` `droid` `agents`; `--all`, `--scope`, `--print`, `--list` |
93
96
  | `rly upgrade` | update the CLI **and** refresh the skill in one step (safe around open boards; `--dry-run`, `--cli-only`, `--skill-only`) |
package/docs/AGENT.md CHANGED
@@ -12,7 +12,7 @@ walls of monospace text, or can't show it at all (a terminal can't draw a PNG).
12
12
  relay renders it in a real browser board. So whenever your output would read better
13
13
  than monospace — or is a file/visual the user should look at — show it in relay
14
14
  instead of printing it. Fast paths: `rly view file.md` (or `data.csv`), `rly diff`,
15
- or an `image`/`chart`/`table` block on a board.
15
+ `rly git pick` / `rly git conflict`, or an `image`/`chart`/`table` block on a board.
16
16
 
17
17
  **Use relay even in plan mode** — for any clarifying or decision question (Claude
18
18
  Code / Codex), route it through a relay board, **not** the native
@@ -161,6 +161,24 @@ rly diff --detach # working-tree diff (git args pass through
161
161
  rly diff --staged --split # --staged, HEAD~1, -- path, …); --split = side-by-side
162
162
  ```
163
163
 
164
+ Pick/cherry-pick commits or resolve conflicts on a board:
165
+
166
+ ```sh
167
+ rly git pick --limit 20 # checklist: pick/cherry-pick/drop + rank order
168
+ rly git cherry-pick main..feature # checklist: cherry-pick/skip/hold + rank order
169
+ rly git conflict # auto-detect unmerged files with conflict markers
170
+ rly git conflict src/app.js # resolve a specific conflicted file
171
+ ```
172
+
173
+ For authored boards, use a `git-conflict` block. It accepts inline conflict
174
+ marker content or a local file path, auto-detects each hunk, and returns user
175
+ choices plus the full resolved file in `result.blockEdits[blockId]`:
176
+
177
+ ```jsonc
178
+ { "type": "git-conflict", "file": "src/app.js" }
179
+ { "type": "git-conflict", "filename": "app.js", "content": "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> feature\n" }
180
+ ```
181
+
164
182
  ## Board spec (JSON)
165
183
 
166
184
  ```jsonc
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanglvm/relay",
3
- "version": "0.13.6",
3
+ "version": "0.13.8",
4
4
  "description": "Question boards with rich blocks (markdown, charts, mermaid, tables, code, diffs, video, sandboxed HTML), clickable local file-links, and element-level annotations for AI coding agents (Claude Code, Codex, …): ask users structured questions, present interactive visuals, collect inline comments, read answers as JSON — in a local browser board OR rendered INLINE inside the Claude & Codex apps as an MCP App (SEP-1865).",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: relay
3
- description: "Collect user decisions & answers — choice, multi, yes-no, text, scale, rank (prioritize), checklist (sign-off), allocate (split a budget), color — and present plans, data, code, or designs with blocks: diagrams (mermaid/graphviz/plantuml), charts, KPI cards, tables (sortable/filterable/CSV, load .csv/.json), code, multi-file diffs, before/after compare, typography, palettes, video, images (click-to-pin), HTML — plus inline element comments and links that open a visual in a modal. Opens a browser board (or inline in Claude/Codex), returns JSON answers/notes/comments/annotations on Submit. Use PROACTIVELY over native ask tools (2+ answers), ASCII/prose for structures/metrics/designs, or printing a file/image/chart/diff the terminal can't render. A dedicated component exists for most content — use the most specific, don't fall back to prose. Triggers: ask the user, decisions, prioritize, sign-off, allocate, plan review, chart, metrics, table, view a CSV, before/after, git diff, markdown file. Skip single yes/no."
3
+ description: "Collect user decisions & answers — choice, multi, yes-no, text, scale, rank, checklist, allocate, color — and present plans, data, code, git diffs/conflicts, designs, charts, tables, diagrams, images, PDFs, videos, HTML, palettes, typography, and file views on a browser or inline MCP board. Returns JSON answers/notes/comments/annotations/blockEdits on Submit. Use PROACTIVELY for 2+ questions, visual review, plans, metrics, tables, diffs, git pick/cherry-pick/conflict decisions, markdown files, or anything the terminal cannot render well. Use the most specific component instead of prose. Skip single yes/no."
4
4
  ---
5
5
 
6
6
  # relay (`rly`)
@@ -46,6 +46,7 @@ at — show it in a relay board instead of printing it.**
46
46
  | Gather requirements / plan approval / feedback round | **rly** |
47
47
  | Architecture or flow that benefits from a diagram | **rly** (mermaid block) |
48
48
  | "Show me the diff" / git diff / code changes / before-after | **rly** (`diff` block — run `git diff`, render it; never dump it in the terminal) |
49
+ | Pick/cherry-pick commits or resolve conflict files | **rly git** (`rly git pick`, `rly git cherry-pick`, `rly git conflict`) |
49
50
  | A demo, screen recording or walkthrough | **rly** (`video` block) |
50
51
  | Point the user at a file to open (log, capture, report) | **rly** (a clickable local file-link in markdown) |
51
52
  | Let the user read a markdown file (README, plan, report) | **rly view file.md** (or a `markdown` block with `mdFile`) — never dump the file into the terminal |
@@ -83,6 +84,7 @@ priority call in a `rank` question — not in paragraphs. Unsure which exists? R
83
84
  | `plantuml` | UML (sequence / class / component) |
84
85
  | `code` | source / config / command output — highlighted, line numbers, hover-a-line to comment |
85
86
  | `diff` | code changes — colored unified/split, multi-file (`rly diff` builds the whole board) |
87
+ | `git-conflict` | conflict-marker files — side-by-side ours/theirs/base with hunk choices; returns resolved content in `result.blockEdits[blockId]` |
86
88
  | `image` | screenshots / mockups / renders — zoom+pan; `pins:true` → click-to-drop point comments |
87
89
  | `compare` | a before/after pair — draggable divider |
88
90
  | `video` | a demo / screen recording / walkthrough |
@@ -283,6 +285,10 @@ single/multi question.
283
285
  // ^ a unified / `git diff` text rendered as a colored, line-numbered comparison
284
286
  // (no git needed — just paste the diff). "view":"split" = side-by-side; the
285
287
  // viewer also has a live Unified⇄Split toggle. "diffFile" loads it from a file.
288
+ { "type": "git-conflict", "file": "src/app.js" }
289
+ { "type": "git-conflict", "filename": "app.js", "content": "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> feature\n" }
290
+ // ^ conflict markers rendered as a resolver: choose ours/theirs/both/custom per
291
+ // hunk. User choices + full resolved content return in result.blockEdits[blockId].
286
292
  { "type": "video", "src": "https://youtu.be/dQw4w9WgXcQ", "title": "Demo walkthrough" }
287
293
  { "type": "video", "src": "recordings/demo.mp4", "title": "Local capture", "height": 360 }
288
294
  // ^ YouTube/Vimeo URL embeds a player; an http(s) media URL or a local video
package/src/cli.js CHANGED
@@ -4,7 +4,7 @@ import os from 'node:os';
4
4
  import { spawn, spawnSync } from 'node:child_process';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { CliError, sleep, pollFor } from './util.js';
7
- import { normalizeSpec, questionFromInline, SPEC_SCHEMA } from './spec.js';
7
+ import { assertSpecReady, normalizeSpec, questionFromInline, SPEC_SCHEMA } from './spec.js';
8
8
  import {
9
9
  createBoard,
10
10
  loadBoard,
@@ -32,7 +32,7 @@ const PKG_NAME = PKG_JSON.name; // e.g. "@khanglvm/relay" — the global package
32
32
  const VALUED_FLAGS = new Set([
33
33
  'file', 'html', 'html-file', 'title', 'intro', 'timeout', 'port',
34
34
  'submit-label', 'height', 'limit', 'target', 'id', 'replies',
35
- 'on-result', 'notify-cmd', 'idle-grace', 'scope',
35
+ 'on-result', 'notify-cmd', 'idle-grace', 'scope', 'range',
36
36
  'host', 'token', 'allow-origin',
37
37
  ]);
38
38
 
@@ -197,6 +197,7 @@ function readFileOrThrow(p) {
197
197
  }
198
198
 
199
199
  async function runOrDetach(record, args) {
200
+ await assertSpecReady(record.spec);
200
201
  const timeoutSec = args.timeout !== undefined ? Math.max(0, Number.parseInt(args.timeout, 10) || 0) : 1800;
201
202
  const port = args.port !== undefined ? Number.parseInt(args.port, 10) || 0 : 0;
202
203
  const open = args.open !== false;
@@ -244,6 +245,7 @@ async function runOrDetach(record, args) {
244
245
  async function cmdAsk(args, mode) {
245
246
  const raw = await resolveSpecInput(args, mode);
246
247
  const spec = normalizeSpec(raw);
248
+ await assertSpecReady(spec);
247
249
  const record = createBoard(spec);
248
250
  return runOrDetach(record, args);
249
251
  }
@@ -275,6 +277,113 @@ async function cmdDiff(rest) {
275
277
  }
276
278
  const title = args.title || ('git diff' + (gitArgs.length ? ' ' + gitArgs.join(' ') : ''));
277
279
  const spec = normalizeSpec({ title, blocks: [{ type: 'diff', diff, view: args.split ? 'split' : 'unified' }] });
280
+ await assertSpecReady(spec);
281
+ const record = createBoard(spec);
282
+ return runOrDetach(record, args);
283
+ }
284
+
285
+ function runGit(args, message) {
286
+ const res = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
287
+ if (res.error) throw new CliError(`could not run git: ${res.error.message}. Is git installed and is this a repo?`);
288
+ if (res.status !== 0) {
289
+ const detail = (res.stderr || res.stdout || '').trim();
290
+ throw new CliError(`${message}: ${detail || `git ${args.join(' ')} exited ${res.status}`}`, res.status || 1);
291
+ }
292
+ return res.stdout || '';
293
+ }
294
+
295
+ function gitConflictPaths(explicitPaths) {
296
+ if (explicitPaths.length) return explicitPaths;
297
+ const out = runGit(['diff', '--name-only', '--diff-filter=U'], 'git conflict detection failed');
298
+ return out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
299
+ }
300
+
301
+ function gitCommitRows(range, limit) {
302
+ const n = Math.max(1, Math.min(200, Number.parseInt(limit, 10) || 20));
303
+ const args = ['log', `-n${n}`, '--date=short', '--pretty=format:%H%x09%h%x09%ad%x09%an%x09%s'];
304
+ if (range) args.push(range);
305
+ return runGit(args, 'git log failed')
306
+ .split(/\r?\n/)
307
+ .filter(Boolean)
308
+ .map((line) => {
309
+ const [sha, short, date, author, ...subjectParts] = line.split('\t');
310
+ const subject = subjectParts.join('\t');
311
+ return { sha, short, date, author, subject };
312
+ })
313
+ .filter((r) => r.sha && r.short);
314
+ }
315
+
316
+ async function cmdGit(rest) {
317
+ const args = parseArgs(rest);
318
+ const modes = new Set(['pick', 'cherry-pick', 'cherrypick', 'conflict', 'conflicts', 'resolve']);
319
+ let mode = 'pick';
320
+ if (args._[0] && modes.has(args._[0])) mode = args._.shift();
321
+ if (mode === 'cherrypick') mode = 'cherry-pick';
322
+ if (mode === 'conflicts' || mode === 'resolve') mode = 'conflict';
323
+
324
+ const blocks = [];
325
+ const questions = [];
326
+
327
+ if (mode === 'pick' || mode === 'cherry-pick') {
328
+ const range = args.range || args._[0] || '';
329
+ const commits = gitCommitRows(range, args.limit);
330
+ if (!commits.length) throw new CliError(`git log${range ? ` ${range}` : ''} produced no commits to show.`);
331
+ blocks.push({
332
+ type: 'table',
333
+ columns: ['short', 'date', 'author', 'subject'],
334
+ rows: commits.map((c) => ({ short: c.short, date: c.date, author: c.author, subject: c.subject })),
335
+ sortable: true,
336
+ filterable: true,
337
+ exportable: true,
338
+ });
339
+ const options = commits.map((c) => ({
340
+ value: c.sha,
341
+ label: `${c.short} ${c.subject}`,
342
+ description: `${c.date} · ${c.author}`,
343
+ }));
344
+ questions.push({
345
+ id: 'commit_actions',
346
+ type: 'checklist',
347
+ label: mode === 'cherry-pick' ? 'Choose cherry-pick actions' : 'Choose commit actions',
348
+ description: 'Set only the commits you want the agent to act on.',
349
+ options,
350
+ statuses: mode === 'cherry-pick'
351
+ ? [{ value: 'cherry-pick', label: 'Cherry-pick', tone: 'ok' }, { value: 'skip', label: 'Skip', tone: 'muted' }, { value: 'hold', label: 'Hold', tone: 'warn' }]
352
+ : [{ value: 'pick', label: 'Pick', tone: 'ok' }, { value: 'cherry-pick', label: 'Cherry-pick', tone: 'warn' }, { value: 'drop', label: 'Drop', tone: 'bad' }],
353
+ });
354
+ questions.push({
355
+ id: 'commit_order',
356
+ type: 'rank',
357
+ label: mode === 'cherry-pick' ? 'Cherry-pick order' : 'Preferred commit order',
358
+ description: 'Drag or move commits into the order the agent should apply them.',
359
+ options,
360
+ });
361
+ }
362
+
363
+ const paths = mode === 'conflict' ? gitConflictPaths(args._) : gitConflictPaths([]);
364
+ for (const p of paths) {
365
+ let text = '';
366
+ try { text = fs.readFileSync(path.resolve(p), 'utf8'); } catch { /* normalizeSpec will report unreadable explicit files below */ }
367
+ if (!text || /<<<<<<<[\s\S]*=======[\s\S]*>>>>>>>/.test(text)) {
368
+ blocks.push({ type: 'git-conflict', path: p, filename: p });
369
+ }
370
+ }
371
+
372
+ if (mode === 'conflict' && !blocks.some((b) => b.type === 'git-conflict')) {
373
+ throw new CliError(args._.length
374
+ ? 'no conflict markers found in the provided file(s).'
375
+ : 'no unmerged git files with conflict markers were detected.');
376
+ }
377
+
378
+ const conflictCount = blocks.filter((b) => b.type === 'git-conflict').length;
379
+ const title = args.title || (mode === 'conflict'
380
+ ? `Resolve ${conflictCount} git conflict${conflictCount === 1 ? '' : 's'}`
381
+ : mode === 'cherry-pick' ? 'Cherry-pick commits' : 'Pick commits');
382
+ const intro = args.intro || (conflictCount
383
+ ? 'Resolve each conflict on the board. The submitted result includes result.blockEdits with per-hunk choices and a full resolved file preview for every conflict block.'
384
+ : 'Pick commit actions and order on the board. Submit returns the selected actions and rank order as JSON.');
385
+ const spec = normalizeSpec({ title, intro, blocks, questions, submitLabel: 'Submit git choices' });
386
+ await assertSpecReady(spec);
278
387
  const record = createBoard(spec);
279
388
  return runOrDetach(record, args);
280
389
  }
@@ -299,6 +408,7 @@ async function cmdView(args) {
299
408
  raw.title = args.title || (multi ? `${files.length} files` : path.basename(files[0]));
300
409
  raw.submitLabel = args.submitLabel || 'Done';
301
410
  const spec = normalizeSpec(raw); // reads + validates each file, clear error if unreadable
411
+ await assertSpecReady(spec);
302
412
  const record = createBoard(spec);
303
413
  return runOrDetach(record, args);
304
414
  }
@@ -445,6 +555,7 @@ async function cmdUpdate(args) {
445
555
  } else {
446
556
  throw new CliError('update needs --file <spec.json>, --title, --intro, or -q "...".', 4);
447
557
  }
558
+ await assertSpecReady(spec);
448
559
 
449
560
  let res;
450
561
  try {
@@ -902,13 +1013,17 @@ the user should view — put it in relay instead of printing it.**
902
1013
  diagram/chart/table/code/image/html blocks — never ASCII diagrams or walls of prose.
903
1014
  - **"Show me the diff / git diff / these changes"** → \`rly diff\` (runs git diff →
904
1015
  a diff board), or render diff text in a \`diff\` block — never paste a raw diff.
1016
+ - **Git pick/cherry-pick/conflict decisions** → \`rly git pick\`, \`rly git cherry-pick\`,
1017
+ or \`rly git conflict [files]\`. Conflict boards can also be authored with a
1018
+ \`git-conflict\` block from inline \`content\` or a local \`file\`/\`path\`; user
1019
+ hunk choices come back in \`result.blockEdits[blockId]\` with resolved content.
905
1020
  - Point the user at a file with a clickable local path in a markdown block; embed a
906
1021
  screen recording with a \`video\` block; when answer choices are visual, give each
907
1022
  option its own visual (\`options[].blocks\`) so the user picks by looking.
908
1023
  - **There's a purpose-built component for most content — use the MOST SPECIFIC one,
909
1024
  never plain prose when a block fits.** Blocks: \`table\` (sortable/filterable/CSV,
910
1025
  load from .csv/.json), \`chart\`, \`kpi\` (stat cards), \`mermaid\`/\`graphviz\`/\`plantuml\`,
911
- \`code\`, \`diff\`, \`image\` (+\`pins\`), \`compare\` (before/after), \`video\`, \`pdf\`, \`palette\`,
1026
+ \`code\`, \`diff\`, \`git-conflict\`, \`image\` (+\`pins\`), \`compare\` (before/after), \`video\`, \`pdf\`, \`palette\`,
912
1027
  \`typography\`, \`html\`. Question types: \`single\`/\`multi\`/\`yesno\`/\`scale\`/\`color\`/
913
1028
  \`text\`/\`textarea\` plus \`rank\` (prioritize), \`allocate\` (split a budget), \`checklist\`
914
1029
  (per-item sign-off). For a business user, reach for \`kpi\`+\`chart\`+\`table\` and
@@ -1398,6 +1513,10 @@ USAGE
1398
1513
  .pdf streams in an inline PDF viewer)
1399
1514
  rly diff [git args…] run git diff and show it in a diff board (--split, --detach,
1400
1515
  --title; other args pass to git: rly diff --staged | HEAD~1 | -- path)
1516
+ rly git pick [range] board for choosing pick/cherry-pick/drop actions plus commit order
1517
+ rly git cherry-pick [range] board for choosing commits and cherry-pick order
1518
+ rly git conflict [files…] board for resolving conflict-marker files; with no files, auto-detects
1519
+ unmerged git files and returns resolved content in result.blockEdits
1401
1520
  rly wait <id> [--timeout 3600] block until board finishes, print result JSON
1402
1521
  --while-active [--idle-grace 180]: keep waiting past the deadline
1403
1522
  while the user is still viewing/focused & recently active
@@ -1474,6 +1593,8 @@ export async function main(argv) {
1474
1593
  return await cmdView(parseArgs(rest));
1475
1594
  case 'diff':
1476
1595
  return await cmdDiff(rest);
1596
+ case 'git':
1597
+ return await cmdGit(rest);
1477
1598
  case 'reopen':
1478
1599
  return await cmdReopen(parseArgs(rest));
1479
1600
  case 'rescue':
@@ -396,6 +396,12 @@
396
396
  },
397
397
  };
398
398
  }
399
+ document.addEventListener('relay:block-edit', (e) => {
400
+ const d = e.detail || {};
401
+ if (!d.blockId) return;
402
+ if (d.value === null || d.value === undefined) delete state.blockEdits[d.blockId];
403
+ else state.blockEdits[d.blockId] = d.value;
404
+ });
399
405
  function renderBlocks(container, blocks, questionId) {
400
406
  if (!Array.isArray(blocks) || !blocks.length) return;
401
407
  const target = el('div', { class: 'blocks' });
package/src/mcp.js CHANGED
@@ -19,7 +19,7 @@ import path from 'node:path';
19
19
  import http from 'node:http';
20
20
  import crypto from 'node:crypto';
21
21
  import { fileURLToPath } from 'node:url';
22
- import { normalizeSpec, SPEC_SCHEMA } from './spec.js';
22
+ import { assertSpecReady, normalizeSpec, SPEC_SCHEMA } from './spec.js';
23
23
  import { CliError } from './util.js';
24
24
 
25
25
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -143,7 +143,7 @@ function resourceMeta() {
143
143
  }
144
144
 
145
145
  // ---------- request routing ----------
146
- function buildResult(method, params, clientProtocol) {
146
+ async function buildResult(method, params, clientProtocol) {
147
147
  switch (method) {
148
148
  case 'initialize':
149
149
  return {
@@ -202,7 +202,7 @@ function readResource(params) {
202
202
  // ui/update-model-context as a best-effort context sync). Spec errors come back
203
203
  // as an isError tool result (not a protocol error) so the model can see and fix
204
204
  // them.
205
- function callTool(params) {
205
+ async function callTool(params) {
206
206
  const name = params && params.name;
207
207
  if (name !== 'relay_ask' && name !== 'relay_show') {
208
208
  return { content: [{ type: 'text', text: 'unknown tool: ' + name }], isError: true };
@@ -211,6 +211,7 @@ function callTool(params) {
211
211
  let spec;
212
212
  try {
213
213
  spec = normalizeSpec(args);
214
+ await assertSpecReady(spec);
214
215
  } catch (err) {
215
216
  const msg = err instanceof CliError ? err.message : String((err && err.message) || err);
216
217
  return { content: [{ type: 'text', text: 'relay: invalid board spec — ' + msg }], isError: true };
@@ -265,7 +266,7 @@ export function runMcp() {
265
266
  catch { stdoutOpen = false; }
266
267
  };
267
268
 
268
- function handleLine(line) {
269
+ async function handleLine(line) {
269
270
  let msg;
270
271
  try { msg = JSON.parse(line); } catch {
271
272
  send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } });
@@ -278,7 +279,7 @@ export function runMcp() {
278
279
  if (typeof m.method !== 'string') continue; // a response to us — we issue none
279
280
  let result;
280
281
  try {
281
- result = buildResult(m.method, m.params || {});
282
+ result = await buildResult(m.method, m.params || {});
282
283
  } catch (err) {
283
284
  if (isRequest) {
284
285
  send({ jsonrpc: '2.0', id: m.id, error: { code: err.code || -32603, message: err.message || String(err) } });
@@ -290,6 +291,7 @@ export function runMcp() {
290
291
  }
291
292
 
292
293
  let buf = '';
294
+ let queue = Promise.resolve();
293
295
  process.stdin.setEncoding('utf8');
294
296
  process.stdin.on('data', (chunk) => {
295
297
  buf += chunk;
@@ -298,7 +300,7 @@ export function runMcp() {
298
300
  const line = buf.slice(0, idx);
299
301
  buf = buf.slice(idx + 1);
300
302
  const trimmed = line.trim();
301
- if (trimmed) handleLine(trimmed);
303
+ if (trimmed) queue = queue.then(() => handleLine(trimmed)).catch(() => {});
302
304
  }
303
305
  });
304
306
 
@@ -406,7 +408,7 @@ export function runMcpHttp({ port = DEFAULT_HTTP_PORT, host = '127.0.0.1', token
406
408
  let body = '';
407
409
  let aborted = false;
408
410
  req.on('data', (c) => { body += c; if (body.length > MAX_BODY) { aborted = true; req.destroy(); } });
409
- req.on('end', () => {
411
+ req.on('end', async () => {
410
412
  if (aborted) { res.writeHead(413, base); return res.end(); }
411
413
  let msg;
412
414
  try { msg = JSON.parse(body); } catch {
@@ -421,7 +423,7 @@ export function runMcpHttp({ port = DEFAULT_HTTP_PORT, host = '127.0.0.1', token
421
423
  if (m.method === 'initialize') isInit = true;
422
424
  const isRequest = m.id !== undefined && m.id !== null;
423
425
  try {
424
- const result = buildResult(m.method, m.params || {});
426
+ const result = await buildResult(m.method, m.params || {});
425
427
  if (isRequest) responses.push({ jsonrpc: '2.0', id: m.id, result });
426
428
  } catch (err) {
427
429
  if (isRequest) responses.push({ jsonrpc: '2.0', id: m.id, error: { code: err.code || -32603, message: err.message || String(err) } });
package/src/server.js CHANGED
@@ -7,6 +7,7 @@ import { spawn } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { loadBoard, saveBoard, saveRunning, removeRunning, loadPref, savePref } from './store.js';
9
9
  import { openUrl } from './open.js';
10
+ import { assertSpecReady } from './spec.js';
10
11
 
11
12
  const UI_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'ui');
12
13
  const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -183,9 +184,44 @@ function sanitizeAnnotations(value) {
183
184
  return out;
184
185
  }
185
186
 
187
+ function limitString(v, max) {
188
+ return typeof v === 'string' ? v.slice(0, max) : '';
189
+ }
190
+
191
+ function sanitizeConflictResolution(value) {
192
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
193
+ if (value.type !== 'git-conflict-resolution') return null;
194
+ const out = {
195
+ type: 'git-conflict-resolution',
196
+ filename: limitString(value.filename, 500),
197
+ file: limitString(value.file, 1200),
198
+ resolved: value.resolved === true,
199
+ resolutions: {},
200
+ content: limitString(value.content, 512 * 1024),
201
+ };
202
+ const raw = value.resolutions && typeof value.resolutions === 'object' && !Array.isArray(value.resolutions)
203
+ ? value.resolutions
204
+ : {};
205
+ let n = 0;
206
+ for (const id of Object.keys(raw)) {
207
+ if (n >= 200) break;
208
+ const r = raw[id];
209
+ if (!r || typeof r !== 'object' || Array.isArray(r)) continue;
210
+ const choice = limitString(r.choice, 30);
211
+ if (!['ours', 'theirs', 'both', 'custom'].includes(choice)) continue;
212
+ out.resolutions[limitString(id, 80)] = {
213
+ choice,
214
+ value: limitString(r.value, 128 * 1024),
215
+ };
216
+ n++;
217
+ }
218
+ return Object.keys(out.resolutions).length ? out : null;
219
+ }
220
+
186
221
  // Validates + sanitizes an incoming blockEdits map (from draft/submit).
187
- // Keeps only string-keyed entries with string values <= 20000 chars; caps at
188
- // 50 entries; drops invalid entries. Returns {} when nothing valid is present.
222
+ // String values are editable Mermaid source. Structured git-conflict-resolution
223
+ // values carry per-hunk choices plus resolved file content. Caps prevent a draft
224
+ // from bloating board storage; invalid entries are dropped.
189
225
  function sanitizeBlockEdits(value) {
190
226
  if (value === null || typeof value !== 'object' || Array.isArray(value)) return {};
191
227
  const out = {};
@@ -194,8 +230,14 @@ function sanitizeBlockEdits(value) {
194
230
  if (n >= 50) break;
195
231
  if (typeof key !== 'string') continue;
196
232
  const v = value[key];
197
- if (typeof v !== 'string' || v.length > 20000) continue;
198
- out[key] = v;
233
+ if (typeof v === 'string') {
234
+ if (v.length > 20000) continue;
235
+ out[key] = v;
236
+ } else {
237
+ const edit = sanitizeConflictResolution(v);
238
+ if (!edit) continue;
239
+ out[key] = edit;
240
+ }
199
241
  n++;
200
242
  }
201
243
  return out;
@@ -592,6 +634,11 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
592
634
  !Array.isArray(next.questions) || !Array.isArray(next.blocks)) {
593
635
  return sendJson(res, 400, { error: 'spec must be an object with questions[] and blocks[] arrays' });
594
636
  }
637
+ try {
638
+ await assertSpecReady(next);
639
+ } catch (err) {
640
+ return sendJson(res, 400, { error: err && err.message ? err.message : String(err) });
641
+ }
595
642
  record.spec = next;
596
643
  rev++;
597
644
  saveBoard(record);
package/src/spec.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import vm from 'node:vm';
4
+ import { fileURLToPath } from 'node:url';
3
5
  import { CliError } from './util.js';
4
6
 
5
7
  export const TYPES = ['single', 'multi', 'yesno', 'text', 'textarea', 'scale', 'color', 'rank', 'checklist', 'allocate'];
@@ -39,7 +41,7 @@ const HTML_HEIGHT = { min: 100, max: 2400, boardDefault: 400, questionDefault: 3
39
41
 
40
42
  // Block heights clamp to the same window; defaults vary per block type.
41
43
  const BLOCK_HEIGHT = { min: 100, max: 2400 };
42
- export const BLOCK_TYPES = ['markdown', 'mermaid', 'graphviz', 'plantuml', 'chart', 'table', 'code', 'diff', 'video', 'pdf', 'html', 'image', 'palette', 'kpi', 'typography', 'compare'];
44
+ export const BLOCK_TYPES = ['markdown', 'mermaid', 'graphviz', 'plantuml', 'chart', 'table', 'code', 'diff', 'git-conflict', 'video', 'pdf', 'html', 'image', 'palette', 'kpi', 'typography', 'compare'];
43
45
  const CHART_KINDS = ['bar', 'line', 'pie', 'doughnut', 'radar', 'scatter'];
44
46
 
45
47
  // code/diff blocks may load their text from a local file (like htmlFile). Caps
@@ -65,6 +67,9 @@ const IMAGE_MIMES = {
65
67
  webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif', bmp: 'image/bmp',
66
68
  };
67
69
  const IMAGE_MAX_BYTES = 8 * 1024 * 1024;
70
+ const VENDOR_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'vendor');
71
+
72
+ let mermaidValidatorPromise = null;
68
73
 
69
74
  const asStr = (v) => (typeof v === 'string' ? v : v == null ? '' : String(v));
70
75
 
@@ -122,6 +127,103 @@ function readTextSource(block, inlineKey, fileKey, cwd, where) {
122
127
  return '';
123
128
  }
124
129
 
130
+ function readAnyTextSource(block, inlineKeys, fileKeys, cwd, where, label) {
131
+ for (const key of inlineKeys) {
132
+ if (typeof block[key] === 'string' && block[key] !== '') return { text: block[key], sourceKey: key };
133
+ }
134
+ for (const key of fileKeys) {
135
+ if (typeof block[key] === 'string' && block[key].trim()) {
136
+ const p = path.resolve(cwd, block[key]);
137
+ let buf;
138
+ try {
139
+ buf = fs.readFileSync(p);
140
+ } catch {
141
+ throw new CliError(`${where}: cannot read ${label} "${block[key]}" (resolved: ${p})`);
142
+ }
143
+ if (buf.length > TEXT_FILE_MAX_BYTES) {
144
+ throw new CliError(`${where}: ${label} "${block[key]}" is ${(buf.length / 1024).toFixed(0)}KB — max ${TEXT_FILE_MAX_BYTES / 1024}KB.`);
145
+ }
146
+ return { text: buf.toString('utf8'), sourceKey: key, file: p };
147
+ }
148
+ }
149
+ return { text: '', sourceKey: '' };
150
+ }
151
+
152
+ function joinConflictLines(lines) {
153
+ return lines.join('\n');
154
+ }
155
+
156
+ function parseGitConflictContent(content, where) {
157
+ const lines = String(content || '').replace(/\r\n?/g, '\n').split('\n');
158
+ const parts = [];
159
+ const conflicts = [];
160
+ let textBuf = [];
161
+ const flushText = () => {
162
+ if (!textBuf.length) return;
163
+ parts.push({ type: 'text', text: joinConflictLines(textBuf) });
164
+ textBuf = [];
165
+ };
166
+
167
+ for (let i = 0; i < lines.length; i++) {
168
+ const start = lines[i].match(/^<<<<<<<(?:\s+(.*))?$/);
169
+ if (!start) {
170
+ textBuf.push(lines[i]);
171
+ continue;
172
+ }
173
+
174
+ flushText();
175
+ const oursLabel = start[1] || 'ours';
176
+ const ours = [];
177
+ const base = [];
178
+ const theirs = [];
179
+ let baseLabel = '';
180
+ let theirsLabel = 'theirs';
181
+ let section = 'ours';
182
+ let closed = false;
183
+
184
+ for (i = i + 1; i < lines.length; i++) {
185
+ const line = lines[i];
186
+ const baseStart = line.match(/^\|\|\|\|\|\|\|(?:\s+(.*))?$/);
187
+ if (baseStart && section === 'ours') {
188
+ baseLabel = baseStart[1] || 'base';
189
+ section = 'base';
190
+ continue;
191
+ }
192
+ if (/^=======$/.test(line) && (section === 'ours' || section === 'base')) {
193
+ section = 'theirs';
194
+ continue;
195
+ }
196
+ const end = line.match(/^>>>>>>>(?:\s+(.*))?$/);
197
+ if (end && section === 'theirs') {
198
+ theirsLabel = end[1] || theirsLabel;
199
+ closed = true;
200
+ break;
201
+ }
202
+ if (section === 'ours') ours.push(line);
203
+ else if (section === 'base') base.push(line);
204
+ else theirs.push(line);
205
+ }
206
+
207
+ if (!closed) {
208
+ throw new CliError(`${where}: conflict marker starting at line ${conflicts.length + 1} is missing a matching >>>>>>> marker.`);
209
+ }
210
+ const id = `c${conflicts.length + 1}`;
211
+ conflicts.push({
212
+ id,
213
+ oursLabel,
214
+ theirsLabel,
215
+ ...(baseLabel ? { baseLabel } : {}),
216
+ ours: joinConflictLines(ours),
217
+ theirs: joinConflictLines(theirs),
218
+ ...(baseLabel ? { base: joinConflictLines(base) } : {}),
219
+ });
220
+ parts.push({ type: 'conflict', conflictId: id });
221
+ }
222
+
223
+ flushText();
224
+ return { conflicts, parts };
225
+ }
226
+
125
227
  // Resolve an image source (shared by the `image` and `compare` blocks): an
126
228
  // http(s)/data URL passes through; a local file is read + embedded as a data
127
229
  // URI (capped) so the board stays self-contained offline.
@@ -222,6 +324,99 @@ function parseVideoEmbed(src) {
222
324
  return null;
223
325
  }
224
326
 
327
+ function compactError(err) {
328
+ const raw = err && (err.str || err.message) ? (err.str || err.message) : String(err || 'unknown error');
329
+ const msg = String(raw).replace(/\s+/g, ' ').trim();
330
+ return msg.length > 500 ? msg.slice(0, 497) + '...' : msg;
331
+ }
332
+
333
+ async function mermaidValidator() {
334
+ if (mermaidValidatorPromise) return mermaidValidatorPromise;
335
+ mermaidValidatorPromise = (async () => {
336
+ const sourcePath = path.join(VENDOR_DIR, 'mermaid.min.js');
337
+ let source;
338
+ try {
339
+ source = fs.readFileSync(sourcePath, 'utf8');
340
+ } catch {
341
+ throw new CliError('mermaid blocks need the vendored parser (vendor/mermaid.min.js is missing).');
342
+ }
343
+ const quietConsole = { log() {}, info() {}, warn() {}, error() {}, debug() {} };
344
+ const context = {
345
+ console: quietConsole,
346
+ setTimeout,
347
+ clearTimeout,
348
+ };
349
+ context.globalThis = context;
350
+ context.window = { addEventListener() {} };
351
+ vm.createContext(context);
352
+ try {
353
+ vm.runInContext(source, context, { filename: sourcePath });
354
+ } catch (err) {
355
+ throw new CliError(`could not load mermaid parser: ${compactError(err)}`);
356
+ }
357
+ if (!context.mermaid || typeof context.mermaid.parse !== 'function') {
358
+ throw new CliError('could not load mermaid parser: vendor/mermaid.min.js did not expose mermaid.parse.');
359
+ }
360
+ try {
361
+ context.mermaid.initialize({
362
+ startOnLoad: false,
363
+ securityLevel: 'strict',
364
+ suppressErrorRendering: true,
365
+ });
366
+ } catch {
367
+ // Some Mermaid versions dislike repeated initialize calls; parse below is
368
+ // the real readiness gate.
369
+ }
370
+ return context.mermaid;
371
+ })();
372
+ return mermaidValidatorPromise;
373
+ }
374
+
375
+ async function assertMermaidSyntax(code, where) {
376
+ const mermaid = await mermaidValidator();
377
+ try {
378
+ const parsed = mermaid.parse(code);
379
+ if (parsed && typeof parsed.then === 'function') await parsed;
380
+ } catch (err) {
381
+ throw new CliError(`${where}: invalid mermaid syntax — ${compactError(err)}`);
382
+ }
383
+ }
384
+
385
+ function* allBlocks(spec) {
386
+ const boardBlocks = Array.isArray(spec.blocks) ? spec.blocks : [];
387
+ for (let i = 0; i < boardBlocks.length; i++) {
388
+ yield { block: boardBlocks[i], where: `board.blocks[${i}]${boardBlocks[i]?.id ? ` (${boardBlocks[i].id})` : ''}` };
389
+ }
390
+ const questions = Array.isArray(spec.questions) ? spec.questions : [];
391
+ for (let qi = 0; qi < questions.length; qi++) {
392
+ const q = questions[qi] || {};
393
+ const qBlocks = Array.isArray(q.blocks) ? q.blocks : [];
394
+ for (let bi = 0; bi < qBlocks.length; bi++) {
395
+ yield { block: qBlocks[bi], where: `questions[${qi}].blocks[${bi}]${qBlocks[bi]?.id ? ` (${qBlocks[bi].id})` : ''}` };
396
+ }
397
+ const opts = Array.isArray(q.options) ? q.options : [];
398
+ for (let oi = 0; oi < opts.length; oi++) {
399
+ const oBlocks = Array.isArray(opts[oi]?.blocks) ? opts[oi].blocks : [];
400
+ for (let bi = 0; bi < oBlocks.length; bi++) {
401
+ yield {
402
+ block: oBlocks[bi],
403
+ where: `questions[${qi}].options[${oi}].blocks[${bi}]${oBlocks[bi]?.id ? ` (${oBlocks[bi].id})` : ''}`,
404
+ };
405
+ }
406
+ }
407
+ }
408
+ }
409
+
410
+ export async function assertSpecReady(spec) {
411
+ if (!spec || typeof spec !== 'object') return spec;
412
+ if (spec.__relayReady === true) return spec;
413
+ for (const { block, where } of allBlocks(spec)) {
414
+ if (block && block.type === 'mermaid') await assertMermaidSyntax(block.code || '', where);
415
+ }
416
+ Object.defineProperty(spec, '__relayReady', { value: true, enumerable: false, configurable: true });
417
+ return spec;
418
+ }
419
+
225
420
  // Normalizes one block object. `id` is the already-assigned block id.
226
421
  // Returns the normalized block (with a guaranteed string `type` + `id`).
227
422
  function normalizeBlock(rawBlock, id, cwd, where) {
@@ -303,6 +498,31 @@ function normalizeBlock(rawBlock, id, cwd, where) {
303
498
  return block;
304
499
  }
305
500
 
501
+ if (type === 'git-conflict') {
502
+ const src = readAnyTextSource(rawBlock, ['content', 'text'], ['file', 'path', 'src'], cwd, where, 'git conflict file');
503
+ if (!src.text.trim()) {
504
+ throw new CliError(`${where}: git-conflict block needs inline "content"/"text" or a readable local "file"/"path".`);
505
+ }
506
+ const parsed = parseGitConflictContent(src.text, where);
507
+ if (!parsed.conflicts.length) {
508
+ throw new CliError(`${where}: no git conflict markers found (expected <<<<<<<, =======, >>>>>>>).`);
509
+ }
510
+ const filename = asStr(rawBlock.filename ?? rawBlock.name ?? (src.file ? path.basename(src.file) : '')).trim();
511
+ const block = {
512
+ id,
513
+ type: 'git-conflict',
514
+ filename,
515
+ conflicts: parsed.conflicts,
516
+ parts: parsed.parts,
517
+ };
518
+ const lang = asStr(rawBlock.lang).trim() || (filename ? path.extname(filename).slice(1).toLowerCase() : '');
519
+ if (lang) block.lang = lang;
520
+ if (rawBlock.title !== undefined) block.title = asStr(rawBlock.title);
521
+ if (src.file) block.file = src.file;
522
+ if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
523
+ return block;
524
+ }
525
+
306
526
  if (type === 'video') {
307
527
  const src = asStr(rawBlock.src ?? rawBlock.file ?? rawBlock.url).trim();
308
528
  if (!src) throw new CliError(`${where}: video block needs a "src" (YouTube/Vimeo URL, http(s) media URL, or local file path).`);
@@ -807,14 +1027,18 @@ const BLOCK_SCHEMA = {
807
1027
  mdFile: { type: 'string', description: 'markdown: path to a local .md file to load + render instead of inline "md" (e.g. view a README/plan/report). Resolved against the CWD. Quick view of one or more files: `rly view a.md b.md`.' },
808
1028
  code: { type: 'string', description: 'mermaid: diagram source (e.g. "graph TD; A-->B"); plantuml: the @startuml…@enduml source; code: the source to display (syntax-highlighted with line numbers).' },
809
1029
  codeFile: { type: 'string', description: 'code: path to a local source file to load + display instead of inline "code". Resolved against the CWD; lang defaults from the file extension.' },
810
- filename: { type: 'string', description: 'code/diff: optional file name/path shown as a header label above the block.' },
1030
+ filename: { type: 'string', description: 'code/diff/git-conflict: optional file name/path shown as a header label above the block.' },
811
1031
  editable: { type: 'boolean', description: 'mermaid: when true, render an "Edit diagram" toggle so the user can edit the diagram source live. The edited source is returned in result.blockEdits[<blockId>].' },
812
1032
  dot: { type: 'string', description: 'graphviz: DOT source (e.g. "digraph { a -> b }"). Rendered offline via vendored Viz.js; nodes and edges are individually commentable.' },
813
1033
  server: { type: 'string', description: 'plantuml: PlantUML server base URL (http(s)). Defaults to https://www.plantuml.com/plantuml. Diagrams render via this server (needs network).' },
814
- lang: { type: 'string', description: 'code/diff block: language hint for syntax highlighting (js, ts, py, go, rust, java, c, cpp, csharp, ruby, php, swift, kotlin, sql, yaml, json, sh, css, html, …).' },
1034
+ lang: { type: 'string', description: 'code/diff/git-conflict block: language hint for syntax highlighting (js, ts, py, go, rust, java, c, cpp, csharp, ruby, php, swift, kotlin, sql, yaml, json, sh, css, html, …).' },
815
1035
  diff: { type: 'string', description: 'diff: a unified diff (git diff / diff -u output) — rendered as a colored, line-numbered comparison with +added / −removed / context rows and file/hunk headers. No git needed; just write/paste the diff text.' },
816
1036
  diffFile: { type: 'string', description: 'diff: path to a local file containing a unified diff (alternative to "diff"). Resolved against the CWD.' },
817
1037
  view: { type: 'string', enum: ['unified', 'split'], description: 'diff: initial layout — "unified" (default, one column) or "split" (side-by-side old vs new). The viewer also has a live toggle either way.' },
1038
+ content: { type: 'string', description: 'git-conflict: inline file content containing conflict markers (<<<<<<< / ======= / >>>>>>>). The board auto-detects each hunk and returns resolutions in result.blockEdits[blockId].' },
1039
+ text: { type: 'string', description: 'git-conflict: alias for inline "content".' },
1040
+ file: { type: 'string', description: 'git-conflict: local conflicted file path to load, parse, and resolve. Resolved against the CWD.' },
1041
+ path: { type: 'string', description: 'git-conflict: alias for local conflicted file path.' },
818
1042
  config: { type: 'object', description: 'chart: a full Chart.js config object.' },
819
1043
  kind: { type: 'string', enum: CHART_KINDS, description: 'chart shorthand: chart kind (alternative to "config").' },
820
1044
  labels: { type: 'array', description: 'chart shorthand: x-axis / category labels.' },
@@ -955,7 +1179,7 @@ export const SPEC_SCHEMA = {
955
1179
  type: 'object',
956
1180
  readOnly: true,
957
1181
  description:
958
- 'Returned in the result (not part of the input spec). A map blockId→edited mermaid source for any editable mermaid block the user changed. null when the user made no edits. Read result.blockEdits[<blockId>] for the user\'s edited diagram source.',
1182
+ 'Returned in the result (not part of the input spec). A map blockId→edited block payload. Editable mermaid blocks return edited source strings. git-conflict blocks return {type:"git-conflict-resolution", resolutions, content, resolved, filename, file}. null when the user made no edits.',
959
1183
  },
960
1184
  },
961
1185
  anyOf: [{ required: ['questions'] }, { required: ['blocks'] }, { required: ['html'] }, { required: ['htmlFile'] }],
package/src/ui/app.js CHANGED
@@ -532,6 +532,12 @@
532
532
  scheduleSave();
533
533
  }
534
534
 
535
+ document.addEventListener('relay:block-edit', (e) => {
536
+ const d = e.detail || {};
537
+ if (!d.blockId) return;
538
+ onBlockEdit(d.blockId, d.value);
539
+ });
540
+
535
541
  // ctx for RelayBlocks.render — theme()/htmlSrc per the shared contract, plus
536
542
  // the editable-mermaid plumbing (edits map + onBlockEdit callback).
537
543
  function blockCtx(questionId) {
package/src/ui/blocks.css CHANGED
@@ -260,6 +260,101 @@
260
260
  }
261
261
  .diff-file-chip:hover { color: var(--accent); border-color: var(--accent); }
262
262
 
263
+ /* ---------- git conflict resolver ---------- */
264
+ .gitconf {
265
+ border: 1px solid var(--border); border-radius: 10px; overflow: hidden;
266
+ background: var(--card);
267
+ }
268
+ .gitconf-head {
269
+ display: flex; justify-content: space-between; gap: 12px; align-items: flex-start;
270
+ padding: 11px 12px; border-bottom: 1px solid var(--border);
271
+ background: linear-gradient(180deg, var(--card), var(--bg-sunken));
272
+ }
273
+ .gitconf-title {
274
+ font-weight: 750; color: var(--fg);
275
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
276
+ }
277
+ .gitconf-file {
278
+ margin-top: 3px; color: var(--muted); font-size: 0.76rem;
279
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
280
+ overflow-wrap: anywhere;
281
+ }
282
+ .gitconf-status {
283
+ flex: none; border: 1px solid rgba(188, 68, 52, 0.35);
284
+ background: rgba(188, 68, 52, 0.12); color: var(--danger);
285
+ border-radius: 999px; padding: 3px 9px; font-size: 0.74rem; font-weight: 700;
286
+ }
287
+ .gitconf-status.is-complete {
288
+ border-color: rgba(77, 138, 102, 0.42);
289
+ background: rgba(77, 138, 102, 0.14); color: var(--ok);
290
+ }
291
+ .gitconf-card { padding: 12px; border-bottom: 1px solid var(--border); }
292
+ .gitconf-cardhead {
293
+ display: flex; align-items: center; justify-content: space-between; gap: 10px;
294
+ margin-bottom: 9px;
295
+ }
296
+ .gitconf-badge {
297
+ font-size: 0.74rem; font-weight: 760; letter-spacing: 0;
298
+ color: var(--fg); background: var(--bg-sunken); border: 1px solid var(--border);
299
+ border-radius: 999px; padding: 3px 8px;
300
+ }
301
+ .gitconf-hint { color: var(--muted); font-size: 0.76rem; }
302
+ .gitconf-choicebar { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 10px; }
303
+ .gitconf-choice {
304
+ cursor: pointer; font: inherit; font-size: 0.76rem; font-weight: 700;
305
+ color: var(--fg-2); background: var(--bg-sunken);
306
+ border: 1px solid var(--border); border-radius: 7px; padding: 5px 9px;
307
+ }
308
+ .gitconf-choice:hover { border-color: var(--accent); color: var(--accent); }
309
+ .gitconf-choice.sel {
310
+ border-color: var(--accent); color: var(--accent);
311
+ background: var(--accent-soft);
312
+ }
313
+ .gitconf-sides {
314
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
315
+ gap: 8px; align-items: stretch;
316
+ }
317
+ .gitconf-side {
318
+ min-width: 0; border: 1px solid var(--border); border-radius: 8px;
319
+ overflow: hidden; background: var(--bg-sunken);
320
+ }
321
+ .gitconf-ours { border-color: rgba(188, 68, 52, 0.28); background: rgba(188, 68, 52, 0.06); }
322
+ .gitconf-base { border-color: rgba(185, 145, 63, 0.32); background: rgba(185, 145, 63, 0.08); }
323
+ .gitconf-theirs { border-color: rgba(77, 138, 102, 0.3); background: rgba(77, 138, 102, 0.07); }
324
+ .gitconf-label {
325
+ padding: 5px 8px; border-bottom: 1px solid var(--border);
326
+ color: var(--fg-2); font-size: 0.72rem; font-weight: 760;
327
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
328
+ overflow-wrap: anywhere;
329
+ }
330
+ .gitconf-pre, .gitconf-resolved {
331
+ margin: 0; padding: 9px 10px; overflow: auto;
332
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
333
+ font-size: 0.8rem; line-height: 1.45; color: var(--fg);
334
+ white-space: pre;
335
+ }
336
+ .gitconf-pre code, .gitconf-resolved code { font: inherit; background: none; color: inherit; }
337
+ .gitconf-custom {
338
+ display: none; width: 100%; box-sizing: border-box; margin-top: 10px;
339
+ border: 1px solid var(--border-strong); border-radius: 8px;
340
+ background: var(--bg); color: var(--fg); padding: 9px 10px;
341
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
342
+ font-size: 0.82rem; line-height: 1.45; resize: vertical;
343
+ }
344
+ .gitconf-body.is-custom .gitconf-custom { display: block; }
345
+ .gitconf-preview { padding: 10px 12px; background: var(--bg-sunken); }
346
+ .gitconf-preview summary {
347
+ cursor: pointer; color: var(--fg-2); font-weight: 720; font-size: 0.82rem;
348
+ }
349
+ .gitconf-resolved {
350
+ margin-top: 8px; border: 1px solid var(--border); border-radius: 8px;
351
+ background: var(--card); max-height: 340px;
352
+ }
353
+ @media (max-width: 640px) {
354
+ .gitconf-head, .gitconf-cardhead { flex-direction: column; align-items: flex-start; }
355
+ .gitconf-sides { grid-template-columns: 1fr; }
356
+ }
357
+
263
358
  /* clickable line-number gutter: hover a number to comment on that line */
264
359
  .blk-gutter-c { padding: 14px 0; }
265
360
  .blk-gutter-c .blk-gutterline {
package/src/ui/blocks.js CHANGED
@@ -649,6 +649,156 @@
649
649
  attachViewer(wrap, { zoomEl: null, label: 'diff', comment: wholeBlockComment(ctx, blockId, 'diff') });
650
650
  return wrap;
651
651
  }
652
+
653
+ // ---------- git conflict resolver ----------
654
+ function markerConflictText(h) {
655
+ const chunks = [
656
+ '<<<<<<< ' + (h.oursLabel || 'ours'),
657
+ h.ours || '',
658
+ ];
659
+ if (h.baseLabel) chunks.push('||||||| ' + h.baseLabel, h.base || '');
660
+ chunks.push('=======', h.theirs || '', '>>>>>>> ' + (h.theirsLabel || 'theirs'));
661
+ return chunks.join('\n');
662
+ }
663
+
664
+ function conflictChoiceText(h, choice, custom) {
665
+ if (choice === 'ours') return h.ours || '';
666
+ if (choice === 'theirs') return h.theirs || '';
667
+ if (choice === 'both') {
668
+ const left = h.ours || '';
669
+ const right = h.theirs || '';
670
+ return left && right ? left + '\n' + right : left || right;
671
+ }
672
+ if (choice === 'custom') return custom || '';
673
+ return markerConflictText(h);
674
+ }
675
+
676
+ function joinResolvedParts(block, resolutions) {
677
+ const byId = new Map((block.conflicts || []).map((h) => [h.id, h]));
678
+ return (block.parts || []).map((part) => {
679
+ if (!part || part.type === 'text') return part && part.text ? part.text : '';
680
+ const h = byId.get(part.conflictId);
681
+ if (!h) return '';
682
+ const r = resolutions && resolutions[h.id] ? resolutions[h.id] : {};
683
+ return conflictChoiceText(h, r.choice, r.value);
684
+ }).join('\n');
685
+ }
686
+
687
+ function renderConflictCode(text, lang) {
688
+ const pre = el('pre', { class: 'gitconf-pre' });
689
+ const code = el('code');
690
+ code.innerHTML = tintCode(text || ' ', lang);
691
+ pre.append(code);
692
+ return pre;
693
+ }
694
+
695
+ function renderGitConflict(block, ctx, blockId) {
696
+ const conflicts = Array.isArray(block.conflicts) ? block.conflicts : [];
697
+ const prior = ctx && ctx.edits && ctx.edits[blockId] && typeof ctx.edits[blockId] === 'object'
698
+ ? ctx.edits[blockId]
699
+ : {};
700
+ const resolutions = prior.resolutions && typeof prior.resolutions === 'object' ? { ...prior.resolutions } : {};
701
+ const wrap = el('div', { class: 'gitconf' });
702
+ const count = conflicts.length;
703
+ const title = block.title || block.filename || 'Git conflict';
704
+ const status = el('span', { class: 'gitconf-status' }, `${count} conflict${count === 1 ? '' : 's'}`);
705
+ const resolvedPreview = el('pre', { class: 'gitconf-resolved' }, el('code'));
706
+
707
+ function emit() {
708
+ const touched = Object.keys(resolutions).filter((id) => resolutions[id] && resolutions[id].choice);
709
+ const payload = touched.length
710
+ ? {
711
+ type: 'git-conflict-resolution',
712
+ filename: block.filename || '',
713
+ file: block.file || '',
714
+ resolved: touched.length === count,
715
+ resolutions,
716
+ content: joinResolvedParts(block, resolutions),
717
+ }
718
+ : null;
719
+ if (ctx && typeof ctx.onBlockEdit === 'function') ctx.onBlockEdit(blockId, payload);
720
+ wrap.dispatchEvent(new CustomEvent('relay:block-edit', {
721
+ bubbles: true,
722
+ detail: { blockId, value: payload },
723
+ }));
724
+ resolvedPreview.querySelector('code').textContent = joinResolvedParts(block, resolutions);
725
+ status.textContent = `${touched.length}/${count} resolved`;
726
+ status.classList.toggle('is-complete', touched.length === count);
727
+ }
728
+
729
+ wrap.append(el('div', { class: 'gitconf-head' },
730
+ el('div', {},
731
+ el('div', { class: 'gitconf-title' }, title),
732
+ block.file ? el('div', { class: 'gitconf-file' }, block.file) : null),
733
+ status
734
+ ));
735
+
736
+ conflicts.forEach((h, idx) => {
737
+ const body = el('div', { class: 'gitconf-body' });
738
+ const custom = el('textarea', {
739
+ class: 'gitconf-custom',
740
+ spellcheck: 'false',
741
+ rows: '5',
742
+ placeholder: 'Write the resolved content for this hunk',
743
+ });
744
+ const buttons = [];
745
+ const setChoice = (choice) => {
746
+ const current = resolutions[h.id] || {};
747
+ const next = { choice, value: choice === 'custom' ? custom.value : conflictChoiceText(h, choice, custom.value) };
748
+ resolutions[h.id] = next;
749
+ for (const b of buttons) b.el.classList.toggle('sel', b.choice === choice);
750
+ body.classList.toggle('is-custom', choice === 'custom');
751
+ emit();
752
+ };
753
+ const makeButton = (choice, label) => {
754
+ const b = el('button', { type: 'button', class: 'gitconf-choice' }, label);
755
+ b.addEventListener('mousedown', (e) => e.stopPropagation());
756
+ b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); setChoice(choice); });
757
+ buttons.push({ choice, el: b });
758
+ return b;
759
+ };
760
+ const existing = resolutions[h.id] || {};
761
+ if (existing.choice === 'custom') custom.value = existing.value || '';
762
+ else custom.value = conflictChoiceText(h, existing.choice || 'ours', '');
763
+ custom.addEventListener('input', () => setChoice('custom'));
764
+
765
+ body.append(el('div', { class: 'gitconf-choicebar' },
766
+ makeButton('ours', 'Use ours'),
767
+ makeButton('theirs', 'Use theirs'),
768
+ makeButton('both', 'Keep both'),
769
+ makeButton('custom', 'Custom')
770
+ ));
771
+ body.append(el('div', { class: 'gitconf-sides' },
772
+ el('section', { class: 'gitconf-side gitconf-ours' },
773
+ el('div', { class: 'gitconf-label' }, h.oursLabel || 'ours'),
774
+ renderConflictCode(h.ours || '', block.lang)),
775
+ h.baseLabel ? el('section', { class: 'gitconf-side gitconf-base' },
776
+ el('div', { class: 'gitconf-label' }, h.baseLabel),
777
+ renderConflictCode(h.base || '', block.lang)) : null,
778
+ el('section', { class: 'gitconf-side gitconf-theirs' },
779
+ el('div', { class: 'gitconf-label' }, h.theirsLabel || 'theirs'),
780
+ renderConflictCode(h.theirs || '', block.lang))
781
+ ));
782
+ body.append(custom);
783
+
784
+ const card = el('section', { class: 'gitconf-card' },
785
+ el('div', { class: 'gitconf-cardhead' },
786
+ el('span', { class: 'gitconf-badge' }, `Conflict ${idx + 1}`),
787
+ el('span', { class: 'gitconf-hint' }, 'Choose a side, keep both, or write the final hunk.')),
788
+ body
789
+ );
790
+ wrap.append(card);
791
+ if (existing.choice) setChoice(existing.choice);
792
+ });
793
+
794
+ wrap.append(el('details', { class: 'gitconf-preview' },
795
+ el('summary', {}, 'Resolved file preview'),
796
+ resolvedPreview
797
+ ));
798
+ emit();
799
+ ctx && ctx.annotate && ctx.annotate.enableTextSelection(wrap, { blockId, questionId: ctx.questionId });
800
+ return wrap;
801
+ }
652
802
  // ---------- video (YouTube/Vimeo embed, local stream, or direct URL) ----------
653
803
  function renderVideo(block, ctx, blockId) {
654
804
  const wrap = el('div', { class: 'blk-videowrap' });
@@ -2216,6 +2366,10 @@
2216
2366
  inner = renderDiff(block, ctx, blockId);
2217
2367
  wrapper.append(inner);
2218
2368
  break;
2369
+ case 'git-conflict':
2370
+ inner = renderGitConflict(block, ctx, blockId);
2371
+ wrapper.append(inner);
2372
+ break;
2219
2373
  case 'video':
2220
2374
  inner = renderVideo(block, ctx, blockId);
2221
2375
  wrapper.append(inner);