@khanglvm/relay 0.13.7 → 0.13.9

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; add `--code` to cherry-pick with split code review and per-hunk Apply/Skip/Hold |
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,25 @@ 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 cherry-pick --code main..feature # adds split code review with Apply/Skip/Hold per hunk
170
+ rly git conflict # auto-detect unmerged files with conflict markers
171
+ rly git conflict src/app.js # resolve a specific conflicted file
172
+ ```
173
+
174
+ For authored boards, use a `git-conflict` block. It accepts inline conflict
175
+ marker content or a local file path, auto-detects each hunk, and returns user
176
+ choices plus the full resolved file in `result.blockEdits[blockId]`:
177
+
178
+ ```jsonc
179
+ { "type": "git-conflict", "file": "src/app.js" }
180
+ { "type": "git-conflict", "filename": "app.js", "content": "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> feature\n" }
181
+ ```
182
+
164
183
  ## Board spec (JSON)
165
184
 
166
185
  ```jsonc
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanglvm/relay",
3
- "version": "0.13.7",
3
+ "version": "0.13.9",
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 --code`, `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
@@ -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
 
@@ -282,6 +282,148 @@ async function cmdDiff(rest) {
282
282
  return runOrDetach(record, args);
283
283
  }
284
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
+ function gitCommitDiff(commit) {
317
+ return runGit([
318
+ '--no-pager',
319
+ 'show',
320
+ '--format=',
321
+ '--patch',
322
+ '--find-renames',
323
+ '--find-copies',
324
+ '--no-ext-diff',
325
+ commit,
326
+ ], `git show ${commit} failed`).trimEnd();
327
+ }
328
+
329
+ async function cmdGit(rest) {
330
+ const args = parseArgs(rest);
331
+ const modes = new Set(['pick', 'cherry-pick', 'cherrypick', 'conflict', 'conflicts', 'resolve']);
332
+ let mode = 'pick';
333
+ if (args._[0] && modes.has(args._[0])) mode = args._.shift();
334
+ if (mode === 'cherrypick') mode = 'cherry-pick';
335
+ if (mode === 'conflicts' || mode === 'resolve') mode = 'conflict';
336
+
337
+ const blocks = [];
338
+ const questions = [];
339
+ let reviewDiffCount = 0;
340
+
341
+ if (mode === 'pick' || mode === 'cherry-pick') {
342
+ const range = args.range || args._[0] || '';
343
+ const commits = gitCommitRows(range, args.limit);
344
+ if (!commits.length) throw new CliError(`git log${range ? ` ${range}` : ''} produced no commits to show.`);
345
+ const withCode = args.code === true || args.patch === true || args.diff === true;
346
+ blocks.push({
347
+ type: 'table',
348
+ columns: ['short', 'date', 'author', 'subject'],
349
+ rows: commits.map((c) => ({ short: c.short, date: c.date, author: c.author, subject: c.subject })),
350
+ sortable: true,
351
+ filterable: true,
352
+ exportable: true,
353
+ });
354
+ const options = commits.map((c) => ({
355
+ value: c.sha,
356
+ label: `${c.short} ${c.subject}`,
357
+ description: `${c.date} · ${c.author}`,
358
+ }));
359
+ questions.push({
360
+ id: 'commit_actions',
361
+ type: 'checklist',
362
+ label: mode === 'cherry-pick' ? 'Choose cherry-pick actions' : 'Choose commit actions',
363
+ description: 'Set only the commits you want the agent to act on.',
364
+ options,
365
+ statuses: mode === 'cherry-pick'
366
+ ? [{ value: 'cherry-pick', label: 'Cherry-pick', tone: 'ok' }, { value: 'skip', label: 'Skip', tone: 'muted' }, { value: 'hold', label: 'Hold', tone: 'warn' }]
367
+ : [{ value: 'pick', label: 'Pick', tone: 'ok' }, { value: 'cherry-pick', label: 'Cherry-pick', tone: 'warn' }, { value: 'drop', label: 'Drop', tone: 'bad' }],
368
+ });
369
+ if (options.length > 1) {
370
+ questions.push({
371
+ id: 'commit_order',
372
+ type: 'rank',
373
+ label: mode === 'cherry-pick' ? 'Cherry-pick order' : 'Preferred commit order',
374
+ description: 'Drag or move commits into the order the agent should apply them.',
375
+ options,
376
+ });
377
+ }
378
+ if (withCode) {
379
+ for (const c of commits) {
380
+ const diff = gitCommitDiff(c.sha);
381
+ if (!diff.trim()) continue;
382
+ blocks.push({
383
+ type: 'diff',
384
+ title: `${c.short} ${c.subject}`,
385
+ filename: `${c.short} ${c.subject}`,
386
+ commit: c.sha,
387
+ reviewKind: mode,
388
+ review: true,
389
+ view: 'split',
390
+ diff,
391
+ });
392
+ reviewDiffCount++;
393
+ }
394
+ }
395
+ }
396
+
397
+ const paths = mode === 'conflict' ? gitConflictPaths(args._) : gitConflictPaths([]);
398
+ for (const p of paths) {
399
+ let text = '';
400
+ try { text = fs.readFileSync(path.resolve(p), 'utf8'); } catch { /* normalizeSpec will report unreadable explicit files below */ }
401
+ if (!text || /<<<<<<<[\s\S]*=======[\s\S]*>>>>>>>/.test(text)) {
402
+ blocks.push({ type: 'git-conflict', path: p, filename: p });
403
+ }
404
+ }
405
+
406
+ if (mode === 'conflict' && !blocks.some((b) => b.type === 'git-conflict')) {
407
+ throw new CliError(args._.length
408
+ ? 'no conflict markers found in the provided file(s).'
409
+ : 'no unmerged git files with conflict markers were detected.');
410
+ }
411
+
412
+ const conflictCount = blocks.filter((b) => b.type === 'git-conflict').length;
413
+ const title = args.title || (mode === 'conflict'
414
+ ? `Resolve ${conflictCount} git conflict${conflictCount === 1 ? '' : 's'}`
415
+ : mode === 'cherry-pick' ? 'Cherry-pick commits' : 'Pick commits');
416
+ const intro = args.intro || (conflictCount
417
+ ? '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.'
418
+ : reviewDiffCount
419
+ ? 'Review commit code hunks on the board. Apply, skip, or hold each hunk; submit returns commit actions plus diff-review hunk choices as JSON.'
420
+ : 'Pick commit actions and order on the board. Submit returns the selected actions and rank order as JSON.');
421
+ const spec = normalizeSpec({ title, intro, blocks, questions, submitLabel: 'Submit git choices' });
422
+ await assertSpecReady(spec);
423
+ const record = createBoard(spec);
424
+ return runOrDetach(record, args);
425
+ }
426
+
285
427
  // `rly view <file.md> [more.md …]` — quick read-only board that renders files
