From 9f5a0c2ee7ad01e68215b6bc0c71d598b045227c Mon Sep 17 00:00:00 2001 From: Jonas Brami Date: Fri, 29 May 2026 09:54:03 +0400 Subject: [PATCH 1/2] feat(quiz): inline code context per question (anchors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional `anchor: {path, start_line, end_line}` to every question type so the browser can show the exact diff hunk inline next to the question — the reader no longer has to hold the diff in their head or switch to GitHub. - models: new `Anchor` model + optional `anchor` field on all four question types. Optional (defaults to None) → old cached quizzes load unchanged; the range is validated (start ≤ end, lines ≥ 1) but a path need not be in the diff (a question may anchor surrounding context). - web: `GET /diff?path=` serves one file's unified-diff section; `_DiffProvider` is hoisted into `main()` and shared by the MCP tools and the web app (one fetch/cache). 503 when no diff source is wired. - frontend: collapsible per-question code panel, fetched on first expand and rendered DOM-built (textContent only, never innerHTML); +/- lines and `@@` headers are styled via CSS classes. Present on both the answering and the results-review cards; absent when a question has no anchor. - generation prompt: instructs the author to emit a tight `anchor` whenever a question probes specific lines; never affects grading. Foundation for the diff coverage map (#9) and drill-this (#5). Tested: models round-trip + backward-compat (no anchor) unit tests; /diff endpoint tests (hit + miss + 503); full mcp/engine suite green (ruff, mypy strict). Anchor UI verified in a real browser (panel renders, expands, loads the hunk, color-codes lines; absent on anchorless questions). Co-Authored-By: Claude Opus 4.8 --- src/cognit/engine/models.py | 22 ++++++++ src/cognit/engine/prompts/system_generate.txt | 8 +++ src/cognit/mcp/assets/quiz_mcp.js | 56 ++++++++++++++++++- src/cognit/mcp/assets/styles.css | 50 +++++++++++++++++ src/cognit/mcp/server.py | 13 +++-- src/cognit/mcp/web.py | 13 ++++- tests/engine/test_models.py | 52 +++++++++++++++++ tests/mcp/test_web.py | 35 ++++++++++++ 8 files changed, 241 insertions(+), 8 deletions(-) diff --git a/src/cognit/engine/models.py b/src/cognit/engine/models.py index 5f64be2..c527b33 100644 --- a/src/cognit/engine/models.py +++ b/src/cognit/engine/models.py @@ -2,6 +2,24 @@ from pydantic import BaseModel, Field, model_validator +class Anchor(BaseModel): + """Where in the diff a question is probing, so the browser can show the hunk inline. + + Optional on every question (defaults to None) — old cached quizzes load unchanged. + A hint, not an assertion the lines are part of the diff: a question may anchor + surrounding unchanged context the reader needs.""" + + path: str # repo-relative path, from changed_files + start_line: int = Field(ge=1) + end_line: int = Field(ge=1) + + @model_validator(mode="after") + def _ordered(self) -> "Anchor": + if self.end_line < self.start_line: + raise ValueError(f"end_line {self.end_line} < start_line {self.start_line}") + return self + + class MCQQuestion(BaseModel): type: Literal["mcq"] = "mcq" id: str @@ -9,6 +27,7 @@ class MCQQuestion(BaseModel): options: list[str] answer: str # must equal one of options explanation: str = "" # shown to the reader after they answer (the "aha") + anchor: Anchor | None = None # diff hunk to show inline (optional) @model_validator(mode="after") def _answer_in_options(self) -> "MCQQuestion": @@ -24,6 +43,7 @@ class MermaidQuestion(BaseModel): options: dict[str, str] # label -> mermaid source answer: str # must be a key of options explanation: str = "" # shown to the reader after they answer (the "aha") + anchor: Anchor | None = None # diff hunk to show inline (optional) @model_validator(mode="after") def _answer_is_option_key(self) -> "MermaidQuestion": @@ -37,6 +57,7 @@ class OpenQuestion(BaseModel): id: str prompt: str rubric: str + anchor: Anchor | None = None # diff hunk to show inline (optional) class TrueFalseQuestion(BaseModel): @@ -45,6 +66,7 @@ class TrueFalseQuestion(BaseModel): prompt: str answer: bool explanation: str = "" # shown to the reader after they answer (the "aha") + anchor: Anchor | None = None # diff hunk to show inline (optional) Question = Annotated[ diff --git a/src/cognit/engine/prompts/system_generate.txt b/src/cognit/engine/prompts/system_generate.txt index bbd4a2c..6d85208 100644 --- a/src/cognit/engine/prompts/system_generate.txt +++ b/src/cognit/engine/prompts/system_generate.txt @@ -77,6 +77,14 @@ Each question is one of these four shapes. **Watch the `answer` field — its ty - **`tf`** — `answer` is a JSON **boolean** (`true`/`false`), not a string. - **`open`** — requires a `rubric`; it has **no `answer`** and **no `explanation`**. +**Anchor each question to its code.** Whenever a question probes specific lines, add an `anchor` so the reader sees that exact hunk inline next to the question: + +```json +"anchor": {"path": "", "start_line": N, "end_line": M} +``` + +Use the new-side line numbers of the relevant lines (`start_line` ≤ `end_line`; a single line is `start == end`). Prefer the tightest range that frames the behavior — the few lines that make the question answerable, not the whole file. Omit `anchor` only for questions not tied to one location (e.g. a broad `open` rationale question spanning the change). The anchor drives an inline code panel; it never affects grading. It works on any question type and is added alongside the fields above. + When the reader asks you to change or skip a specific question, use `replace_question(index, question)` (0-based index) — same per-type shapes as above. When the reader says they're ready to be graded, call `grade`. diff --git a/src/cognit/mcp/assets/quiz_mcp.js b/src/cognit/mcp/assets/quiz_mcp.js index dc3a70a..a53f5c0 100644 --- a/src/cognit/mcp/assets/quiz_mcp.js +++ b/src/cognit/mcp/assets/quiz_mcp.js @@ -82,6 +82,7 @@ function quizSig(q) { return JSON.stringify(q.questions.map((x) => [ x.id, x.type, x.prompt, x.type === "mcq" ? x.options : x.type === "mermaid" ? Object.keys(x.options) : null, + x.anchor ? [x.anchor.path, x.anchor.start_line, x.anchor.end_line] : null, ])); } @@ -238,6 +239,58 @@ function renderTF(q) { return [wrap]; } +// ── inline code context (anchors) ─────────────────────────────── +// A collapsible panel under a question that shows the anchored diff hunk. The hunk +// is fetched from GET /diff on first expand and rendered DOM-built (textContent only, +// never innerHTML) so agent/repo-supplied diff text can't inject markup. +const _diffCache = {}; // path -> diff text (one fetch per file per page) + +function renderDiff(text) { + const pre = el("pre", { class: "diff" }); + String(text).split("\n").forEach((line) => { + let cls = "diff-line"; + if (line.startsWith("@@")) cls += " hunk"; + else if (/^(diff |index |\+\+\+|---)/.test(line)) cls += " fmeta"; + else if (line.startsWith("+")) cls += " add"; + else if (line.startsWith("-")) cls += " del"; + pre.appendChild(el("div", { class: cls, text: line === "" ? " " : line })); + }); + return pre; +} + +async function loadHunk(path, body) { + body.textContent = "Loading…"; + try { + if (!(path in _diffCache)) { + _diffCache[path] = await (await fetch("/diff?path=" + encodeURIComponent(path))).text(); + } + body.textContent = ""; + body.appendChild(renderDiff(_diffCache[path])); + } catch (e) { + body.textContent = "Could not load the diff for this file."; + } +} + +function renderAnchor(q) { + const a = q.anchor; + if (!a || !a.path) return null; + const range = a.start_line === a.end_line ? `${a.start_line}` : `${a.start_line}–${a.end_line}`; + const body = el("div", { class: "codepanel__body" }); + const details = el("details", { class: "codepanel" }, [ + el("summary", { class: "codepanel__summary" }, [ + el("span", { class: "codepanel__icon", "aria-hidden": "true", text: "▸" }), + el("span", { class: "codepanel__file", text: a.path }), + el("span", { class: "codepanel__lines", text: `:${range}` }), + ]), + body, + ]); + let loaded = false; + details.addEventListener("toggle", () => { + if (details.open && !loaded) { loaded = true; loadHunk(a.path, body); } + }); + return details; +} + function renderQuestion(q, i) { const inputsByType = { mcq: renderMCQ, mermaid: renderMermaid, open: renderOpen, tf: renderTF }; const inputs = inputsByType[q.type](q); @@ -248,6 +301,7 @@ function renderQuestion(q, i) { ]), el("div", { class: "file__body" }, [ el("p", { class: "prompt" }, renderPrompt(q.prompt)), + renderAnchor(q), // null when no anchor — el() skips null children ...inputs, ]), ]); @@ -357,7 +411,7 @@ function renderSummary(res) { function renderResultCard(q, r, i) { const cls = scoreClass(r.score); const verdict = cls === "ok" ? "correct" : cls === "bad" ? "incorrect" : "partial"; - const body = [el("p", { class: "prompt" }, renderPrompt(q.prompt))]; + const body = [el("p", { class: "prompt" }, renderPrompt(q.prompt)), renderAnchor(q)]; const userVal = answers[q.id]; if (q.type === "mcq" || q.type === "tf") { const correctAnswer = q.type === "tf" ? String(q.answer) : q.answer; diff --git a/src/cognit/mcp/assets/styles.css b/src/cognit/mcp/assets/styles.css index 422e5da..49a5803 100644 --- a/src/cognit/mcp/assets/styles.css +++ b/src/cognit/mcp/assets/styles.css @@ -820,3 +820,53 @@ textarea.open:focus { border-color: var(--blue); outline: none; box-shadow: 0 0 .gen__spinner { animation: none; } .term::after { animation: none; } } + +/* inline code context (anchors) ──────────────────────────────────── */ +.codepanel { + margin: 0 0 16px; + border: 1px solid var(--border-mute); + border-radius: 6px; + background: var(--bg-subtle); +} +.codepanel__summary { + display: flex; + align-items: baseline; + gap: 4px; + padding: 8px 12px; + cursor: pointer; + font-family: var(--mono); + font-size: 12px; + color: var(--fg-mute); + list-style: none; + user-select: none; +} +.codepanel__summary::-webkit-details-marker { display: none; } +.codepanel__icon { color: var(--fg-faint); transition: transform 0.12s ease; display: inline-block; } +.codepanel[open] > .codepanel__summary .codepanel__icon { transform: rotate(90deg); } +.codepanel__file { color: var(--fg); font-weight: 500; } +.codepanel__lines { color: var(--fg-faint); } +.codepanel__summary:hover .codepanel__file { color: var(--blue); } +.codepanel__body { border-top: 1px solid var(--border-mute); } +.codepanel pre.diff { + margin: 0; + padding: 8px 0; + overflow-x: auto; + font-family: var(--mono); + font-size: 12px; + line-height: 1.55; + background: var(--bg-canvas); + border-radius: 0 0 6px 6px; +} +.diff-line { + padding: 0 12px; + white-space: pre; + color: var(--fg); +} +.diff-line.add { background: var(--green-bg); } +.diff-line.del { background: var(--red-bg); } +.diff-line.hunk { color: var(--blue); background: var(--blue-bg); } +.diff-line.fmeta { color: var(--fg-faint); } + +@media (prefers-reduced-motion: reduce) { + .codepanel__icon { transition: none; } +} diff --git a/src/cognit/mcp/server.py b/src/cognit/mcp/server.py index 0b78ef7..6e91326 100644 --- a/src/cognit/mcp/server.py +++ b/src/cognit/mcp/server.py @@ -116,9 +116,8 @@ def overview(self) -> str: # ── process wiring ─────────────────────────────────────────────────────────── -def _build_mcp(state: QuizState, llm: LLMClient, pr_url: str) -> FastMCP: +def _build_mcp(state: QuizState, llm: LLMClient, diffs: _DiffProvider) -> FastMCP: mcp = FastMCP("cognit") - diffs = _DiffProvider(pr_url) @mcp.tool() async def set_quiz(quiz: dict[str, Any]) -> dict[str, Any]: @@ -161,11 +160,14 @@ def file_diff(path: str) -> str: return mcp -def _start_web(state: QuizState, *, llm: LLMClient, pr_url: str, port: int) -> None: +def _start_web( + state: QuizState, *, llm: LLMClient, pr_url: str, port: int, diffs: _DiffProvider +) -> None: app = build_web_app( state, post_comment=lambda body: gh_post_comment(pr_url, body), grade=lambda: grade_state(state, llm=llm), + diff_section=lambda path: do_file_diff(path, diffs.sections()), pr_url=pr_url, ) url = f"http://127.0.0.1:{port}" @@ -222,10 +224,11 @@ def main() -> None: print(f"cognit quiz: http://127.0.0.1:{port}", file=sys.stderr) llm: LLMClient = ClaudeAgentLLM() + diffs = _DiffProvider(pr_url) # one fetch/cache shared by the MCP tools and the web /diff threading.Thread( target=_start_web, args=(state,), - kwargs={"llm": llm, "pr_url": pr_url, "port": port}, + kwargs={"llm": llm, "pr_url": pr_url, "port": port, "diffs": diffs}, daemon=True, ).start() - _build_mcp(state, llm, pr_url).run() + _build_mcp(state, llm, diffs).run() diff --git a/src/cognit/mcp/web.py b/src/cognit/mcp/web.py index 75ba549..d39d6e5 100644 --- a/src/cognit/mcp/web.py +++ b/src/cognit/mcp/web.py @@ -21,7 +21,7 @@ from fastapi import FastAPI, Request from fastapi.concurrency import run_in_threadpool -from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles from cognit.comment.render import render_results_inlined @@ -38,13 +38,16 @@ def build_web_app( *, post_comment: Callable[[str], str], grade: Callable[[], Results] | None = None, + diff_section: Callable[[str], str] | None = None, pr_url: str = "", ) -> FastAPI: """Browser projection over `state`. `grade` (when provided) is invoked by POST /grade — the human-clicked "Submit quiz" button. It runs the same handler-owned grading the agent's `grade` tool uses and - must store the result in `state`; we return the Results to the page. `pr_url` feeds + must store the result in `state`; we return the Results to the page. `diff_section` + (when provided) returns the unified-diff section for one changed file — GET /diff + serves it so the browser can show a question's anchored hunk inline. `pr_url` feeds the page chrome (the "on GitHub" links). """ app = FastAPI() @@ -73,6 +76,12 @@ async def post_answer(req: Request) -> JSONResponse: state.record_answer(qid, value) return JSONResponse({"ok": True}) + @app.get("/diff", response_class=PlainTextResponse) + def get_diff(path: str = "") -> PlainTextResponse: + if diff_section is None: + return PlainTextResponse("diff not available", status_code=503) + return PlainTextResponse(diff_section(path)) + @app.post("/grade") async def do_grade() -> JSONResponse: if grade is None: diff --git a/tests/engine/test_models.py b/tests/engine/test_models.py index e9f8f86..67ae4b7 100644 --- a/tests/engine/test_models.py +++ b/tests/engine/test_models.py @@ -1,6 +1,7 @@ import pytest from pydantic import ValidationError from cognit.engine.models import ( + Anchor, Quiz, MCQQuestion, MermaidQuestion, @@ -108,3 +109,54 @@ def test_objective_questions_carry_optional_explanation(): ).explanation == "" ) + + +def test_anchor_round_trip(): + a = Anchor(path="src/cognit/mcp/state.py", start_line=40, end_line=46) + assert Anchor.model_validate(a.model_dump()) == a + + +def test_anchor_single_line_ok(): + a = Anchor(path="x.py", start_line=12, end_line=12) + assert a.start_line == a.end_line == 12 + + +def test_anchor_rejects_reversed_range(): + with pytest.raises(ValidationError): + Anchor(path="x.py", start_line=10, end_line=5) + + +def test_anchor_rejects_nonpositive_lines(): + with pytest.raises(ValidationError): + Anchor(path="x.py", start_line=0, end_line=4) + + +def test_questions_carry_optional_anchor(): + anchor = Anchor(path="src/a.py", start_line=3, end_line=9) + mcq = MCQQuestion(id="q1", prompt="?", options=["a", "b"], answer="a", anchor=anchor) + assert mcq.anchor == anchor + # every type accepts an anchor + assert ( + MermaidQuestion( + id="q2", prompt="?", options={"A": "flowchart LR\nA-->B"}, answer="A", anchor=anchor + ).anchor + == anchor + ) + assert OpenQuestion(id="q3", prompt="?", rubric="r", anchor=anchor).anchor == anchor + assert TrueFalseQuestion(id="q4", prompt="?", answer=True, anchor=anchor).anchor == anchor + + +def test_anchor_defaults_to_none_backward_compatible(): + # questions without an anchor (old cached quizzes) still validate, anchor is None + assert MCQQuestion(id="q1", prompt="?", options=["a", "b"], answer="a").anchor is None + assert OpenQuestion(id="q3", prompt="?", rubric="r").anchor is None + # and a quiz dict produced before the field existed round-trips + legacy = { + "version": "1", + "pr_number": 7, + "questions": [ + {"type": "mcq", "id": "q1", "prompt": "?", "options": ["a", "b"], "answer": "a"} + ], + } + quiz = Quiz.model_validate(legacy) + assert quiz.questions[0].anchor is None diff --git a/tests/mcp/test_web.py b/tests/mcp/test_web.py index c2c8f49..e5bed33 100644 --- a/tests/mcp/test_web.py +++ b/tests/mcp/test_web.py @@ -174,6 +174,41 @@ def boom(_body: str) -> str: t.join(timeout=2) +def test_diff_endpoint_returns_section(tmp_path: Path) -> None: + state = QuizState(pr_number=7, snapshot_path=tmp_path / "s.json") + state.set_quiz( + Quiz( + pr_number=7, + questions=[ + MCQQuestion(id="q1", prompt="p", options=["A", "B"], answer="A", explanation="x") + ], + ) + ) + sections = {"src/a.py": "diff --git a/src/a.py b/src/a.py\n@@ -1 +1 @@\n-x\n+y\n"} + app = build_web_app( + state, + post_comment=lambda b: "http://c/1", + diff_section=lambda path: sections.get(path, f"No changed file matches {path!r}."), + ) + port = _free_port() + server, t = _serve(app, port) + try: + r = httpx.get(f"http://127.0.0.1:{port}/diff", params={"path": "src/a.py"}) + assert r.status_code == 200 + assert "+y" in r.text + miss = httpx.get(f"http://127.0.0.1:{port}/diff", params={"path": "nope.py"}) + assert miss.status_code == 200 and "No changed file matches" in miss.text + finally: + server.should_exit = True + t.join(timeout=2) + + +def test_diff_endpoint_503_when_unavailable(client) -> None: + # the default fixture app wires no `diff_section` + c, _state, _ = client + assert c.get("/diff", params={"path": "x.py"}).status_code == 503 + + def test_publish_before_grading_409(tmp_path: Path) -> None: state = QuizState(pr_number=7, snapshot_path=tmp_path / "s.json") state.set_quiz( From ee3ab63be58f19db6d7ed4885b964701cb3a0cb2 Mon Sep 17 00:00:00 2001 From: Jonas Brami Date: Fri, 29 May 2026 10:19:31 +0400 Subject: [PATCH 2/2] address review: scope inline hunk to the anchor + handle not-found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on the anchor UI: - The panel showed the whole file's diff; the anchor's line range was only a cosmetic label. Now the diff is parsed into hunks and scoped to the hunk(s) overlapping the anchor's new-side range (falls back to all hunks if none overlap), the anchored new-side lines are highlighted, and the file-level header lines (diff --git/index/---/+++) are dropped as noise. - A `/diff` miss (renamed file, or a binary/minified file filtered out of the diff) returned the "No changed file matches…" sentence with 200, which the UI rendered as a fake one-line diff. Now the UI shows a clean "Not part of the PR diff." note instead. - loadHunk now logs the error on a fetch failure (was silently swallowed). - Added a guard test that a question's anchor survives set_quiz + snapshot. Verified in a real browser: a question anchored to the 2nd hunk shows only that hunk with its added lines highlighted; a question anchored to a non-diff path shows the note. Co-Authored-By: Claude Opus 4.8 --- src/cognit/mcp/assets/quiz_mcp.js | 84 ++++++++++++++++++++++++++----- src/cognit/mcp/assets/styles.css | 2 + tests/mcp/test_tools.py | 17 +++++++ 3 files changed, 91 insertions(+), 12 deletions(-) diff --git a/src/cognit/mcp/assets/quiz_mcp.js b/src/cognit/mcp/assets/quiz_mcp.js index a53f5c0..a874d09 100644 --- a/src/cognit/mcp/assets/quiz_mcp.js +++ b/src/cognit/mcp/assets/quiz_mcp.js @@ -245,30 +245,90 @@ function renderTF(q) { // never innerHTML) so agent/repo-supplied diff text can't inject markup. const _diffCache = {}; // path -> diff text (one fetch per file per page) -function renderDiff(text) { +// Split a unified-diff file section into the lines before the first hunk (file header) +// and the hunks. Each hunk carries its new-side line span so we can scope to an anchor. +function parseDiff(text) { + const hunks = []; + let cur = null; + for (const line of String(text).split("\n")) { + if (line.startsWith("@@")) { + const m = /@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line); + const startNew = m ? parseInt(m[1], 10) : 1; + const countNew = m && m[2] != null ? parseInt(m[2], 10) : 1; + cur = { startNew, endNew: startNew + Math.max(countNew, 1) - 1, lines: [line] }; + hunks.push(cur); + } else if (cur) { + cur.lines.push(line); + } + } + return hunks; +} + +function diffLineNode(line, lineNo, anchor) { + let cls = "diff-line"; + if (line.startsWith("@@")) cls += " hunk"; + else if (/^(diff |index |\+\+\+|---)/.test(line)) cls += " fmeta"; + else if (line.startsWith("+")) cls += " add"; + else if (line.startsWith("-")) cls += " del"; + if (anchor && lineNo != null && lineNo >= anchor.start_line && lineNo <= anchor.end_line) { + cls += " anchor-hit"; + } + return el("div", { class: cls, text: line === "" ? " " : line }); +} + +// Render the diff, scoped to the hunk(s) the anchor points at (new-side line range). +// Falls back to all hunks if the anchor overlaps none (e.g. it points at context the +// diff doesn't touch). Anchored new-side lines are highlighted. textContent only. +function renderDiff(text, anchor) { + const hunks = parseDiff(text); + let shown = hunks; + if (anchor && hunks.length) { + const overlap = hunks.filter( + (h) => h.startNew <= anchor.end_line && h.endNew >= anchor.start_line + ); + if (overlap.length) shown = overlap; + } const pre = el("pre", { class: "diff" }); - String(text).split("\n").forEach((line) => { - let cls = "diff-line"; - if (line.startsWith("@@")) cls += " hunk"; - else if (/^(diff |index |\+\+\+|---)/.test(line)) cls += " fmeta"; - else if (line.startsWith("+")) cls += " add"; - else if (line.startsWith("-")) cls += " del"; - pre.appendChild(el("div", { class: cls, text: line === "" ? " " : line })); + if (!shown.length) { // no @@ hunks (binary/empty section) — show raw, no numbering + String(text).split("\n").forEach((line) => pre.appendChild(diffLineNode(line, null, null))); + return pre; + } + shown.forEach((h) => { + let newLine = h.startNew; + h.lines.forEach((line) => { + if (line.startsWith("@@")) { + pre.appendChild(diffLineNode(line, null, anchor)); + } else if (line.startsWith("-")) { + pre.appendChild(diffLineNode(line, null, anchor)); // removed: no new-side number + } else { + pre.appendChild(diffLineNode(line, newLine, anchor)); // context / added + newLine++; + } + }); }); return pre; } -async function loadHunk(path, body) { +async function loadHunk(path, anchor, body) { body.textContent = "Loading…"; + let text; try { if (!(path in _diffCache)) { _diffCache[path] = await (await fetch("/diff?path=" + encodeURIComponent(path))).text(); } - body.textContent = ""; - body.appendChild(renderDiff(_diffCache[path])); + text = _diffCache[path]; } catch (e) { + console.warn("diff fetch failed for", path, e); body.textContent = "Could not load the diff for this file."; + return; + } + body.textContent = ""; + if (/^No changed file matches/.test(text)) { + // path isn't in the PR diff (e.g. renamed, or a filtered binary/minified file) + body.appendChild(el("div", { class: "codepanel__note", text: "Not part of the PR diff." })); + return; } + body.appendChild(renderDiff(text, anchor)); } function renderAnchor(q) { @@ -286,7 +346,7 @@ function renderAnchor(q) { ]); let loaded = false; details.addEventListener("toggle", () => { - if (details.open && !loaded) { loaded = true; loadHunk(a.path, body); } + if (details.open && !loaded) { loaded = true; loadHunk(a.path, a, body); } }); return details; } diff --git a/src/cognit/mcp/assets/styles.css b/src/cognit/mcp/assets/styles.css index 49a5803..3ddff69 100644 --- a/src/cognit/mcp/assets/styles.css +++ b/src/cognit/mcp/assets/styles.css @@ -866,6 +866,8 @@ textarea.open:focus { border-color: var(--blue); outline: none; box-shadow: 0 0 .diff-line.del { background: var(--red-bg); } .diff-line.hunk { color: var(--blue); background: var(--blue-bg); } .diff-line.fmeta { color: var(--fg-faint); } +.diff-line.anchor-hit { box-shadow: inset 3px 0 0 var(--blue); } +.codepanel__note { padding: 10px 12px; font-size: 12px; color: var(--fg-mute); background: var(--bg-canvas); border-radius: 0 0 6px 6px; } @media (prefers-reduced-motion: reduce) { .codepanel__icon { transition: none; } diff --git a/tests/mcp/test_tools.py b/tests/mcp/test_tools.py index 58b2fd2..e180562 100644 --- a/tests/mcp/test_tools.py +++ b/tests/mcp/test_tools.py @@ -94,6 +94,23 @@ def test_grade_without_quiz_returns_structured_failure(tmp_path: Path): assert any("no quiz" in f for f in out["failures"]) +def test_set_quiz_preserves_anchor(tmp_path: Path) -> None: + state = _state(tmp_path) + draft = _draft() + draft["questions"][0]["anchor"] = { + "path": "src/cognit/mcp/state.py", + "start_line": 40, + "end_line": 46, + } + out = srv.do_set_quiz(state, draft) + assert out["ok"] is True + anchor = state.quiz.questions[0].anchor + assert anchor is not None and anchor.path == "src/cognit/mcp/state.py" + assert (anchor.start_line, anchor.end_line) == (40, 46) + # and it survives the snapshot round-trip + assert state.snapshot()["quiz"]["questions"][0]["anchor"]["start_line"] == 40 + + def test_replace_question_rejects_blank_explanation(tmp_path: Path) -> None: state = _state(tmp_path) srv.do_set_quiz(state, _draft())