Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
{
"name": "code-walkthrough",
"description": "code-walkthrough: turn a diff, branch, PR, or existing code into a top-down walkthrough page: overview, story map, call graph, annotated diff, and line comments you hand back to the agent",
"version": "1.3.0",
"version": "1.4.0",
"source": "./",
"author": {
"name": "crisperity"
Expand Down
107 changes: 104 additions & 3 deletions skills/code-walkthrough/assets/diff-review-template.html
Original file line number Diff line number Diff line change
Expand Up @@ -906,6 +906,12 @@
.cw-turn{margin:8px 0}
.cw-turn-q{margin:0;color:var(--muted);font-style:italic;overflow-wrap:anywhere;white-space:pre-wrap}
.cw-turn-a{margin:4px 0 0;overflow-wrap:anywhere}
.cw-term{text-decoration:underline dotted;text-underline-offset:3px;cursor:help;-webkit-touch-callout:none}
.cw-term:focus-visible{outline:2px solid var(--accent);outline-offset:1px;border-radius:2px}
.cw-term-pop{position:fixed;z-index:1000;max-width:min(18rem,calc(100vw - 16px));padding:8px 10px;
background:var(--panel2);color:var(--text);border:1px solid var(--border);border-radius:8px;
box-shadow:0 4px 14px rgba(0,0,0,.25);font-size:var(--fs-sm);line-height:1.45;pointer-events:none}
.cw-term-pop[hidden]{display:none}
.cw-md>*{margin:6px 0}
.cw-md>:first-child{margin-top:0}
.cw-md pre{white-space:pre-wrap;max-width:100%;overflow-x:auto}
Expand Down Expand Up @@ -1197,6 +1203,17 @@
.cw-vis img{max-width:100%}
.cw-vis-group{margin:8px 0 14px}

/* First-glance concepts (render.py concepts_html): a card per idea, picture then Before/After/Why. */
.cw-concept{margin:12px 0;padding:12px;background:var(--panel);border:1px solid var(--border);border-radius:10px}
.cw-concept-title{margin:0 0 4px;font-weight:700;font-size:var(--fs-md)}
.cw-concept .cw-vis{margin:8px 0;padding:8px;background:var(--bg)}
.cw-concept-ba{margin:8px 0 0;display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:8px}
.cw-concept-ba div{padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-left:3px solid var(--muted);border-radius:6px}
.cw-concept-ba .cw-concept-before{border-left-color:var(--red)} .cw-concept-ba .cw-concept-after{border-left-color:var(--green)}
.cw-concept-ba .cw-concept-why{border-left-color:var(--accent)}
.cw-concept-ba dt{margin:0 0 2px;font-size:var(--fs-sm);font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
.cw-concept-ba dd{margin:0;font-size:var(--fs-md);line-height:1.4}

/* System change diagram (render.py overview_svg). Kind colour is one custom property per kind
so node, chip, legend swatch and edge all read the same theme variable. */
.cw-ov-sub{margin:-4px 0 8px;color:var(--muted);font-size:var(--fs-sm)}
Expand Down Expand Up @@ -2643,7 +2660,7 @@
return wrap;
}

function mdEl(text){
function mdEl(text,terms){
const frag=document.createDocumentFragment();
const listEl=l=>{
const e=document.createElement(l.ordered?'ol':'ul');
Expand All @@ -2661,9 +2678,92 @@
else if(b.kind==='list') frag.append(listEl(b));
else frag.append(richEl('p',b.text));
}
if(Array.isArray(terms)&&terms.length) markTerms(frag,terms);
return frag;
}

// Glossary: the first whole-word, case-insensitive occurrence of each term outside code and
// links becomes a focusable .cw-term; the definition shows in one shared tooltip. The boundary
// rule mirrors cw_mcp.clean_terms; longer terms go first so "order book" beats "order".
const termDefs=new WeakMap();
const termRe=term=>new RegExp('(?<![\\p{L}\\p{N}_])'
+term.trim().split(/\s+/).map(w=>w.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')).join('\\s+')
+'(?![\\p{L}\\p{N}_])','iu');
function markTerms(root,terms){
const valid=terms.filter(t=>t&&typeof t.term==='string'&&typeof t.def==='string'&&t.term.trim()&&t.def);
for(const t of valid.sort((a,b)=>b.term.length-a.term.length)){
const re=termRe(t.term);
const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT);
for(let n=walker.nextNode();n;n=walker.nextNode()){
const par=n.parentElement;
if(!par||par.closest('code,pre,a,.cw-term')) continue;
const m=re.exec(n.data);
if(!m) continue;
const hit=n.splitText(m.index);
hit.splitText(m[0].length);
const el=mk('span','cw-term',m[0]);
el.tabIndex=0;
termDefs.set(el,t.def);
hit.replaceWith(el);
break;
}
}
}

const termPop={el:null,anchor:null,timer:0,watch:null};
function hideTerm(){
clearTimeout(termPop.timer);
if(termPop.watch) termPop.watch.disconnect();
if(termPop.el) termPop.el.hidden=true;
if(termPop.anchor) termPop.anchor.removeAttribute('aria-describedby');
termPop.anchor=null;
}
function showTerm(el){
if(termPop.anchor===el) return;
hideTerm();
if(!termPop.el){
const p=mk('div','cw-term-pop'); p.id='cw-term-pop'; p.setAttribute('role','tooltip'); p.hidden=true;
document.body.append(p);
termPop.el=p;
termPop.watch=new MutationObserver(()=>{ if(termPop.anchor&&!termPop.anchor.isConnected) hideTerm(); });
}
const p=termPop.el;
p.textContent=termDefs.get(el)||'';
p.style.left=p.style.top='0px';
p.hidden=false;
el.setAttribute('aria-describedby',p.id);
termPop.anchor=el;
termPop.watch.observe(document.body,{childList:true,subtree:true});
const r=el.getBoundingClientRect(), w=document.documentElement.clientWidth;
const left=Math.max(8,Math.min(r.left,w-p.offsetWidth-8));
const below=r.bottom+6+p.offsetHeight<=window.innerHeight||r.top<p.offsetHeight+12;
p.style.left=left+'px';
p.style.top=(below?r.bottom+6:r.top-6-p.offsetHeight)+'px';
}
{
const termOf=e=>e.target.closest?e.target.closest('.cw-term'):null;
document.addEventListener('pointerover',e=>{ const el=termOf(e); if(el&&e.pointerType==='mouse') showTerm(el); });
document.addEventListener('pointerout',e=>{
if(e.pointerType==='mouse'&&termOf(e)&&!(e.relatedTarget&&e.relatedTarget.closest&&e.relatedTarget.closest('.cw-term'))) hideTerm();
});
// :focus-visible keeps a touch or mouse tap from opening it; those use hover or the long press.
document.addEventListener('focusin',e=>{ const el=termOf(e); if(el&&el.matches(':focus-visible')) showTerm(el); });
document.addEventListener('focusout',e=>{ if(termOf(e)) hideTerm(); });
document.addEventListener('pointerdown',e=>{
const el=termOf(e);
if(el&&e.pointerType!=='mouse'){ clearTimeout(termPop.timer); termPop.timer=setTimeout(()=>showTerm(el),400); }
else if(!el) hideTerm();
});
for(const ev of ['pointerup','pointercancel','pointermove']) document.addEventListener(ev,e=>{
if(e.pointerType!=='mouse'&&!termPop.anchor) clearTimeout(termPop.timer);
});
document.addEventListener('contextmenu',e=>{ if(termOf(e)&&termPop.anchor) e.preventDefault(); });
document.addEventListener('keydown',e=>{
if(e.key==='Escape'&&termPop.anchor){ hideTerm(); e.stopPropagation(); }
},true);
window.addEventListener('scroll',hideTerm,true);
}

// Pinned locale: the page's own text is English, so a browser-locale relative/absolute
// date (e.g. Polish "2 dni temu") would be the odd one out.
const RTF=new Intl.RelativeTimeFormat('en-GB',{numeric:'auto'});
Expand Down Expand Up @@ -5153,6 +5253,7 @@
}

function outcomeEl(t,o){
if(o.outcome==='terms') return null;
if(o.outcome==='draft_comments') return draftCommentsCard(o);
if(o.outcome==='task') return taskCard(t,o);
if(o.outcome==='page_edit') return pageEditCard(t,o);
Expand Down Expand Up @@ -5235,7 +5336,7 @@
d.append(mk('p','cw-turn-a cw-turn-err','Error: '+(r.error||'unknown')+(r.remedy?' ('+r.remedy+')':'')));
} else {
const a=mk('div','cw-turn-a cw-md');
a.append(mdEl(r.answer||''));
a.append(mdEl(r.answer||'',r.terms));
if(r.status==='cancelled') a.append(mk('span','cw-stopped','(stopped)'));
d.append(a);
}
Expand Down Expand Up @@ -5264,7 +5365,7 @@
}
}
if((r.status==='error'||r.status==='cancelled')&&isLast) d.append(retryBtn(t,r));
for(const o of (outcomesByQid.get(r.qid)||new Map()).values()) d.append(outcomeEl(t,o));
for(const o of (outcomesByQid.get(r.qid)||new Map()).values()){ const oe=outcomeEl(t,o); if(oe) d.append(oe); }
return d;
}