286
428
  // with purpose-built blocks (markdown, data tables, PDF). With 2+ files a small
287
429
  // filename heading separates them. Sugar over `rly show`.
@@ -907,13 +1049,17 @@ the user should view — put it in relay instead of printing it.**
907
1049
  diagram/chart/table/code/image/html blocks — never ASCII diagrams or walls of prose.
908
1050
  - **"Show me the diff / git diff / these changes"** → \`rly diff\` (runs git diff →
909
1051
  a diff board), or render diff text in a \`diff\` block — never paste a raw diff.
1052
+ - **Git pick/cherry-pick/conflict decisions** → \`rly git pick\`, \`rly git cherry-pick\`,
1053
+ or \`rly git conflict [files]\`. Conflict boards can also be authored with a
1054
+ \`git-conflict\` block from inline \`content\` or a local \`file\`/\`path\`; user
1055
+ hunk choices come back in \`result.blockEdits[blockId]\` with resolved content.
910
1056
  - Point the user at a file with a clickable local path in a markdown block; embed a
911
1057
  screen recording with a \`video\` block; when answer choices are visual, give each
912
1058
  option its own visual (\`options[].blocks\`) so the user picks by looking.
913
1059
  - **There's a purpose-built component for most content — use the MOST SPECIFIC one,
914
1060
  never plain prose when a block fits.** Blocks: \`table\` (sortable/filterable/CSV,
915
1061
  load from .csv/.json), \`chart\`, \`kpi\` (stat cards), \`mermaid\`/\`graphviz\`/\`plantuml\`,
916
- \`code\`, \`diff\`, \`image\` (+\`pins\`), \`compare\` (before/after), \`video\`, \`pdf\`, \`palette\`,
1062
+ \`code\`, \`diff\`, \`git-conflict\`, \`image\` (+\`pins\`), \`compare\` (before/after), \`video\`, \`pdf\`, \`palette\`,
917
1063
  \`typography\`, \`html\`. Question types: \`single\`/\`multi\`/\`yesno\`/\`scale\`/\`color\`/
918
1064
  \`text\`/\`textarea\` plus \`rank\` (prioritize), \`allocate\` (split a budget), \`checklist\`
919
1065
  (per-item sign-off). For a business user, reach for \`kpi\`+\`chart\`+\`table\` and
@@ -1403,6 +1549,11 @@ USAGE
1403
1549
  .pdf streams in an inline PDF viewer)
1404
1550
  rly diff [git args…] run git diff and show it in a diff board (--split, --detach,
1405
1551
  --title; other args pass to git: rly diff --staged | HEAD~1 | -- path)
1552
+ rly git pick [range] board for choosing pick/cherry-pick/drop actions plus commit order
1553
+ rly git cherry-pick [range] board for choosing commits and cherry-pick order
1554
+ add --code for split code view with per-hunk Apply/Skip/Hold
1555
+ rly git conflict [files…] board for resolving conflict-marker files; with no files, auto-detects
1556
+ unmerged git files and returns resolved content in result.blockEdits
1406
1557
  rly wait <id> [--timeout 3600] block until board finishes, print result JSON
1407
1558
  --while-active [--idle-grace 180]: keep waiting past the deadline
1408
1559
  while the user is still viewing/focused & recently active
@@ -1479,6 +1630,8 @@ export async function main(argv) {
1479
1630
  return await cmdView(parseArgs(rest));
1480
1631
  case 'diff':
1481
1632
  return await cmdDiff(rest);
1633
+ case 'git':
1634
+ return await cmdGit(rest);
1482
1635
  case 'reopen':
1483
1636
  return await cmdReopen(parseArgs(rest));
1484
1637
  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/server.js CHANGED
@@ -184,9 +184,75 @@ function sanitizeAnnotations(value) {
184
184
  return out;
185
185
  }
186
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
+
221
+ function sanitizeDiffReview(value) {
222
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
223
+ if (value.type !== 'diff-review') return null;
224
+ const out = {
225
+ type: 'diff-review',
226
+ reviewKind: limitString(value.reviewKind, 80),
227
+ commit: limitString(value.commit, 120),
228
+ title: limitString(value.title, 500),
229
+ resolved: value.resolved === true,
230
+ hunks: {},
231
+ };
232
+ const raw = value.hunks && typeof value.hunks === 'object' && !Array.isArray(value.hunks)
233
+ ? value.hunks
234
+ : {};
235
+ let n = 0;
236
+ for (const id of Object.keys(raw)) {
237
+ if (n >= 500) break;
238
+ const h = raw[id];
239
+ if (!h || typeof h !== 'object' || Array.isArray(h)) continue;
240
+ const choice = limitString(h.choice, 30);
241
+ if (!['apply', 'skip', 'hold'].includes(choice)) continue;
242
+ out.hunks[limitString(id, 80)] = {
243
+ choice,
244
+ file: limitString(h.file, 1200),
245
+ header: limitString(h.header, 500),
246
+ };
247
+ n++;
248
+ }
249
+ return Object.keys(out.hunks).length ? out : null;
250
+ }
251
+
187
252
  // Validates + sanitizes an incoming blockEdits map (from draft/submit).
188
- // Keeps only string-keyed entries with string values <= 20000 chars; caps at
189
- // 50 entries; drops invalid entries. Returns {} when nothing valid is present.
253
+ // String values are editable Mermaid source. Structured git-conflict-resolution
254
+ // and diff-review values carry bounded per-hunk choices. Caps prevent a draft
255
+ // from bloating board storage; invalid entries are dropped.
190
256
  function sanitizeBlockEdits(value) {
191
257
  if (value === null || typeof value !== 'object' || Array.isArray(value)) return {};
192
258
  const out = {};
@@ -195,8 +261,14 @@ function sanitizeBlockEdits(value) {
195
261
  if (n >= 50) break;
196
262
  if (typeof key !== 'string') continue;
197
263
  const v = value[key];
198
- if (typeof v !== 'string' || v.length > 20000) continue;
199
- out[key] = v;
264
+ if (typeof v === 'string') {
265
+ if (v.length > 20000) continue;
266
+ out[key] = v;
267
+ } else {
268
+ const edit = sanitizeConflictResolution(v) || sanitizeDiffReview(v);
269
+ if (!edit) continue;
270
+ out[key] = edit;
271
+ }
200
272
  n++;
201
273
  }
202
274
  return out;
package/src/spec.js CHANGED
@@ -41,7 +41,7 @@ const HTML_HEIGHT = { min: 100, max: 2400, boardDefault: 400, questionDefault: 3
41
41
 
42
42
  // Block heights clamp to the same window; defaults vary per block type.
43
43
  const BLOCK_HEIGHT = { min: 100, max: 2400 };
44
- 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'];
45
45
  const CHART_KINDS = ['bar', 'line', 'pie', 'doughnut', 'radar', 'scatter'];
46
46
 
47
47
  // code/diff blocks may load their text from a local file (like htmlFile). Caps
@@ -127,6 +127,103 @@ function readTextSource(block, inlineKey, fileKey, cwd, where) {
127
127
  return '';
128
128
  }
129
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
+
130
227
  // Resolve an image source (shared by the `image` and `compare` blocks): an
131
228
  // http(s)/data URL passes through; a local file is read + embedded as a data
132
229
  // URI (capped) so the board stays self-contained offline.
@@ -395,12 +492,41 @@ function normalizeBlock(rawBlock, id, cwd, where) {
395
492
  const block = { id, type: 'diff', diff };
396
493
  if (rawBlock.lang !== undefined) block.lang = asStr(rawBlock.lang);
397
494
  if (rawBlock.filename !== undefined) block.filename = asStr(rawBlock.filename);
495
+ if (rawBlock.title !== undefined) block.title = asStr(rawBlock.title);
496
+ if (rawBlock.review === true) block.review = true;
497
+ if (rawBlock.reviewKind !== undefined) block.reviewKind = asStr(rawBlock.reviewKind);
498
+ if (rawBlock.commit !== undefined) block.commit = asStr(rawBlock.commit);
398
499
  const view = asStr(rawBlock.view).trim().toLowerCase();
399
500
  if (view === 'split' || view === 'unified') block.view = view;
400
501
  if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
401
502
  return block;
402
503
  }
403
504
 
505
+ if (type === 'git-conflict') {
506
+ const src = readAnyTextSource(rawBlock, ['content', 'text'], ['file', 'path', 'src'], cwd, where, 'git conflict file');
507
+ if (!src.text.trim()) {
508
+ throw new CliError(`${where}: git-conflict block needs inline "content"/"text" or a readable local "file"/"path".`);
509
+ }
510
+ const parsed = parseGitConflictContent(src.text, where);
511
+ if (!parsed.conflicts.length) {
512
+ throw new CliError(`${where}: no git conflict markers found (expected <<<<<<<, =======, >>>>>>>).`);
513
+ }
514
+ const filename = asStr(rawBlock.filename ?? rawBlock.name ?? (src.file ? path.basename(src.file) : '')).trim();
515
+ const block = {
516
+ id,
517
+ type: 'git-conflict',
518
+ filename,
519
+ conflicts: parsed.conflicts,
520
+ parts: parsed.parts,
521
+ };
522
+ const lang = asStr(rawBlock.lang).trim() || (filename ? path.extname(filename).slice(1).toLowerCase() : '');
523
+ if (lang) block.lang = lang;
524
+ if (rawBlock.title !== undefined) block.title = asStr(rawBlock.title);
525
+ if (src.file) block.file = src.file;
526
+ if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
527
+ return block;
528
+ }
529
+
404
530
  if (type === 'video') {
405
531
  const src = asStr(rawBlock.src ?? rawBlock.file ?? rawBlock.url).trim();
406
532
  if (!src) throw new CliError(`${where}: video block needs a "src" (YouTube/Vimeo URL, http(s) media URL, or local file path).`);
@@ -905,14 +1031,21 @@ const BLOCK_SCHEMA = {
905
1031
  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`.' },
