@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 +3 -0
- package/docs/AGENT.md +19 -1
- package/package.json +1 -1
- package/skills/relay/SKILL.md +7 -1
- package/src/cli.js +124 -3
- package/src/mcp-ui/board.js +6 -0
- package/src/mcp.js +10 -8
- package/src/server.js +51 -4
- package/src/spec.js +228 -4
- package/src/ui/app.js +6 -0
- package/src/ui/blocks.css +95 -0
- package/src/ui/blocks.js +154 -0
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.
|
|
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",
|
package/skills/relay/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: relay
|
|
3
|
-
description: "Collect user decisions & answers — choice, multi, yes-no, text, scale, rank
|
|
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':
|
package/src/mcp-ui/board.js
CHANGED
|
@@ -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
|
-
//
|
|
188
|
-
//
|
|
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
|
|
198
|
-
|
|
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
|
|
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);
|