Expand Down
6 changes: 6 additions & 0 deletions skills/code-walkthrough/prompts/comment.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ Call `post_draft_comments` when the reviewer asks you to review the change and l
items of `{path, line, side, end_line?, body, suggestion?}`, each on a line inside the diff. They become
local drafts the user edits and submits later; nothing reaches GitHub until they press Submit review.

When your reply uses words that are specific to this project or its domain (an internal concept, a
product or ticket term, a house abbreviation) and a newcomer to the codebase would not know them, call
`define_terms` once with up to 5 `{term, def}` pairs, each term spelled as it appears in your reply and
each `def` one or two short sentences. Skip common programming vocabulary (function, API, cache, async)
and anything the reply already explains. Most replies need no glossary.

A comment that begins "Triage this review thread" is the app's fixed triage request, not a request
to reword or rewrite. Judge the thread against the head code and use the matching tool or reply.

Expand Down
15 changes: 12 additions & 3 deletions skills/code-walkthrough/prompts/visuals.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
You write the visuals for a code walkthrough page: one overview graph of the change, and one
picture for each walkthrough group. Deliver once, by calling `submit_visuals`:
You write the visuals for a code walkthrough page: the concepts of the change, an overview graph, and
one picture for each walkthrough group. Deliver once, by calling `submit_visuals`:

{"overview": {...} | omitted, "groups": [{"index": <0-based group index>, "html": "<fragment>"}]}
{"concepts": [...] | omitted, "overview": {...} | omitted,
"groups": [{"index": <0-based group index>, "html": "<fragment>"}]}

## Concepts

The change in the fewest ideas: one concept, two only for distinct novelties. Plain sentences, no file
names: `{"title": "1-80 chars", "before": "how it worked", "after": "how it works now", "why": "what that
buys"}`, each at most 200 characters. The task says which half to do. Text: all four fields, no `html`.
Picture: for the concept you are handed, one entry with only `html`, a before -> after diagram with the
difference highlighted. Docs-, tooling- or config-only change: no concepts.

## Overview graph

Expand Down
5 changes: 5 additions & 0 deletions skills/code-walkthrough/scripts/cw_ask.py
Original file line number Diff line number Diff line change
Expand Up @@ -1270,6 +1270,11 @@ def on_usage(usage):
record.update(status="error", error=str(e), remedy=None)

sweep_outcomes(d, qid, record["thread_id"], on_event)
if record["status"] == "ok":
defined = next((o for o in read_outcomes(d) if o["qid"] == qid and o["outcome"] == "terms"), None)
terms = cw_mcp.clean_terms((defined or {}).get("payload", {}).get("terms"), record.get("answer") or "")
if terms:
record["terms"] = terms
if live.get("queued_behind"):
record["queued_behind"] = live["queued_behind"]
if after_turn:
Expand Down
2 changes: 1 addition & 1 deletion skills/code-walkthrough/scripts/cw_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ def claude_call(profile, system, prompt, *, cwd, tools, schema=None, timeout=300

# cw_mcp imports this module, so the names are literal here; test_cw_task pins them to outcome_tools.
THREAD_ALLOWED = ",".join(f"mcp__cw__{n}" for n in (
"propose_resolve", "propose_page_edit", "propose_github_draft", "propose_task", "post_draft_comments"))
"propose_resolve", "propose_page_edit", "define_terms", "propose_github_draft", "propose_task", "post_draft_comments"))


def thread_argv(profile, system, *, mcp_config, add_dir, session_id=None, resume=None):
Expand Down
63 changes: 62 additions & 1 deletion skills/code-walkthrough/scripts/cw_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,59 @@ def rpc(tool, args, *, timeout=300):
})