906
1032
  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).' },
907
1033
  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.' },
908
- filename: { type: 'string', description: 'code/diff: optional file name/path shown as a header label above the block.' },
1034
+ filename: { type: 'string', description: 'code/diff/git-conflict: optional file name/path shown as a header label above the block.' },
909
1035
  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>].' },
910
1036
  dot: { type: 'string', description: 'graphviz: DOT source (e.g. "digraph { a -> b }"). Rendered offline via vendored Viz.js; nodes and edges are individually commentable.' },
911
1037
  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).' },
912
- 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, …).' },
1038
+ 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, …).' },
913
1039
  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.' },
914
1040
  diffFile: { type: 'string', description: 'diff: path to a local file containing a unified diff (alternative to "diff"). Resolved against the CWD.' },
915
1041
  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.' },
1042
+ review: { type: 'boolean', description: 'diff: when true, render per-hunk Apply / Skip / Hold controls and return choices in result.blockEdits[blockId] as {type:"diff-review", hunks:{...}}. Used by `rly git cherry-pick --code`.' },
1043
+ reviewKind: { type: 'string', description: 'diff review: optional label for the review workflow, such as "cherry-pick" or "pick".' },
1044
+ commit: { type: 'string', description: 'diff review: optional commit SHA associated with this diff.' },
1045
+ 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].' },
1046
+ text: { type: 'string', description: 'git-conflict: alias for inline "content".' },
1047
+ file: { type: 'string', description: 'git-conflict: local conflicted file path to load, parse, and resolve. Resolved against the CWD.' },
1048
+ path: { type: 'string', description: 'git-conflict: alias for local conflicted file path.' },
916
1049
  config: { type: 'object', description: 'chart: a full Chart.js config object.' },
917
1050
  kind: { type: 'string', enum: CHART_KINDS, description: 'chart shorthand: chart kind (alternative to "config").' },
918
1051
  labels: { type: 'array', description: 'chart shorthand: x-axis / category labels.' },
@@ -1053,7 +1186,7 @@ export const SPEC_SCHEMA = {
1053
1186
  type: 'object',
1054
1187
  readOnly: true,
1055
1188
  description:
1056
- '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.',
1189
+ '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}. diff review blocks return {type:"diff-review", hunks, resolved, commit}. null when the user made no edits.',
1057
1190
  },
1058
1191
  },
1059
1192
  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,163 @@
260
260
  }
261
261
  .diff-file-chip:hover { color: var(--accent); border-color: var(--accent); }
262
262
 
263
+ /* diff review: cherry-pick/apply hunk choices on top of the diff renderer */
264
+ .diff-review-head {
265
+ align-items: flex-start;
266
+ }
267
+ .diff-review-topactions {
268
+ display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 6px;
269
+ }
270
+ .diff-review-status {
271
+ flex: none; border: 1px solid rgba(185, 145, 63, 0.34);
272
+ background: rgba(185, 145, 63, 0.12); color: var(--warn);
273
+ border-radius: 999px; padding: 3px 8px; font-size: 0.72rem; font-weight: 760;
274
+ }
275
+ .diff-review-status.is-complete {
276
+ border-color: rgba(77, 138, 102, 0.42);
277
+ background: rgba(77, 138, 102, 0.14); color: var(--ok);
278
+ }
279
+ .diff-review-btn {
280
+ flex: none; cursor: pointer; font: inherit; font-size: 0.72rem; font-weight: 700;
281
+ color: var(--fg-2); background: var(--card);
282
+ border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px;
283
+ transition: color 150ms var(--ease), border-color 150ms var(--ease), background 150ms var(--ease);
284
+ }
285
+ .diff-review-btn:hover {
286
+ color: var(--accent); border-color: var(--accent);
287
+ }
288
+ .diff-review-choice.sel {
289
+ color: var(--accent); border-color: var(--accent); background: var(--accent-soft);
290
+ }
291
+ .diff-review-list {
292
+ border: 1px solid var(--border); border-radius: 0 0 10px 10px;
293
+ overflow: hidden; background: var(--bg-sunken);
294
+ }
295
+ .diff-review-hunk + .diff-review-hunk {
296
+ border-top: 1px solid var(--border);
297
+ }
298
+ .diff-review-hunkbar {
299
+ display: flex; justify-content: space-between; align-items: center; gap: 10px;
300
+ padding: 6px 8px 6px 12px; background: var(--card); border-bottom: 1px solid var(--border);
301
+ }
302
+ .diff-review-hunkmeta {
303
+ min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
304
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
305
+ }
306
+ .diff-review-file {
307
+ max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
308
+ color: var(--fg-2); font-size: 0.76rem; font-weight: 760;
309
+ }
310
+ .diff-review-header {
311
+ color: var(--muted); font-size: 0.72rem;
312
+ }
313
+ .diff-review-actions {
314
+ flex: none; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px;
315
+ }
316
+ .diff-review-hunk .blk-difftable {
317
+ min-width: 760px;
318
+ }
319
+ @media (max-width: 720px) {
320
+ .diff-review-head, .diff-review-hunkbar { flex-direction: column; align-items: flex-start; }
321
+ .diff-review-topactions, .diff-review-actions { justify-content: flex-start; }
322
+ .diff-review-file { max-width: 100%; }
323
+ }
324
+
325
+ /* ---------- git conflict resolver ---------- */
326
+ .gitconf {
327
+ border: 1px solid var(--border); border-radius: 10px; overflow: hidden;
328
+ background: var(--card);
329
+ }
330
+ .gitconf-head {
331
+ display: flex; justify-content: space-between; gap: 12px; align-items: flex-start;
332
+ padding: 11px 12px; border-bottom: 1px solid var(--border);
333
+ background: linear-gradient(180deg, var(--card), var(--bg-sunken));
334
+ }
335
+ .gitconf-title {
336
+ font-weight: 750; color: var(--fg);
337
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
338
+ }
339
+ .gitconf-file {
340
+ margin-top: 3px; color: var(--muted); font-size: 0.76rem;
341
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
342
+ overflow-wrap: anywhere;
343
+ }
344
+ .gitconf-status {
345
+ flex: none; border: 1px solid rgba(188, 68, 52, 0.35);
346
+ background: rgba(188, 68, 52, 0.12); color: var(--danger);
347
+ border-radius: 999px; padding: 3px 9px; font-size: 0.74rem; font-weight: 700;
348
+ }
349
+ .gitconf-status.is-complete {
350
+ border-color: rgba(77, 138, 102, 0.42);
351
+ background: rgba(77, 138, 102, 0.14); color: var(--ok);
352
+ }
353
+ .gitconf-card { padding: 12px; border-bottom: 1px solid var(--border); }
354
+ .gitconf-cardhead {
355
+ display: flex; align-items: center; justify-content: space-between; gap: 10px;
356
+ margin-bottom: 9px;
357
+ }
358
+ .gitconf-badge {
359
+ font-size: 0.74rem; font-weight: 760; letter-spacing: 0;
360
+ color: var(--fg); background: var(--bg-sunken); border: 1px solid var(--border);
361
+ border-radius: 999px; padding: 3px 8px;
362
+ }
363
+ .gitconf-hint { color: var(--muted); font-size: 0.76rem; }
364
+ .gitconf-choicebar { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 10px; }
365
+ .gitconf-choice {
366
+ cursor: pointer; font: inherit; font-size: 0.76rem; font-weight: 700;
367
+ color: var(--fg-2); background: var(--bg-sunken);
368
+ border: 1px solid var(--border); border-radius: 7px; padding: 5px 9px;
369
+ }
370
+ .gitconf-choice:hover { border-color: var(--accent); color: var(--accent); }
371
+ .gitconf-choice.sel {
372
+ border-color: var(--accent); color: var(--accent);
373
+ background: var(--accent-soft);
374
+ }
375
+ .gitconf-sides {
376
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
377
+ gap: 8px; align-items: stretch;
378
+ }
379
+ .gitconf-side {
380
+ min-width: 0; border: 1px solid var(--border); border-radius: 8px;
381
+ overflow: hidden; background: var(--bg-sunken);
382
+ }
383
+ .gitconf-ours { border-color: rgba(188, 68, 52, 0.28); background: rgba(188, 68, 52, 0.06); }
384
+ .gitconf-base { border-color: rgba(185, 145, 63, 0.32); background: rgba(185, 145, 63, 0.08); }
385
+ .gitconf-theirs { border-color: rgba(77, 138, 102, 0.3); background: rgba(77, 138, 102, 0.07); }
386
+ .gitconf-label {
387
+ padding: 5px 8px; border-bottom: 1px solid var(--border);
388
+ color: var(--fg-2); font-size: 0.72rem; font-weight: 760;
389
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
390
+ overflow-wrap: anywhere;
391
+ }
392
+ .gitconf-pre, .gitconf-resolved {
393
+ margin: 0; padding: 9px 10px; overflow: auto;
394
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
395
+ font-size: 0.8rem; line-height: 1.45; color: var(--fg);
396
+ white-space: pre;
397
+ }
398
+ .gitconf-pre code, .gitconf-resolved code { font: inherit; background: none; color: inherit; }
399
+ .gitconf-custom {
400
+ display: none; width: 100%; box-sizing: border-box; margin-top: 10px;
401
+ border: 1px solid var(--border-strong); border-radius: 8px;
402
+ background: var(--bg); color: var(--fg); padding: 9px 10px;
403
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
404
+ font-size: 0.82rem; line-height: 1.45; resize: vertical;
405
+ }
406
+ .gitconf-body.is-custom .gitconf-custom { display: block; }
407
+ .gitconf-preview { padding: 10px 12px; background: var(--bg-sunken); }
408
+ .gitconf-preview summary {
409
+ cursor: pointer; color: var(--fg-2); font-weight: 720; font-size: 0.82rem;
410
+ }
411
+ .gitconf-resolved {
412
+ margin-top: 8px; border: 1px solid var(--border); border-radius: 8px;
413
+ background: var(--card); max-height: 340px;
414
+ }
415
+ @media (max-width: 640px) {
416
+ .gitconf-head, .gitconf-cardhead { flex-direction: column; align-items: flex-start; }
417
+ .gitconf-sides { grid-template-columns: 1fr; }
418
+ }
419
+
263
420
  /* clickable line-number gutter: hover a number to comment on that line */