TERMS_MAX, TERM_MAX, TERM_DEF_MAX = 5, 40, 240

OUTCOME_TOOLS.append({
"name": "define_terms",
"description": ("Attach a glossary to your reply: up to 5 domain-specific words or phrases from your reply "
"text, each with a 1-2 sentence definition. The page underlines them and shows the "
"definition on hover. Only terms specific to this project or its domain; never common "
"programming vocabulary (function, API, cache). A term must appear verbatim in your reply. "
"Call it once per turn, and not at all when your reply has no such term."),
"inputSchema": {
"type": "object", "required": ["terms"],
"properties": {"terms": {"type": "array", "maxItems": TERMS_MAX, "items": {
"type": "object", "required": ["term", "def"],
"properties": {"term": {"type": "string", "maxLength": TERM_MAX},
"def": {"type": "string", "maxLength": TERM_DEF_MAX}}}}},
},
})


def _term_re(term):
return re.compile(r"(?<![\w])" + r"\s+".join(map(re.escape, term.split())) + r"(?!\w)", re.IGNORECASE)


def clean_terms(raw, text=None):
"""Valid {term, def} pairs, deduplicated case-insensitively, at most TERMS_MAX. With `text`, drops
terms that do not occur in it as whole words outside code spans and fences (the page does not
mark those)."""
if text is not None:
text = re.sub(r"```.*?```|`[^`\n]*`", " ", text, flags=re.DOTALL)
out, seen = [], set()
for item in raw if isinstance(raw, list) else []:
if not isinstance(item, dict) or not isinstance(item.get("term"), str) or not isinstance(item.get("def"), str):
continue
term, definition = " ".join(item["term"].split()), " ".join(item["def"].split())
if not 2 <= len(term) <= TERM_MAX or not definition or term.lower() in seen:
continue
if text is not None and not _term_re(term).search(text):
continue
seen.add(term.lower())
out.append({"term": term, "def": definition[:TERM_DEF_MAX]})
if len(out) == TERMS_MAX:
break
return out


def _check_terms(anchor_doc, args, already):
if not clean_terms(args.get("terms")):
return "terms must be a list of {term, def} strings (term 2-%d characters, def non-empty)" % TERM_MAX
if "terms" in already:
return "already defined terms in this turn"
return None


GITHUB_DRAFT_CAP = 4000

OUTCOME_TOOLS.append({
Expand Down Expand Up @@ -656,6 +709,11 @@ def _context_payload(args, anchor_doc=None):
"payload": _draft_comments_payload, "key": lambda payload: "draft_comments",
"accepted": "added to the page as local drafts; the user can edit or remove them and nothing was posted to GitHub",
},
"define_terms": {
"outcome": "terms", "state": "applied", "check": _check_terms,
"payload": lambda args, anchor_doc=None: {"terms": clean_terms(args["terms"])},
"key": lambda payload: "terms", "accepted": "shown as a glossary on your reply",
},
"set_context": {
"outcome": "context", "state": "applied", "check": _check_context, "payload": _context_payload,
"key": lambda payload: "context", "accepted": "shown in the Context section",
Expand All @@ -672,7 +730,7 @@ def check_outcome(anchor_doc, name, args, already=()):
kind = OUTCOME_KINDS.get(name)
if kind is None:
return f"unknown tool {name}"
if name not in ("post_draft_comments", "reload_walkthrough", "set_context") and all(r.get("kind") == "free" for r in _refs(anchor_doc)):
if name not in ("post_draft_comments", "reload_walkthrough", "set_context", "define_terms") and all(r.get("kind") == "free" for r in _refs(anchor_doc)):
return "this comment has no reference (no diff line, prose block or review thread), so this tool cannot be used; reply instead"
return kind["check"](anchor_doc, args if isinstance(args, dict) else {}, already)

Expand Down Expand Up @@ -707,6 +765,9 @@ def accept_outcome(d, qid, name, args):
text = kind["accepted"]
if name == "propose_page_edit":
text += f" (oid {record['oid']})"
elif name == "define_terms" and len(record["arguments"]["terms"]) < len(args["terms"]):
text += (f"; kept {len(record['arguments']['terms'])} of {len(args['terms'])} terms, the rest were "
f"duplicates, malformed or over the limit of {TERMS_MAX}")
return True, text


Expand Down
Loading
Loading