264
421
  .blk-gutter-c { padding: 14px 0; }
265
422
  .blk-gutter-c .blk-gutterline {
package/src/ui/blocks.js CHANGED
@@ -582,8 +582,159 @@
582
582
  // Per-file header lines are redundant once we render a filename header bar.
583
583
  const DIFF_NOISE = /^(diff --git |index |--- |\+\+\+ )/;
584
584
 
585
+ function buildDiffReviewHunks(files, block) {
586
+ const hunks = [];
587
+ files.forEach((f) => {
588
+ const name = f.name || (files.length === 1 ? (block.filename || '') : '') || 'file';
589
+ let rows = parseDiff(f.text);
590
+ if (name) rows = rows.filter((r) => !(r.kind === 'meta' && DIFF_NOISE.test(r.text)));
591
+ let current = null;
592
+ const push = () => {
593
+ if (!current) return;
594
+ const hasChange = current.rows.some((r) => r.kind === 'add' || r.kind === 'del');
595
+ if (hasChange) hunks.push(current);
596
+ current = null;
597
+ };
598
+ rows.forEach((r) => {
599
+ if (r.kind === 'hunk') {
600
+ push();
601
+ current = { id: 'h' + (hunks.length + 1), file: name, header: r.text, rows: [r] };
602
+ } else if (current) {
603
+ current.rows.push(r);
604
+ } else if (r.kind === 'add' || r.kind === 'del' || r.kind === 'ctx') {
605
+ current = { id: 'h' + (hunks.length + 1), file: name, header: 'Change', rows: [r] };
606
+ }
607
+ });
608
+ push();
609
+ });
610
+ return hunks;
611
+ }
612
+
613
+ function renderDiffReview(block, ctx, blockId, files) {
614
+ const hunks = buildDiffReviewHunks(files, block);
615
+ let view = block.view === 'split' ? 'split' : 'unified';
616
+ const prior = ctx && ctx.edits && ctx.edits[blockId] && typeof ctx.edits[blockId] === 'object'
617
+ ? ctx.edits[blockId]
618
+ : {};
619
+ const choices = prior.hunks && typeof prior.hunks === 'object' ? { ...prior.hunks } : {};
620
+ const buttonsByHunk = new Map();
621
+ const wrap = el('div', { class: 'blk-codewrap blk-diffwrap diff-review' });
622
+ const status = el('span', { class: 'diff-review-status' }, '');
623
+ const list = el('div', { class: 'diff-review-list' });
624
+
625
+ const emit = () => {
626
+ const picked = {};
627
+ hunks.forEach((h) => {
628
+ const r = choices[h.id];
629
+ if (!r || !['apply', 'skip', 'hold'].includes(r.choice)) return;
630
+ picked[h.id] = { choice: r.choice, file: h.file, header: h.header };
631
+ });
632
+ const touched = Object.keys(picked).length;
633
+ const payload = touched
634
+ ? {
635
+ type: 'diff-review',
636
+ reviewKind: block.reviewKind || '',
637
+ commit: block.commit || '',
638
+ title: block.title || block.filename || '',
639
+ resolved: touched === hunks.length,
640
+ hunks: picked,
641
+ }
642
+ : null;
643
+ if (ctx && typeof ctx.onBlockEdit === 'function') ctx.onBlockEdit(blockId, payload);
644
+ wrap.dispatchEvent(new CustomEvent('relay:block-edit', {
645
+ bubbles: true,
646
+ detail: { blockId, value: payload },
647
+ }));
648
+ status.textContent = `${touched}/${hunks.length} reviewed`;
649
+ status.classList.toggle('is-complete', hunks.length > 0 && touched === hunks.length);
650
+ for (const h of hunks) {
651
+ const set = buttonsByHunk.get(h.id) || [];
652
+ const chosen = choices[h.id] && choices[h.id].choice;
653
+ set.forEach((b) => b.el.classList.toggle('sel', b.choice === chosen));
654
+ }
655
+ };
656
+
657
+ const setChoice = (h, choice) => {
658
+ choices[h.id] = { choice, file: h.file, header: h.header };
659
+ emit();
660
+ };
661
+
662
+ const setAll = (choice) => {
663
+ hunks.forEach((h) => {
664
+ choices[h.id] = { choice, file: h.file, header: h.header };
665
+ });
666
+ emit();
667
+ };
668
+
669
+ const makeReviewButton = (label, onClick, extraClass = '') => {
670
+ const b = el('button', { type: 'button', class: 'diff-review-btn' + (extraClass ? ' ' + extraClass : '') }, label);
671
+ b.addEventListener('mousedown', (e) => e.stopPropagation());
672
+ b.addEventListener('click', (e) => {
673
+ e.preventDefault();
674
+ e.stopPropagation();
675
+ onClick();
676
+ });
677
+ return b;
678
+ };
679
+
680
+ const repaint = () => {
681
+ list.replaceChildren();
682
+ hunks.forEach((h) => {
683
+ const actionbar = el('div', { class: 'diff-review-hunkbar' },
684
+ el('div', { class: 'diff-review-hunkmeta' },
685
+ el('span', { class: 'diff-review-file' }, h.file),
686
+ el('span', { class: 'diff-review-header' }, h.header)
687
+ )
688
+ );
689
+ const rowButtons = [
690
+ { choice: 'apply', label: 'Apply hunk' },
691
+ { choice: 'skip', label: 'Skip hunk' },
692
+ { choice: 'hold', label: 'Hold' },
693
+ ].map((item) => ({
694
+ choice: item.choice,
695
+ el: makeReviewButton(item.label, () => setChoice(h, item.choice), 'diff-review-choice'),
696
+ }));
697
+ buttonsByHunk.set(h.id, rowButtons);
698
+ actionbar.append(el('div', { class: 'diff-review-actions' }, rowButtons.map((b) => b.el)));
699
+ list.append(el('section', { class: 'diff-review-hunk' },
700
+ actionbar,
701
+ view === 'split' ? buildSplitDiff(h.rows, block.lang) : buildUnifiedDiff(h.rows, block.lang)
702
+ ));
703
+ });
704
+ emit();
705
+ };
706
+
707
+ const toggle = makeReviewButton('', () => {
708
+ view = view === 'split' ? 'unified' : 'split';
709
+ wrap.classList.toggle('is-split', view === 'split');
710
+ toggle.textContent = view === 'split' ? 'Unified view' : 'Split view';
711
+ repaint();
712
+ }, 'blk-difftoggle');
713
+ toggle.title = 'Toggle side-by-side view';
714
+ toggle.textContent = view === 'split' ? 'Unified view' : 'Split view';
715
+
716
+ const title = block.title || block.filename || (files.length > 1 ? `${files.length} files changed` : 'Diff review');
717
+ wrap.append(el('div', { class: 'blk-codehead diff-review-head' },
718
+ el('span', { class: 'blk-codename' }, title),
719
+ el('div', { class: 'diff-review-topactions' },
720
+ status,
721
+ makeReviewButton('Apply all', () => setAll('apply')),
722
+ makeReviewButton('Skip all', () => setAll('skip')),
723
+ makeReviewButton('Hold all', () => setAll('hold')),
724
+ toggle
725
+ )
726
+ ));
727
+ wrap.classList.toggle('is-split', view === 'split');
728
+ wrap.append(list);
729
+ repaint();
730
+ ctx && ctx.annotate && ctx.annotate.enableTextSelection(list, { blockId, questionId: ctx.questionId });
731
+ attachViewer(wrap, { zoomEl: null, label: 'diff review', comment: wholeBlockComment(ctx, blockId, 'diff review') });
732
+ return wrap;
733
+ }
734
+
585
735
  function renderDiff(block, ctx, blockId) {
586
736
  const files = splitDiffFiles(block.diff);
737
+ if (block.review === true) return renderDiffReview(block, ctx, blockId, files);
587
738
  const named = files.filter((f) => f.name);
588
739
  const multi = named.length > 1;
589
740
  let view = block.view === 'split' ? 'split' : 'unified';
@@ -649,6 +800,156 @@
649
800
  attachViewer(wrap, { zoomEl: null, label: 'diff', comment: wholeBlockComment(ctx, blockId, 'diff') });
650
801
  return wrap;
651
802
  }
803
+
804
+ // ---------- git conflict resolver ----------
805
+ function markerConflictText(h) {
806
+ const chunks = [
807
+ '<<<<<<< ' + (h.oursLabel || 'ours'),
808
+ h.ours || '',
809
+ ];
810
+ if (h.baseLabel) chunks.push('||||||| ' + h.baseLabel, h.base || '');
811
+ chunks.push('=======', h.theirs || '', '>>>>>>> ' + (h.theirsLabel || 'theirs'));
812
+ return chunks.join('\n');
813
+ }
814
+
815
+ function conflictChoiceText(h, choice, custom) {
816
+ if (choice === 'ours') return h.ours || '';
817
+ if (choice === 'theirs') return h.theirs || '';
818
+ if (choice === 'both') {
819
+ const left = h.ours || '';
820
+ const right = h.theirs || '';
821
+ return left && right ? left + '\n' + right : left || right;
822
+ }
823
+ if (choice === 'custom') return custom || '';
824
+ return markerConflictText(h);
825
+ }
826
+
827
+ function joinResolvedParts(block, resolutions) {
828
+ const byId = new Map((block.conflicts || []).map((h) => [h.id, h]));
829
+ return (block.parts || []).map((part) => {
830
+ if (!part || part.type === 'text') return part && part.text ? part.text : '';
831
+ const h = byId.get(part.conflictId);
832
+ if (!h) return '';
833
+ const r = resolutions && resolutions[h.id] ? resolutions[h.id] : {};
834
+ return conflictChoiceText(h, r.choice, r.value);
835
+ }).join('\n');
836
+ }
837
+
838
+ function renderConflictCode(text, lang) {
839
+ const pre = el('pre', { class: 'gitconf-pre' });
840
+ const code = el('code');
841
+ code.innerHTML = tintCode(text || ' ', lang);
842
+ pre.append(code);
843
+ return pre;
844
+ }
845
+
846
+ function renderGitConflict(block, ctx, blockId) {
847
+ const conflicts = Array.isArray(block.conflicts) ? block.conflicts : [];
848
+ const prior = ctx && ctx.edits && ctx.edits[blockId] && typeof ctx.edits[blockId] === 'object'
849
+ ? ctx.edits[blockId]
850
+ : {};
851
+ const resolutions = prior.resolutions && typeof prior.resolutions === 'object' ? { ...prior.resolutions } : {};
852
+ const wrap = el('div', { class: 'gitconf' });
853
+ const count = conflicts.length;
854
+ const title = block.title || block.filename || 'Git conflict';
855
+ const status = el('span', { class: 'gitconf-status' }, `${count} conflict${count === 1 ? '' : 's'}`);
856
+ const resolvedPreview = el('pre', { class: 'gitconf-resolved' }, el('code'));
857
+
858
+ function emit() {
859
+ const touched = Object.keys(resolutions).filter((id) => resolutions[id] && resolutions[id].choice);
860
+ const payload = touched.length
861
+ ? {
862
+ type: 'git-conflict-resolution',
863
+ filename: block.filename || '',
864
+ file: block.file || '',
865
+ resolved: touched.length === count,
866
+ resolutions,
867
+ content: joinResolvedParts(block, resolutions),
868
+ }
869
+ : null;
870
+ if (ctx && typeof ctx.onBlockEdit === 'function') ctx.onBlockEdit(blockId, payload);
871
+ wrap.dispatchEvent(new CustomEvent('relay:block-edit', {
872
+ bubbles: true,
873
+ detail: { blockId, value: payload },
874
+ }));
875
+ resolvedPreview.querySelector('code').textContent = joinResolvedParts(block, resolutions);
876
+ status.textContent = `${touched.length}/${count} resolved`;
877
+ status.classList.toggle('is-complete', touched.length === count);
878
+ }
879
+
880
+ wrap.append(el('div', { class: 'gitconf-head' },
881
+ el('div', {},
882
+ el('div', { class: 'gitconf-title' }, title),
883
+ block.file ? el('div', { class: 'gitconf-file' }, block.file) : null),
884
+ status
885
+ ));
886
+
887
+ conflicts.forEach((h, idx) => {
888
+ const body = el('div', { class: 'gitconf-body' });
889
+ const custom = el('textarea', {
890
+ class: 'gitconf-custom',
891
+ spellcheck: 'false',
892
+ rows: '5',
893
+ placeholder: 'Write the resolved content for this hunk',
894
+ });
895
+ const buttons = [];
896
+ const setChoice = (choice) => {
897
+ const current = resolutions[h.id] || {};
898
+ const next = { choice, value: choice === 'custom' ? custom.value : conflictChoiceText(h, choice, custom.value) };
899
+ resolutions[h.id] = next;
900
+ for (const b of buttons) b.el.classList.toggle('sel', b.choice === choice);
901
+ body.classList.toggle('is-custom', choice === 'custom');
902
+ emit();
903
+ };
904
+ const makeButton = (choice, label) => {
905
+ const b = el('button', { type: 'button', class: 'gitconf-choice' }, label);
906
+ b.addEventListener('mousedown', (e) => e.stopPropagation());
907
+ b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); setChoice(choice); });
908
+ buttons.push({ choice, el: b });
909
+ return b;
910
+ };
911
+ const existing = resolutions[h.id] || {};
912
+ if (existing.choice === 'custom') custom.value = existing.value || '';
913
+ else custom.value = conflictChoiceText(h, existing.choice || 'ours', '');
914
+ custom.addEventListener('input', () => setChoice('custom'));
915
+
916
+ body.append(el('div', { class: 'gitconf-choicebar' },
917
+ makeButton('ours', 'Use ours'),
918
+ makeButton('theirs', 'Use theirs'),
919
+ makeButton('both', 'Keep both'),
920
+ makeButton('custom', 'Custom')
921
+ ));
922
+ body.append(el('div', { class: 'gitconf-sides' },
923
+ el('section', { class: 'gitconf-side gitconf-ours' },
924
+ el('div', { class: 'gitconf-label' }, h.oursLabel || 'ours'),
925
+ renderConflictCode(h.ours || '', block.lang)),
926
+ h.baseLabel ? el('section', { class: 'gitconf-side gitconf-base' },
927
+ el('div', { class: 'gitconf-label' }, h.baseLabel),
928
+ renderConflictCode(h.base || '', block.lang)) : null,
929
+ el('section', { class: 'gitconf-side gitconf-theirs' },
930
+ el('div', { class: 'gitconf-label' }, h.theirsLabel || 'theirs'),
931
+ renderConflictCode(h.theirs || '', block.lang))
932
+ ));
933
+ body.append(custom);
934
+
935
+ const card = el('section', { class: 'gitconf-card' },
936
+ el('div', { class: 'gitconf-cardhead' },
937
+ el('span', { class: 'gitconf-badge' }, `Conflict ${idx + 1}`),
938
+ el('span', { class: 'gitconf-hint' }, 'Choose a side, keep both, or write the final hunk.')),
939
+ body
940
+ );
941
+ wrap.append(card);
942
+ if (existing.choice) setChoice(existing.choice);
943
+ });
944
+
945
+ wrap.append(el('details', { class: 'gitconf-preview' },
946
+ el('summary', {}, 'Resolved file preview'),
947
+ resolvedPreview
948
+ ));
949
+ emit();
950
+ ctx && ctx.annotate && ctx.annotate.enableTextSelection(wrap, { blockId, questionId: ctx.questionId });
951
+ return wrap;
952
+ }
652
953
  // ---------- video (YouTube/Vimeo embed, local stream, or direct URL) ----------
653
954
  function renderVideo(block, ctx, blockId) {
654
955
  const wrap = el('div', { class: 'blk-videowrap' });
@@ -2216,6 +2517,10 @@
2216
2517
  inner = renderDiff(block, ctx, blockId);
2217
2518
  wrapper.append(inner);
2218
2519
  break;
2520
+ case 'git-conflict':
2521
+ inner = renderGitConflict(block, ctx, blockId);
2522
+ wrapper.append(inner);
2523
+ break;
2219
2524
  case 'video':
2220
2525
  inner = renderVideo(block, ctx, blockId);
2221
2526
  wrapper.append(inner);