From 3dfcd2c6840d0d4c2bc55a913ae3510fb984f5aa Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 01/15] feat(md-copy): standalone session exporter cc-export.py (markdown/text/open) Co-Authored-By: Claude Opus 4.8 (1M context) --- fixes/markdown-copy-export/cc-export.py | 184 ++++++++++++++++++++++++ tests/test_md_export.py | 147 +++++++++++++++++++ 2 files changed, 331 insertions(+) create mode 100644 fixes/markdown-copy-export/cc-export.py create mode 100644 tests/test_md_export.py diff --git a/fixes/markdown-copy-export/cc-export.py b/fixes/markdown-copy-export/cc-export.py new file mode 100644 index 0000000..107746d --- /dev/null +++ b/fixes/markdown-copy-export/cc-export.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""cc-export - export a Claude Code session transcript to Markdown or plain text. + +Reads the session JSONL directly (the high-fidelity source: exact text, no +re-conversion), so it is independent of the VS Code webview. Default output is +the readable conversation only (user text + assistant text); thinking and tool +calls are opt-in. `--open` opens the raw .jsonl in the editor instead. + + python3 cc-export.py # latest session in this cwd -> stdout (markdown) + python3 cc-export.py --session ID # a specific session id + python3 cc-export.py --format text # plain text + python3 cc-export.py --include-thinking --include-tools + python3 cc-export.py -o out.md # write to a file + python3 cc-export.py --open # open the raw transcript in VS Code + python3 cc-export.py --cwd /path/to/proj # resolve as if launched from there + +The JSONL is an internal format that can change, so each row's shape is validated +and unknown shapes are skipped rather than assuming a fixed schema. +""" +import argparse +import glob +import json +import os +import subprocess +import sys +import unicodedata + + +def config_dir(env=None): + env = env if env is not None else os.environ + return env.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude") + + +def project_key_for_cwd(cwd): + """The on-disk project dir name: realpath, NFC-normalized, slashes -> dashes.""" + real = os.path.realpath(cwd) + norm = unicodedata.normalize("NFC", real) + return norm.replace(os.sep, "-").replace("/", "-") + + +def resolve_session(config, cwd, session_id): + """Return the path to the session JSONL, or None. + + With an explicit id: look in this cwd's project dir, then scan all project + dirs. With no id: the most recently modified .jsonl in this cwd's project dir. + """ + projects = os.path.join(config, "projects") + key = project_key_for_cwd(cwd) + proj_dir = os.path.join(projects, key) + if session_id: + local = os.path.join(proj_dir, f"{session_id}.jsonl") + if os.path.isfile(local): + return local + for hit in glob.glob(os.path.join(projects, "*", f"{session_id}.jsonl")): + if os.path.isfile(hit): + return hit + return None + candidates = glob.glob(os.path.join(proj_dir, "*.jsonl")) + if not candidates: + return None + return max(candidates, key=lambda p: os.path.getmtime(p)) + + +def read_rows(path): + rows = [] + with open(path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + rows.append(json.loads(line)) + except json.JSONDecodeError: + continue # skip a corrupt line rather than abort + return rows + + +def _blocks(content): + """Normalize a message's content to a list of block dicts. + + Content may be a plain string (treated as one text block) or a list. + """ + if isinstance(content, str): + return [{"type": "text", "text": content}] + if isinstance(content, list): + return [b for b in content if isinstance(b, dict)] + return [] + + +def render(rows, fmt="markdown", include_thinking=False, include_tools=False): + """Render rows to markdown (with role headers) or plain text.""" + md = fmt != "text" + parts = [] + for row in rows: + if not isinstance(row, dict): + continue + rtype = row.get("type") + if rtype not in ("user", "assistant"): + continue # skip summary / file-history-snapshot / etc. + message = row.get("message") + if not isinstance(message, dict): + continue + blocks = _blocks(message.get("content")) + chunks = [] + for b in blocks: + bt = b.get("type") + if bt == "text": + t = b.get("text") + if isinstance(t, str) and t.strip(): + chunks.append(t.rstrip()) + elif bt == "thinking" and include_thinking: + t = b.get("thinking") + if isinstance(t, str) and t.strip(): + chunks.append(_fence("thinking", t) if md else t.rstrip()) + elif bt == "tool_use" and include_tools: + name = b.get("name", "tool") + payload = json.dumps(b.get("input", {}), indent=2, ensure_ascii=False) + chunks.append(_fence(f"tool_use {name}", payload) if md else f"[tool_use {name}] {payload}") + elif bt == "tool_result" and include_tools: + payload = b.get("content") + if isinstance(payload, (dict, list)): + payload = json.dumps(payload, indent=2, ensure_ascii=False) + payload = "" if payload is None else str(payload) + chunks.append(_fence("tool_result", payload) if md else f"[tool_result] {payload}") + if not chunks: + continue + body = "\n\n".join(chunks) + if md: + header = "## User" if rtype == "user" else "## Assistant" + parts.append(f"{header}\n\n{body}") + else: + parts.append(body) + return ("\n\n".join(parts) + "\n") if parts else "" + + +def _fence(label, text): + return f"```{label}\n{text.rstrip()}\n```" + + +def main(argv=None, config=None, env=None): + env = env if env is not None else os.environ + argv = list(sys.argv[1:] if argv is None else argv) + ap = argparse.ArgumentParser(prog="cc-export", description="Export a Claude Code session.") + ap.add_argument("--session", default=None, help="session id (default: latest in cwd)") + ap.add_argument("--cwd", default=None, help="resolve as if launched from this dir") + ap.add_argument("--format", choices=("markdown", "text"), default="markdown") + ap.add_argument("--include-thinking", action="store_true") + ap.add_argument("--include-tools", action="store_true") + ap.add_argument("--open", action="store_true", help="open the raw .jsonl in VS Code") + ap.add_argument("-o", "--output", default=None, help="write to FILE (default: stdout)") + args = ap.parse_args(argv) + + conf = config if config is not None else config_dir(env) + cwd = args.cwd or os.getcwd() + path = resolve_session(conf, cwd, args.session) + if not path: + sys.stderr.write("cc-export: no matching session transcript found\n") + return 1 + + if args.open: + editor = env.get("CC_EDITOR", "code") + try: + subprocess.run([editor, path], check=False, env=env) + except FileNotFoundError: + sys.stderr.write(f"cc-export: editor '{editor}' not found on PATH\n") + return 1 + return 0 + + out = render( + read_rows(path), + fmt=args.format, + include_thinking=args.include_thinking, + include_tools=args.include_tools, + ) + if args.output: + with open(args.output, "w", encoding="utf-8") as f: + f.write(out) + else: + sys.stdout.write(out) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_md_export.py b/tests/test_md_export.py new file mode 100644 index 0000000..1bc5579 --- /dev/null +++ b/tests/test_md_export.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Tests for cc-export.py: session resolution, markdown/text rendering, opt-in +thinking/tools, and --open. Synthetic JSONL pins the documented row schema +without needing a real transcript.""" +import importlib.util +import json +import os +import pathlib +import stat +import tempfile +import unittest + +REPO = pathlib.Path(__file__).resolve().parents[1] +EXPORT_PY = REPO / "fixes" / "markdown-copy-export" / "cc-export.py" + + +def load_export(): + spec = importlib.util.spec_from_file_location("cc_export", EXPORT_PY) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def write_session(projects_dir, project_key, session_id, rows): + d = pathlib.Path(projects_dir) / project_key + d.mkdir(parents=True, exist_ok=True) + p = d / f"{session_id}.jsonl" + p.write_text("".join(json.dumps(r) + "\n" for r in rows), encoding="utf-8") + return p + + +# A small but representative transcript (documented shapes from spec §2). +ROWS = [ + {"type": "user", "message": {"role": "user", "content": "Hello **world**"}}, + {"type": "assistant", "message": {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "secret reasoning", "signature": "sig"}, + {"type": "text", "text": "Hi there"}, + {"type": "tool_use", "id": "t1", "name": "Bash", "input": {"command": "ls"}}, + ]}}, + {"type": "user", "message": {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "file.txt"}, + {"type": "text", "text": "thanks"}, + ]}}, + {"type": "summary", "summary": "ignored metadata row"}, + {"type": "file-history-snapshot", "snapshot": {"ignored": True}}, +] + + +class CcExportTests(unittest.TestCase): + def setUp(self): + self.mod = load_export() + + def test_markdown_default_is_readable_conversation_only(self): + md = self.mod.render(ROWS, fmt="markdown", include_thinking=False, include_tools=False) + self.assertIn("## User", md) + self.assertIn("Hello **world**", md) + self.assertIn("## Assistant", md) + self.assertIn("Hi there", md) + self.assertIn("thanks", md) + # thinking and tools are excluded by default + self.assertNotIn("secret reasoning", md) + self.assertNotIn("Bash", md) + self.assertNotIn("file.txt", md) + + def test_include_thinking_adds_thinking_blocks(self): + md = self.mod.render(ROWS, fmt="markdown", include_thinking=True, include_tools=False) + self.assertIn("secret reasoning", md) + self.assertNotIn("Bash", md) + + def test_include_tools_adds_tool_use_and_result(self): + md = self.mod.render(ROWS, fmt="markdown", include_thinking=False, include_tools=True) + self.assertIn("Bash", md) + self.assertIn("ls", md) + self.assertIn("file.txt", md) + + def test_text_format_has_no_role_markdown_headers(self): + txt = self.mod.render(ROWS, fmt="text", include_thinking=False, include_tools=False) + self.assertIn("Hi there", txt) + self.assertNotIn("## User", txt) + self.assertNotIn("## Assistant", txt) + + def test_resolve_latest_in_cwd_project_dir(self): + with tempfile.TemporaryDirectory() as td: + config = pathlib.Path(td) / "config" + projects = config / "projects" + cwd = pathlib.Path(td) / "work" / "proj" + cwd.mkdir(parents=True) + key = self.mod.project_key_for_cwd(str(cwd)) + write_session(projects, key, "older", [ROWS[0]]) + newer = write_session(projects, key, "newer", [ROWS[0]]) + os.utime(newer, (10**10, 10**10)) # make 'newer' the most recent (yr 2286; 10**9 is 2001 = past) + got = self.mod.resolve_session(config=str(config), cwd=str(cwd), session_id=None) + self.assertEqual(pathlib.Path(got).name, "newer.jsonl") + + def test_resolve_explicit_session_scans_other_project_dirs(self): + with tempfile.TemporaryDirectory() as td: + config = pathlib.Path(td) / "config" + projects = config / "projects" + cwd = pathlib.Path(td) / "work" / "proj" + cwd.mkdir(parents=True) + # session lives under a DIFFERENT project key than the current cwd + write_session(projects, "some-other-key", "abc123", [ROWS[0]]) + got = self.mod.resolve_session(config=str(config), cwd=str(cwd), session_id="abc123") + self.assertEqual(pathlib.Path(got).name, "abc123.jsonl") + + def test_resolve_missing_returns_none(self): + with tempfile.TemporaryDirectory() as td: + config = pathlib.Path(td) / "config" + (config / "projects").mkdir(parents=True) + cwd = pathlib.Path(td) / "nope" + cwd.mkdir() + self.assertIsNone( + self.mod.resolve_session(config=str(config), cwd=str(cwd), session_id="ghost") + ) + + def test_open_invokes_editor_with_resolved_path(self): + # Stub `code` on PATH; assert main(--open) calls it with the jsonl path. + with tempfile.TemporaryDirectory() as td: + config = pathlib.Path(td) / "config" + projects = config / "projects" + cwd = pathlib.Path(td) / "work" / "proj" + cwd.mkdir(parents=True) + key = self.mod.project_key_for_cwd(str(cwd)) + sess = write_session(projects, key, "s1", [ROWS[0]]) + bindir = pathlib.Path(td) / "bin" + bindir.mkdir() + capture = pathlib.Path(td) / "opened.txt" + code = bindir / "code" + code.write_text( + "#!/usr/bin/env bash\nprintf '%s' \"$1\" > \"$CC_OPEN_CAPTURE\"\n", + encoding="utf-8", + ) + code.chmod(code.stat().st_mode | stat.S_IXUSR) + env = dict(os.environ) + env["PATH"] = str(bindir) + os.pathsep + env["PATH"] + env["CC_OPEN_CAPTURE"] = str(capture) + rc = self.mod.main( + ["--open", "--cwd", str(cwd)], + config=str(config), + env=env, + ) + self.assertEqual(rc, 0) + self.assertEqual(capture.read_text(encoding="utf-8"), str(sess)) + + +if __name__ == "__main__": + unittest.main() From 340c6e46fa35d72c067c31461242cbba29d3c558 Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 02/15] feat(md-copy): DOM-walk HTML->markdown converter + node unit tests --- fixes/markdown-copy-export/webview-inject.js | 219 +++++++++++++++++++ tests/md_dom_shim.js | 39 ++++ tests/test_md_converter.py | 99 +++++++++ 3 files changed, 357 insertions(+) create mode 100644 fixes/markdown-copy-export/webview-inject.js create mode 100644 tests/md_dom_shim.js create mode 100644 tests/test_md_converter.py diff --git a/fixes/markdown-copy-export/webview-inject.js b/fixes/markdown-copy-export/webview-inject.js new file mode 100644 index 0000000..7d67a53 --- /dev/null +++ b/fixes/markdown-copy-export/webview-inject.js @@ -0,0 +1,219 @@ +/* cc-md-copy: per-message and whole-conversation copy (markdown/plain) for the + * Claude Code VS Code webview. Self-contained IIFE appended to webview/index.js. + * Additive and read-only w.r.t. app state; keyed on stable CSS-module class + * prefixes, so it fails safe (controls simply do not appear) if a prefix moves. + * Exposes its pure functions for node unit tests; boot()s only in a real webview. */ +(function () { + "use strict"; + + var CONTROL_PREFIX = "cc-md-copy"; // every injected node's class starts with this + var USER_BUBBLE = '[class*="userMessageContainer_"]'; + // Assistant message wrapper. Verified on 2.1.170: the render emits exactly one + // `data-testid="assistant-message"` div per assistant turn, with the rating + // widget and content blocks as its children. (The earlier `[data-message-rating]` + // was WRONG: that attribute sits on the nested rating control, which is also only + // rendered behind an experiment+analytics gate.) Re-pinned in Task 6. + var ASSISTANT_BUBBLE = '[data-testid="assistant-message"]'; + + // ---- HTML -> Markdown (DOM walk) ------------------------------------------- + // Uses only: nodeType, tagName, childNodes, textContent, getAttribute, className. + function htmlToMarkdown(root) { + function inline(node) { + var out = ""; + var kids = node.childNodes || []; + for (var i = 0; i < kids.length; i++) { + var c = kids[i]; + if (c.nodeType === 3) { out += c.textContent || ""; continue; } + if (c.nodeType !== 1) continue; + var tag = (c.tagName || "").toUpperCase(); + if (tag === "BR") out += "\n"; + else if (tag === "STRONG" || tag === "B") out += "**" + inline(c) + "**"; + else if (tag === "EM" || tag === "I") out += "*" + inline(c) + "*"; + else if (tag === "DEL" || tag === "S") out += "~~" + inline(c) + "~~"; + else if (tag === "CODE") out += "`" + (c.textContent || "") + "`"; + else if (tag === "A") { + var href = c.getAttribute ? c.getAttribute("href") : null; + var t = inline(c); + out += href ? "[" + t + "](" + href + ")" : t; + } else out += inline(c); // unknown inline wrapper: keep text, drop tag + } + return out; + } + function langOf(codeEl) { + var cls = ""; + if (codeEl) cls = (codeEl.getAttribute && codeEl.getAttribute("class")) || codeEl.className || ""; + var m = /language-([A-Za-z0-9+#.\-]+)/.exec(cls || ""); + return m ? m[1] : ""; + } + function findChildTag(node, tag) { + var kids = node.childNodes || []; + for (var i = 0; i < kids.length; i++) { + if (kids[i].nodeType === 1 && (kids[i].tagName || "").toUpperCase() === tag) return kids[i]; + } + return null; + } + function list(node, ordered, depth) { + var out = "", n = 1; + var kids = node.childNodes || []; + for (var i = 0; i < kids.length; i++) { + var li = kids[i]; + if (li.nodeType !== 1 || (li.tagName || "").toUpperCase() !== "LI") continue; + var marker = ordered ? n++ + ". " : "- "; + var indent = new Array(depth + 1).join(" "); + var lead = "", nested = ""; + var lk = li.childNodes || []; + for (var j = 0; j < lk.length; j++) { + var ch = lk[j]; + var ct = ch.nodeType === 1 ? (ch.tagName || "").toUpperCase() : ""; + if (ct === "UL") nested += list(ch, false, depth + 1); + else if (ct === "OL") nested += list(ch, true, depth + 1); + else if (ch.nodeType === 3) lead += ch.textContent || ""; + else lead += inline(ch); + } + out += indent + marker + lead.trim() + "\n" + nested; + } + return out; + } + function table(node) { + var rows = []; + (function collect(container) { + var kids = container.childNodes || []; + for (var i = 0; i < kids.length; i++) { + var c = kids[i]; + if (c.nodeType !== 1) continue; + var t = (c.tagName || "").toUpperCase(); + if (t === "THEAD" || t === "TBODY" || t === "TFOOT") collect(c); + else if (t === "TR") { + var cells = [], cc = c.childNodes || []; + for (var j = 0; j < cc.length; j++) { + var d = cc[j]; + if (d.nodeType !== 1) continue; + var dt = (d.tagName || "").toUpperCase(); + if (dt === "TH" || dt === "TD") cells.push(inline(d).trim()); + } + rows.push(cells); + } + } + })(node); + if (!rows.length) return ""; + var head = rows[0], body = rows.slice(1); + var sep = head.map(function () { return "---"; }); + var out = "| " + head.join(" | ") + " |\n| " + sep.join(" | ") + " |\n"; + for (var k = 0; k < body.length; k++) out += "| " + body[k].join(" | ") + " |\n"; + return out; + } + function block(node) { + var out = ""; + var kids = node.childNodes || []; + for (var i = 0; i < kids.length; i++) { + var c = kids[i]; + if (c.nodeType === 3) { if ((c.textContent || "").trim()) out += c.textContent; continue; } + if (c.nodeType !== 1) continue; + var tag = (c.tagName || "").toUpperCase(); + if (/^H[1-6]$/.test(tag)) out += new Array(+tag[1] + 1).join("#") + " " + inline(c).trim() + "\n\n"; + else if (tag === "P") out += inline(c).trim() + "\n\n"; + else if (tag === "UL") out += list(c, false, 0) + "\n"; + else if (tag === "OL") out += list(c, true, 0) + "\n"; + else if (tag === "PRE") { + var code = findChildTag(c, "CODE"); + var lang = langOf(code || c); + var body = (code || c).textContent || ""; + out += "```" + lang + "\n" + body.replace(/\n$/, "") + "\n```\n\n"; + } else if (tag === "BLOCKQUOTE") { + var inner = block(c).trim().split("\n").map(function (l) { return "> " + l; }).join("\n"); + out += inner + "\n\n"; + } else if (tag === "HR") out += "---\n\n"; + else if (tag === "TABLE") out += table(c) + "\n"; + else if (tag === "BR") out += "\n"; + else if (tag === "STRONG" || tag === "B" || tag === "EM" || tag === "I" || + tag === "A" || tag === "CODE" || tag === "DEL" || tag === "S") + out += inline(c) + "\n\n"; + else out += block(c); // unknown wrapper: recurse (drop tag, keep content) + } + return out; + } + var _w = {nodeType:1, tagName:"DIV", childNodes:[root], className:"", getAttribute:function(){return null;}}; + return block(_w).replace(/\n{3,}/g, "\n\n").trim(); + } + + // ---- pure helpers ---------------------------------------------------------- + function hasPrefix(node, prefix) { + if (node.nodeType !== 1 || typeof node.className !== "string") return false; + var parts = node.className.split(/\s+/); + for (var i = 0; i < parts.length; i++) if (parts[i].indexOf(prefix) === 0) return true; + return false; + } + + // Class-prefix hooks for non-content chrome that renders *inside* an assistant + // bubble (verified on 2.1.170; Task 6 re-pins these). tool*/thinking_ are the v1 + // exclusions; unknownContent_ is the renderer's fallback for unrecognized block + // types, so stripping it makes a *future* block type fail safe to excluded rather + // than leaking "Unsupported content" into the copy. Re-pin if a prefix moves. + var CHROME_PREFIXES = ["toolUse_", "toolResult_", "toolReference_", "thinking_", "unknownContent_"]; + + // True for any node that must never appear in copied output: our own controls, + // the rating widget (`data-message-rating` + its "Thanks for your feedback" + // text), any button (copy-code chrome), and the excluded content blocks above. + function isChrome(node) { + if (node.nodeType !== 1) return false; + if ((node.tagName || "").toUpperCase() === "BUTTON") return true; + if (node.getAttribute && node.getAttribute("data-message-rating") !== null) return true; + if (hasPrefix(node, CONTROL_PREFIX)) return true; + for (var i = 0; i < CHROME_PREFIXES.length; i++) if (hasPrefix(node, CHROME_PREFIXES[i])) return true; + return false; + } + + // Deep-clone `contentNode`, then strip every chrome node so copied output is the + // message's text content only. This is a CORRECTNESS GATE, not cosmetic: the + // default content node is the whole bubble (all content-block siblings, so multi- + // block assistant turns are captured), and this strip-list is the only thing + // keeping the rating widget and v1-excluded blocks out of the copy. + function sanitizeClone(contentNode) { + var clone = contentNode.cloneNode(true); + (function strip(node) { + var kids = (node.childNodes || []).slice(); + for (var i = 0; i < kids.length; i++) { + var c = kids[i]; + if (c.nodeType === 1 && isChrome(c)) { node.removeChild(c); continue; } + if (c.nodeType === 1) strip(c); + } + })(clone); + return clone; + } + + function classifyBubble(node) { + if (node.nodeType !== 1) return null; + if (hasPrefix(node, "userMessageContainer_")) return "user"; + if (node.getAttribute && node.getAttribute("data-testid") === "assistant-message") return "assistant"; + return null; + } + + // Build the whole-conversation markdown from an ordered list of bubbles. + // `contentOf(bubble)` resolves the content node (default: the bubble itself, so + // every content block is included; sanitizeClone drops chrome); a default is + // provided for tests. + function conversationToMarkdown(bubbles, contentOf) { + contentOf = contentOf || function (b) { return b; }; + var parts = []; + for (var i = 0; i < bubbles.length; i++) { + var role = classifyBubble(bubbles[i]); + if (!role) continue; + var clean = sanitizeClone(contentOf(bubbles[i])); + var body = role === "assistant" ? htmlToMarkdown(clean) : (clean.textContent || "").trim(); + if (!body) continue; + parts.push((role === "user" ? "## User" : "## Assistant") + "\n\n" + body); + } + return parts.join("\n\n") + (parts.length ? "\n" : ""); + } + + // ---- exports (node tests) / boot (real webview) ---------------------------- + if (typeof document !== "undefined") { + boot(); + } else if (typeof module !== "undefined" && module.exports) { + module.exports = { htmlToMarkdown: htmlToMarkdown, sanitizeClone: sanitizeClone, + classifyBubble: classifyBubble, conversationToMarkdown: conversationToMarkdown }; + } + + // boot() is defined in Phase 3; declare a no-op so the file is valid until then. + function boot() {} +})(); diff --git a/tests/md_dom_shim.js b/tests/md_dom_shim.js new file mode 100644 index 0000000..78dfd12 --- /dev/null +++ b/tests/md_dom_shim.js @@ -0,0 +1,39 @@ +// Minimal DOM-node shim so webview-inject.js's pure functions can be unit-tested +// under plain node (no jsdom, no dependency). Implements exactly the API subset +// the converter/sanitizer use: nodeType, tagName, childNodes, textContent, +// getAttribute, className, cloneNode(deep), removeChild. +"use strict"; +function txt(s) { + return { nodeType: 3, textContent: String(s), childNodes: [], cloneNode() { return txt(this.textContent); } }; +} +function el(tag, attrs, children) { + attrs = attrs || {}; + children = (children || []).slice(); + const node = { + nodeType: 1, + tagName: String(tag).toUpperCase(), + childNodes: children, + className: attrs.class || attrs.className || "", + _attrs: Object.assign({}, attrs), + getAttribute(n) { + if (n === "class") return this.className; + return n in this._attrs ? this._attrs[n] : null; + }, + get textContent() { + return this.childNodes.map((c) => c.textContent).join(""); + }, + set textContent(v) { + this.childNodes = [txt(v)]; + }, + removeChild(c) { + const i = this.childNodes.indexOf(c); + if (i >= 0) this.childNodes.splice(i, 1); + return c; + }, + cloneNode(deep) { + return el(tag, this._attrs, deep ? this.childNodes.map((c) => c.cloneNode(true)) : []); + }, + }; + return node; +} +module.exports = { el, txt }; diff --git a/tests/test_md_converter.py b/tests/test_md_converter.py new file mode 100644 index 0000000..0f3062c --- /dev/null +++ b/tests/test_md_converter.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""HTML->markdown converter unit tests. Runs webview-inject.js under node with the +md_dom_shim, builds DOM trees, and asserts the markdown the converter emits.""" +import json +import os +import pathlib +import subprocess +import unittest + +REPO = pathlib.Path(__file__).resolve().parents[1] +INJECT_JS = REPO / "fixes" / "markdown-copy-export" / "webview-inject.js" +SHIM = REPO / "tests" / "md_dom_shim.js" + + +def convert(builder_js): + """Run a node snippet that builds a node `root` and prints htmlToMarkdown(root).""" + script = ( + f"const {{el, txt}} = require({json.dumps(str(SHIM))});\n" + f"const M = require({json.dumps(str(INJECT_JS))});\n" + f"const root = ({builder_js});\n" + f"process.stdout.write(M.htmlToMarkdown(root));\n" + ) + res = subprocess.run( + ["node", "-e", script], cwd=REPO, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10, + ) + assert res.returncode == 0, res.stderr + return res.stdout + + +class ConverterTests(unittest.TestCase): + def test_headings_and_paragraph(self): + out = convert("el('div',{},[el('h2',{},[txt('Title')]), el('p',{},[txt('Body text')])])") + self.assertIn("## Title", out) + self.assertIn("Body text", out) + + def test_bold_italic_inline_code(self): + out = convert( + "el('p',{},[txt('a '), el('strong',{},[txt('b')]), txt(' '), " + "el('em',{},[txt('c')]), txt(' '), el('code',{},[txt('d')])])" + ) + self.assertIn("**b**", out) + self.assertIn("*c*", out) + self.assertIn("`d`", out) + + def test_link_uses_href(self): + out = convert("el('p',{},[el('a',{href:'https://x.test'},[txt('link')])])") + self.assertIn("[link](https://x.test)", out) + + def test_fenced_code_block_with_language(self): + out = convert( + "el('pre',{},[el('code',{class:'language-python'},[txt('print(1)\\n')])])" + ) + self.assertIn("```python", out) + self.assertIn("print(1)", out) + self.assertTrue(out.strip().endswith("```")) + + def test_unordered_list(self): + out = convert("el('ul',{},[el('li',{},[txt('one')]), el('li',{},[txt('two')])])") + self.assertIn("- one", out) + self.assertIn("- two", out) + + def test_ordered_list(self): + out = convert("el('ol',{},[el('li',{},[txt('a')]), el('li',{},[txt('b')])])") + self.assertIn("1. a", out) + self.assertIn("2. b", out) + + def test_nested_list_indents(self): + out = convert( + "el('ul',{},[el('li',{},[txt('top'), el('ul',{},[el('li',{},[txt('child')])])])])" + ) + self.assertIn("- top", out) + self.assertIn(" - child", out) + + def test_blockquote(self): + out = convert("el('blockquote',{},[el('p',{},[txt('quoted')])])") + self.assertIn("> quoted", out) + + def test_horizontal_rule(self): + out = convert("el('div',{},[el('p',{},[txt('a')]), el('hr',{},[]), el('p',{},[txt('b')])])") + self.assertIn("---", out) + + def test_table_gfm_pipes(self): + out = convert( + "el('table',{},[el('thead',{},[el('tr',{},[el('th',{},[txt('H1')]), el('th',{},[txt('H2')])])])," + "el('tbody',{},[el('tr',{},[el('td',{},[txt('a')]), el('td',{},[txt('b')])])])])" + ) + self.assertIn("| H1 | H2 |", out) + self.assertIn("| --- | --- |", out) + self.assertIn("| a | b |", out) + + def test_unknown_wrapper_keeps_text_drops_tag(self): + out = convert("el('p',{},[el('span',{},[txt('kept')])])") + self.assertIn("kept", out) + self.assertNotIn("span", out) + + +if __name__ == "__main__": + unittest.main() From 2a599a339f7241beaf46d99d60b8dcb56cea4aac Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 03/15] test(md-copy): sanitizer/classify/conversation-join scoping coverage --- tests/test_md_inject.py | 108 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 tests/test_md_inject.py diff --git a/tests/test_md_inject.py b/tests/test_md_inject.py new file mode 100644 index 0000000..0652871 --- /dev/null +++ b/tests/test_md_inject.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Scoping/correctness tests for the inject IIFE's pure helpers: sanitizeClone +strips our controls and never mutates the original; classifyBubble keys off the +stable prefixes; conversationToMarkdown joins with role headers and excludes our +UI text. Runs under node with md_dom_shim (no DOM, no dependency).""" +import json +import pathlib +import subprocess +import unittest + +REPO = pathlib.Path(__file__).resolve().parents[1] +INJECT_JS = REPO / "fixes" / "markdown-copy-export" / "webview-inject.js" +SHIM = REPO / "tests" / "md_dom_shim.js" + + +def run_node(body_js): + script = ( + f"const {{el, txt}} = require({json.dumps(str(SHIM))});\n" + f"const M = require({json.dumps(str(INJECT_JS))});\n" + f"{body_js}\n" + ) + res = subprocess.run( + ["node", "-e", script], cwd=REPO, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10, + ) + assert res.returncode == 0, res.stderr + return res.stdout + + +class InjectHelperTests(unittest.TestCase): + def test_sanitize_strips_all_chrome_and_asserts_clean(self): + # Fixture shaped like a real assistant bubble: a text content block plus the + # sibling chrome the live DOM puts inside it (our controls, the rating + # widget, and the v1-excluded tool/thinking/unknown blocks). Sanitize must + # remove ALL of it and leave the original untouched. + out = run_node( + "const content = el('div',{'data-testid':'assistant-message'},[" + " el('p',{},[txt('keep me')])," + " el('button',{class:'cc-md-copy'},[txt('Copy')])," + " el('div',{class:'cc-md-copy-feedback'},[txt('Copied')])," + " el('div',{class:'toolUse_uq5aLg'},[txt('ran a tool')])," + " el('div',{class:'toolResult_uq5aLg'},[txt('tool output')])," + " el('div',{class:'thinking_aHyQPQ'},[txt('secret thoughts')])," + " el('div',{class:'unknownContent_uq5aLg'},[txt('Unsupported content')])," + " el('div',{'data-message-rating':'0'},[txt('Thanks for your feedback')])," + "]);" + "const clean = M.sanitizeClone(content);" + "const cleanMd = M.htmlToMarkdown(clean);" + # Independent post-condition walk (does not reuse isChrome): assert no + # chrome node survives. fail-closed in the TEST, never at runtime. + "function residue(node, acc){var k=node.childNodes||[];for(var i=0;i must NOT classify as assistant. + "const m = el('div',{class:'message_07S1Yg'},[]);" + "const x = el('div',{class:'somethingElse_zz'},[]);" + "process.stdout.write(JSON.stringify(" + "[M.classifyBubble(u), M.classifyBubble(a), M.classifyBubble(m), M.classifyBubble(x)]));" + ) + self.assertEqual(json.loads(out), ["user", "assistant", None, None]) + + def test_conversation_join_has_headers_and_excludes_chrome(self): + out = run_node( + "const u = el('div',{class:'userMessageContainer_07S1Yg'},[txt('hi there')]);" + "const a = el('div',{'data-testid':'assistant-message'},[" + " el('p',{},[txt('hello back')])," + " el('div',{class:'thinking_aHyQPQ'},[txt('secret thoughts')])," + " el('button',{class:'cc-md-copy'},[txt('Copy')])," + " el('div',{'data-message-rating':'0'},[txt('Thanks for your feedback')])," + "]);" + "process.stdout.write(M.conversationToMarkdown([u, a]));" + ) + self.assertIn("## User", out) + self.assertIn("hi there", out) + self.assertIn("## Assistant", out) + self.assertIn("hello back", out) + self.assertNotIn("Copy", out) + self.assertNotIn("secret thoughts", out) + self.assertNotIn("Thanks for your feedback", out) + + +if __name__ == "__main__": + unittest.main() From ea95e7c3218d0780a2c6d0cea4786b49f1e0aa17 Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 04/15] fix(md-copy): htmlToMarkdown handles a block element passed as root block() dispatches on each child's tag, so passing a bare
/
    / as root flattened it (its own tag never handled). Wrap root in a one-off container so its own tag is dispatched too; behavior is unchanged when root is the bubble container (the production path). Found while reaching GREEN on the converter tests, which pass single block elements as root. Co-Authored-By: Claude Opus 4.8 (1M context) --- fixes/markdown-copy-export/webview-inject.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/fixes/markdown-copy-export/webview-inject.js b/fixes/markdown-copy-export/webview-inject.js index 7d67a53..d422a11 100644 --- a/fixes/markdown-copy-export/webview-inject.js +++ b/fixes/markdown-copy-export/webview-inject.js @@ -132,8 +132,11 @@ } return out; } - var _w = {nodeType:1, tagName:"DIV", childNodes:[root], className:"", getAttribute:function(){return null;}}; - return block(_w).replace(/\n{3,}/g, "\n\n").trim(); + // block() dispatches on each CHILD's tag, treating the passed node as a plain + // container. Wrap root in a one-off container so root's OWN tag is dispatched + // too: callers pass either the bubble container (its block children render) or + // a single block element like
    /
      /
    (now handled, not flattened). + return block({ childNodes: [root] }).replace(/\n{3,}/g, "\n\n").trim(); } // ---- pure helpers ---------------------------------------------------------- From d3db52d8d3f7075a706aaddc98304ae9a2764332 Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 05/15] feat(md-copy): webview boot() - inject copy controls, observer, conversation copy --- fixes/markdown-copy-export/webview-inject.js | 144 ++++++++++++++++++- 1 file changed, 142 insertions(+), 2 deletions(-) diff --git a/fixes/markdown-copy-export/webview-inject.js b/fixes/markdown-copy-export/webview-inject.js index d422a11..9634c72 100644 --- a/fixes/markdown-copy-export/webview-inject.js +++ b/fixes/markdown-copy-export/webview-inject.js @@ -14,6 +14,12 @@ // was WRONG: that attribute sits on the nested rating control, which is also only // rendered behind an experiment+analytics gate.) Re-pinned in Task 6. var ASSISTANT_BUBBLE = '[data-testid="assistant-message"]'; + var MESSAGES_CONTAINER = '[class*="messagesContainer_"]'; // e.g. '[class*="timeline_"]'; "" -> observe document.body + // Optional narrowing only. MUST be a single wrapper around ALL content blocks, + // not a per-block class (a turn has multiple blocks). "" -> use the bubble itself + // (already aggregates all blocks; sanitizeClone is the correctness gate). + var ASSISTANT_CONTENT = ""; + var FEEDBACK_MS = 1800; // ---- HTML -> Markdown (DOM walk) ------------------------------------------- // Uses only: nodeType, tagName, childNodes, textContent, getAttribute, className. @@ -217,6 +223,140 @@ classifyBubble: classifyBubble, conversationToMarkdown: conversationToMarkdown }; } - // boot() is defined in Phase 3; declare a no-op so the file is valid until then. - function boot() {} + // ---- live-webview wiring (runs only when a document exists) ---------------- + function qs(node, sel) { try { return sel && node.querySelector ? node.querySelector(sel) : null; } catch (_) { return null; } } + function qsa(sel) { try { return Array.prototype.slice.call(document.querySelectorAll(sel)); } catch (_) { return []; } } + + // The content node to convert/copy: the optional ASSISTANT_CONTENT wrapper if + // pinned and present, else the bubble itself. The bubble already contains every + // content-block sibling of a multi-block turn, and sanitizeClone strips the + // chrome (rating widget, tool/thinking/unknown blocks, buttons, our controls) + // either way -- so this is a narrowing, never the thing that guarantees + // correctness. + function contentNodeOf(bubble, role) { + if (role === "assistant" && ASSISTANT_CONTENT) { + var n = qs(bubble, ASSISTANT_CONTENT); + if (n) return n; + } + return bubble; + } + + function copyText(text) { + try { + if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(text); + } catch (_) {} + return Promise.resolve(); // best-effort; never throw into the app + } + + function flashFeedback(host) { + try { + var fb = document.createElement("span"); + fb.className = CONTROL_PREFIX + "-feedback"; + fb.textContent = "Copied"; + host.appendChild(fb); + setTimeout(function () { if (fb && fb.parentNode) fb.parentNode.removeChild(fb); }, FEEDBACK_MS); + } catch (_) {} + } + + function bubbleMarkdown(bubble, role) { + var clean = sanitizeClone(contentNodeOf(bubble, role)); + return role === "assistant" ? htmlToMarkdown(clean) : (clean.textContent || "").trim(); + } + function bubblePlain(bubble, role) { + return (sanitizeClone(contentNodeOf(bubble, role)).textContent || "").trim(); + } + + // Build a single control: a primary "Copy" (markdown) plus a small caret that + // toggles a menu with "Copy as plain text". All nodes carry the CONTROL_PREFIX + // class so sanitizeClone removes them from any copied content. + function buildControl(onMarkdown, onPlain) { + var wrap = document.createElement("span"); + wrap.className = CONTROL_PREFIX; + var primary = document.createElement("button"); + primary.type = "button"; + primary.className = CONTROL_PREFIX + "-btn"; + primary.title = "Copy as Markdown"; + primary.textContent = "Copy"; + primary.addEventListener("click", function (e) { e.stopPropagation(); onMarkdown(primary); }); + var caret = document.createElement("button"); + caret.type = "button"; + caret.className = CONTROL_PREFIX + "-caret"; + caret.title = "Copy options"; + caret.textContent = "▾"; // black down-pointing small triangle + var menu = document.createElement("span"); + menu.className = CONTROL_PREFIX + "-menu"; + menu.style.display = "none"; + var plain = document.createElement("button"); + plain.type = "button"; + plain.className = CONTROL_PREFIX + "-btn"; + plain.textContent = "Copy as plain text"; + plain.addEventListener("click", function (e) { e.stopPropagation(); menu.style.display = "none"; onPlain(plain); }); + menu.appendChild(plain); + caret.addEventListener("click", function (e) { + e.stopPropagation(); + menu.style.display = menu.style.display === "none" ? "inline-block" : "none"; + }); + wrap.appendChild(primary); + wrap.appendChild(caret); + wrap.appendChild(menu); + return wrap; + } + + function decorate(bubble) { + try { + var role = classifyBubble(bubble); + if (!role) return; + if (qs(bubble, "." + CONTROL_PREFIX)) return; // already decorated + var control = buildControl( + function (host) { copyText(bubbleMarkdown(bubble, role)).then(function () { flashFeedback(control); }); }, + function (host) { copyText(bubblePlain(bubble, role)).then(function () { flashFeedback(control); }); } + ); + bubble.appendChild(control); + } catch (_) {} + } + + function copyConversation(format) { + var bubbles = qsa(USER_BUBBLE + "," + ASSISTANT_BUBBLE); + if (format === "text") { + var lines = []; + for (var i = 0; i < bubbles.length; i++) { + var role = classifyBubble(bubbles[i]); + if (!role) continue; + var body = bubblePlain(bubbles[i], role); + if (body) lines.push(body); + } + return copyText(lines.join("\n\n") + (lines.length ? "\n" : "")); + } + return copyText(conversationToMarkdown(bubbles, function (b) { + return contentNodeOf(b, classifyBubble(b)); + })); + } + + function installConversationControl() { + try { + if (qs(document, "." + CONTROL_PREFIX + "-conversation")) return; + var bar = document.createElement("div"); + bar.className = CONTROL_PREFIX + "-conversation"; + var control = buildControl( + function () { copyConversation("markdown").then(function () { flashFeedback(bar); }); }, + function () { copyConversation("text").then(function () { flashFeedback(bar); }); } + ); + control.title = "Copy entire conversation"; + bar.appendChild(control); + document.body.appendChild(bar); // fixed-position via CSS; placement refined in Task 6 + } catch (_) {} + } + + function sweep() { var b = qsa(USER_BUBBLE + "," + ASSISTANT_BUBBLE); for (var i = 0; i < b.length; i++) decorate(b[i]); } + + function boot() { + try { + var target = (MESSAGES_CONTAINER && qs(document, MESSAGES_CONTAINER)) || document.body; + sweep(); + installConversationControl(); + if (typeof MutationObserver === "undefined") return; + var obs = new MutationObserver(function () { sweep(); }); + obs.observe(target, { childList: true, subtree: true }); + } catch (_) {} + } })(); From 534edbffe73500234c828bd547a6c394c5cd9f62 Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 06/15] feat(md-copy): webview CSS (themed, namespaced under .cc-md-copy) --- fixes/markdown-copy-export/webview-inject.css | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 fixes/markdown-copy-export/webview-inject.css diff --git a/fixes/markdown-copy-export/webview-inject.css b/fixes/markdown-copy-export/webview-inject.css new file mode 100644 index 0000000..21ce251 --- /dev/null +++ b/fixes/markdown-copy-export/webview-inject.css @@ -0,0 +1,54 @@ +.cc-md-copy { + display: inline-flex; + align-items: center; + gap: 2px; + vertical-align: middle; + margin-left: 6px; +} +.cc-md-copy-btn, +.cc-md-copy-caret { + font: inherit; + font-size: 11px; + line-height: 1.4; + padding: 1px 6px; + color: var(--vscode-foreground); + background: transparent; + border: 1px solid var(--vscode-widget-border, transparent); + border-radius: 4px; + cursor: pointer; + opacity: 0.65; +} +.cc-md-copy-btn:hover, +.cc-md-copy-caret:hover { + opacity: 1; + background: var(--vscode-toolbar-hoverBackground, rgba(128, 128, 128, 0.15)); +} +.cc-md-copy-menu { + position: relative; + margin-left: 4px; + padding: 2px; + background: var(--vscode-menu-background, var(--vscode-editorWidget-background)); + border: 1px solid var(--vscode-menu-border, var(--vscode-widget-border, transparent)); + border-radius: 4px; + z-index: 5; +} +.cc-md-copy-feedback { + margin-left: 6px; + font-size: 11px; + opacity: 0.85; + color: var(--vscode-foreground); +} +.cc-md-copy-conversation { + position: fixed; + right: 16px; + bottom: 56px; + z-index: 10; + padding: 2px; + background: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-widget-border, transparent); + border-radius: 6px; + opacity: 0.85; +} +.cc-md-copy-conversation:hover { + opacity: 1; +} From 358b36dcb9891e3c4d9e15d3e30b29b63732c8ac Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 07/15] feat(md-copy): tools/gen-embeds payload generator + drift check Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_gen_embeds.py | 127 ++++++++++++++++++++++++++++++++ tools/gen-embeds | 151 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 tests/test_gen_embeds.py create mode 100755 tools/gen-embeds diff --git a/tests/test_gen_embeds.py b/tests/test_gen_embeds.py new file mode 100644 index 0000000..b34cdb8 --- /dev/null +++ b/tests/test_gen_embeds.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Tests for tools/gen-embeds: region replacement, per-language block escaping +(round-trip), the heredoc-collision guard, the payload sentinel guard, region +counting, the strict all-consumers gate, and a whole-tree drift check.""" +import base64 +import importlib.machinery +import importlib.util +import json +import pathlib +import subprocess +import tempfile +import unittest + +REPO = pathlib.Path(__file__).resolve().parents[1] +GEN = REPO / "tools" / "gen-embeds" + + +def load_gen(): + # tools/gen-embeds has no .py extension, so spec_from_file_location returns + # None (it guesses the loader from the suffix). Load via an explicit loader. + loader = importlib.machinery.SourceFileLoader("gen_embeds", str(GEN)) + spec = importlib.util.spec_from_loader("gen_embeds", loader) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +class GenPureTests(unittest.TestCase): + def setUp(self): + self.g = load_gen() + + def test_replace_region_keeps_markers_and_swaps_inner(self): + text = ( + "head\n" + "# >>>CCWA-MD-COPY-EMBED>>> (generated)\n" + "OLD INNER\n" + "# <<>>CCWA-MD-COPY-EMBED>>>", out) + self.assertIn("<<>>CCWA-MD-COPY-EMBED>>>\n# << 0 (unwired skipped) + rc_strict = g.main(["--check", "--strict"]) # strict -> 1 (unwired flagged) + finally: + g.REPO, g.TARGETS = old_repo, old_targets + self.assertEqual(rc_lenient, 0) + self.assertEqual(rc_strict, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/gen-embeds b/tools/gen-embeds new file mode 100755 index 0000000..f45b6c1 --- /dev/null +++ b/tools/gen-embeds @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""gen-embeds - embed the md-copy webview payload (single source) into each +self-contained consumer, and check for drift. + +Single source: fixes/markdown-copy-export/webview-inject.js / .css +Consumers (each carries its own embedded copy so it is independently copyable): + - launcher/claudemax (bash quoted heredocs) + - launcher/claudemax.win.js (node JSON string literals) + - fixes/markdown-copy-export/add-md-copy.py (python base64) + + python3 tools/gen-embeds # rewrite the embed region in each consumer + python3 tools/gen-embeds --check # exit nonzero if any wired consumer is stale + +Each consumer marks its region with comment lines containing +`>>>CCWA-MD-COPY-EMBED>>>` and `<<>>%s>>>" % core) in ln: + si = i + elif ("<<<%s<<<" % core) in ln: + ei = i + if si is None or ei is None or ei <= si: + raise ValueError("embed region %s not found or malformed" % core) + inner = new_inner if new_inner.endswith("\n") else new_inner + "\n" + return "".join(lines[: si + 1]) + inner + "".join(lines[ei:]) + + +def node_block(js, css): + return "const MD_COPY_JS = %s;\nconst MD_COPY_CSS = %s;\n" % ( + json.dumps(js), json.dumps(css) + ) + + +def py_block(js, css): + enc = lambda s: base64.b64encode(s.encode("utf-8")).decode("ascii") + return 'INJECT_JS = base64.b64decode("%s").decode("utf-8")\nINJECT_CSS = base64.b64decode("%s").decode("utf-8")\n' % ( + enc(js), enc(css) + ) + + +def bash_block(js, css): + for delim, payload in (("CCMDCOPYJS", js), ("CCMDCOPYCSS", css)): + for ln in payload.splitlines(): + if ln.strip() == delim: + raise ValueError("payload contains a line equal to heredoc delimiter %s" % delim) + return ( + "_cc_md_copy_js() { cat <<'CCMDCOPYJS'\n%s\nCCMDCOPYJS\n}\n" + "_cc_md_copy_css() { cat <<'CCMDCOPYCSS'\n%s\nCCMDCOPYCSS\n}\n" + ) % (js.rstrip("\n"), css.rstrip("\n")) + + +BLOCKS = {"bash": bash_block, "node": node_block, "py": py_block} + + +def generated_text(target, js, css): + path = REPO / target["path"] + current = path.read_text(encoding="utf-8") + block = BLOCKS[target["style"]](js, css) + return current, replace_region(current, CORE, block) + + +def region_count(text, core): + return (text.count(">>>%s>>>" % core), text.count("<<<%s<<<" % core)) + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + check = "--check" in argv + # --strict (CI's final gate): every consumer must exist and carry exactly one + # embed region. Without it, a missing file or a vanished region is silently + # skipped, which is correct only for early, not-yet-wired phases. + strict = "--strict" in argv + js, css = load_sources() + drift = [] + missing = [] + for target in TARGETS: + path = REPO / target["path"] + if not path.exists(): + if strict: + missing.append("%s (file absent)" % target["path"]) + continue # consumer file not present yet + if strict: + opens, closes = region_count(path.read_text(encoding="utf-8"), CORE) + if opens != 1 or closes != 1: + missing.append("%s (%d open / %d close markers, want exactly 1 each)" % (target["path"], opens, closes)) + continue + try: + current, updated = generated_text(target, js, css) + except ValueError: + if strict: + missing.append("%s (embed region missing/malformed)" % target["path"]) + continue # file exists but has no embed region yet (not wired) + if check: + if current != updated: + drift.append(target["path"]) + elif current != updated: + path.write_text(updated, encoding="utf-8") + sys.stdout.write("regenerated %s\n" % target["path"]) + if strict and missing: + sys.stderr.write("gen-embeds: NOT WIRED (--strict requires all consumers): %s\n" % ", ".join(missing)) + return 1 + if check and drift: + sys.stderr.write("gen-embeds: STALE (re-run tools/gen-embeds): %s\n" % ", ".join(drift)) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From ae03b9f18c0bbf08180e0821ec7812a0f480ecd2 Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 08/15] feat(md-copy): standalone add-md-copy.py (sentinel append, reverse-transform --revert) --- fixes/markdown-copy-export/add-md-copy.py | 165 ++++++++++++++++++++++ tests/test_md_patcher.py | 130 +++++++++++++++++ 2 files changed, 295 insertions(+) create mode 100644 fixes/markdown-copy-export/add-md-copy.py create mode 100644 tests/test_md_patcher.py diff --git a/fixes/markdown-copy-export/add-md-copy.py b/fixes/markdown-copy-export/add-md-copy.py new file mode 100644 index 0000000..2df12ff --- /dev/null +++ b/fixes/markdown-copy-export/add-md-copy.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""add-md-copy.py - install per-message and whole-conversation markdown/plain copy +controls into the Claude Code VS Code webview, without a launcher. + +Appends a sentinel-delimited block to webview/index.js (the inject IIFE) and to +webview/index.css (its styles). Idempotent (detects the open sentinel), atomic +(temp file + verified rename, metadata preserved). `--revert` removes the block +by REVERSE TRANSFORM (not a backup restore), so it composes with the context-icon +patcher on the same file - removing md-copy never disturbs context-icon. A one-time +whole-file .bak-md-copy snapshot is kept for emergency manual restore only and is +NOT used by --revert (restoring it is not composition-safe). + + python3 add-md-copy.py # auto-discover & patch all installs + python3 add-md-copy.py --revert # remove our block (reverse transform) + python3 add-md-copy.py /path/to/webview/index.js # explicit target(s) + +The payload below is GENERATED from fixes/markdown-copy-export/webview-inject.{js,css} +by tools/gen-embeds; do not edit it by hand. Run `tools/gen-embeds` after changing +the source, and `tools/gen-embeds --check` verifies it is in sync (CI drift check). +""" +import base64 +import glob +import os +import re +import shutil +import sys +import tempfile + +OPEN = "/* cc-md-copy v1 */" +CLOSE = "/* /cc-md-copy v1 */" +BACKUP_SUFFIX = ".bak-md-copy" + +# >>>CCWA-MD-COPY-EMBED>>> (generated by tools/gen-embeds; do not edit) +INJECT_JS = base64.b64decode("LyogY2MtbWQtY29weTogcGVyLW1lc3NhZ2UgYW5kIHdob2xlLWNvbnZlcnNhdGlvbiBjb3B5IChtYXJrZG93bi9wbGFpbikgZm9yIHRoZQogKiBDbGF1ZGUgQ29kZSBWUyBDb2RlIHdlYnZpZXcuIFNlbGYtY29udGFpbmVkIElJRkUgYXBwZW5kZWQgdG8gd2Vidmlldy9pbmRleC5qcy4KICogQWRkaXRpdmUgYW5kIHJlYWQtb25seSB3LnIudC4gYXBwIHN0YXRlOyBrZXllZCBvbiBzdGFibGUgQ1NTLW1vZHVsZSBjbGFzcwogKiBwcmVmaXhlcywgc28gaXQgZmFpbHMgc2FmZSAoY29udHJvbHMgc2ltcGx5IGRvIG5vdCBhcHBlYXIpIGlmIGEgcHJlZml4IG1vdmVzLgogKiBFeHBvc2VzIGl0cyBwdXJlIGZ1bmN0aW9ucyBmb3Igbm9kZSB1bml0IHRlc3RzOyBib290KClzIG9ubHkgaW4gYSByZWFsIHdlYnZpZXcuICovCihmdW5jdGlvbiAoKSB7CiAgInVzZSBzdHJpY3QiOwoKICB2YXIgQ09OVFJPTF9QUkVGSVggPSAiY2MtbWQtY29weSI7IC8vIGV2ZXJ5IGluamVjdGVkIG5vZGUncyBjbGFzcyBzdGFydHMgd2l0aCB0aGlzCiAgdmFyIFVTRVJfQlVCQkxFID0gJ1tjbGFzcyo9InVzZXJNZXNzYWdlQ29udGFpbmVyXyJdJzsKICAvLyBBc3Npc3RhbnQgbWVzc2FnZSB3cmFwcGVyLiBWZXJpZmllZCBvbiAyLjEuMTcwOiB0aGUgcmVuZGVyIGVtaXRzIGV4YWN0bHkgb25lCiAgLy8gYGRhdGEtdGVzdGlkPSJhc3Npc3RhbnQtbWVzc2FnZSJgIGRpdiBwZXIgYXNzaXN0YW50IHR1cm4sIHdpdGggdGhlIHJhdGluZwogIC8vIHdpZGdldCBhbmQgY29udGVudCBibG9ja3MgYXMgaXRzIGNoaWxkcmVuLiAoVGhlIGVhcmxpZXIgYFtkYXRhLW1lc3NhZ2UtcmF0aW5nXWAKICAvLyB3YXMgV1JPTkc6IHRoYXQgYXR0cmlidXRlIHNpdHMgb24gdGhlIG5lc3RlZCByYXRpbmcgY29udHJvbCwgd2hpY2ggaXMgYWxzbyBvbmx5CiAgLy8gcmVuZGVyZWQgYmVoaW5kIGFuIGV4cGVyaW1lbnQrYW5hbHl0aWNzIGdhdGUuKSBSZS1waW5uZWQgaW4gVGFzayA2LgogIHZhciBBU1NJU1RBTlRfQlVCQkxFID0gJ1tkYXRhLXRlc3RpZD0iYXNzaXN0YW50LW1lc3NhZ2UiXSc7CiAgdmFyIE1FU1NBR0VTX0NPTlRBSU5FUiA9ICdbY2xhc3MqPSJtZXNzYWdlc0NvbnRhaW5lcl8iXSc7IC8vIGUuZy4gJ1tjbGFzcyo9InRpbWVsaW5lXyJdJzsgIiIgLT4gb2JzZXJ2ZSBkb2N1bWVudC5ib2R5CiAgLy8gT3B0aW9uYWwgbmFycm93aW5nIG9ubHkuIE1VU1QgYmUgYSBzaW5nbGUgd3JhcHBlciBhcm91bmQgQUxMIGNvbnRlbnQgYmxvY2tzLAogIC8vIG5vdCBhIHBlci1ibG9jayBjbGFzcyAoYSB0dXJuIGhhcyBtdWx0aXBsZSBibG9ja3MpLiAiIiAtPiB1c2UgdGhlIGJ1YmJsZSBpdHNlbGYKICAvLyAoYWxyZWFkeSBhZ2dyZWdhdGVzIGFsbCBibG9ja3M7IHNhbml0aXplQ2xvbmUgaXMgdGhlIGNvcnJlY3RuZXNzIGdhdGUpLgogIHZhciBBU1NJU1RBTlRfQ09OVEVOVCA9ICIiOwogIHZhciBGRUVEQkFDS19NUyA9IDE4MDA7CgogIC8vIC0tLS0gSFRNTCAtPiBNYXJrZG93biAoRE9NIHdhbGspIC0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0KICAvLyBVc2VzIG9ubHk6IG5vZGVUeXBlLCB0YWdOYW1lLCBjaGlsZE5vZGVzLCB0ZXh0Q29udGVudCwgZ2V0QXR0cmlidXRlLCBjbGFzc05hbWUuCiAgZnVuY3Rpb24gaHRtbFRvTWFya2Rvd24ocm9vdCkgewogICAgZnVuY3Rpb24gaW5saW5lKG5vZGUpIHsKICAgICAgdmFyIG91dCA9ICIiOwogICAgICB2YXIga2lkcyA9IG5vZGUuY2hpbGROb2RlcyB8fCBbXTsKICAgICAgZm9yICh2YXIgaSA9IDA7IGkgPCBraWRzLmxlbmd0aDsgaSsrKSB7CiAgICAgICAgdmFyIGMgPSBraWRzW2ldOwogICAgICAgIGlmIChjLm5vZGVUeXBlID09PSAzKSB7IG91dCArPSBjLnRleHRDb250ZW50IHx8ICIiOyBjb250aW51ZTsgfQogICAgICAgIGlmIChjLm5vZGVUeXBlICE9PSAxKSBjb250aW51ZTsKICAgICAgICB2YXIgdGFnID0gKGMudGFnTmFtZSB8fCAiIikudG9VcHBlckNhc2UoKTsKICAgICAgICBpZiAodGFnID09PSAiQlIiKSBvdXQgKz0gIlxuIjsKICAgICAgICBlbHNlIGlmICh0YWcgPT09ICJTVFJPTkciIHx8IHRhZyA9PT0gIkIiKSBvdXQgKz0gIioqIiArIGlubGluZShjKSArICIqKiI7CiAgICAgICAgZWxzZSBpZiAodGFnID09PSAiRU0iIHx8IHRhZyA9PT0gIkkiKSBvdXQgKz0gIioiICsgaW5saW5lKGMpICsgIioiOwogICAgICAgIGVsc2UgaWYgKHRhZyA9PT0gIkRFTCIgfHwgdGFnID09PSAiUyIpIG91dCArPSAifn4iICsgaW5saW5lKGMpICsgIn5+IjsKICAgICAgICBlbHNlIGlmICh0YWcgPT09ICJDT0RFIikgb3V0ICs9ICJgIiArIChjLnRleHRDb250ZW50IHx8ICIiKSArICJgIjsKICAgICAgICBlbHNlIGlmICh0YWcgPT09ICJBIikgewogICAgICAgICAgdmFyIGhyZWYgPSBjLmdldEF0dHJpYnV0ZSA/IGMuZ2V0QXR0cmlidXRlKCJocmVmIikgOiBudWxsOwogICAgICAgICAgdmFyIHQgPSBpbmxpbmUoYyk7CiAgICAgICAgICBvdXQgKz0gaHJlZiA/ICJbIiArIHQgKyAiXSgiICsgaHJlZiArICIpIiA6IHQ7CiAgICAgICAgfSBlbHNlIG91dCArPSBpbmxpbmUoYyk7IC8vIHVua25vd24gaW5saW5lIHdyYXBwZXI6IGtlZXAgdGV4dCwgZHJvcCB0YWcKICAgICAgfQogICAgICByZXR1cm4gb3V0OwogICAgfQogICAgZnVuY3Rpb24gbGFuZ09mKGNvZGVFbCkgewogICAgICB2YXIgY2xzID0gIiI7CiAgICAgIGlmIChjb2RlRWwpIGNscyA9IChjb2RlRWwuZ2V0QXR0cmlidXRlICYmIGNvZGVFbC5nZXRBdHRyaWJ1dGUoImNsYXNzIikpIHx8IGNvZGVFbC5jbGFzc05hbWUgfHwgIiI7CiAgICAgIHZhciBtID0gL2xhbmd1YWdlLShbQS1aYS16MC05KyMuXC1dKykvLmV4ZWMoY2xzIHx8ICIiKTsKICAgICAgcmV0dXJuIG0gPyBtWzFdIDogIiI7CiAgICB9CiAgICBmdW5jdGlvbiBmaW5kQ2hpbGRUYWcobm9kZSwgdGFnKSB7CiAgICAgIHZhciBraWRzID0gbm9kZS5jaGlsZE5vZGVzIHx8IFtdOwogICAgICBmb3IgKHZhciBpID0gMDsgaSA8IGtpZHMubGVuZ3RoOyBpKyspIHsKICAgICAgICBpZiAoa2lkc1tpXS5ub2RlVHlwZSA9PT0gMSAmJiAoa2lkc1tpXS50YWdOYW1lIHx8ICIiKS50b1VwcGVyQ2FzZSgpID09PSB0YWcpIHJldHVybiBraWRzW2ldOwogICAgICB9CiAgICAgIHJldHVybiBudWxsOwogICAgfQogICAgZnVuY3Rpb24gbGlzdChub2RlLCBvcmRlcmVkLCBkZXB0aCkgewogICAgICB2YXIgb3V0ID0gIiIsIG4gPSAxOwogICAgICB2YXIga2lkcyA9IG5vZGUuY2hpbGROb2RlcyB8fCBbXTsKICAgICAgZm9yICh2YXIgaSA9IDA7IGkgPCBraWRzLmxlbmd0aDsgaSsrKSB7CiAgICAgICAgdmFyIGxpID0ga2lkc1tpXTsKICAgICAgICBpZiAobGkubm9kZVR5cGUgIT09IDEgfHwgKGxpLnRhZ05hbWUgfHwgIiIpLnRvVXBwZXJDYXNlKCkgIT09ICJMSSIpIGNvbnRpbnVlOwogICAgICAgIHZhciBtYXJrZXIgPSBvcmRlcmVkID8gbisrICsgIi4gIiA6ICItICI7CiAgICAgICAgdmFyIGluZGVudCA9IG5ldyBBcnJheShkZXB0aCArIDEpLmpvaW4oIiAgIik7CiAgICAgICAgdmFyIGxlYWQgPSAiIiwgbmVzdGVkID0gIiI7CiAgICAgICAgdmFyIGxrID0gbGkuY2hpbGROb2RlcyB8fCBbXTsKICAgICAgICBmb3IgKHZhciBqID0gMDsgaiA8IGxrLmxlbmd0aDsgaisrKSB7CiAgICAgICAgICB2YXIgY2ggPSBsa1tqXTsKICAgICAgICAgIHZhciBjdCA9IGNoLm5vZGVUeXBlID09PSAxID8gKGNoLnRhZ05hbWUgfHwgIiIpLnRvVXBwZXJDYXNlKCkgOiAiIjsKICAgICAgICAgIGlmIChjdCA9PT0gIlVMIikgbmVzdGVkICs9IGxpc3QoY2gsIGZhbHNlLCBkZXB0aCArIDEpOwogICAgICAgICAgZWxzZSBpZiAoY3QgPT09ICJPTCIpIG5lc3RlZCArPSBsaXN0KGNoLCB0cnVlLCBkZXB0aCArIDEpOwogICAgICAgICAgZWxzZSBpZiAoY2gubm9kZVR5cGUgPT09IDMpIGxlYWQgKz0gY2gudGV4dENvbnRlbnQgfHwgIiI7CiAgICAgICAgICBlbHNlIGxlYWQgKz0gaW5saW5lKGNoKTsKICAgICAgICB9CiAgICAgICAgb3V0ICs9IGluZGVudCArIG1hcmtlciArIGxlYWQudHJpbSgpICsgIlxuIiArIG5lc3RlZDsKICAgICAgfQogICAgICByZXR1cm4gb3V0OwogICAgfQogICAgZnVuY3Rpb24gdGFibGUobm9kZSkgewogICAgICB2YXIgcm93cyA9IFtdOwogICAgICAoZnVuY3Rpb24gY29sbGVjdChjb250YWluZXIpIHsKICAgICAgICB2YXIga2lkcyA9IGNvbnRhaW5lci5jaGlsZE5vZGVzIHx8IFtdOwogICAgICAgIGZvciAodmFyIGkgPSAwOyBpIDwga2lkcy5sZW5ndGg7IGkrKykgewogICAgICAgICAgdmFyIGMgPSBraWRzW2ldOwogICAgICAgICAgaWYgKGMubm9kZVR5cGUgIT09IDEpIGNvbnRpbnVlOwogICAgICAgICAgdmFyIHQgPSAoYy50YWdOYW1lIHx8ICIiKS50b1VwcGVyQ2FzZSgpOwogICAgICAgICAgaWYgKHQgPT09ICJUSEVBRCIgfHwgdCA9PT0gIlRCT0RZIiB8fCB0ID09PSAiVEZPT1QiKSBjb2xsZWN0KGMpOwogICAgICAgICAgZWxzZSBpZiAodCA9PT0gIlRSIikgewogICAgICAgICAgICB2YXIgY2VsbHMgPSBbXSwgY2MgPSBjLmNoaWxkTm9kZXMgfHwgW107CiAgICAgICAgICAgIGZvciAodmFyIGogPSAwOyBqIDwgY2MubGVuZ3RoOyBqKyspIHsKICAgICAgICAgICAgICB2YXIgZCA9IGNjW2pdOwogICAgICAgICAgICAgIGlmIChkLm5vZGVUeXBlICE9PSAxKSBjb250aW51ZTsKICAgICAgICAgICAgICB2YXIgZHQgPSAoZC50YWdOYW1lIHx8ICIiKS50b1VwcGVyQ2FzZSgpOwogICAgICAgICAgICAgIGlmIChkdCA9PT0gIlRIIiB8fCBkdCA9PT0gIlREIikgY2VsbHMucHVzaChpbmxpbmUoZCkudHJpbSgpKTsKICAgICAgICAgICAgfQogICAgICAgICAgICByb3dzLnB1c2goY2VsbHMpOwogICAgICAgICAgfQogICAgICAgIH0KICAgICAgfSkobm9kZSk7CiAgICAgIGlmICghcm93cy5sZW5ndGgpIHJldHVybiAiIjsKICAgICAgdmFyIGhlYWQgPSByb3dzWzBdLCBib2R5ID0gcm93cy5zbGljZSgxKTsKICAgICAgdmFyIHNlcCA9IGhlYWQubWFwKGZ1bmN0aW9uICgpIHsgcmV0dXJuICItLS0iOyB9KTsKICAgICAgdmFyIG91dCA9ICJ8ICIgKyBoZWFkLmpvaW4oIiB8ICIpICsgIiB8XG58ICIgKyBzZXAuam9pbigiIHwgIikgKyAiIHxcbiI7CiAgICAgIGZvciAodmFyIGsgPSAwOyBrIDwgYm9keS5sZW5ndGg7IGsrKykgb3V0ICs9ICJ8ICIgKyBib2R5W2tdLmpvaW4oIiB8ICIpICsgIiB8XG4iOwogICAgICByZXR1cm4gb3V0OwogICAgfQogICAgZnVuY3Rpb24gYmxvY2sobm9kZSkgewogICAgICB2YXIgb3V0ID0gIiI7CiAgICAgIHZhciBraWRzID0gbm9kZS5jaGlsZE5vZGVzIHx8IFtdOwogICAgICBmb3IgKHZhciBpID0gMDsgaSA8IGtpZHMubGVuZ3RoOyBpKyspIHsKICAgICAgICB2YXIgYyA9IGtpZHNbaV07CiAgICAgICAgaWYgKGMubm9kZVR5cGUgPT09IDMpIHsgaWYgKChjLnRleHRDb250ZW50IHx8ICIiKS50cmltKCkpIG91dCArPSBjLnRleHRDb250ZW50OyBjb250aW51ZTsgfQogICAgICAgIGlmIChjLm5vZGVUeXBlICE9PSAxKSBjb250aW51ZTsKICAgICAgICB2YXIgdGFnID0gKGMudGFnTmFtZSB8fCAiIikudG9VcHBlckNhc2UoKTsKICAgICAgICBpZiAoL15IWzEtNl0kLy50ZXN0KHRhZykpIG91dCArPSBuZXcgQXJyYXkoK3RhZ1sxXSArIDEpLmpvaW4oIiMiKSArICIgIiArIGlubGluZShjKS50cmltKCkgKyAiXG5cbiI7CiAgICAgICAgZWxzZSBpZiAodGFnID09PSAiUCIpIG91dCArPSBpbmxpbmUoYykudHJpbSgpICsgIlxuXG4iOwogICAgICAgIGVsc2UgaWYgKHRhZyA9PT0gIlVMIikgb3V0ICs9IGxpc3QoYywgZmFsc2UsIDApICsgIlxuIjsKICAgICAgICBlbHNlIGlmICh0YWcgPT09ICJPTCIpIG91dCArPSBsaXN0KGMsIHRydWUsIDApICsgIlxuIjsKICAgICAgICBlbHNlIGlmICh0YWcgPT09ICJQUkUiKSB7CiAgICAgICAgICB2YXIgY29kZSA9IGZpbmRDaGlsZFRhZyhjLCAiQ09ERSIpOwogICAgICAgICAgdmFyIGxhbmcgPSBsYW5nT2YoY29kZSB8fCBjKTsKICAgICAgICAgIHZhciBib2R5ID0gKGNvZGUgfHwgYykudGV4dENvbnRlbnQgfHwgIiI7CiAgICAgICAgICBvdXQgKz0gImBgYCIgKyBsYW5nICsgIlxuIiArIGJvZHkucmVwbGFjZSgvXG4kLywgIiIpICsgIlxuYGBgXG5cbiI7CiAgICAgICAgfSBlbHNlIGlmICh0YWcgPT09ICJCTE9DS1FVT1RFIikgewogICAgICAgICAgdmFyIGlubmVyID0gYmxvY2soYykudHJpbSgpLnNwbGl0KCJcbiIpLm1hcChmdW5jdGlvbiAobCkgeyByZXR1cm4gIj4gIiArIGw7IH0pLmpvaW4oIlxuIik7CiAgICAgICAgICBvdXQgKz0gaW5uZXIgKyAiXG5cbiI7CiAgICAgICAgfSBlbHNlIGlmICh0YWcgPT09ICJIUiIpIG91dCArPSAiLS0tXG5cbiI7CiAgICAgICAgZWxzZSBpZiAodGFnID09PSAiVEFCTEUiKSBvdXQgKz0gdGFibGUoYykgKyAiXG4iOwogICAgICAgIGVsc2UgaWYgKHRhZyA9PT0gIkJSIikgb3V0ICs9ICJcbiI7CiAgICAgICAgZWxzZSBpZiAodGFnID09PSAiU1RST05HIiB8fCB0YWcgPT09ICJCIiB8fCB0YWcgPT09ICJFTSIgfHwgdGFnID09PSAiSSIgfHwKICAgICAgICAgICAgICAgICB0YWcgPT09ICJBIiB8fCB0YWcgPT09ICJDT0RFIiB8fCB0YWcgPT09ICJERUwiIHx8IHRhZyA9PT0gIlMiKQogICAgICAgICAgb3V0ICs9IGlubGluZShjKSArICJcblxuIjsKICAgICAgICBlbHNlIG91dCArPSBibG9jayhjKTsgLy8gdW5rbm93biB3cmFwcGVyOiByZWN1cnNlIChkcm9wIHRhZywga2VlcCBjb250ZW50KQogICAgICB9CiAgICAgIHJldHVybiBvdXQ7CiAgICB9CiAgICAvLyBibG9jaygpIGRpc3BhdGNoZXMgb24gZWFjaCBDSElMRCdzIHRhZywgdHJlYXRpbmcgdGhlIHBhc3NlZCBub2RlIGFzIGEgcGxhaW4KICAgIC8vIGNvbnRhaW5lci4gV3JhcCByb290IGluIGEgb25lLW9mZiBjb250YWluZXIgc28gcm9vdCdzIE9XTiB0YWcgaXMgZGlzcGF0Y2hlZAogICAgLy8gdG9vOiBjYWxsZXJzIHBhc3MgZWl0aGVyIHRoZSBidWJibGUgY29udGFpbmVyIChpdHMgYmxvY2sgY2hpbGRyZW4gcmVuZGVyKSBvcgogICAgLy8gYSBzaW5nbGUgYmxvY2sgZWxlbWVudCBsaWtlIDxwcmU+Lzx1bD4vPHRhYmxlPiAobm93IGhhbmRsZWQsIG5vdCBmbGF0dGVuZWQpLgogICAgcmV0dXJuIGJsb2NrKHsgY2hpbGROb2RlczogW3Jvb3RdIH0pLnJlcGxhY2UoL1xuezMsfS9nLCAiXG5cbiIpLnRyaW0oKTsKICB9CgogIC8vIC0tLS0gcHVyZSBoZWxwZXJzIC0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0KICBmdW5jdGlvbiBoYXNQcmVmaXgobm9kZSwgcHJlZml4KSB7CiAgICBpZiAobm9kZS5ub2RlVHlwZSAhPT0gMSB8fCB0eXBlb2Ygbm9kZS5jbGFzc05hbWUgIT09ICJzdHJpbmciKSByZXR1cm4gZmFsc2U7CiAgICB2YXIgcGFydHMgPSBub2RlLmNsYXNzTmFtZS5zcGxpdCgvXHMrLyk7CiAgICBmb3IgKHZhciBpID0gMDsgaSA8IHBhcnRzLmxlbmd0aDsgaSsrKSBpZiAocGFydHNbaV0uaW5kZXhPZihwcmVmaXgpID09PSAwKSByZXR1cm4gdHJ1ZTsKICAgIHJldHVybiBmYWxzZTsKICB9CgogIC8vIENsYXNzLXByZWZpeCBob29rcyBmb3Igbm9uLWNvbnRlbnQgY2hyb21lIHRoYXQgcmVuZGVycyAqaW5zaWRlKiBhbiBhc3Npc3RhbnQKICAvLyBidWJibGUgKHZlcmlmaWVkIG9uIDIuMS4xNzA7IFRhc2sgNiByZS1waW5zIHRoZXNlKS4gdG9vbCovdGhpbmtpbmdfIGFyZSB0aGUgdjEKICAvLyBleGNsdXNpb25zOyB1bmtub3duQ29udGVudF8gaXMgdGhlIHJlbmRlcmVyJ3MgZmFsbGJhY2sgZm9yIHVucmVjb2duaXplZCBibG9jawogIC8vIHR5cGVzLCBzbyBzdHJpcHBpbmcgaXQgbWFrZXMgYSAqZnV0dXJlKiBibG9jayB0eXBlIGZhaWwgc2FmZSB0byBleGNsdWRlZCByYXRoZXIKICAvLyB0aGFuIGxlYWtpbmcgIlVuc3VwcG9ydGVkIGNvbnRlbnQiIGludG8gdGhlIGNvcHkuIFJlLXBpbiBpZiBhIHByZWZpeCBtb3Zlcy4KICB2YXIgQ0hST01FX1BSRUZJWEVTID0gWyJ0b29sVXNlXyIsICJ0b29sUmVzdWx0XyIsICJ0b29sUmVmZXJlbmNlXyIsICJ0aGlua2luZ18iLCAidW5rbm93bkNvbnRlbnRfIl07CgogIC8vIFRydWUgZm9yIGFueSBub2RlIHRoYXQgbXVzdCBuZXZlciBhcHBlYXIgaW4gY29waWVkIG91dHB1dDogb3VyIG93biBjb250cm9scywKICAvLyB0aGUgcmF0aW5nIHdpZGdldCAoYGRhdGEtbWVzc2FnZS1yYXRpbmdgICsgaXRzICJUaGFua3MgZm9yIHlvdXIgZmVlZGJhY2siCiAgLy8gdGV4dCksIGFueSBidXR0b24gKGNvcHktY29kZSBjaHJvbWUpLCBhbmQgdGhlIGV4Y2x1ZGVkIGNvbnRlbnQgYmxvY2tzIGFib3ZlLgogIGZ1bmN0aW9uIGlzQ2hyb21lKG5vZGUpIHsKICAgIGlmIChub2RlLm5vZGVUeXBlICE9PSAxKSByZXR1cm4gZmFsc2U7CiAgICBpZiAoKG5vZGUudGFnTmFtZSB8fCAiIikudG9VcHBlckNhc2UoKSA9PT0gIkJVVFRPTiIpIHJldHVybiB0cnVlOwogICAgaWYgKG5vZGUuZ2V0QXR0cmlidXRlICYmIG5vZGUuZ2V0QXR0cmlidXRlKCJkYXRhLW1lc3NhZ2UtcmF0aW5nIikgIT09IG51bGwpIHJldHVybiB0cnVlOwogICAgaWYgKGhhc1ByZWZpeChub2RlLCBDT05UUk9MX1BSRUZJWCkpIHJldHVybiB0cnVlOwogICAgZm9yICh2YXIgaSA9IDA7IGkgPCBDSFJPTUVfUFJFRklYRVMubGVuZ3RoOyBpKyspIGlmIChoYXNQcmVmaXgobm9kZSwgQ0hST01FX1BSRUZJWEVTW2ldKSkgcmV0dXJuIHRydWU7CiAgICByZXR1cm4gZmFsc2U7CiAgfQoKICAvLyBEZWVwLWNsb25lIGBjb250ZW50Tm9kZWAsIHRoZW4gc3RyaXAgZXZlcnkgY2hyb21lIG5vZGUgc28gY29waWVkIG91dHB1dCBpcyB0aGUKICAvLyBtZXNzYWdlJ3MgdGV4dCBjb250ZW50IG9ubHkuIFRoaXMgaXMgYSBDT1JSRUNUTkVTUyBHQVRFLCBub3QgY29zbWV0aWM6IHRoZQogIC8vIGRlZmF1bHQgY29udGVudCBub2RlIGlzIHRoZSB3aG9sZSBidWJibGUgKGFsbCBjb250ZW50LWJsb2NrIHNpYmxpbmdzLCBzbyBtdWx0aS0KICAvLyBibG9jayBhc3Npc3RhbnQgdHVybnMgYXJlIGNhcHR1cmVkKSwgYW5kIHRoaXMgc3RyaXAtbGlzdCBpcyB0aGUgb25seSB0aGluZwogIC8vIGtlZXBpbmcgdGhlIHJhdGluZyB3aWRnZXQgYW5kIHYxLWV4Y2x1ZGVkIGJsb2NrcyBvdXQgb2YgdGhlIGNvcHkuCiAgZnVuY3Rpb24gc2FuaXRpemVDbG9uZShjb250ZW50Tm9kZSkgewogICAgdmFyIGNsb25lID0gY29udGVudE5vZGUuY2xvbmVOb2RlKHRydWUpOwogICAgKGZ1bmN0aW9uIHN0cmlwKG5vZGUpIHsKICAgICAgdmFyIGtpZHMgPSAobm9kZS5jaGlsZE5vZGVzIHx8IFtdKS5zbGljZSgpOwogICAgICBmb3IgKHZhciBpID0gMDsgaSA8IGtpZHMubGVuZ3RoOyBpKyspIHsKICAgICAgICB2YXIgYyA9IGtpZHNbaV07CiAgICAgICAgaWYgKGMubm9kZVR5cGUgPT09IDEgJiYgaXNDaHJvbWUoYykpIHsgbm9kZS5yZW1vdmVDaGlsZChjKTsgY29udGludWU7IH0KICAgICAgICBpZiAoYy5ub2RlVHlwZSA9PT0gMSkgc3RyaXAoYyk7CiAgICAgIH0KICAgIH0pKGNsb25lKTsKICAgIHJldHVybiBjbG9uZTsKICB9CgogIGZ1bmN0aW9uIGNsYXNzaWZ5QnViYmxlKG5vZGUpIHsKICAgIGlmIChub2RlLm5vZGVUeXBlICE9PSAxKSByZXR1cm4gbnVsbDsKICAgIGlmIChoYXNQcmVmaXgobm9kZSwgInVzZXJNZXNzYWdlQ29udGFpbmVyXyIpKSByZXR1cm4gInVzZXIiOwogICAgaWYgKG5vZGUuZ2V0QXR0cmlidXRlICYmIG5vZGUuZ2V0QXR0cmlidXRlKCJkYXRhLXRlc3RpZCIpID09PSAiYXNzaXN0YW50LW1lc3NhZ2UiKSByZXR1cm4gImFzc2lzdGFudCI7CiAgICByZXR1cm4gbnVsbDsKICB9CgogIC8vIEJ1aWxkIHRoZSB3aG9sZS1jb252ZXJzYXRpb24gbWFya2Rvd24gZnJvbSBhbiBvcmRlcmVkIGxpc3Qgb2YgYnViYmxlcy4KICAvLyBgY29udGVudE9mKGJ1YmJsZSlgIHJlc29sdmVzIHRoZSBjb250ZW50IG5vZGUgKGRlZmF1bHQ6IHRoZSBidWJibGUgaXRzZWxmLCBzbwogIC8vIGV2ZXJ5IGNvbnRlbnQgYmxvY2sgaXMgaW5jbHVkZWQ7IHNhbml0aXplQ2xvbmUgZHJvcHMgY2hyb21lKTsgYSBkZWZhdWx0IGlzCiAgLy8gcHJvdmlkZWQgZm9yIHRlc3RzLgogIGZ1bmN0aW9uIGNvbnZlcnNhdGlvblRvTWFya2Rvd24oYnViYmxlcywgY29udGVudE9mKSB7CiAgICBjb250ZW50T2YgPSBjb250ZW50T2YgfHwgZnVuY3Rpb24gKGIpIHsgcmV0dXJuIGI7IH07CiAgICB2YXIgcGFydHMgPSBbXTsKICAgIGZvciAodmFyIGkgPSAwOyBpIDwgYnViYmxlcy5sZW5ndGg7IGkrKykgewogICAgICB2YXIgcm9sZSA9IGNsYXNzaWZ5QnViYmxlKGJ1YmJsZXNbaV0pOwogICAgICBpZiAoIXJvbGUpIGNvbnRpbnVlOwogICAgICB2YXIgY2xlYW4gPSBzYW5pdGl6ZUNsb25lKGNvbnRlbnRPZihidWJibGVzW2ldKSk7CiAgICAgIHZhciBib2R5ID0gcm9sZSA9PT0gImFzc2lzdGFudCIgPyBodG1sVG9NYXJrZG93bihjbGVhbikgOiAoY2xlYW4udGV4dENvbnRlbnQgfHwgIiIpLnRyaW0oKTsKICAgICAgaWYgKCFib2R5KSBjb250aW51ZTsKICAgICAgcGFydHMucHVzaCgocm9sZSA9PT0gInVzZXIiID8gIiMjIFVzZXIiIDogIiMjIEFzc2lzdGFudCIpICsgIlxuXG4iICsgYm9keSk7CiAgICB9CiAgICByZXR1cm4gcGFydHMuam9pbigiXG5cbiIpICsgKHBhcnRzLmxlbmd0aCA/ICJcbiIgOiAiIik7CiAgfQoKICAvLyAtLS0tIGV4cG9ydHMgKG5vZGUgdGVzdHMpIC8gYm9vdCAocmVhbCB3ZWJ2aWV3KSAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tCiAgaWYgKHR5cGVvZiBkb2N1bWVudCAhPT0gInVuZGVmaW5lZCIpIHsKICAgIGJvb3QoKTsKICB9IGVsc2UgaWYgKHR5cGVvZiBtb2R1bGUgIT09ICJ1bmRlZmluZWQiICYmIG1vZHVsZS5leHBvcnRzKSB7CiAgICBtb2R1bGUuZXhwb3J0cyA9IHsgaHRtbFRvTWFya2Rvd246IGh0bWxUb01hcmtkb3duLCBzYW5pdGl6ZUNsb25lOiBzYW5pdGl6ZUNsb25lLAogICAgICAgICAgICAgICAgICAgICAgIGNsYXNzaWZ5QnViYmxlOiBjbGFzc2lmeUJ1YmJsZSwgY29udmVyc2F0aW9uVG9NYXJrZG93bjogY29udmVyc2F0aW9uVG9NYXJrZG93biB9OwogIH0KCiAgLy8gLS0tLSBsaXZlLXdlYnZpZXcgd2lyaW5nIChydW5zIG9ubHkgd2hlbiBhIGRvY3VtZW50IGV4aXN0cykgLS0tLS0tLS0tLS0tLS0tLQogIGZ1bmN0aW9uIHFzKG5vZGUsIHNlbCkgeyB0cnkgeyByZXR1cm4gc2VsICYmIG5vZGUucXVlcnlTZWxlY3RvciA/IG5vZGUucXVlcnlTZWxlY3RvcihzZWwpIDogbnVsbDsgfSBjYXRjaCAoXykgeyByZXR1cm4gbnVsbDsgfSB9CiAgZnVuY3Rpb24gcXNhKHNlbCkgeyB0cnkgeyByZXR1cm4gQXJyYXkucHJvdG90eXBlLnNsaWNlLmNhbGwoZG9jdW1lbnQucXVlcnlTZWxlY3RvckFsbChzZWwpKTsgfSBjYXRjaCAoXykgeyByZXR1cm4gW107IH0gfQoKICAvLyBUaGUgY29udGVudCBub2RlIHRvIGNvbnZlcnQvY29weTogdGhlIG9wdGlvbmFsIEFTU0lTVEFOVF9DT05URU5UIHdyYXBwZXIgaWYKICAvLyBwaW5uZWQgYW5kIHByZXNlbnQsIGVsc2UgdGhlIGJ1YmJsZSBpdHNlbGYuIFRoZSBidWJibGUgYWxyZWFkeSBjb250YWlucyBldmVyeQogIC8vIGNvbnRlbnQtYmxvY2sgc2libGluZyBvZiBhIG11bHRpLWJsb2NrIHR1cm4sIGFuZCBzYW5pdGl6ZUNsb25lIHN0cmlwcyB0aGUKICAvLyBjaHJvbWUgKHJhdGluZyB3aWRnZXQsIHRvb2wvdGhpbmtpbmcvdW5rbm93biBibG9ja3MsIGJ1dHRvbnMsIG91ciBjb250cm9scykKICAvLyBlaXRoZXIgd2F5IC0tIHNvIHRoaXMgaXMgYSBuYXJyb3dpbmcsIG5ldmVyIHRoZSB0aGluZyB0aGF0IGd1YXJhbnRlZXMKICAvLyBjb3JyZWN0bmVzcy4KICBmdW5jdGlvbiBjb250ZW50Tm9kZU9mKGJ1YmJsZSwgcm9sZSkgewogICAgaWYgKHJvbGUgPT09ICJhc3Npc3RhbnQiICYmIEFTU0lTVEFOVF9DT05URU5UKSB7CiAgICAgIHZhciBuID0gcXMoYnViYmxlLCBBU1NJU1RBTlRfQ09OVEVOVCk7CiAgICAgIGlmIChuKSByZXR1cm4gbjsKICAgIH0KICAgIHJldHVybiBidWJibGU7CiAgfQoKICBmdW5jdGlvbiBjb3B5VGV4dCh0ZXh0KSB7CiAgICB0cnkgewogICAgICBpZiAobmF2aWdhdG9yLmNsaXBib2FyZCAmJiBuYXZpZ2F0b3IuY2xpcGJvYXJkLndyaXRlVGV4dCkgcmV0dXJuIG5hdmlnYXRvci5jbGlwYm9hcmQud3JpdGVUZXh0KHRleHQpOwogICAgfSBjYXRjaCAoXykge30KICAgIHJldHVybiBQcm9taXNlLnJlc29sdmUoKTsgLy8gYmVzdC1lZmZvcnQ7IG5ldmVyIHRocm93IGludG8gdGhlIGFwcAogIH0KCiAgZnVuY3Rpb24gZmxhc2hGZWVkYmFjayhob3N0KSB7CiAgICB0cnkgewogICAgICB2YXIgZmIgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCJzcGFuIik7CiAgICAgIGZiLmNsYXNzTmFtZSA9IENPTlRST0xfUFJFRklYICsgIi1mZWVkYmFjayI7CiAgICAgIGZiLnRleHRDb250ZW50ID0gIkNvcGllZCI7CiAgICAgIGhvc3QuYXBwZW5kQ2hpbGQoZmIpOwogICAgICBzZXRUaW1lb3V0KGZ1bmN0aW9uICgpIHsgaWYgKGZiICYmIGZiLnBhcmVudE5vZGUpIGZiLnBhcmVudE5vZGUucmVtb3ZlQ2hpbGQoZmIpOyB9LCBGRUVEQkFDS19NUyk7CiAgICB9IGNhdGNoIChfKSB7fQogIH0KCiAgZnVuY3Rpb24gYnViYmxlTWFya2Rvd24oYnViYmxlLCByb2xlKSB7CiAgICB2YXIgY2xlYW4gPSBzYW5pdGl6ZUNsb25lKGNvbnRlbnROb2RlT2YoYnViYmxlLCByb2xlKSk7CiAgICByZXR1cm4gcm9sZSA9PT0gImFzc2lzdGFudCIgPyBodG1sVG9NYXJrZG93bihjbGVhbikgOiAoY2xlYW4udGV4dENvbnRlbnQgfHwgIiIpLnRyaW0oKTsKICB9CiAgZnVuY3Rpb24gYnViYmxlUGxhaW4oYnViYmxlLCByb2xlKSB7CiAgICByZXR1cm4gKHNhbml0aXplQ2xvbmUoY29udGVudE5vZGVPZihidWJibGUsIHJvbGUpKS50ZXh0Q29udGVudCB8fCAiIikudHJpbSgpOwogIH0KCiAgLy8gQnVpbGQgYSBzaW5nbGUgY29udHJvbDogYSBwcmltYXJ5ICJDb3B5IiAobWFya2Rvd24pIHBsdXMgYSBzbWFsbCBjYXJldCB0aGF0CiAgLy8gdG9nZ2xlcyBhIG1lbnUgd2l0aCAiQ29weSBhcyBwbGFpbiB0ZXh0Ii4gQWxsIG5vZGVzIGNhcnJ5IHRoZSBDT05UUk9MX1BSRUZJWAogIC8vIGNsYXNzIHNvIHNhbml0aXplQ2xvbmUgcmVtb3ZlcyB0aGVtIGZyb20gYW55IGNvcGllZCBjb250ZW50LgogIGZ1bmN0aW9uIGJ1aWxkQ29udHJvbChvbk1hcmtkb3duLCBvblBsYWluKSB7CiAgICB2YXIgd3JhcCA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoInNwYW4iKTsKICAgIHdyYXAuY2xhc3NOYW1lID0gQ09OVFJPTF9QUkVGSVg7CiAgICB2YXIgcHJpbWFyeSA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoImJ1dHRvbiIpOwogICAgcHJpbWFyeS50eXBlID0gImJ1dHRvbiI7CiAgICBwcmltYXJ5LmNsYXNzTmFtZSA9IENPTlRST0xfUFJFRklYICsgIi1idG4iOwogICAgcHJpbWFyeS50aXRsZSA9ICJDb3B5IGFzIE1hcmtkb3duIjsKICAgIHByaW1hcnkudGV4dENvbnRlbnQgPSAiQ29weSI7CiAgICBwcmltYXJ5LmFkZEV2ZW50TGlzdGVuZXIoImNsaWNrIiwgZnVuY3Rpb24gKGUpIHsgZS5zdG9wUHJvcGFnYXRpb24oKTsgb25NYXJrZG93bihwcmltYXJ5KTsgfSk7CiAgICB2YXIgY2FyZXQgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCJidXR0b24iKTsKICAgIGNhcmV0LnR5cGUgPSAiYnV0dG9uIjsKICAgIGNhcmV0LmNsYXNzTmFtZSA9IENPTlRST0xfUFJFRklYICsgIi1jYXJldCI7CiAgICBjYXJldC50aXRsZSA9ICJDb3B5IG9wdGlvbnMiOwogICAgY2FyZXQudGV4dENvbnRlbnQgPSAi4pa+IjsgLy8gYmxhY2sgZG93bi1wb2ludGluZyBzbWFsbCB0cmlhbmdsZQogICAgdmFyIG1lbnUgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCJzcGFuIik7CiAgICBtZW51LmNsYXNzTmFtZSA9IENPTlRST0xfUFJFRklYICsgIi1tZW51IjsKICAgIG1lbnUuc3R5bGUuZGlzcGxheSA9ICJub25lIjsKICAgIHZhciBwbGFpbiA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoImJ1dHRvbiIpOwogICAgcGxhaW4udHlwZSA9ICJidXR0b24iOwogICAgcGxhaW4uY2xhc3NOYW1lID0gQ09OVFJPTF9QUkVGSVggKyAiLWJ0biI7CiAgICBwbGFpbi50ZXh0Q29udGVudCA9ICJDb3B5IGFzIHBsYWluIHRleHQiOwogICAgcGxhaW4uYWRkRXZlbnRMaXN0ZW5lcigiY2xpY2siLCBmdW5jdGlvbiAoZSkgeyBlLnN0b3BQcm9wYWdhdGlvbigpOyBtZW51LnN0eWxlLmRpc3BsYXkgPSAibm9uZSI7IG9uUGxhaW4ocGxhaW4pOyB9KTsKICAgIG1lbnUuYXBwZW5kQ2hpbGQocGxhaW4pOwogICAgY2FyZXQuYWRkRXZlbnRMaXN0ZW5lcigiY2xpY2siLCBmdW5jdGlvbiAoZSkgewogICAgICBlLnN0b3BQcm9wYWdhdGlvbigpOwogICAgICBtZW51LnN0eWxlLmRpc3BsYXkgPSBtZW51LnN0eWxlLmRpc3BsYXkgPT09ICJub25lIiA/ICJpbmxpbmUtYmxvY2siIDogIm5vbmUiOwogICAgfSk7CiAgICB3cmFwLmFwcGVuZENoaWxkKHByaW1hcnkpOwogICAgd3JhcC5hcHBlbmRDaGlsZChjYXJldCk7CiAgICB3cmFwLmFwcGVuZENoaWxkKG1lbnUpOwogICAgcmV0dXJuIHdyYXA7CiAgfQoKICBmdW5jdGlvbiBkZWNvcmF0ZShidWJibGUpIHsKICAgIHRyeSB7CiAgICAgIHZhciByb2xlID0gY2xhc3NpZnlCdWJibGUoYnViYmxlKTsKICAgICAgaWYgKCFyb2xlKSByZXR1cm47CiAgICAgIGlmIChxcyhidWJibGUsICIuIiArIENPTlRST0xfUFJFRklYKSkgcmV0dXJuOyAvLyBhbHJlYWR5IGRlY29yYXRlZAogICAgICB2YXIgY29udHJvbCA9IGJ1aWxkQ29udHJvbCgKICAgICAgICBmdW5jdGlvbiAoaG9zdCkgeyBjb3B5VGV4dChidWJibGVNYXJrZG93bihidWJibGUsIHJvbGUpKS50aGVuKGZ1bmN0aW9uICgpIHsgZmxhc2hGZWVkYmFjayhjb250cm9sKTsgfSk7IH0sCiAgICAgICAgZnVuY3Rpb24gKGhvc3QpIHsgY29weVRleHQoYnViYmxlUGxhaW4oYnViYmxlLCByb2xlKSkudGhlbihmdW5jdGlvbiAoKSB7IGZsYXNoRmVlZGJhY2soY29udHJvbCk7IH0pOyB9CiAgICAgICk7CiAgICAgIGJ1YmJsZS5hcHBlbmRDaGlsZChjb250cm9sKTsKICAgIH0gY2F0Y2ggKF8pIHt9CiAgfQoKICBmdW5jdGlvbiBjb3B5Q29udmVyc2F0aW9uKGZvcm1hdCkgewogICAgdmFyIGJ1YmJsZXMgPSBxc2EoVVNFUl9CVUJCTEUgKyAiLCIgKyBBU1NJU1RBTlRfQlVCQkxFKTsKICAgIGlmIChmb3JtYXQgPT09ICJ0ZXh0IikgewogICAgICB2YXIgbGluZXMgPSBbXTsKICAgICAgZm9yICh2YXIgaSA9IDA7IGkgPCBidWJibGVzLmxlbmd0aDsgaSsrKSB7CiAgICAgICAgdmFyIHJvbGUgPSBjbGFzc2lmeUJ1YmJsZShidWJibGVzW2ldKTsKICAgICAgICBpZiAoIXJvbGUpIGNvbnRpbnVlOwogICAgICAgIHZhciBib2R5ID0gYnViYmxlUGxhaW4oYnViYmxlc1tpXSwgcm9sZSk7CiAgICAgICAgaWYgKGJvZHkpIGxpbmVzLnB1c2goYm9keSk7CiAgICAgIH0KICAgICAgcmV0dXJuIGNvcHlUZXh0KGxpbmVzLmpvaW4oIlxuXG4iKSArIChsaW5lcy5sZW5ndGggPyAiXG4iIDogIiIpKTsKICAgIH0KICAgIHJldHVybiBjb3B5VGV4dChjb252ZXJzYXRpb25Ub01hcmtkb3duKGJ1YmJsZXMsIGZ1bmN0aW9uIChiKSB7CiAgICAgIHJldHVybiBjb250ZW50Tm9kZU9mKGIsIGNsYXNzaWZ5QnViYmxlKGIpKTsKICAgIH0pKTsKICB9CgogIGZ1bmN0aW9uIGluc3RhbGxDb252ZXJzYXRpb25Db250cm9sKCkgewogICAgdHJ5IHsKICAgICAgaWYgKHFzKGRvY3VtZW50LCAiLiIgKyBDT05UUk9MX1BSRUZJWCArICItY29udmVyc2F0aW9uIikpIHJldHVybjsKICAgICAgdmFyIGJhciA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoImRpdiIpOwogICAgICBiYXIuY2xhc3NOYW1lID0gQ09OVFJPTF9QUkVGSVggKyAiLWNvbnZlcnNhdGlvbiI7CiAgICAgIHZhciBjb250cm9sID0gYnVpbGRDb250cm9sKAogICAgICAgIGZ1bmN0aW9uICgpIHsgY29weUNvbnZlcnNhdGlvbigibWFya2Rvd24iKS50aGVuKGZ1bmN0aW9uICgpIHsgZmxhc2hGZWVkYmFjayhiYXIpOyB9KTsgfSwKICAgICAgICBmdW5jdGlvbiAoKSB7IGNvcHlDb252ZXJzYXRpb24oInRleHQiKS50aGVuKGZ1bmN0aW9uICgpIHsgZmxhc2hGZWVkYmFjayhiYXIpOyB9KTsgfQogICAgICApOwogICAgICBjb250cm9sLnRpdGxlID0gIkNvcHkgZW50aXJlIGNvbnZlcnNhdGlvbiI7CiAgICAgIGJhci5hcHBlbmRDaGlsZChjb250cm9sKTsKICAgICAgZG9jdW1lbnQuYm9keS5hcHBlbmRDaGlsZChiYXIpOyAvLyBmaXhlZC1wb3NpdGlvbiB2aWEgQ1NTOyBwbGFjZW1lbnQgcmVmaW5lZCBpbiBUYXNrIDYKICAgIH0gY2F0Y2ggKF8pIHt9CiAgfQoKICBmdW5jdGlvbiBzd2VlcCgpIHsgdmFyIGIgPSBxc2EoVVNFUl9CVUJCTEUgKyAiLCIgKyBBU1NJU1RBTlRfQlVCQkxFKTsgZm9yICh2YXIgaSA9IDA7IGkgPCBiLmxlbmd0aDsgaSsrKSBkZWNvcmF0ZShiW2ldKTsgfQoKICBmdW5jdGlvbiBib290KCkgewogICAgdHJ5IHsKICAgICAgdmFyIHRhcmdldCA9IChNRVNTQUdFU19DT05UQUlORVIgJiYgcXMoZG9jdW1lbnQsIE1FU1NBR0VTX0NPTlRBSU5FUikpIHx8IGRvY3VtZW50LmJvZHk7CiAgICAgIHN3ZWVwKCk7CiAgICAgIGluc3RhbGxDb252ZXJzYXRpb25Db250cm9sKCk7CiAgICAgIGlmICh0eXBlb2YgTXV0YXRpb25PYnNlcnZlciA9PT0gInVuZGVmaW5lZCIpIHJldHVybjsKICAgICAgdmFyIG9icyA9IG5ldyBNdXRhdGlvbk9ic2VydmVyKGZ1bmN0aW9uICgpIHsgc3dlZXAoKTsgfSk7CiAgICAgIG9icy5vYnNlcnZlKHRhcmdldCwgeyBjaGlsZExpc3Q6IHRydWUsIHN1YnRyZWU6IHRydWUgfSk7CiAgICB9IGNhdGNoIChfKSB7fQogIH0KfSkoKTsK").decode("utf-8") +INJECT_CSS = base64.b64decode("LmNjLW1kLWNvcHkgewogIGRpc3BsYXk6IGlubGluZS1mbGV4OwogIGFsaWduLWl0ZW1zOiBjZW50ZXI7CiAgZ2FwOiAycHg7CiAgdmVydGljYWwtYWxpZ246IG1pZGRsZTsKICBtYXJnaW4tbGVmdDogNnB4Owp9Ci5jYy1tZC1jb3B5LWJ0biwKLmNjLW1kLWNvcHktY2FyZXQgewogIGZvbnQ6IGluaGVyaXQ7CiAgZm9udC1zaXplOiAxMXB4OwogIGxpbmUtaGVpZ2h0OiAxLjQ7CiAgcGFkZGluZzogMXB4IDZweDsKICBjb2xvcjogdmFyKC0tdnNjb2RlLWZvcmVncm91bmQpOwogIGJhY2tncm91bmQ6IHRyYW5zcGFyZW50OwogIGJvcmRlcjogMXB4IHNvbGlkIHZhcigtLXZzY29kZS13aWRnZXQtYm9yZGVyLCB0cmFuc3BhcmVudCk7CiAgYm9yZGVyLXJhZGl1czogNHB4OwogIGN1cnNvcjogcG9pbnRlcjsKICBvcGFjaXR5OiAwLjY1Owp9Ci5jYy1tZC1jb3B5LWJ0bjpob3ZlciwKLmNjLW1kLWNvcHktY2FyZXQ6aG92ZXIgewogIG9wYWNpdHk6IDE7CiAgYmFja2dyb3VuZDogdmFyKC0tdnNjb2RlLXRvb2xiYXItaG92ZXJCYWNrZ3JvdW5kLCByZ2JhKDEyOCwgMTI4LCAxMjgsIDAuMTUpKTsKfQouY2MtbWQtY29weS1tZW51IHsKICBwb3NpdGlvbjogcmVsYXRpdmU7CiAgbWFyZ2luLWxlZnQ6IDRweDsKICBwYWRkaW5nOiAycHg7CiAgYmFja2dyb3VuZDogdmFyKC0tdnNjb2RlLW1lbnUtYmFja2dyb3VuZCwgdmFyKC0tdnNjb2RlLWVkaXRvcldpZGdldC1iYWNrZ3JvdW5kKSk7CiAgYm9yZGVyOiAxcHggc29saWQgdmFyKC0tdnNjb2RlLW1lbnUtYm9yZGVyLCB2YXIoLS12c2NvZGUtd2lkZ2V0LWJvcmRlciwgdHJhbnNwYXJlbnQpKTsKICBib3JkZXItcmFkaXVzOiA0cHg7CiAgei1pbmRleDogNTsKfQouY2MtbWQtY29weS1mZWVkYmFjayB7CiAgbWFyZ2luLWxlZnQ6IDZweDsKICBmb250LXNpemU6IDExcHg7CiAgb3BhY2l0eTogMC44NTsKICBjb2xvcjogdmFyKC0tdnNjb2RlLWZvcmVncm91bmQpOwp9Ci5jYy1tZC1jb3B5LWNvbnZlcnNhdGlvbiB7CiAgcG9zaXRpb246IGZpeGVkOwogIHJpZ2h0OiAxNnB4OwogIGJvdHRvbTogNTZweDsKICB6LWluZGV4OiAxMDsKICBwYWRkaW5nOiAycHg7CiAgYmFja2dyb3VuZDogdmFyKC0tdnNjb2RlLWVkaXRvcldpZGdldC1iYWNrZ3JvdW5kKTsKICBib3JkZXI6IDFweCBzb2xpZCB2YXIoLS12c2NvZGUtd2lkZ2V0LWJvcmRlciwgdHJhbnNwYXJlbnQpOwogIGJvcmRlci1yYWRpdXM6IDZweDsKICBvcGFjaXR5OiAwLjg1Owp9Ci5jYy1tZC1jb3B5LWNvbnZlcnNhdGlvbjpob3ZlciB7CiAgb3BhY2l0eTogMTsKfQo=").decode("utf-8") +# << the CSS block; everything else (index.js) -> the JS block.""" + return INJECT_CSS if path.endswith(".css") else INJECT_JS + + +def write_atomic_preserving_metadata(path, text): + st = os.stat(path) + directory = os.path.dirname(path) or "." + basename = os.path.basename(path) + fd, tmp = tempfile.mkstemp(prefix="." + basename + ".", suffix=".tmp", dir=directory) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="") as f: + f.write(text) + f.flush() + os.fsync(f.fileno()) + try: + os.chown(tmp, st.st_uid, st.st_gid) + except (AttributeError, PermissionError, OSError): + pass + shutil.copystat(path, tmp) + os.replace(tmp, path) + tmp = None + finally: + if tmp is not None: + try: + os.unlink(tmp) + except FileNotFoundError: + pass + + +def patch_file(path, payload): + with open(path, "r", encoding="utf-8", newline="") as f: + data = f.read() + if OPEN in data: + return "already-patched" + if not payload.strip(): + return "no-payload (run tools/gen-embeds)" + backup = path + BACKUP_SUFFIX + if not os.path.exists(backup): + with open(backup, "w", encoding="utf-8", newline="") as b: + b.write(data) + block = OPEN + "\n" + payload.rstrip("\n") + "\n" + CLOSE + write_atomic_preserving_metadata(path, data + "\n" + block + "\n") + return "PATCHED" + + +def revert_file(path): + with open(path, "r", encoding="utf-8", newline="") as f: + data = f.read() + if OPEN not in data: + return "not-patched" + write_atomic_preserving_metadata(path, _BLOCK_RE.sub("", data, count=1)) + return "REVERTED" + + +def siblings(js_path): + """Given a .../webview/index.js, also target the sibling index.css.""" + targets = [js_path] + css = os.path.join(os.path.dirname(js_path), "index.css") + if os.path.isfile(css): + targets.append(css) + return targets + + +def main(argv): + revert = "--revert" in argv + explicit = [a for a in argv if not a.startswith("--")] + if explicit: + # An explicit .../webview/index.js expands to its index.css sibling too, so + # `add-md-copy.py .../index.js` patches BOTH files (matching auto-discovery + # and the index.js+index.css architecture). An explicit index.css (or a + # webview dir, resolved by siblings() to js+css) stays as given. + targets = [] + for p in explicit: + rp = os.path.realpath(p) + if os.path.basename(rp) == "index.js": + targets.extend(siblings(rp)) + elif os.path.isdir(rp): + targets.extend(siblings(os.path.join(rp, "index.js"))) + else: + targets.append(rp) + seen = set() + targets = [t for t in targets if not (t in seen or seen.add(t))] + else: + targets = [] + for js in discover(): + targets.extend(siblings(js)) + if not targets: + print("No Claude Code extension webview found.") + return 1 + action = (lambda p: revert_file(p)) if revert else (lambda p: patch_file(p, payload_for(p))) + print(("Reverting" if revert else "Patching") + " %d file(s):\n" % len(targets)) + changed = 0 + for t in targets: + try: + status = action(t) + except OSError as e: + status = "ERROR: %s" % e + if status in ("PATCHED", "REVERTED"): + changed += 1 + print(" [%s] %s" % (status, t)) + print("\n%d file(s) changed." % changed) + if changed: + print('Reload the webview to apply: Command Palette -> "Developer: Reload Window".') + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/test_md_patcher.py b/tests/test_md_patcher.py new file mode 100644 index 0000000..bf6f9bc --- /dev/null +++ b/tests/test_md_patcher.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""add-md-copy.py: sentinel-block apply (idempotent), reverse-transform --revert, +composition with context-icon (revert leaves context-icon intact), metadata +preservation, and emergency .bak-md-copy snapshot.""" +import importlib.util +import os +import pathlib +import stat +import tempfile +import unittest + +REPO = pathlib.Path(__file__).resolve().parents[1] +ADD_MD = REPO / "fixes" / "markdown-copy-export" / "add-md-copy.py" + +OPEN = "/* cc-md-copy v1 */" +CLOSE = "/* /cc-md-copy v1 */" +CONTEXT_MARKED = ">=101)return null}/*ccwa-context-icon*/" + + +def load_mod(): + spec = importlib.util.spec_from_file_location("add_md_copy", ADD_MD) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def make_webview(td, js_body, css_body=".x{}\n"): + d = pathlib.Path(td) / ".vscode" / "extensions" / "anthropic.claude-code-test" / "webview" + d.mkdir(parents=True) + js = d / "index.js"; js.write_text(js_body, encoding="utf-8") + css = d / "index.css"; css.write_text(css_body, encoding="utf-8") + return js, css + + +class PatcherTests(unittest.TestCase): + def setUp(self): + self.mod = load_mod() + self.assertTrue(self.mod.INJECT_JS.strip(), "embed not generated; run tools/gen-embeds") + self.assertTrue(self.mod.INJECT_CSS.strip(), "embed not generated; run tools/gen-embeds") + + def test_apply_appends_sentinel_block_to_both_files(self): + with tempfile.TemporaryDirectory() as td: + js, css = make_webview(td, "console.log(1)\n") + self.assertEqual(self.mod.patch_file(str(js), self.mod.INJECT_JS), "PATCHED") + self.assertEqual(self.mod.patch_file(str(css), self.mod.INJECT_CSS), "PATCHED") + jt = js.read_text(encoding="utf-8") + self.assertIn(OPEN, jt) + self.assertIn(CLOSE, jt) + self.assertIn("cc-md-copy", jt) # the IIFE payload is present + self.assertTrue(jt.startswith("console.log(1)")) # original preserved + self.assertIn(OPEN, css.read_text(encoding="utf-8")) + + def test_apply_is_idempotent(self): + with tempfile.TemporaryDirectory() as td: + js, _ = make_webview(td, "x\n") + self.assertEqual(self.mod.patch_file(str(js), self.mod.INJECT_JS), "PATCHED") + first = js.read_text(encoding="utf-8") + self.assertEqual(self.mod.patch_file(str(js), self.mod.INJECT_JS), "already-patched") + self.assertEqual(js.read_text(encoding="utf-8"), first) + self.assertEqual(first.count(OPEN), 1) + + def test_revert_is_exact_reverse_transform(self): + with tempfile.TemporaryDirectory() as td: + original = "console.log(1)\n" + js, _ = make_webview(td, original) + self.mod.patch_file(str(js), self.mod.INJECT_JS) + self.assertEqual(self.mod.revert_file(str(js)), "REVERTED") + self.assertEqual(js.read_text(encoding="utf-8"), original) # byte-exact + + def test_revert_composes_leaving_context_icon_intact(self): + with tempfile.TemporaryDirectory() as td: + # a bundle already carrying a context-icon MARKED patch + original = f"head {CONTEXT_MARKED} tail\n" + js, _ = make_webview(td, original) + self.mod.patch_file(str(js), self.mod.INJECT_JS) + self.assertIn(CONTEXT_MARKED, js.read_text(encoding="utf-8")) + self.assertIn(OPEN, js.read_text(encoding="utf-8")) + self.mod.revert_file(str(js)) + after = js.read_text(encoding="utf-8") + self.assertEqual(after, original) # md-copy gone + self.assertIn(CONTEXT_MARKED, after) # context-icon untouched + + def test_emergency_backup_written_once_and_not_used_by_revert(self): + with tempfile.TemporaryDirectory() as td: + js, _ = make_webview(td, "orig\n") + self.mod.patch_file(str(js), self.mod.INJECT_JS) + bak = pathlib.Path(str(js) + self.mod.BACKUP_SUFFIX) + self.assertTrue(bak.exists()) + self.assertEqual(bak.read_text(encoding="utf-8"), "orig\n") + # corrupt the backup; revert must NOT consult it (reverse-transform only) + bak.write_text("GARBAGE", encoding="utf-8") + self.mod.revert_file(str(js)) + self.assertEqual(js.read_text(encoding="utf-8"), "orig\n") + + def test_explicit_index_js_arg_patches_css_sibling_too(self): + # `add-md-copy.py .../webview/index.js` must patch index.css as well, so an + # explicit path matches auto-discovery and the index.js+index.css design. + with tempfile.TemporaryDirectory() as td: + js, css = make_webview(td, "console.log(1)\n") + rc = self.mod.main([str(js)]) + self.assertEqual(rc, 0) + self.assertIn(OPEN, js.read_text(encoding="utf-8")) + self.assertIn(OPEN, css.read_text(encoding="utf-8")) # the sibling was patched + + def test_revert_is_marker_scoped_and_keeps_trailing_bytes(self): + # undo removes ONLY our OPEN..CLOSE block, even when foreign bytes follow + # CLOSE (a future second end-of-file append feature): marker-scoped removal, + # not truncate-to-EOF. + with tempfile.TemporaryDirectory() as td: + js, _ = make_webview(td, "orig\n") + self.mod.patch_file(str(js), self.mod.INJECT_JS) + with open(str(js), "a", encoding="utf-8") as f: + f.write("\n/* other-feature */\nlater\n") # bytes after our CLOSE + self.assertEqual(self.mod.revert_file(str(js)), "REVERTED") + after = js.read_text(encoding="utf-8") + self.assertNotIn(OPEN, after) # our block is gone + self.assertIn("/* other-feature */", after) # foreign bytes preserved + self.assertTrue(after.startswith("orig\n")) + + @unittest.skipIf(os.name == "nt", "POSIX mode bits") + def test_apply_preserves_file_mode(self): + with tempfile.TemporaryDirectory() as td: + js, _ = make_webview(td, "x\n") + js.chmod(0o640) + self.mod.patch_file(str(js), self.mod.INJECT_JS) + self.assertEqual(stat.S_IMODE(js.stat().st_mode), 0o640) + + +if __name__ == "__main__": + unittest.main() From b7f43b0014a17a6ec81d347fb304bea3bf313477 Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 09/15] feat(launcher): md-copy bash feature - multi-file reconcile (index.js + index.css) --- launcher/claudemax | 549 ++++++++++++++++++++++++++++++++++++++-- tests/test_reconcile.py | 88 ++++++- 2 files changed, 612 insertions(+), 25 deletions(-) diff --git a/launcher/claudemax b/launcher/claudemax index 9526333..0be4b1a 100755 --- a/launcher/claudemax +++ b/launcher/claudemax @@ -119,7 +119,8 @@ CC_RECONCILE="${CC_RECONCILE:-1}" # context-icon bundle patch: 0 leaves the webview's context-usage icon unpatched. CC_PATCH_CONTEXT_ICON="${CC_PATCH_CONTEXT_ICON:-1}" # (CC_THINKING_DISPLAY is handled above as DISPLAY_VALUE: summarized | omitted.) -# (CC_PATCH_MD_COPY is added by the markdown-copy-export fix in a later PR.) +# markdown copy/export bundle patch: 0 leaves the webview without the copy controls. +CC_PATCH_MD_COPY="${CC_PATCH_MD_COPY:-1}" # ============================================================================ # --- Optional customizations ------------------------------------------------ @@ -243,6 +244,7 @@ _cc_feature_enabled() { [ "$CC_WORKAROUNDS" != "0" ] || return 1 case "$1" in context-icon) [ "$CC_PATCH_CONTEXT_ICON" != "0" ] ;; + md-copy) [ "$CC_PATCH_MD_COPY" != "0" ] ;; *) return 1 ;; esac } @@ -285,35 +287,495 @@ _cc_undo_context_icon() { rm -f "$tmp" 2>/dev/null || true } -# Reconcile one target file. Registry (forward apply order): context-icon. -# md-copy appends its undo/apply calls here in its own PR. -_cc_reconcile_index_js() { - local f="$1" base patched tmpmeta - [ -f "$f" ] && [ -r "$f" ] || return 0 - - base="${f}.ccbase.$$" - patched="${f}.ccnew.$$" - - # Clean base C = current with every KNOWN feature undone, REVERSE order. - cp "$f" "$base" 2>/dev/null || { rm -f "$base" 2>/dev/null || true; return 0; } - _cc_undo_context_icon "$base" +# md-copy feature - a large IIFE appended to webview/index.js plus matching CSS +# appended to webview/index.css, each bracketed by the sentinel +# /* cc-md-copy v1 */ ... /* /cc-md-copy v1 */ (its ownership marker). apply +# appends the block at END-OF-FILE; undo removes exactly that OPEN..CLOSE block +# (marker-scoped, keeps any bytes after CLOSE), so it composes with context-icon +# (an in-place swap elsewhere in index.js) regardless of ordering. The payload below is GENERATED from +# fixes/markdown-copy-export/webview-inject.{js,css} by tools/gen-embeds; do not +# edit it by hand (CI drift check: tools/gen-embeds --check). +# >>>CCWA-MD-COPY-EMBED>>> (generated by tools/gen-embeds; do not edit) +_cc_md_copy_js() { cat <<'CCMDCOPYJS' +/* cc-md-copy: per-message and whole-conversation copy (markdown/plain) for the + * Claude Code VS Code webview. Self-contained IIFE appended to webview/index.js. + * Additive and read-only w.r.t. app state; keyed on stable CSS-module class + * prefixes, so it fails safe (controls simply do not appear) if a prefix moves. + * Exposes its pure functions for node unit tests; boot()s only in a real webview. */ +(function () { + "use strict"; + + var CONTROL_PREFIX = "cc-md-copy"; // every injected node's class starts with this + var USER_BUBBLE = '[class*="userMessageContainer_"]'; + // Assistant message wrapper. Verified on 2.1.170: the render emits exactly one + // `data-testid="assistant-message"` div per assistant turn, with the rating + // widget and content blocks as its children. (The earlier `[data-message-rating]` + // was WRONG: that attribute sits on the nested rating control, which is also only + // rendered behind an experiment+analytics gate.) Re-pinned in Task 6. + var ASSISTANT_BUBBLE = '[data-testid="assistant-message"]'; + var MESSAGES_CONTAINER = '[class*="messagesContainer_"]'; // e.g. '[class*="timeline_"]'; "" -> observe document.body + // Optional narrowing only. MUST be a single wrapper around ALL content blocks, + // not a per-block class (a turn has multiple blocks). "" -> use the bubble itself + // (already aggregates all blocks; sanitizeClone is the correctness gate). + var ASSISTANT_CONTENT = ""; + var FEEDBACK_MS = 1800; + + // ---- HTML -> Markdown (DOM walk) ------------------------------------------- + // Uses only: nodeType, tagName, childNodes, textContent, getAttribute, className. + function htmlToMarkdown(root) { + function inline(node) { + var out = ""; + var kids = node.childNodes || []; + for (var i = 0; i < kids.length; i++) { + var c = kids[i]; + if (c.nodeType === 3) { out += c.textContent || ""; continue; } + if (c.nodeType !== 1) continue; + var tag = (c.tagName || "").toUpperCase(); + if (tag === "BR") out += "\n"; + else if (tag === "STRONG" || tag === "B") out += "**" + inline(c) + "**"; + else if (tag === "EM" || tag === "I") out += "*" + inline(c) + "*"; + else if (tag === "DEL" || tag === "S") out += "~~" + inline(c) + "~~"; + else if (tag === "CODE") out += "`" + (c.textContent || "") + "`"; + else if (tag === "A") { + var href = c.getAttribute ? c.getAttribute("href") : null; + var t = inline(c); + out += href ? "[" + t + "](" + href + ")" : t; + } else out += inline(c); // unknown inline wrapper: keep text, drop tag + } + return out; + } + function langOf(codeEl) { + var cls = ""; + if (codeEl) cls = (codeEl.getAttribute && codeEl.getAttribute("class")) || codeEl.className || ""; + var m = /language-([A-Za-z0-9+#.\-]+)/.exec(cls || ""); + return m ? m[1] : ""; + } + function findChildTag(node, tag) { + var kids = node.childNodes || []; + for (var i = 0; i < kids.length; i++) { + if (kids[i].nodeType === 1 && (kids[i].tagName || "").toUpperCase() === tag) return kids[i]; + } + return null; + } + function list(node, ordered, depth) { + var out = "", n = 1; + var kids = node.childNodes || []; + for (var i = 0; i < kids.length; i++) { + var li = kids[i]; + if (li.nodeType !== 1 || (li.tagName || "").toUpperCase() !== "LI") continue; + var marker = ordered ? n++ + ". " : "- "; + var indent = new Array(depth + 1).join(" "); + var lead = "", nested = ""; + var lk = li.childNodes || []; + for (var j = 0; j < lk.length; j++) { + var ch = lk[j]; + var ct = ch.nodeType === 1 ? (ch.tagName || "").toUpperCase() : ""; + if (ct === "UL") nested += list(ch, false, depth + 1); + else if (ct === "OL") nested += list(ch, true, depth + 1); + else if (ch.nodeType === 3) lead += ch.textContent || ""; + else lead += inline(ch); + } + out += indent + marker + lead.trim() + "\n" + nested; + } + return out; + } + function table(node) { + var rows = []; + (function collect(container) { + var kids = container.childNodes || []; + for (var i = 0; i < kids.length; i++) { + var c = kids[i]; + if (c.nodeType !== 1) continue; + var t = (c.tagName || "").toUpperCase(); + if (t === "THEAD" || t === "TBODY" || t === "TFOOT") collect(c); + else if (t === "TR") { + var cells = [], cc = c.childNodes || []; + for (var j = 0; j < cc.length; j++) { + var d = cc[j]; + if (d.nodeType !== 1) continue; + var dt = (d.tagName || "").toUpperCase(); + if (dt === "TH" || dt === "TD") cells.push(inline(d).trim()); + } + rows.push(cells); + } + } + })(node); + if (!rows.length) return ""; + var head = rows[0], body = rows.slice(1); + var sep = head.map(function () { return "---"; }); + var out = "| " + head.join(" | ") + " |\n| " + sep.join(" | ") + " |\n"; + for (var k = 0; k < body.length; k++) out += "| " + body[k].join(" | ") + " |\n"; + return out; + } + function block(node) { + var out = ""; + var kids = node.childNodes || []; + for (var i = 0; i < kids.length; i++) { + var c = kids[i]; + if (c.nodeType === 3) { if ((c.textContent || "").trim()) out += c.textContent; continue; } + if (c.nodeType !== 1) continue; + var tag = (c.tagName || "").toUpperCase(); + if (/^H[1-6]$/.test(tag)) out += new Array(+tag[1] + 1).join("#") + " " + inline(c).trim() + "\n\n"; + else if (tag === "P") out += inline(c).trim() + "\n\n"; + else if (tag === "UL") out += list(c, false, 0) + "\n"; + else if (tag === "OL") out += list(c, true, 0) + "\n"; + else if (tag === "PRE") { + var code = findChildTag(c, "CODE"); + var lang = langOf(code || c); + var body = (code || c).textContent || ""; + out += "```" + lang + "\n" + body.replace(/\n$/, "") + "\n```\n\n"; + } else if (tag === "BLOCKQUOTE") { + var inner = block(c).trim().split("\n").map(function (l) { return "> " + l; }).join("\n"); + out += inner + "\n\n"; + } else if (tag === "HR") out += "---\n\n"; + else if (tag === "TABLE") out += table(c) + "\n"; + else if (tag === "BR") out += "\n"; + else if (tag === "STRONG" || tag === "B" || tag === "EM" || tag === "I" || + tag === "A" || tag === "CODE" || tag === "DEL" || tag === "S") + out += inline(c) + "\n\n"; + else out += block(c); // unknown wrapper: recurse (drop tag, keep content) + } + return out; + } + // block() dispatches on each CHILD's tag, treating the passed node as a plain + // container. Wrap root in a one-off container so root's OWN tag is dispatched + // too: callers pass either the bubble container (its block children render) or + // a single block element like
    /
      /
    (now handled, not flattened). + return block({ childNodes: [root] }).replace(/\n{3,}/g, "\n\n").trim(); + } + + // ---- pure helpers ---------------------------------------------------------- + function hasPrefix(node, prefix) { + if (node.nodeType !== 1 || typeof node.className !== "string") return false; + var parts = node.className.split(/\s+/); + for (var i = 0; i < parts.length; i++) if (parts[i].indexOf(prefix) === 0) return true; + return false; + } + + // Class-prefix hooks for non-content chrome that renders *inside* an assistant + // bubble (verified on 2.1.170; Task 6 re-pins these). tool*/thinking_ are the v1 + // exclusions; unknownContent_ is the renderer's fallback for unrecognized block + // types, so stripping it makes a *future* block type fail safe to excluded rather + // than leaking "Unsupported content" into the copy. Re-pin if a prefix moves. + var CHROME_PREFIXES = ["toolUse_", "toolResult_", "toolReference_", "thinking_", "unknownContent_"]; + + // True for any node that must never appear in copied output: our own controls, + // the rating widget (`data-message-rating` + its "Thanks for your feedback" + // text), any button (copy-code chrome), and the excluded content blocks above. + function isChrome(node) { + if (node.nodeType !== 1) return false; + if ((node.tagName || "").toUpperCase() === "BUTTON") return true; + if (node.getAttribute && node.getAttribute("data-message-rating") !== null) return true; + if (hasPrefix(node, CONTROL_PREFIX)) return true; + for (var i = 0; i < CHROME_PREFIXES.length; i++) if (hasPrefix(node, CHROME_PREFIXES[i])) return true; + return false; + } + + // Deep-clone `contentNode`, then strip every chrome node so copied output is the + // message's text content only. This is a CORRECTNESS GATE, not cosmetic: the + // default content node is the whole bubble (all content-block siblings, so multi- + // block assistant turns are captured), and this strip-list is the only thing + // keeping the rating widget and v1-excluded blocks out of the copy. + function sanitizeClone(contentNode) { + var clone = contentNode.cloneNode(true); + (function strip(node) { + var kids = (node.childNodes || []).slice(); + for (var i = 0; i < kids.length; i++) { + var c = kids[i]; + if (c.nodeType === 1 && isChrome(c)) { node.removeChild(c); continue; } + if (c.nodeType === 1) strip(c); + } + })(clone); + return clone; + } + + function classifyBubble(node) { + if (node.nodeType !== 1) return null; + if (hasPrefix(node, "userMessageContainer_")) return "user"; + if (node.getAttribute && node.getAttribute("data-testid") === "assistant-message") return "assistant"; + return null; + } + + // Build the whole-conversation markdown from an ordered list of bubbles. + // `contentOf(bubble)` resolves the content node (default: the bubble itself, so + // every content block is included; sanitizeClone drops chrome); a default is + // provided for tests. + function conversationToMarkdown(bubbles, contentOf) { + contentOf = contentOf || function (b) { return b; }; + var parts = []; + for (var i = 0; i < bubbles.length; i++) { + var role = classifyBubble(bubbles[i]); + if (!role) continue; + var clean = sanitizeClone(contentOf(bubbles[i])); + var body = role === "assistant" ? htmlToMarkdown(clean) : (clean.textContent || "").trim(); + if (!body) continue; + parts.push((role === "user" ? "## User" : "## Assistant") + "\n\n" + body); + } + return parts.join("\n\n") + (parts.length ? "\n" : ""); + } + + // ---- exports (node tests) / boot (real webview) ---------------------------- + if (typeof document !== "undefined") { + boot(); + } else if (typeof module !== "undefined" && module.exports) { + module.exports = { htmlToMarkdown: htmlToMarkdown, sanitizeClone: sanitizeClone, + classifyBubble: classifyBubble, conversationToMarkdown: conversationToMarkdown }; + } + + // ---- live-webview wiring (runs only when a document exists) ---------------- + function qs(node, sel) { try { return sel && node.querySelector ? node.querySelector(sel) : null; } catch (_) { return null; } } + function qsa(sel) { try { return Array.prototype.slice.call(document.querySelectorAll(sel)); } catch (_) { return []; } } + + // The content node to convert/copy: the optional ASSISTANT_CONTENT wrapper if + // pinned and present, else the bubble itself. The bubble already contains every + // content-block sibling of a multi-block turn, and sanitizeClone strips the + // chrome (rating widget, tool/thinking/unknown blocks, buttons, our controls) + // either way -- so this is a narrowing, never the thing that guarantees + // correctness. + function contentNodeOf(bubble, role) { + if (role === "assistant" && ASSISTANT_CONTENT) { + var n = qs(bubble, ASSISTANT_CONTENT); + if (n) return n; + } + return bubble; + } + + function copyText(text) { + try { + if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(text); + } catch (_) {} + return Promise.resolve(); // best-effort; never throw into the app + } + + function flashFeedback(host) { + try { + var fb = document.createElement("span"); + fb.className = CONTROL_PREFIX + "-feedback"; + fb.textContent = "Copied"; + host.appendChild(fb); + setTimeout(function () { if (fb && fb.parentNode) fb.parentNode.removeChild(fb); }, FEEDBACK_MS); + } catch (_) {} + } + + function bubbleMarkdown(bubble, role) { + var clean = sanitizeClone(contentNodeOf(bubble, role)); + return role === "assistant" ? htmlToMarkdown(clean) : (clean.textContent || "").trim(); + } + function bubblePlain(bubble, role) { + return (sanitizeClone(contentNodeOf(bubble, role)).textContent || "").trim(); + } + + // Build a single control: a primary "Copy" (markdown) plus a small caret that + // toggles a menu with "Copy as plain text". All nodes carry the CONTROL_PREFIX + // class so sanitizeClone removes them from any copied content. + function buildControl(onMarkdown, onPlain) { + var wrap = document.createElement("span"); + wrap.className = CONTROL_PREFIX; + var primary = document.createElement("button"); + primary.type = "button"; + primary.className = CONTROL_PREFIX + "-btn"; + primary.title = "Copy as Markdown"; + primary.textContent = "Copy"; + primary.addEventListener("click", function (e) { e.stopPropagation(); onMarkdown(primary); }); + var caret = document.createElement("button"); + caret.type = "button"; + caret.className = CONTROL_PREFIX + "-caret"; + caret.title = "Copy options"; + caret.textContent = "▾"; // black down-pointing small triangle + var menu = document.createElement("span"); + menu.className = CONTROL_PREFIX + "-menu"; + menu.style.display = "none"; + var plain = document.createElement("button"); + plain.type = "button"; + plain.className = CONTROL_PREFIX + "-btn"; + plain.textContent = "Copy as plain text"; + plain.addEventListener("click", function (e) { e.stopPropagation(); menu.style.display = "none"; onPlain(plain); }); + menu.appendChild(plain); + caret.addEventListener("click", function (e) { + e.stopPropagation(); + menu.style.display = menu.style.display === "none" ? "inline-block" : "none"; + }); + wrap.appendChild(primary); + wrap.appendChild(caret); + wrap.appendChild(menu); + return wrap; + } + + function decorate(bubble) { + try { + var role = classifyBubble(bubble); + if (!role) return; + if (qs(bubble, "." + CONTROL_PREFIX)) return; // already decorated + var control = buildControl( + function (host) { copyText(bubbleMarkdown(bubble, role)).then(function () { flashFeedback(control); }); }, + function (host) { copyText(bubblePlain(bubble, role)).then(function () { flashFeedback(control); }); } + ); + bubble.appendChild(control); + } catch (_) {} + } + + function copyConversation(format) { + var bubbles = qsa(USER_BUBBLE + "," + ASSISTANT_BUBBLE); + if (format === "text") { + var lines = []; + for (var i = 0; i < bubbles.length; i++) { + var role = classifyBubble(bubbles[i]); + if (!role) continue; + var body = bubblePlain(bubbles[i], role); + if (body) lines.push(body); + } + return copyText(lines.join("\n\n") + (lines.length ? "\n" : "")); + } + return copyText(conversationToMarkdown(bubbles, function (b) { + return contentNodeOf(b, classifyBubble(b)); + })); + } + + function installConversationControl() { + try { + if (qs(document, "." + CONTROL_PREFIX + "-conversation")) return; + var bar = document.createElement("div"); + bar.className = CONTROL_PREFIX + "-conversation"; + var control = buildControl( + function () { copyConversation("markdown").then(function () { flashFeedback(bar); }); }, + function () { copyConversation("text").then(function () { flashFeedback(bar); }); } + ); + control.title = "Copy entire conversation"; + bar.appendChild(control); + document.body.appendChild(bar); // fixed-position via CSS; placement refined in Task 6 + } catch (_) {} + } + + function sweep() { var b = qsa(USER_BUBBLE + "," + ASSISTANT_BUBBLE); for (var i = 0; i < b.length; i++) decorate(b[i]); } + + function boot() { + try { + var target = (MESSAGES_CONTAINER && qs(document, MESSAGES_CONTAINER)) || document.body; + sweep(); + installConversationControl(); + if (typeof MutationObserver === "undefined") return; + var obs = new MutationObserver(function () { sweep(); }); + obs.observe(target, { childList: true, subtree: true }); + } catch (_) {} + } +})(); +CCMDCOPYJS +} +_cc_md_copy_css() { cat <<'CCMDCOPYCSS' +.cc-md-copy { + display: inline-flex; + align-items: center; + gap: 2px; + vertical-align: middle; + margin-left: 6px; +} +.cc-md-copy-btn, +.cc-md-copy-caret { + font: inherit; + font-size: 11px; + line-height: 1.4; + padding: 1px 6px; + color: var(--vscode-foreground); + background: transparent; + border: 1px solid var(--vscode-widget-border, transparent); + border-radius: 4px; + cursor: pointer; + opacity: 0.65; +} +.cc-md-copy-btn:hover, +.cc-md-copy-caret:hover { + opacity: 1; + background: var(--vscode-toolbar-hoverBackground, rgba(128, 128, 128, 0.15)); +} +.cc-md-copy-menu { + position: relative; + margin-left: 4px; + padding: 2px; + background: var(--vscode-menu-background, var(--vscode-editorWidget-background)); + border: 1px solid var(--vscode-menu-border, var(--vscode-widget-border, transparent)); + border-radius: 4px; + z-index: 5; +} +.cc-md-copy-feedback { + margin-left: 6px; + font-size: 11px; + opacity: 0.85; + color: var(--vscode-foreground); +} +.cc-md-copy-conversation { + position: fixed; + right: 16px; + bottom: 56px; + z-index: 10; + padding: 2px; + background: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-widget-border, transparent); + border-radius: 6px; + opacity: 0.85; +} +.cc-md-copy-conversation:hover { + opacity: 1; +} +CCMDCOPYCSS +} +# <</dev/null; } + +# Append our sentinel block (byte-identical to the node/python deliveries): +# "\n" + OPEN + "\n" + PAYLOAD + "\n" + CLOSE + "\n" +_cc_apply_md_copy() { # $1=file $2=js|css + local f="$1" kind="$2" tmp + _cc_md_copy_has "$f" && return 0 # already applied + tmp="${f}.ccmdapply.$$" + if { cat "$f" \ + && printf '\n/* cc-md-copy v1 */\n' \ + && { if [ "$kind" = css ]; then _cc_md_copy_css; else _cc_md_copy_js; fi; } \ + && printf '/* /cc-md-copy v1 */\n'; } > "$tmp" 2>/dev/null \ + && [ -s "$tmp" ] && _cc_md_copy_has "$tmp"; then + cat "$tmp" > "$f" 2>/dev/null || true + fi + rm -f "$tmp" 2>/dev/null || true +} - # Desired D = C with every ENABLED feature applied, FORWARD order. - cp "$base" "$patched" 2>/dev/null || { rm -f "$base" "$patched" 2>/dev/null || true; return 0; } - if _cc_feature_enabled context-icon; then _cc_apply_context_icon "$patched"; fi +# Reverse transform: marker-scoped block removal (same algorithm as the node/python +# deliveries). Removes exactly our OPEN..CLOSE block plus the separator newline +# apply added, and KEEPS any bytes after CLOSE (prefix + suffix splice, not a +# truncate-to-EOF) - so undo is independent of file ordering and composes with a +# future end-of-file append feature. +_cc_undo_md_copy() { + local f="$1" ooff coff cend size tmp + _cc_md_copy_has "$f" || return 0 # nothing of ours + ooff="$(grep -boF '/* cc-md-copy v1 */' "$f" 2>/dev/null | head -1 | cut -d: -f1)" + coff="$(grep -boF '/* /cc-md-copy v1 */' "$f" 2>/dev/null | head -1 | cut -d: -f1)" + [ -n "$ooff" ] && [ -n "$coff" ] && [ "$coff" -ge "$ooff" ] || return 0 # malformed -> leave intact + [ "$ooff" -gt 0 ] && ooff=$((ooff - 1)) # also remove the separator newline before OPEN + cend=$((coff + 20)) # 20 = byte length of CLOSE marker '/* /cc-md-copy v1 */' + size="$(wc -c < "$f" 2>/dev/null | tr -d ' ')" + # drop the one trailing newline apply added, iff the byte after CLOSE is "\n" + if [ -n "$size" ] && [ "$cend" -lt "$size" ] \ + && [ "$(tail -c "+$((cend + 1))" "$f" 2>/dev/null | head -c 1 | od -An -tu1 | tr -d ' ')" = "10" ]; then + cend=$((cend + 1)) + fi + tmp="${f}.ccmdundo.$$" + if { head -c "$ooff" "$f" 2>/dev/null + [ -n "$size" ] && [ "$cend" -lt "$size" ] && tail -c "+$((cend + 1))" "$f" 2>/dev/null + true; } > "$tmp" 2>/dev/null; then + cat "$tmp" > "$f" 2>/dev/null || true + fi + rm -f "$tmp" 2>/dev/null || true +} - # No change -> done (the common idempotent case). +# Shared tail for both file reconcilers: write `patched` if it differs from the +# live file, taking the one-time pristine snapshot (= `base`) on first change. +_cc_commit_reconciled() { # $1=f $2=base $3=patched + local f="$1" base="$2" patched="$3" tmpmeta if cmp -s "$patched" "$f"; then rm -f "$base" "$patched" 2>/dev/null || true; return 0; fi - - # One-time pristine snapshot (C) for EMERGENCY manual restore only; routine - # reconcile never reads it. Carry the live file's mode/owner onto it. if [ ! -e "${f}.bak-cc-workarounds" ]; then if cp -p "$f" "${f}.bak-cc-workarounds" 2>/dev/null; then cat "$base" > "${f}.bak-cc-workarounds" 2>/dev/null || true fi fi - - # Atomic write of D, preserving mode/owner via a cp -p temp + mv -f. tmpmeta="${f}.ccwrite.$$" if cp -p "$f" "$tmpmeta" 2>/dev/null && cat "$patched" > "$tmpmeta" 2>/dev/null; then mv -f "$tmpmeta" "$f" 2>/dev/null || rm -f "$tmpmeta" 2>/dev/null || true @@ -323,6 +785,43 @@ _cc_reconcile_index_js() { rm -f "$base" "$patched" 2>/dev/null || true } +# Reconcile webview/index.js. Registry (forward apply order): context-icon +# (in-place), then md-copy (append, registered LAST). Undo runs in REVERSE. +_cc_reconcile_index_js() { + local f="$1" base patched + [ -f "$f" ] && [ -r "$f" ] || return 0 + base="${f}.ccbase.$$" + patched="${f}.ccnew.$$" + cp "$f" "$base" 2>/dev/null || { rm -f "$base" 2>/dev/null || true; return 0; } + + # Clean base C = current with every KNOWN feature undone, REVERSE order. + _cc_undo_md_copy "$base" + _cc_undo_context_icon "$base" + + # Desired D = C with every ENABLED feature applied, FORWARD order. + cp "$base" "$patched" 2>/dev/null || { rm -f "$base" "$patched" 2>/dev/null || true; return 0; } + if _cc_feature_enabled context-icon; then _cc_apply_context_icon "$patched"; fi + if _cc_feature_enabled md-copy; then _cc_apply_md_copy "$patched" js; fi + + _cc_commit_reconciled "$f" "$base" "$patched" +} + +# Reconcile webview/index.css. Registry: md-copy (append) only. +_cc_reconcile_index_css() { + local f="$1" base patched + [ -f "$f" ] && [ -r "$f" ] || return 0 + base="${f}.ccbase.$$" + patched="${f}.ccnew.$$" + cp "$f" "$base" 2>/dev/null || { rm -f "$base" 2>/dev/null || true; return 0; } + + _cc_undo_md_copy "$base" + + cp "$base" "$patched" 2>/dev/null || { rm -f "$base" "$patched" 2>/dev/null || true; return 0; } + if _cc_feature_enabled md-copy; then _cc_apply_md_copy "$patched" css; fi + + _cc_commit_reconciled "$f" "$base" "$patched" +} + _cc_reconcile() { [ "$CC_RECONCILE" != "0" ] || return 0 # emergency bypass: touch nothing local d extdir f @@ -333,7 +832,10 @@ _cc_reconcile() { case "${d##*/}" in anthropic.claude-code-*) extdir="$d"; break ;; esac d="$(dirname "$d" 2>/dev/null || echo "")" done - if [ -n "$extdir" ]; then _cc_reconcile_index_js "$extdir/webview/index.js"; fi + if [ -n "$extdir" ]; then + _cc_reconcile_index_js "$extdir/webview/index.js" + _cc_reconcile_index_css "$extdir/webview/index.css" + fi # Also cover any installed extension under this user's VS Code dirs (terminal # launches, or when the real binary is the standalone CLI). Unmatched globs @@ -344,6 +846,7 @@ _cc_reconcile() { "$HOME"/.vscode-server/extensions/anthropic.claude-code-*/webview/index.js \ "$HOME"/.vscode-server-insiders/extensions/anthropic.claude-code-*/webview/index.js; do _cc_reconcile_index_js "$f" + _cc_reconcile_index_css "${f%/index.js}/index.css" done } diff --git a/tests/test_reconcile.py b/tests/test_reconcile.py index 180b6a9..9d7af1c 100644 --- a/tests/test_reconcile.py +++ b/tests/test_reconcile.py @@ -29,7 +29,9 @@ MARKED = ">=101)return null}" + MARKER BARE101 = ">=101)return null}" BAK = ".bak-cc-workarounds" -STRAY_FRAGMENTS = (".ccbase.", ".ccnew.", ".ccwrite.", ".ccapply.", ".ccundo.", ".ccpatch.") +MD_OPEN = "/* cc-md-copy v1 */" +MD_CLOSE = "/* /cc-md-copy v1 */" +STRAY_FRAGMENTS = (".ccbase.", ".ccnew.", ".ccwrite.", ".ccapply.", ".ccundo.", ".ccpatch.", ".ccmdapply.", ".ccmdundo.") def make_extension(home, content): @@ -205,8 +207,85 @@ def test_ambiguous_match_is_skipped(self): self.assertEqual(idx.read_text(encoding="utf-8"), original) +class MdCopyReconcileMixin: + """md-copy reconcile assertions (append feature on index.js + index.css, and + composition with context-icon). Subclasses provide `_run` and inherit from + ReconcileMixin too (so make_extension/_captured are available).""" + + def _css_sibling(self, idx, content=".x{}\n"): + css = idx.with_name("index.css") + css.write_text(content, encoding="utf-8") + return css + + def test_md_copy_block_applied_to_js_and_css_and_is_idempotent(self): + with tempfile.TemporaryDirectory() as td: + home = pathlib.Path(td) + idx = make_extension(home, "console.log(1)\n") + css = self._css_sibling(idx) + self.assertEqual(self._run(td, home, env_extra={"CC_PATCH_MD_COPY": "1"}).returncode, 0) + jt, ct = idx.read_text(encoding="utf-8"), css.read_text(encoding="utf-8") + self.assertIn(MD_OPEN, jt) + self.assertIn(MD_CLOSE, jt) + self.assertIn("cc-md-copy", jt) # the IIFE payload landed + self.assertTrue(jt.startswith("console.log(1)")) + self.assertIn(MD_OPEN, ct) + # idempotent: a second launch changes nothing and leaves no temp files + self.assertEqual(self._run(td, home, env_extra={"CC_PATCH_MD_COPY": "1"}).returncode, 0) + self.assertEqual(idx.read_text(encoding="utf-8"), jt) + self.assertEqual(jt.count(MD_OPEN), 1) + strays = [p.name for p in idx.parent.iterdir() if any(s in p.name for s in STRAY_FRAGMENTS)] + self.assertEqual(strays, []) + + def test_md_copy_reverted_when_disabled(self): + with tempfile.TemporaryDirectory() as td: + home = pathlib.Path(td) + original = "console.log(1)\n" + idx = make_extension(home, original) + css = self._css_sibling(idx) + self.assertEqual(self._run(td, home, env_extra={"CC_PATCH_MD_COPY": "1"}).returncode, 0) + self.assertIn(MD_OPEN, idx.read_text(encoding="utf-8")) + # disable -> reconcile removes our block, byte-exactly + self.assertEqual(self._run(td, home, env_extra={"CC_PATCH_MD_COPY": "0"}).returncode, 0) + self.assertEqual(idx.read_text(encoding="utf-8"), original) + self.assertNotIn(MD_OPEN, css.read_text(encoding="utf-8")) + + def test_md_copy_composes_with_context_icon_on_index_js(self): + with tempfile.TemporaryDirectory() as td: + home = pathlib.Path(td) + idx = make_extension(home, f"head {OLD} tail\n") + self._css_sibling(idx) + # both features on + self.assertEqual(self._run(td, home, env_extra={"CC_PATCH_MD_COPY": "1"}).returncode, 0) + both = idx.read_text(encoding="utf-8") + self.assertIn(MARKED, both) # context-icon applied + self.assertIn(MD_OPEN, both) # md-copy applied + # turn context-icon OFF, md-copy stays on -> context reverts, md-copy intact + self.assertEqual(self._run(td, home, env_extra={"CC_PATCH_CONTEXT_ICON": "0", "CC_PATCH_MD_COPY": "1"}).returncode, 0) + t = idx.read_text(encoding="utf-8") + self.assertIn(OLD, t) + self.assertNotIn(MARKED, t) + self.assertIn(MD_OPEN, t) + # turn md-copy OFF, context-icon back on -> md-copy reverts, context intact + self.assertEqual(self._run(td, home, env_extra={"CC_PATCH_MD_COPY": "0"}).returncode, 0) + t2 = idx.read_text(encoding="utf-8") + self.assertIn(MARKED, t2) + self.assertNotIn(MD_OPEN, t2) + + def test_master_switch_reverts_md_copy_too(self): + with tempfile.TemporaryDirectory() as td: + home = pathlib.Path(td) + original = "x\n" + idx = make_extension(home, original) + css = self._css_sibling(idx) + self.assertEqual(self._run(td, home, env_extra={"CC_PATCH_MD_COPY": "1"}).returncode, 0) + self.assertIn(MD_OPEN, idx.read_text(encoding="utf-8")) + self.assertEqual(self._run(td, home, env_extra={"CC_WORKAROUNDS": "0"}).returncode, 0) + self.assertEqual(idx.read_text(encoding="utf-8"), original) + self.assertNotIn(MD_OPEN, css.read_text(encoding="utf-8")) + + @unittest.skipIf(os.name == "nt", "POSIX bash launcher test") -class BashReconcileTests(ReconcileMixin, unittest.TestCase): +class BashReconcileTests(ReconcileMixin, MdCopyReconcileMixin, unittest.TestCase): def _run(self, td, home, args=None, env_extra=None): fake, capture = make_fake_claude(td) self._capture_path = capture @@ -214,6 +293,9 @@ def _run(self, td, home, args=None, env_extra=None): "HOME": str(home), "CLAUDE_REAL_BIN": str(fake), "CAPTURE_ARGS": str(capture), + # Default md-copy off so ReconcileMixin tests (which assert context-icon + # only) are unaffected; MdCopyReconcileMixin tests pass CC_PATCH_MD_COPY=1. + "CC_PATCH_MD_COPY": "0", } if env_extra: env.update(env_extra) @@ -250,6 +332,7 @@ def test_precise_walkup_patches_extension_outside_scan_dirs(self): "HOME": str(home), "CLAUDE_REAL_BIN": str(fake), "CAPTURE_ARGS": str(capture), + "CC_PATCH_MD_COPY": "0", } res = run([str(LAUNCHER_BASH), "--thinking=adaptive"], env=env) self.assertEqual(res.returncode, 0, res.stderr) @@ -309,6 +392,7 @@ def test_bash_and_node_produce_identical_bundle_and_args(self): "HOME": str(home), "CLAUDE_REAL_BIN": str(fake), "CAPTURE_ARGS": str(capture), + "CC_PATCH_MD_COPY": "0", # parity with node until Task 12 } res = run([str(LAUNCHER_BASH), "--max-thinking-tokens=200"], env=env) else: From ba44460a10520cfb79131a5189a8c056195a4ce6 Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 10/15] feat(launcher): md-copy node parity - per-file feature map (index.js + index.css) --- launcher/claudemax.win.js | 90 ++++++++++++++++++++++++++++++--------- tests/test_reconcile.py | 21 ++++++--- 2 files changed, 85 insertions(+), 26 deletions(-) diff --git a/launcher/claudemax.win.js b/launcher/claudemax.win.js index 284a590..b63ba8a 100644 --- a/launcher/claudemax.win.js +++ b/launcher/claudemax.win.js @@ -274,18 +274,67 @@ function contextIconEnabled() { return process.env.CC_PATCH_CONTEXT_ICON !== "0"; } -const BUNDLE_FEATURES = [ - { - id: "context-icon", - enabled: contextIconEnabled, - apply: applyContextIcon, - undo: undoContextIcon, - }, -]; +// ---- md-copy: large IIFE appended to index.js + CSS appended to index.css ---- +// Bracketed by the sentinel /* cc-md-copy v1 */ ... /* /cc-md-copy v1 */ (its +// ownership marker). apply appends at END-OF-FILE; undo removes exactly that +// OPEN..CLOSE block (marker-scoped, keeps any bytes after CLOSE), so it composes +// with context-icon regardless of ordering. The block is byte-identical to the +// bash/python deliveries. MD_COPY_JS / MD_COPY_CSS are GENERATED by +// tools/gen-embeds (CI drift check: tools/gen-embeds --check); do not edit. +const MD_OPEN = "/* cc-md-copy v1 */"; +const MD_CLOSE = "/* /cc-md-copy v1 */"; +// >>>CCWA-MD-COPY-EMBED>>> (generated by tools/gen-embeds; do not edit) +const MD_COPY_JS = "/* cc-md-copy: per-message and whole-conversation copy (markdown/plain) for the\n * Claude Code VS Code webview. Self-contained IIFE appended to webview/index.js.\n * Additive and read-only w.r.t. app state; keyed on stable CSS-module class\n * prefixes, so it fails safe (controls simply do not appear) if a prefix moves.\n * Exposes its pure functions for node unit tests; boot()s only in a real webview. */\n(function () {\n \"use strict\";\n\n var CONTROL_PREFIX = \"cc-md-copy\"; // every injected node's class starts with this\n var USER_BUBBLE = '[class*=\"userMessageContainer_\"]';\n // Assistant message wrapper. Verified on 2.1.170: the render emits exactly one\n // `data-testid=\"assistant-message\"` div per assistant turn, with the rating\n // widget and content blocks as its children. (The earlier `[data-message-rating]`\n // was WRONG: that attribute sits on the nested rating control, which is also only\n // rendered behind an experiment+analytics gate.) Re-pinned in Task 6.\n var ASSISTANT_BUBBLE = '[data-testid=\"assistant-message\"]';\n var MESSAGES_CONTAINER = '[class*=\"messagesContainer_\"]'; // e.g. '[class*=\"timeline_\"]'; \"\" -> observe document.body\n // Optional narrowing only. MUST be a single wrapper around ALL content blocks,\n // not a per-block class (a turn has multiple blocks). \"\" -> use the bubble itself\n // (already aggregates all blocks; sanitizeClone is the correctness gate).\n var ASSISTANT_CONTENT = \"\";\n var FEEDBACK_MS = 1800;\n\n // ---- HTML -> Markdown (DOM walk) -------------------------------------------\n // Uses only: nodeType, tagName, childNodes, textContent, getAttribute, className.\n function htmlToMarkdown(root) {\n function inline(node) {\n var out = \"\";\n var kids = node.childNodes || [];\n for (var i = 0; i < kids.length; i++) {\n var c = kids[i];\n if (c.nodeType === 3) { out += c.textContent || \"\"; continue; }\n if (c.nodeType !== 1) continue;\n var tag = (c.tagName || \"\").toUpperCase();\n if (tag === \"BR\") out += \"\\n\";\n else if (tag === \"STRONG\" || tag === \"B\") out += \"**\" + inline(c) + \"**\";\n else if (tag === \"EM\" || tag === \"I\") out += \"*\" + inline(c) + \"*\";\n else if (tag === \"DEL\" || tag === \"S\") out += \"~~\" + inline(c) + \"~~\";\n else if (tag === \"CODE\") out += \"`\" + (c.textContent || \"\") + \"`\";\n else if (tag === \"A\") {\n var href = c.getAttribute ? c.getAttribute(\"href\") : null;\n var t = inline(c);\n out += href ? \"[\" + t + \"](\" + href + \")\" : t;\n } else out += inline(c); // unknown inline wrapper: keep text, drop tag\n }\n return out;\n }\n function langOf(codeEl) {\n var cls = \"\";\n if (codeEl) cls = (codeEl.getAttribute && codeEl.getAttribute(\"class\")) || codeEl.className || \"\";\n var m = /language-([A-Za-z0-9+#.\\-]+)/.exec(cls || \"\");\n return m ? m[1] : \"\";\n }\n function findChildTag(node, tag) {\n var kids = node.childNodes || [];\n for (var i = 0; i < kids.length; i++) {\n if (kids[i].nodeType === 1 && (kids[i].tagName || \"\").toUpperCase() === tag) return kids[i];\n }\n return null;\n }\n function list(node, ordered, depth) {\n var out = \"\", n = 1;\n var kids = node.childNodes || [];\n for (var i = 0; i < kids.length; i++) {\n var li = kids[i];\n if (li.nodeType !== 1 || (li.tagName || \"\").toUpperCase() !== \"LI\") continue;\n var marker = ordered ? n++ + \". \" : \"- \";\n var indent = new Array(depth + 1).join(\" \");\n var lead = \"\", nested = \"\";\n var lk = li.childNodes || [];\n for (var j = 0; j < lk.length; j++) {\n var ch = lk[j];\n var ct = ch.nodeType === 1 ? (ch.tagName || \"\").toUpperCase() : \"\";\n if (ct === \"UL\") nested += list(ch, false, depth + 1);\n else if (ct === \"OL\") nested += list(ch, true, depth + 1);\n else if (ch.nodeType === 3) lead += ch.textContent || \"\";\n else lead += inline(ch);\n }\n out += indent + marker + lead.trim() + \"\\n\" + nested;\n }\n return out;\n }\n function table(node) {\n var rows = [];\n (function collect(container) {\n var kids = container.childNodes || [];\n for (var i = 0; i < kids.length; i++) {\n var c = kids[i];\n if (c.nodeType !== 1) continue;\n var t = (c.tagName || \"\").toUpperCase();\n if (t === \"THEAD\" || t === \"TBODY\" || t === \"TFOOT\") collect(c);\n else if (t === \"TR\") {\n var cells = [], cc = c.childNodes || [];\n for (var j = 0; j < cc.length; j++) {\n var d = cc[j];\n if (d.nodeType !== 1) continue;\n var dt = (d.tagName || \"\").toUpperCase();\n if (dt === \"TH\" || dt === \"TD\") cells.push(inline(d).trim());\n }\n rows.push(cells);\n }\n }\n })(node);\n if (!rows.length) return \"\";\n var head = rows[0], body = rows.slice(1);\n var sep = head.map(function () { return \"---\"; });\n var out = \"| \" + head.join(\" | \") + \" |\\n| \" + sep.join(\" | \") + \" |\\n\";\n for (var k = 0; k < body.length; k++) out += \"| \" + body[k].join(\" | \") + \" |\\n\";\n return out;\n }\n function block(node) {\n var out = \"\";\n var kids = node.childNodes || [];\n for (var i = 0; i < kids.length; i++) {\n var c = kids[i];\n if (c.nodeType === 3) { if ((c.textContent || \"\").trim()) out += c.textContent; continue; }\n if (c.nodeType !== 1) continue;\n var tag = (c.tagName || \"\").toUpperCase();\n if (/^H[1-6]$/.test(tag)) out += new Array(+tag[1] + 1).join(\"#\") + \" \" + inline(c).trim() + \"\\n\\n\";\n else if (tag === \"P\") out += inline(c).trim() + \"\\n\\n\";\n else if (tag === \"UL\") out += list(c, false, 0) + \"\\n\";\n else if (tag === \"OL\") out += list(c, true, 0) + \"\\n\";\n else if (tag === \"PRE\") {\n var code = findChildTag(c, \"CODE\");\n var lang = langOf(code || c);\n var body = (code || c).textContent || \"\";\n out += \"```\" + lang + \"\\n\" + body.replace(/\\n$/, \"\") + \"\\n```\\n\\n\";\n } else if (tag === \"BLOCKQUOTE\") {\n var inner = block(c).trim().split(\"\\n\").map(function (l) { return \"> \" + l; }).join(\"\\n\");\n out += inner + \"\\n\\n\";\n } else if (tag === \"HR\") out += \"---\\n\\n\";\n else if (tag === \"TABLE\") out += table(c) + \"\\n\";\n else if (tag === \"BR\") out += \"\\n\";\n else if (tag === \"STRONG\" || tag === \"B\" || tag === \"EM\" || tag === \"I\" ||\n tag === \"A\" || tag === \"CODE\" || tag === \"DEL\" || tag === \"S\")\n out += inline(c) + \"\\n\\n\";\n else out += block(c); // unknown wrapper: recurse (drop tag, keep content)\n }\n return out;\n }\n // block() dispatches on each CHILD's tag, treating the passed node as a plain\n // container. Wrap root in a one-off container so root's OWN tag is dispatched\n // too: callers pass either the bubble container (its block children render) or\n // a single block element like
    /
      /
    (now handled, not flattened).\n return block({ childNodes: [root] }).replace(/\\n{3,}/g, \"\\n\\n\").trim();\n }\n\n // ---- pure helpers ----------------------------------------------------------\n function hasPrefix(node, prefix) {\n if (node.nodeType !== 1 || typeof node.className !== \"string\") return false;\n var parts = node.className.split(/\\s+/);\n for (var i = 0; i < parts.length; i++) if (parts[i].indexOf(prefix) === 0) return true;\n return false;\n }\n\n // Class-prefix hooks for non-content chrome that renders *inside* an assistant\n // bubble (verified on 2.1.170; Task 6 re-pins these). tool*/thinking_ are the v1\n // exclusions; unknownContent_ is the renderer's fallback for unrecognized block\n // types, so stripping it makes a *future* block type fail safe to excluded rather\n // than leaking \"Unsupported content\" into the copy. Re-pin if a prefix moves.\n var CHROME_PREFIXES = [\"toolUse_\", \"toolResult_\", \"toolReference_\", \"thinking_\", \"unknownContent_\"];\n\n // True for any node that must never appear in copied output: our own controls,\n // the rating widget (`data-message-rating` + its \"Thanks for your feedback\"\n // text), any button (copy-code chrome), and the excluded content blocks above.\n function isChrome(node) {\n if (node.nodeType !== 1) return false;\n if ((node.tagName || \"\").toUpperCase() === \"BUTTON\") return true;\n if (node.getAttribute && node.getAttribute(\"data-message-rating\") !== null) return true;\n if (hasPrefix(node, CONTROL_PREFIX)) return true;\n for (var i = 0; i < CHROME_PREFIXES.length; i++) if (hasPrefix(node, CHROME_PREFIXES[i])) return true;\n return false;\n }\n\n // Deep-clone `contentNode`, then strip every chrome node so copied output is the\n // message's text content only. This is a CORRECTNESS GATE, not cosmetic: the\n // default content node is the whole bubble (all content-block siblings, so multi-\n // block assistant turns are captured), and this strip-list is the only thing\n // keeping the rating widget and v1-excluded blocks out of the copy.\n function sanitizeClone(contentNode) {\n var clone = contentNode.cloneNode(true);\n (function strip(node) {\n var kids = (node.childNodes || []).slice();\n for (var i = 0; i < kids.length; i++) {\n var c = kids[i];\n if (c.nodeType === 1 && isChrome(c)) { node.removeChild(c); continue; }\n if (c.nodeType === 1) strip(c);\n }\n })(clone);\n return clone;\n }\n\n function classifyBubble(node) {\n if (node.nodeType !== 1) return null;\n if (hasPrefix(node, \"userMessageContainer_\")) return \"user\";\n if (node.getAttribute && node.getAttribute(\"data-testid\") === \"assistant-message\") return \"assistant\";\n return null;\n }\n\n // Build the whole-conversation markdown from an ordered list of bubbles.\n // `contentOf(bubble)` resolves the content node (default: the bubble itself, so\n // every content block is included; sanitizeClone drops chrome); a default is\n // provided for tests.\n function conversationToMarkdown(bubbles, contentOf) {\n contentOf = contentOf || function (b) { return b; };\n var parts = [];\n for (var i = 0; i < bubbles.length; i++) {\n var role = classifyBubble(bubbles[i]);\n if (!role) continue;\n var clean = sanitizeClone(contentOf(bubbles[i]));\n var body = role === \"assistant\" ? htmlToMarkdown(clean) : (clean.textContent || \"\").trim();\n if (!body) continue;\n parts.push((role === \"user\" ? \"## User\" : \"## Assistant\") + \"\\n\\n\" + body);\n }\n return parts.join(\"\\n\\n\") + (parts.length ? \"\\n\" : \"\");\n }\n\n // ---- exports (node tests) / boot (real webview) ----------------------------\n if (typeof document !== \"undefined\") {\n boot();\n } else if (typeof module !== \"undefined\" && module.exports) {\n module.exports = { htmlToMarkdown: htmlToMarkdown, sanitizeClone: sanitizeClone,\n classifyBubble: classifyBubble, conversationToMarkdown: conversationToMarkdown };\n }\n\n // ---- live-webview wiring (runs only when a document exists) ----------------\n function qs(node, sel) { try { return sel && node.querySelector ? node.querySelector(sel) : null; } catch (_) { return null; } }\n function qsa(sel) { try { return Array.prototype.slice.call(document.querySelectorAll(sel)); } catch (_) { return []; } }\n\n // The content node to convert/copy: the optional ASSISTANT_CONTENT wrapper if\n // pinned and present, else the bubble itself. The bubble already contains every\n // content-block sibling of a multi-block turn, and sanitizeClone strips the\n // chrome (rating widget, tool/thinking/unknown blocks, buttons, our controls)\n // either way -- so this is a narrowing, never the thing that guarantees\n // correctness.\n function contentNodeOf(bubble, role) {\n if (role === \"assistant\" && ASSISTANT_CONTENT) {\n var n = qs(bubble, ASSISTANT_CONTENT);\n if (n) return n;\n }\n return bubble;\n }\n\n function copyText(text) {\n try {\n if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(text);\n } catch (_) {}\n return Promise.resolve(); // best-effort; never throw into the app\n }\n\n function flashFeedback(host) {\n try {\n var fb = document.createElement(\"span\");\n fb.className = CONTROL_PREFIX + \"-feedback\";\n fb.textContent = \"Copied\";\n host.appendChild(fb);\n setTimeout(function () { if (fb && fb.parentNode) fb.parentNode.removeChild(fb); }, FEEDBACK_MS);\n } catch (_) {}\n }\n\n function bubbleMarkdown(bubble, role) {\n var clean = sanitizeClone(contentNodeOf(bubble, role));\n return role === \"assistant\" ? htmlToMarkdown(clean) : (clean.textContent || \"\").trim();\n }\n function bubblePlain(bubble, role) {\n return (sanitizeClone(contentNodeOf(bubble, role)).textContent || \"\").trim();\n }\n\n // Build a single control: a primary \"Copy\" (markdown) plus a small caret that\n // toggles a menu with \"Copy as plain text\". All nodes carry the CONTROL_PREFIX\n // class so sanitizeClone removes them from any copied content.\n function buildControl(onMarkdown, onPlain) {\n var wrap = document.createElement(\"span\");\n wrap.className = CONTROL_PREFIX;\n var primary = document.createElement(\"button\");\n primary.type = \"button\";\n primary.className = CONTROL_PREFIX + \"-btn\";\n primary.title = \"Copy as Markdown\";\n primary.textContent = \"Copy\";\n primary.addEventListener(\"click\", function (e) { e.stopPropagation(); onMarkdown(primary); });\n var caret = document.createElement(\"button\");\n caret.type = \"button\";\n caret.className = CONTROL_PREFIX + \"-caret\";\n caret.title = \"Copy options\";\n caret.textContent = \"\u25be\"; // black down-pointing small triangle\n var menu = document.createElement(\"span\");\n menu.className = CONTROL_PREFIX + \"-menu\";\n menu.style.display = \"none\";\n var plain = document.createElement(\"button\");\n plain.type = \"button\";\n plain.className = CONTROL_PREFIX + \"-btn\";\n plain.textContent = \"Copy as plain text\";\n plain.addEventListener(\"click\", function (e) { e.stopPropagation(); menu.style.display = \"none\"; onPlain(plain); });\n menu.appendChild(plain);\n caret.addEventListener(\"click\", function (e) {\n e.stopPropagation();\n menu.style.display = menu.style.display === \"none\" ? \"inline-block\" : \"none\";\n });\n wrap.appendChild(primary);\n wrap.appendChild(caret);\n wrap.appendChild(menu);\n return wrap;\n }\n\n function decorate(bubble) {\n try {\n var role = classifyBubble(bubble);\n if (!role) return;\n if (qs(bubble, \".\" + CONTROL_PREFIX)) return; // already decorated\n var control = buildControl(\n function (host) { copyText(bubbleMarkdown(bubble, role)).then(function () { flashFeedback(control); }); },\n function (host) { copyText(bubblePlain(bubble, role)).then(function () { flashFeedback(control); }); }\n );\n bubble.appendChild(control);\n } catch (_) {}\n }\n\n function copyConversation(format) {\n var bubbles = qsa(USER_BUBBLE + \",\" + ASSISTANT_BUBBLE);\n if (format === \"text\") {\n var lines = [];\n for (var i = 0; i < bubbles.length; i++) {\n var role = classifyBubble(bubbles[i]);\n if (!role) continue;\n var body = bubblePlain(bubbles[i], role);\n if (body) lines.push(body);\n }\n return copyText(lines.join(\"\\n\\n\") + (lines.length ? \"\\n\" : \"\"));\n }\n return copyText(conversationToMarkdown(bubbles, function (b) {\n return contentNodeOf(b, classifyBubble(b));\n }));\n }\n\n function installConversationControl() {\n try {\n if (qs(document, \".\" + CONTROL_PREFIX + \"-conversation\")) return;\n var bar = document.createElement(\"div\");\n bar.className = CONTROL_PREFIX + \"-conversation\";\n var control = buildControl(\n function () { copyConversation(\"markdown\").then(function () { flashFeedback(bar); }); },\n function () { copyConversation(\"text\").then(function () { flashFeedback(bar); }); }\n );\n control.title = \"Copy entire conversation\";\n bar.appendChild(control);\n document.body.appendChild(bar); // fixed-position via CSS; placement refined in Task 6\n } catch (_) {}\n }\n\n function sweep() { var b = qsa(USER_BUBBLE + \",\" + ASSISTANT_BUBBLE); for (var i = 0; i < b.length; i++) decorate(b[i]); }\n\n function boot() {\n try {\n var target = (MESSAGES_CONTAINER && qs(document, MESSAGES_CONTAINER)) || document.body;\n sweep();\n installConversationControl();\n if (typeof MutationObserver === \"undefined\") return;\n var obs = new MutationObserver(function () { sweep(); });\n obs.observe(target, { childList: true, subtree: true });\n } catch (_) {}\n }\n})();\n"; +const MD_COPY_CSS = ".cc-md-copy {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n vertical-align: middle;\n margin-left: 6px;\n}\n.cc-md-copy-btn,\n.cc-md-copy-caret {\n font: inherit;\n font-size: 11px;\n line-height: 1.4;\n padding: 1px 6px;\n color: var(--vscode-foreground);\n background: transparent;\n border: 1px solid var(--vscode-widget-border, transparent);\n border-radius: 4px;\n cursor: pointer;\n opacity: 0.65;\n}\n.cc-md-copy-btn:hover,\n.cc-md-copy-caret:hover {\n opacity: 1;\n background: var(--vscode-toolbar-hoverBackground, rgba(128, 128, 128, 0.15));\n}\n.cc-md-copy-menu {\n position: relative;\n margin-left: 4px;\n padding: 2px;\n background: var(--vscode-menu-background, var(--vscode-editorWidget-background));\n border: 1px solid var(--vscode-menu-border, var(--vscode-widget-border, transparent));\n border-radius: 4px;\n z-index: 5;\n}\n.cc-md-copy-feedback {\n margin-left: 6px;\n font-size: 11px;\n opacity: 0.85;\n color: var(--vscode-foreground);\n}\n.cc-md-copy-conversation {\n position: fixed;\n right: 16px;\n bottom: 56px;\n z-index: 10;\n padding: 2px;\n background: var(--vscode-editorWidget-background);\n border: 1px solid var(--vscode-widget-border, transparent);\n border-radius: 6px;\n opacity: 0.85;\n}\n.cc-md-copy-conversation:hover {\n opacity: 1;\n}\n"; +// << no-op + return data + mdBlock(payload); +} +function undoMdCopy(data) { + // Marker-scoped block removal (same algorithm as the bash/python deliveries): + // remove exactly our OPEN..CLOSE block plus the separator newline apply added, + // and KEEP any bytes after CLOSE. This makes undo independent of file ordering, + // so it composes with context-icon and any future end-of-file append feature. + var oi = data.indexOf(MD_OPEN); + if (oi === -1) return data; // nothing of ours + var ci = data.indexOf(MD_CLOSE, oi); + if (ci === -1) return data; // malformed (open without close) -> leave file intact + var start = oi > 0 && data.charAt(oi - 1) === "\n" ? oi - 1 : oi; // drop the separator newline + var end = ci + MD_CLOSE.length; + if (data.charAt(end) === "\n") end += 1; // drop the one trailing newline apply added + return data.slice(0, start) + data.slice(end); +} +function mdCopyEnabled() { + if (process.env.CC_WORKAROUNDS === "0") return false; + return process.env.CC_PATCH_MD_COPY !== "0"; +} + +// Per-file feature lists. index.js: context-icon (in-place) then md-copy (append, +// registered LAST). index.css: md-copy (append) only. apply runs forward, undo +// reverse. md-copy binds the matching payload per file. +const contextIconFeature = { + id: "context-icon", enabled: contextIconEnabled, apply: applyContextIcon, undo: undoContextIcon, +}; +const mdCopyJsFeature = { + id: "md-copy", enabled: mdCopyEnabled, apply: function (d) { return applyMdCopy(d, MD_COPY_JS); }, undo: undoMdCopy, +}; +const mdCopyCssFeature = { + id: "md-copy", enabled: mdCopyEnabled, apply: function (d) { return applyMdCopy(d, MD_COPY_CSS); }, undo: undoMdCopy, +}; +const FILE_FEATURES = { + "index.js": [contextIconFeature, mdCopyJsFeature], + "index.css": [mdCopyCssFeature], +}; + +// Reconcile one file against its feature list: undo every feature (reverse), +// re-apply enabled ones (forward), write only when the bytes change. Best-effort. +function reconcileFile(file, features) { try { if (!fs.existsSync(file)) return; let current; @@ -295,13 +344,9 @@ function reconcileIndexJs(file) { return; // not readable } let base = current; - for (let i = BUNDLE_FEATURES.length - 1; i >= 0; i--) { - base = BUNDLE_FEATURES[i].undo(base); - } + for (let i = features.length - 1; i >= 0; i--) base = features[i].undo(base); let desired = base; - for (const feat of BUNDLE_FEATURES) { - if (feat.enabled()) desired = feat.apply(desired); - } + for (const feat of features) if (feat.enabled()) desired = feat.apply(desired); if (desired === current) return; // idempotent: nothing to write const bak = file + ".bak-cc-workarounds"; if (!fs.existsSync(bak)) { @@ -373,13 +418,16 @@ function scanExtensionIndexes() { function reconcile(binPath) { if (process.env.CC_RECONCILE === "0") return; // emergency bypass: touch nothing - const targets = new Set(); + const dirs = new Set(); if (binPath) { const root = extensionRootFromBinary(binPath); - if (root) targets.add(path.join(root, "webview", "index.js")); + if (root) dirs.add(path.join(root, "webview")); + } + for (const f of scanExtensionIndexes()) dirs.add(path.dirname(f)); + for (const dir of dirs) { + reconcileFile(path.join(dir, "index.js"), FILE_FEATURES["index.js"]); + reconcileFile(path.join(dir, "index.css"), FILE_FEATURES["index.css"]); } - for (const f of scanExtensionIndexes()) targets.add(f); - for (const f of targets) reconcileIndexJs(f); } // --- Behavior -------------------------------------------------------------- diff --git a/tests/test_reconcile.py b/tests/test_reconcile.py index 9d7af1c..3182925 100644 --- a/tests/test_reconcile.py +++ b/tests/test_reconcile.py @@ -339,7 +339,7 @@ def test_precise_walkup_patches_extension_outside_scan_dirs(self): self.assertEqual(idx.read_text(encoding="utf-8"), f"before {MARKED} after") -class WinReconcileTests(ReconcileMixin, unittest.TestCase): +class WinReconcileTests(ReconcileMixin, MdCopyReconcileMixin, unittest.TestCase): def _run(self, td, home, args=None, env_extra=None): cli, capture = make_fake_node_cli(td) shim = make_fake_cmd_shim(td, cli) @@ -349,6 +349,9 @@ def _run(self, td, home, args=None, env_extra=None): "USERPROFILE": str(home), "CLAUDE_REAL_BIN": str(shim), "CAPTURE_ARGS": str(capture), + # Default md-copy off so ReconcileMixin tests (which assert context-icon + # only) are unaffected; MdCopyReconcileMixin tests pass CC_PATCH_MD_COPY=1. + "CC_PATCH_MD_COPY": "0", } if env_extra: env.update(env_extra) @@ -372,6 +375,7 @@ def test_precise_walkup_patches_extension_outside_scan_dirs(self): "USERPROFILE": str(home), "CLAUDE_REAL_BIN": str(shim), "CAPTURE_ARGS": str(capture), + "CC_PATCH_MD_COPY": "0", } res = run(["node", str(LAUNCHER_WIN), "--thinking=adaptive"], env=env) self.assertEqual(res.returncode, 0, res.stderr) @@ -386,13 +390,14 @@ def test_bash_and_node_produce_identical_bundle_and_args(self): with tempfile.TemporaryDirectory() as td: home = pathlib.Path(td) idx = make_extension(home, f"before {OLD} after") + css = idx.with_name("index.css") + css.write_text(".x{}\n", encoding="utf-8") if kind == "bash": fake, capture = make_fake_claude(td) env = { "HOME": str(home), "CLAUDE_REAL_BIN": str(fake), "CAPTURE_ARGS": str(capture), - "CC_PATCH_MD_COPY": "0", # parity with node until Task 12 } res = run([str(LAUNCHER_BASH), "--max-thinking-tokens=200"], env=env) else: @@ -411,10 +416,16 @@ def test_bash_and_node_produce_identical_bundle_and_args(self): results[kind] = ( idx.read_text(encoding="utf-8"), captured_args(capture), + css.read_text(encoding="utf-8"), ) - self.assertEqual(results["bash"][0], results["node"][0]) - self.assertEqual(results["bash"][1], results["node"][1]) - self.assertEqual(results["bash"][0], f"before {MARKED} after") + # identical bytes from both launchers, for index.js AND index.css + self.assertEqual(results["bash"][0], results["node"][0]) # index.js + self.assertEqual(results["bash"][2], results["node"][2]) # index.css + self.assertEqual(results["bash"][1], results["node"][1]) # injected args + # index.js carries the context-icon swap AND the md-copy block; css the block + self.assertIn(MARKED, results["bash"][0]) + self.assertIn(MD_OPEN, results["bash"][0]) + self.assertIn(MD_OPEN, results["bash"][2]) self.assertEqual( results["bash"][1], ["--max-thinking-tokens=200", "--thinking-display", "summarized"], From edcc6c4bc88e7d8d65d5e06c25aa3ec2728e1fa2 Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 11/15] docs(md-copy): fix README, toggle table + migration line, TECHNICAL composition --- README.md | 20 ++++++++- TECHNICAL.md | 19 +++++++++ fixes/markdown-copy-export/README.md | 64 ++++++++++++++++++++++++++++ launcher/README.md | 1 + 4 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 fixes/markdown-copy-export/README.md diff --git a/README.md b/README.md index 00aac21..590c7fc 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,12 @@ Not affiliated with or endorsed by Anthropic. A future Claude Code update could The context-usage pie in the chat input is hidden until you have used more than 50% of the context window. With the 1M window that is about 500,000 tokens, so it is effectively never shown. Fix via the launcher (re-patches the webview on each launch), or a standalone patcher script. -> [details](#workaround-2-context-usage-icon) +3. **No markdown copy / export of chat** [added 2026-06-09]. + The chat cannot copy a whole message or the whole conversation as Markdown, and + has no transcript export. Fix via the launcher (adds copy controls, re-applied + each launch), a standalone patcher, or a standalone session exporter CLI. + -> [details](fixes/markdown-copy-export/README.md) + ## The launcher The recommended fix for everything is one small launcher that wraps the real `claude` binary. It is a drop-in process wrapper carrying every fix in this repo; each fix is on by default and independently switchable with an environment variable, so the same artifact serves "I want everything" and "I want only X" without editing code and without recompiling. @@ -29,6 +35,7 @@ Toggles (set in the environment where Claude Code launches, then reload): | `CC_RECONCILE` | `1` | `0` = do not read or write the webview bundle this launch (emergency bypass). Argument injection still runs. | | `CC_THINKING_DISPLAY` | `summarized` | `summarized` shows extended-thinking summaries; `omitted` hides them (no injection). | | `CC_PATCH_CONTEXT_ICON` | `1` | `0` leaves the context-usage icon unpatched (and reverts ours on the next launch). | +| `CC_PATCH_MD_COPY` | `1` | `0` leaves the webview without the markdown copy/export controls (and reverts ours on the next launch). | See [`launcher/README.md`](launcher/README.md) for wiring details, the VS Code env-setting how-to, and the build command. @@ -45,10 +52,15 @@ The three bash launchers and three Windows launchers are gone. There is now one | Old launcher | New equivalent | | --- | --- | | `claudemax` (both fixes) | `launcher/claudemax` - all fixes on (same behavior) | -| `claude-think` (thinking only) | `launcher/claudemax` with `CC_PATCH_CONTEXT_ICON=0` | -| `claude-context` (context icon only) | `launcher/claudemax` with `CC_THINKING_DISPLAY=omitted` | +| `claude-think` (thinking only) | `launcher/claudemax` with `CC_PATCH_CONTEXT_ICON=0` (and `CC_PATCH_MD_COPY=0` if you do not want the copy UI) | +| `claude-context` (context icon only) | `launcher/claudemax` with `CC_THINKING_DISPLAY=omitted` (and `CC_PATCH_MD_COPY=0` if you do not want the copy UI) | | any `.exe` | the single `claudemax.exe`; scope features via `CC_*` (VS Code `claudeCode.environmentVariables`) | +> The unified launcher enables **every** fix by default, including the new +> markdown copy/export controls in the chat UI. If you do not want those controls, +> set `CC_PATCH_MD_COPY=0` (the webview is left untouched and any prior install is +> reverted on the next launch). + Old release assets remain available for anyone pinned to a previous version. --- @@ -309,6 +321,10 @@ Setup is otherwise identical to Option 1. This is unrelated to the fixes above. | [`fixes/thinking-summaries/proxy.js`](fixes/thinking-summaries/proxy.js) | thinking | Option 3 localhost proxy. Advanced and untested. | | [`fixes/thinking-summaries/test-thinking-display.sh`](fixes/thinking-summaries/test-thinking-display.sh) | thinking | Live A/B test showing that the flag is the relevant lever. | | [`fixes/context-icon/fix-context-icon.py`](fixes/context-icon/fix-context-icon.py) | context icon | Option 2 standalone webview patcher with `--revert`. | +| [`fixes/markdown-copy-export/add-md-copy.py`](fixes/markdown-copy-export/add-md-copy.py) | markdown copy | Standalone webview patcher (sentinel block, reverse-transform `--revert`). | +| [`fixes/markdown-copy-export/cc-export.py`](fixes/markdown-copy-export/cc-export.py) | markdown copy | Standalone session exporter (markdown/text, `--open`). | +| [`fixes/markdown-copy-export/webview-inject.js`](fixes/markdown-copy-export/webview-inject.js) | markdown copy | Single source of the appended copy-controls IIFE. | +| [`tools/gen-embeds`](tools/gen-embeds) | markdown copy | Generates the embedded payload into the launcher + patcher; `--check` drift gate. | | [`TECHNICAL.md`](TECHNICAL.md) | both | Full root-cause analysis, the reconcile model, and design notes. | ## Releases diff --git a/TECHNICAL.md b/TECHNICAL.md index 34bd2fd..2be784d 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -217,6 +217,25 @@ Every launch, for each webview file a bundle-patch feature targets, the launcher Timing note: the wrapper patches `index.js` on disk when the CLI is spawned, which can be *after* the webview already loaded the old bundle. So the first time you enable it you may need two reloads (the spawn patches the file, then the webview loads the patched bundle). Later windows and post-update launches are already patched on disk. +**Append features and multiple files.** A feature is either an *in-place* edit +(context-icon: a marked swap deep in `index.js`) or an *append* (md-copy: a +sentinel-delimited block at end-of-file). Each append feature's `undo` is +marker-scoped block removal (it deletes exactly its own OPEN..CLOSE block and +keeps any bytes after CLOSE), so append features compose with in-place features +and with each other without overlap, regardless of registration order. The reconcile runs per +file with that file's own feature list: `index.js` carries `[context-icon, +md-copy]`; `index.css` carries `[md-copy]`. md-copy's block is byte-identical +across the bash launcher (quoted heredocs), the node launcher (JSON string +literals), and the standalone `add-md-copy.py` (base64): `"\n" + OPEN + "\n" + +PAYLOAD + "\n" + CLOSE + "\n"`, where `OPEN`/`CLOSE` are `/* cc-md-copy v1 */` +and `/* /cc-md-copy v1 */` and `PAYLOAD` is the source with trailing newlines +stripped. The payload's single source is `fixes/markdown-copy-export/webview-inject.{js,css}`; +`tools/gen-embeds` writes the embedded copies and `tools/gen-embeds --check` +fails the build on drift. The standalone `add-md-copy.py` keeps its own +`.bak-md-copy` emergency snapshot; its `--revert` is the same reverse transform +(not a backup restore), so removing md-copy never disturbs a co-applied +context-icon. + ## How the icon works (context for future changes) ### Data source resets on reload diff --git a/fixes/markdown-copy-export/README.md b/fixes/markdown-copy-export/README.md new file mode 100644 index 0000000..98ae7f0 --- /dev/null +++ b/fixes/markdown-copy-export/README.md @@ -0,0 +1,64 @@ +# markdown-copy-export + +## What it fixes + +The Claude Code VS Code chat has no way to copy a whole message or the whole +conversation as Markdown (only code blocks have a copy button). This adds a +per-message copy control (Markdown primary, plain text secondary) on every user +and assistant message, a "copy entire conversation" control, and a standalone +CLI that exports a session transcript to Markdown/plain text or opens the raw +`.jsonl`. Affects the VS Code extension webview; the CLI is independent of it. + +## Standalone usage + +Webview controls (one-shot patch, re-applied automatically by the launcher): + + python3 fixes/markdown-copy-export/add-md-copy.py # patch all installs + python3 fixes/markdown-copy-export/add-md-copy.py --revert # remove (reverse transform) + python3 fixes/markdown-copy-export/add-md-copy.py /path/to/webview/index.js + +Reload the window after patching (first enable may need two reloads, like +context-icon). `--revert` removes only our sentinel block, so it composes with +the context-icon patcher on the same file. + +Session exporter (independent of the webview): + + python3 fixes/markdown-copy-export/cc-export.py # latest session -> markdown + python3 fixes/markdown-copy-export/cc-export.py --format text + python3 fixes/markdown-copy-export/cc-export.py --include-thinking --include-tools + python3 fixes/markdown-copy-export/cc-export.py --session ID -o out.md + python3 fixes/markdown-copy-export/cc-export.py --open # open raw .jsonl in VS Code + +## Launcher toggle + +`CC_PATCH_MD_COPY` (default `1`). `0` leaves the webview without the copy +controls and reverts ours on the next launch. `CC_WORKAROUNDS=0` reverts it too. + +## Maintenance Contract + +- Anchors / selectors: user bubble `[class*="userMessageContainer_"]`; assistant + bubble `[data-testid="assistant-message"]` (NOT `[data-message-rating]` — that is + the nested, experiment+analytics-gated rating widget, which the sanitizer strips); + chrome strip-prefixes `toolUse_`/`toolResult_`/`toolReference_`/`thinking_`/ + `unknownContent_` plus `[data-message-rating]` and `button`; + `navigator.clipboard.writeText` (proven to work via the built-in copy-code + button). Optional refinements pinned at install: the messages container and the + single all-content wrapper, if any (see the inject source constants + `MESSAGES_CONTAINER` / `ASSISTANT_CONTENT`). When a bundle update renames the + bubble anchor or a chrome hook, the Phase-9 selector guard fails loudly; re-pin + the constant and the Phase-2 fixtures together. +- Ownership marker: the sentinel block `/* cc-md-copy v1 */ ... /* /cc-md-copy v1 */` + appended to `webview/index.js` (the IIFE) and `webview/index.css` (its styles). +- Failure mode if an anchor moves: fails safe - the controls simply do not appear + (the IIFE no-ops; `boot()` is fully guarded). The conversation walk degrades to + "no bubbles matched", never wrong output. The launcher patch still installs. +- Launcher registry entry: feature id `md-copy`, files `webview/index.js` + + `webview/index.css`, apply = append the sentinel block (registered LAST, after + context-icon), undo = marker-scoped block removal (deletes exactly its own + OPEN..CLOSE block, keeps any bytes after CLOSE; composes regardless of ordering). + The payload is the single source `webview-inject.{js,css}`, + embedded into the launcher and `add-md-copy.py` by `tools/gen-embeds` (CI drift + check: `tools/gen-embeds --check`). +- Test fixture: `tests/test_md_converter.py`, `tests/test_md_inject.py`, + `tests/test_md_patcher.py`, `tests/test_md_export.py`, `tests/test_gen_embeds.py`, + and the `md-copy` cases in `tests/test_reconcile.py`. diff --git a/launcher/README.md b/launcher/README.md index 64aecf5..d9f2c43 100644 --- a/launcher/README.md +++ b/launcher/README.md @@ -37,6 +37,7 @@ Terminal: | `CC_RECONCILE` | `1` | `0` = do not read or write the webview bundle this launch (emergency bypass). Argument injection still runs. | | `CC_THINKING_DISPLAY` | `summarized` | `summarized` shows extended-thinking summaries; `omitted` hides them (no injection). | | `CC_PATCH_CONTEXT_ICON` | `1` | `0` leaves the context-usage icon unpatched (and reverts ours on the next launch). | +| `CC_PATCH_MD_COPY` | `1` | `0` leaves the webview without the markdown copy/export controls (and reverts ours on the next launch). | Setting toggles without touching the script: From f8e1f19fa331743fa784fb8c4051cd1e6e2debd5 Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 12/15] ci(md-copy): node/py checks for the new files + embed drift check --- .github/workflows/ci.yml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9e882d..1685652 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,12 +32,27 @@ jobs: - name: Node syntax check (node --check) run: | - for f in launcher/claudemax.win.js fixes/thinking-summaries/proxy.js; do + for f in launcher/claudemax.win.js fixes/thinking-summaries/proxy.js fixes/markdown-copy-export/webview-inject.js; do node --check "$f" done - name: Python compile - run: python3 -m py_compile fixes/context-icon/fix-context-icon.py tests/test_regressions.py tests/test_reconcile.py + run: > + python3 -m py_compile + fixes/context-icon/fix-context-icon.py + fixes/markdown-copy-export/cc-export.py + fixes/markdown-copy-export/add-md-copy.py + tools/gen-embeds + tests/test_regressions.py tests/test_reconcile.py + tests/test_md_export.py tests/test_md_converter.py tests/test_md_inject.py + tests/test_md_patcher.py tests/test_gen_embeds.py + + - name: Embed drift check + # The launcher + standalone patcher carry a generated copy of the md-copy + # webview payload. --strict fails the build if any of the three consumers is + # missing its embed region (so a vanished region can't pass silently); --check + # fails if a present region drifts from the single source. + run: python3 tools/gen-embeds --check --strict - name: Regression tests run: python3 -m unittest discover -s tests -v From 40dd8f90be4335542a38ca5a7c69a647e65e566a Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 13/15] refactor(md-copy): drop unused import os from tools/gen-embeds Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/gen-embeds | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/gen-embeds b/tools/gen-embeds index f45b6c1..ec6b6b2 100755 --- a/tools/gen-embeds +++ b/tools/gen-embeds @@ -18,7 +18,6 @@ file that would change is drift. """ import base64 import json -import os import pathlib import sys From e790c45a31e21811c4dd014b2c8a740c585828de Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 14/15] fix(md-copy): delimiter-safe code fences in converter and exporter Inline code used single backticks and fenced blocks always used three, so copied/exported content that itself contained a backtick run closed the fence early and produced structurally-broken Markdown. Both the webview converter (htmlToMarkdown) and the CLI exporter (_fence) now choose a delimiter one longer than the longest backtick run in the content (>=3 for fences), and inline code gets CommonMark space-padding when an edge is a backtick. Regenerated the three embeds from the updated source. Adds converter + exporter tests for backtick content (Codex audit, Medium). Co-Authored-By: Claude Opus 4.8 (1M context) --- fixes/markdown-copy-export/add-md-copy.py | 2 +- fixes/markdown-copy-export/cc-export.py | 13 +++++++++++- fixes/markdown-copy-export/webview-inject.js | 22 ++++++++++++++++++-- launcher/claudemax | 22 ++++++++++++++++++-- launcher/claudemax.win.js | 2 +- tests/test_md_converter.py | 16 ++++++++++++++ tests/test_md_export.py | 12 +++++++++++ 7 files changed, 82 insertions(+), 7 deletions(-) diff --git a/fixes/markdown-copy-export/add-md-copy.py b/fixes/markdown-copy-export/add-md-copy.py index 2df12ff..2958e3e 100644 --- a/fixes/markdown-copy-export/add-md-copy.py +++ b/fixes/markdown-copy-export/add-md-copy.py @@ -31,7 +31,7 @@ BACKUP_SUFFIX = ".bak-md-copy" # >>>CCWA-MD-COPY-EMBED>>> (generated by tools/gen-embeds; do not edit) -INJECT_JS = base64.b64decode("LyogY2MtbWQtY29weTogcGVyLW1lc3NhZ2UgYW5kIHdob2xlLWNvbnZlcnNhdGlvbiBjb3B5IChtYXJrZG93bi9wbGFpbikgZm9yIHRoZQogKiBDbGF1ZGUgQ29kZSBWUyBDb2RlIHdlYnZpZXcuIFNlbGYtY29udGFpbmVkIElJRkUgYXBwZW5kZWQgdG8gd2Vidmlldy9pbmRleC5qcy4KICogQWRkaXRpdmUgYW5kIHJlYWQtb25seSB3LnIudC4gYXBwIHN0YXRlOyBrZXllZCBvbiBzdGFibGUgQ1NTLW1vZHVsZSBjbGFzcwogKiBwcmVmaXhlcywgc28gaXQgZmFpbHMgc2FmZSAoY29udHJvbHMgc2ltcGx5IGRvIG5vdCBhcHBlYXIpIGlmIGEgcHJlZml4IG1vdmVzLgogKiBFeHBvc2VzIGl0cyBwdXJlIGZ1bmN0aW9ucyBmb3Igbm9kZSB1bml0IHRlc3RzOyBib290KClzIG9ubHkgaW4gYSByZWFsIHdlYnZpZXcuICovCihmdW5jdGlvbiAoKSB7CiAgInVzZSBzdHJpY3QiOwoKICB2YXIgQ09OVFJPTF9QUkVGSVggPSAiY2MtbWQtY29weSI7IC8vIGV2ZXJ5IGluamVjdGVkIG5vZGUncyBjbGFzcyBzdGFydHMgd2l0aCB0aGlzCiAgdmFyIFVTRVJfQlVCQkxFID0gJ1tjbGFzcyo9InVzZXJNZXNzYWdlQ29udGFpbmVyXyJdJzsKICAvLyBBc3Npc3RhbnQgbWVzc2FnZSB3cmFwcGVyLiBWZXJpZmllZCBvbiAyLjEuMTcwOiB0aGUgcmVuZGVyIGVtaXRzIGV4YWN0bHkgb25lCiAgLy8gYGRhdGEtdGVzdGlkPSJhc3Npc3RhbnQtbWVzc2FnZSJgIGRpdiBwZXIgYXNzaXN0YW50IHR1cm4sIHdpdGggdGhlIHJhdGluZwogIC8vIHdpZGdldCBhbmQgY29udGVudCBibG9ja3MgYXMgaXRzIGNoaWxkcmVuLiAoVGhlIGVhcmxpZXIgYFtkYXRhLW1lc3NhZ2UtcmF0aW5nXWAKICAvLyB3YXMgV1JPTkc6IHRoYXQgYXR0cmlidXRlIHNpdHMgb24gdGhlIG5lc3RlZCByYXRpbmcgY29udHJvbCwgd2hpY2ggaXMgYWxzbyBvbmx5CiAgLy8gcmVuZGVyZWQgYmVoaW5kIGFuIGV4cGVyaW1lbnQrYW5hbHl0aWNzIGdhdGUuKSBSZS1waW5uZWQgaW4gVGFzayA2LgogIHZhciBBU1NJU1RBTlRfQlVCQkxFID0gJ1tkYXRhLXRlc3RpZD0iYXNzaXN0YW50LW1lc3NhZ2UiXSc7CiAgdmFyIE1FU1NBR0VTX0NPTlRBSU5FUiA9ICdbY2xhc3MqPSJtZXNzYWdlc0NvbnRhaW5lcl8iXSc7IC8vIGUuZy4gJ1tjbGFzcyo9InRpbWVsaW5lXyJdJzsgIiIgLT4gb2JzZXJ2ZSBkb2N1bWVudC5ib2R5CiAgLy8gT3B0aW9uYWwgbmFycm93aW5nIG9ubHkuIE1VU1QgYmUgYSBzaW5nbGUgd3JhcHBlciBhcm91bmQgQUxMIGNvbnRlbnQgYmxvY2tzLAogIC8vIG5vdCBhIHBlci1ibG9jayBjbGFzcyAoYSB0dXJuIGhhcyBtdWx0aXBsZSBibG9ja3MpLiAiIiAtPiB1c2UgdGhlIGJ1YmJsZSBpdHNlbGYKICAvLyAoYWxyZWFkeSBhZ2dyZWdhdGVzIGFsbCBibG9ja3M7IHNhbml0aXplQ2xvbmUgaXMgdGhlIGNvcnJlY3RuZXNzIGdhdGUpLgogIHZhciBBU1NJU1RBTlRfQ09OVEVOVCA9ICIiOwogIHZhciBGRUVEQkFDS19NUyA9IDE4MDA7CgogIC8vIC0tLS0gSFRNTCAtPiBNYXJrZG93biAoRE9NIHdhbGspIC0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0KICAvLyBVc2VzIG9ubHk6IG5vZGVUeXBlLCB0YWdOYW1lLCBjaGlsZE5vZGVzLCB0ZXh0Q29udGVudCwgZ2V0QXR0cmlidXRlLCBjbGFzc05hbWUuCiAgZnVuY3Rpb24gaHRtbFRvTWFya2Rvd24ocm9vdCkgewogICAgZnVuY3Rpb24gaW5saW5lKG5vZGUpIHsKICAgICAgdmFyIG91dCA9ICIiOwogICAgICB2YXIga2lkcyA9IG5vZGUuY2hpbGROb2RlcyB8fCBbXTsKICAgICAgZm9yICh2YXIgaSA9IDA7IGkgPCBraWRzLmxlbmd0aDsgaSsrKSB7CiAgICAgICAgdmFyIGMgPSBraWRzW2ldOwogICAgICAgIGlmIChjLm5vZGVUeXBlID09PSAzKSB7IG91dCArPSBjLnRleHRDb250ZW50IHx8ICIiOyBjb250aW51ZTsgfQogICAgICAgIGlmIChjLm5vZGVUeXBlICE9PSAxKSBjb250aW51ZTsKICAgICAgICB2YXIgdGFnID0gKGMudGFnTmFtZSB8fCAiIikudG9VcHBlckNhc2UoKTsKICAgICAgICBpZiAodGFnID09PSAiQlIiKSBvdXQgKz0gIlxuIjsKICAgICAgICBlbHNlIGlmICh0YWcgPT09ICJTVFJPTkciIHx8IHRhZyA9PT0gIkIiKSBvdXQgKz0gIioqIiArIGlubGluZShjKSArICIqKiI7CiAgICAgICAgZWxzZSBpZiAodGFnID09PSAiRU0iIHx8IHRhZyA9PT0gIkkiKSBvdXQgKz0gIioiICsgaW5saW5lKGMpICsgIioiOwogICAgICAgIGVsc2UgaWYgKHRhZyA9PT0gIkRFTCIgfHwgdGFnID09PSAiUyIpIG91dCArPSAifn4iICsgaW5saW5lKGMpICsgIn5+IjsKICAgICAgICBlbHNlIGlmICh0YWcgPT09ICJDT0RFIikgb3V0ICs9ICJgIiArIChjLnRleHRDb250ZW50IHx8ICIiKSArICJgIjsKICAgICAgICBlbHNlIGlmICh0YWcgPT09ICJBIikgewogICAgICAgICAgdmFyIGhyZWYgPSBjLmdldEF0dHJpYnV0ZSA/IGMuZ2V0QXR0cmlidXRlKCJocmVmIikgOiBudWxsOwogICAgICAgICAgdmFyIHQgPSBpbmxpbmUoYyk7CiAgICAgICAgICBvdXQgKz0gaHJlZiA/ICJbIiArIHQgKyAiXSgiICsgaHJlZiArICIpIiA6IHQ7CiAgICAgICAgfSBlbHNlIG91dCArPSBpbmxpbmUoYyk7IC8vIHVua25vd24gaW5saW5lIHdyYXBwZXI6IGtlZXAgdGV4dCwgZHJvcCB0YWcKICAgICAgfQogICAgICByZXR1cm4gb3V0OwogICAgfQogICAgZnVuY3Rpb24gbGFuZ09mKGNvZGVFbCkgewogICAgICB2YXIgY2xzID0gIiI7CiAgICAgIGlmIChjb2RlRWwpIGNscyA9IChjb2RlRWwuZ2V0QXR0cmlidXRlICYmIGNvZGVFbC5nZXRBdHRyaWJ1dGUoImNsYXNzIikpIHx8IGNvZGVFbC5jbGFzc05hbWUgfHwgIiI7CiAgICAgIHZhciBtID0gL2xhbmd1YWdlLShbQS1aYS16MC05KyMuXC1dKykvLmV4ZWMoY2xzIHx8ICIiKTsKICAgICAgcmV0dXJuIG0gPyBtWzFdIDogIiI7CiAgICB9CiAgICBmdW5jdGlvbiBmaW5kQ2hpbGRUYWcobm9kZSwgdGFnKSB7CiAgICAgIHZhciBraWRzID0gbm9kZS5jaGlsZE5vZGVzIHx8IFtdOwogICAgICBmb3IgKHZhciBpID0gMDsgaSA8IGtpZHMubGVuZ3RoOyBpKyspIHsKICAgICAgICBpZiAoa2lkc1tpXS5ub2RlVHlwZSA9PT0gMSAmJiAoa2lkc1tpXS50YWdOYW1lIHx8ICIiKS50b1VwcGVyQ2FzZSgpID09PSB0YWcpIHJldHVybiBraWRzW2ldOwogICAgICB9CiAgICAgIHJldHVybiBudWxsOwogICAgfQogICAgZnVuY3Rpb24gbGlzdChub2RlLCBvcmRlcmVkLCBkZXB0aCkgewogICAgICB2YXIgb3V0ID0gIiIsIG4gPSAxOwogICAgICB2YXIga2lkcyA9IG5vZGUuY2hpbGROb2RlcyB8fCBbXTsKICAgICAgZm9yICh2YXIgaSA9IDA7IGkgPCBraWRzLmxlbmd0aDsgaSsrKSB7CiAgICAgICAgdmFyIGxpID0ga2lkc1tpXTsKICAgICAgICBpZiAobGkubm9kZVR5cGUgIT09IDEgfHwgKGxpLnRhZ05hbWUgfHwgIiIpLnRvVXBwZXJDYXNlKCkgIT09ICJMSSIpIGNvbnRpbnVlOwogICAgICAgIHZhciBtYXJrZXIgPSBvcmRlcmVkID8gbisrICsgIi4gIiA6ICItICI7CiAgICAgICAgdmFyIGluZGVudCA9IG5ldyBBcnJheShkZXB0aCArIDEpLmpvaW4oIiAgIik7CiAgICAgICAgdmFyIGxlYWQgPSAiIiwgbmVzdGVkID0gIiI7CiAgICAgICAgdmFyIGxrID0gbGkuY2hpbGROb2RlcyB8fCBbXTsKICAgICAgICBmb3IgKHZhciBqID0gMDsgaiA8IGxrLmxlbmd0aDsgaisrKSB7CiAgICAgICAgICB2YXIgY2ggPSBsa1tqXTsKICAgICAgICAgIHZhciBjdCA9IGNoLm5vZGVUeXBlID09PSAxID8gKGNoLnRhZ05hbWUgfHwgIiIpLnRvVXBwZXJDYXNlKCkgOiAiIjsKICAgICAgICAgIGlmIChjdCA9PT0gIlVMIikgbmVzdGVkICs9IGxpc3QoY2gsIGZhbHNlLCBkZXB0aCArIDEpOwogICAgICAgICAgZWxzZSBpZiAoY3QgPT09ICJPTCIpIG5lc3RlZCArPSBsaXN0KGNoLCB0cnVlLCBkZXB0aCArIDEpOwogICAgICAgICAgZWxzZSBpZiAoY2gubm9kZVR5cGUgPT09IDMpIGxlYWQgKz0gY2gudGV4dENvbnRlbnQgfHwgIiI7CiAgICAgICAgICBlbHNlIGxlYWQgKz0gaW5saW5lKGNoKTsKICAgICAgICB9CiAgICAgICAgb3V0ICs9IGluZGVudCArIG1hcmtlciArIGxlYWQudHJpbSgpICsgIlxuIiArIG5lc3RlZDsKICAgICAgfQogICAgICByZXR1cm4gb3V0OwogICAgfQogICAgZnVuY3Rpb24gdGFibGUobm9kZSkgewogICAgICB2YXIgcm93cyA9IFtdOwogICAgICAoZnVuY3Rpb24gY29sbGVjdChjb250YWluZXIpIHsKICAgICAgICB2YXIga2lkcyA9IGNvbnRhaW5lci5jaGlsZE5vZGVzIHx8IFtdOwogICAgICAgIGZvciAodmFyIGkgPSAwOyBpIDwga2lkcy5sZW5ndGg7IGkrKykgewogICAgICAgICAgdmFyIGMgPSBraWRzW2ldOwogICAgICAgICAgaWYgKGMubm9kZVR5cGUgIT09IDEpIGNvbnRpbnVlOwogICAgICAgICAgdmFyIHQgPSAoYy50YWdOYW1lIHx8ICIiKS50b1VwcGVyQ2FzZSgpOwogICAgICAgICAgaWYgKHQgPT09ICJUSEVBRCIgfHwgdCA9PT0gIlRCT0RZIiB8fCB0ID09PSAiVEZPT1QiKSBjb2xsZWN0KGMpOwogICAgICAgICAgZWxzZSBpZiAodCA9PT0gIlRSIikgewogICAgICAgICAgICB2YXIgY2VsbHMgPSBbXSwgY2MgPSBjLmNoaWxkTm9kZXMgfHwgW107CiAgICAgICAgICAgIGZvciAodmFyIGogPSAwOyBqIDwgY2MubGVuZ3RoOyBqKyspIHsKICAgICAgICAgICAgICB2YXIgZCA9IGNjW2pdOwogICAgICAgICAgICAgIGlmIChkLm5vZGVUeXBlICE9PSAxKSBjb250aW51ZTsKICAgICAgICAgICAgICB2YXIgZHQgPSAoZC50YWdOYW1lIHx8ICIiKS50b1VwcGVyQ2FzZSgpOwogICAgICAgICAgICAgIGlmIChkdCA9PT0gIlRIIiB8fCBkdCA9PT0gIlREIikgY2VsbHMucHVzaChpbmxpbmUoZCkudHJpbSgpKTsKICAgICAgICAgICAgfQogICAgICAgICAgICByb3dzLnB1c2goY2VsbHMpOwogICAgICAgICAgfQogICAgICAgIH0KICAgICAgfSkobm9kZSk7CiAgICAgIGlmICghcm93cy5sZW5ndGgpIHJldHVybiAiIjsKICAgICAgdmFyIGhlYWQgPSByb3dzWzBdLCBib2R5ID0gcm93cy5zbGljZSgxKTsKICAgICAgdmFyIHNlcCA9IGhlYWQubWFwKGZ1bmN0aW9uICgpIHsgcmV0dXJuICItLS0iOyB9KTsKICAgICAgdmFyIG91dCA9ICJ8ICIgKyBoZWFkLmpvaW4oIiB8ICIpICsgIiB8XG58ICIgKyBzZXAuam9pbigiIHwgIikgKyAiIHxcbiI7CiAgICAgIGZvciAodmFyIGsgPSAwOyBrIDwgYm9keS5sZW5ndGg7IGsrKykgb3V0ICs9ICJ8ICIgKyBib2R5W2tdLmpvaW4oIiB8ICIpICsgIiB8XG4iOwogICAgICByZXR1cm4gb3V0OwogICAgfQogICAgZnVuY3Rpb24gYmxvY2sobm9kZSkgewogICAgICB2YXIgb3V0ID0gIiI7CiAgICAgIHZhciBraWRzID0gbm9kZS5jaGlsZE5vZGVzIHx8IFtdOwogICAgICBmb3IgKHZhciBpID0gMDsgaSA8IGtpZHMubGVuZ3RoOyBpKyspIHsKICAgICAgICB2YXIgYyA9IGtpZHNbaV07CiAgICAgICAgaWYgKGMubm9kZVR5cGUgPT09IDMpIHsgaWYgKChjLnRleHRDb250ZW50IHx8ICIiKS50cmltKCkpIG91dCArPSBjLnRleHRDb250ZW50OyBjb250aW51ZTsgfQogICAgICAgIGlmIChjLm5vZGVUeXBlICE9PSAxKSBjb250aW51ZTsKICAgICAgICB2YXIgdGFnID0gKGMudGFnTmFtZSB8fCAiIikudG9VcHBlckNhc2UoKTsKICAgICAgICBpZiAoL15IWzEtNl0kLy50ZXN0KHRhZykpIG91dCArPSBuZXcgQXJyYXkoK3RhZ1sxXSArIDEpLmpvaW4oIiMiKSArICIgIiArIGlubGluZShjKS50cmltKCkgKyAiXG5cbiI7CiAgICAgICAgZWxzZSBpZiAodGFnID09PSAiUCIpIG91dCArPSBpbmxpbmUoYykudHJpbSgpICsgIlxuXG4iOwogICAgICAgIGVsc2UgaWYgKHRhZyA9PT0gIlVMIikgb3V0ICs9IGxpc3QoYywgZmFsc2UsIDApICsgIlxuIjsKICAgICAgICBlbHNlIGlmICh0YWcgPT09ICJPTCIpIG91dCArPSBsaXN0KGMsIHRydWUsIDApICsgIlxuIjsKICAgICAgICBlbHNlIGlmICh0YWcgPT09ICJQUkUiKSB7CiAgICAgICAgICB2YXIgY29kZSA9IGZpbmRDaGlsZFRhZyhjLCAiQ09ERSIpOwogICAgICAgICAgdmFyIGxhbmcgPSBsYW5nT2YoY29kZSB8fCBjKTsKICAgICAgICAgIHZhciBib2R5ID0gKGNvZGUgfHwgYykudGV4dENvbnRlbnQgfHwgIiI7CiAgICAgICAgICBvdXQgKz0gImBgYCIgKyBsYW5nICsgIlxuIiArIGJvZHkucmVwbGFjZSgvXG4kLywgIiIpICsgIlxuYGBgXG5cbiI7CiAgICAgICAgfSBlbHNlIGlmICh0YWcgPT09ICJCTE9DS1FVT1RFIikgewogICAgICAgICAgdmFyIGlubmVyID0gYmxvY2soYykudHJpbSgpLnNwbGl0KCJcbiIpLm1hcChmdW5jdGlvbiAobCkgeyByZXR1cm4gIj4gIiArIGw7IH0pLmpvaW4oIlxuIik7CiAgICAgICAgICBvdXQgKz0gaW5uZXIgKyAiXG5cbiI7CiAgICAgICAgfSBlbHNlIGlmICh0YWcgPT09ICJIUiIpIG91dCArPSAiLS0tXG5cbiI7CiAgICAgICAgZWxzZSBpZiAodGFnID09PSAiVEFCTEUiKSBvdXQgKz0gdGFibGUoYykgKyAiXG4iOwogICAgICAgIGVsc2UgaWYgKHRhZyA9PT0gIkJSIikgb3V0ICs9ICJcbiI7CiAgICAgICAgZWxzZSBpZiAodGFnID09PSAiU1RST05HIiB8fCB0YWcgPT09ICJCIiB8fCB0YWcgPT09ICJFTSIgfHwgdGFnID09PSAiSSIgfHwKICAgICAgICAgICAgICAgICB0YWcgPT09ICJBIiB8fCB0YWcgPT09ICJDT0RFIiB8fCB0YWcgPT09ICJERUwiIHx8IHRhZyA9PT0gIlMiKQogICAgICAgICAgb3V0ICs9IGlubGluZShjKSArICJcblxuIjsKICAgICAgICBlbHNlIG91dCArPSBibG9jayhjKTsgLy8gdW5rbm93biB3cmFwcGVyOiByZWN1cnNlIChkcm9wIHRhZywga2VlcCBjb250ZW50KQogICAgICB9CiAgICAgIHJldHVybiBvdXQ7CiAgICB9CiAgICAvLyBibG9jaygpIGRpc3BhdGNoZXMgb24gZWFjaCBDSElMRCdzIHRhZywgdHJlYXRpbmcgdGhlIHBhc3NlZCBub2RlIGFzIGEgcGxhaW4KICAgIC8vIGNvbnRhaW5lci4gV3JhcCByb290IGluIGEgb25lLW9mZiBjb250YWluZXIgc28gcm9vdCdzIE9XTiB0YWcgaXMgZGlzcGF0Y2hlZAogICAgLy8gdG9vOiBjYWxsZXJzIHBhc3MgZWl0aGVyIHRoZSBidWJibGUgY29udGFpbmVyIChpdHMgYmxvY2sgY2hpbGRyZW4gcmVuZGVyKSBvcgogICAgLy8gYSBzaW5nbGUgYmxvY2sgZWxlbWVudCBsaWtlIDxwcmU+Lzx1bD4vPHRhYmxlPiAobm93IGhhbmRsZWQsIG5vdCBmbGF0dGVuZWQpLgogICAgcmV0dXJuIGJsb2NrKHsgY2hpbGROb2RlczogW3Jvb3RdIH0pLnJlcGxhY2UoL1xuezMsfS9nLCAiXG5cbiIpLnRyaW0oKTsKICB9CgogIC8vIC0tLS0gcHVyZSBoZWxwZXJzIC0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0KICBmdW5jdGlvbiBoYXNQcmVmaXgobm9kZSwgcHJlZml4KSB7CiAgICBpZiAobm9kZS5ub2RlVHlwZSAhPT0gMSB8fCB0eXBlb2Ygbm9kZS5jbGFzc05hbWUgIT09ICJzdHJpbmciKSByZXR1cm4gZmFsc2U7CiAgICB2YXIgcGFydHMgPSBub2RlLmNsYXNzTmFtZS5zcGxpdCgvXHMrLyk7CiAgICBmb3IgKHZhciBpID0gMDsgaSA8IHBhcnRzLmxlbmd0aDsgaSsrKSBpZiAocGFydHNbaV0uaW5kZXhPZihwcmVmaXgpID09PSAwKSByZXR1cm4gdHJ1ZTsKICAgIHJldHVybiBmYWxzZTsKICB9CgogIC8vIENsYXNzLXByZWZpeCBob29rcyBmb3Igbm9uLWNvbnRlbnQgY2hyb21lIHRoYXQgcmVuZGVycyAqaW5zaWRlKiBhbiBhc3Npc3RhbnQKICAvLyBidWJibGUgKHZlcmlmaWVkIG9uIDIuMS4xNzA7IFRhc2sgNiByZS1waW5zIHRoZXNlKS4gdG9vbCovdGhpbmtpbmdfIGFyZSB0aGUgdjEKICAvLyBleGNsdXNpb25zOyB1bmtub3duQ29udGVudF8gaXMgdGhlIHJlbmRlcmVyJ3MgZmFsbGJhY2sgZm9yIHVucmVjb2duaXplZCBibG9jawogIC8vIHR5cGVzLCBzbyBzdHJpcHBpbmcgaXQgbWFrZXMgYSAqZnV0dXJlKiBibG9jayB0eXBlIGZhaWwgc2FmZSB0byBleGNsdWRlZCByYXRoZXIKICAvLyB0aGFuIGxlYWtpbmcgIlVuc3VwcG9ydGVkIGNvbnRlbnQiIGludG8gdGhlIGNvcHkuIFJlLXBpbiBpZiBhIHByZWZpeCBtb3Zlcy4KICB2YXIgQ0hST01FX1BSRUZJWEVTID0gWyJ0b29sVXNlXyIsICJ0b29sUmVzdWx0XyIsICJ0b29sUmVmZXJlbmNlXyIsICJ0aGlua2luZ18iLCAidW5rbm93bkNvbnRlbnRfIl07CgogIC8vIFRydWUgZm9yIGFueSBub2RlIHRoYXQgbXVzdCBuZXZlciBhcHBlYXIgaW4gY29waWVkIG91dHB1dDogb3VyIG93biBjb250cm9scywKICAvLyB0aGUgcmF0aW5nIHdpZGdldCAoYGRhdGEtbWVzc2FnZS1yYXRpbmdgICsgaXRzICJUaGFua3MgZm9yIHlvdXIgZmVlZGJhY2siCiAgLy8gdGV4dCksIGFueSBidXR0b24gKGNvcHktY29kZSBjaHJvbWUpLCBhbmQgdGhlIGV4Y2x1ZGVkIGNvbnRlbnQgYmxvY2tzIGFib3ZlLgogIGZ1bmN0aW9uIGlzQ2hyb21lKG5vZGUpIHsKICAgIGlmIChub2RlLm5vZGVUeXBlICE9PSAxKSByZXR1cm4gZmFsc2U7CiAgICBpZiAoKG5vZGUudGFnTmFtZSB8fCAiIikudG9VcHBlckNhc2UoKSA9PT0gIkJVVFRPTiIpIHJldHVybiB0cnVlOwogICAgaWYgKG5vZGUuZ2V0QXR0cmlidXRlICYmIG5vZGUuZ2V0QXR0cmlidXRlKCJkYXRhLW1lc3NhZ2UtcmF0aW5nIikgIT09IG51bGwpIHJldHVybiB0cnVlOwogICAgaWYgKGhhc1ByZWZpeChub2RlLCBDT05UUk9MX1BSRUZJWCkpIHJldHVybiB0cnVlOwogICAgZm9yICh2YXIgaSA9IDA7IGkgPCBDSFJPTUVfUFJFRklYRVMubGVuZ3RoOyBpKyspIGlmIChoYXNQcmVmaXgobm9kZSwgQ0hST01FX1BSRUZJWEVTW2ldKSkgcmV0dXJuIHRydWU7CiAgICByZXR1cm4gZmFsc2U7CiAgfQoKICAvLyBEZWVwLWNsb25lIGBjb250ZW50Tm9kZWAsIHRoZW4gc3RyaXAgZXZlcnkgY2hyb21lIG5vZGUgc28gY29waWVkIG91dHB1dCBpcyB0aGUKICAvLyBtZXNzYWdlJ3MgdGV4dCBjb250ZW50IG9ubHkuIFRoaXMgaXMgYSBDT1JSRUNUTkVTUyBHQVRFLCBub3QgY29zbWV0aWM6IHRoZQogIC8vIGRlZmF1bHQgY29udGVudCBub2RlIGlzIHRoZSB3aG9sZSBidWJibGUgKGFsbCBjb250ZW50LWJsb2NrIHNpYmxpbmdzLCBzbyBtdWx0aS0KICAvLyBibG9jayBhc3Npc3RhbnQgdHVybnMgYXJlIGNhcHR1cmVkKSwgYW5kIHRoaXMgc3RyaXAtbGlzdCBpcyB0aGUgb25seSB0aGluZwogIC8vIGtlZXBpbmcgdGhlIHJhdGluZyB3aWRnZXQgYW5kIHYxLWV4Y2x1ZGVkIGJsb2NrcyBvdXQgb2YgdGhlIGNvcHkuCiAgZnVuY3Rpb24gc2FuaXRpemVDbG9uZShjb250ZW50Tm9kZSkgewogICAgdmFyIGNsb25lID0gY29udGVudE5vZGUuY2xvbmVOb2RlKHRydWUpOwogICAgKGZ1bmN0aW9uIHN0cmlwKG5vZGUpIHsKICAgICAgdmFyIGtpZHMgPSAobm9kZS5jaGlsZE5vZGVzIHx8IFtdKS5zbGljZSgpOwogICAgICBmb3IgKHZhciBpID0gMDsgaSA8IGtpZHMubGVuZ3RoOyBpKyspIHsKICAgICAgICB2YXIgYyA9IGtpZHNbaV07CiAgICAgICAgaWYgKGMubm9kZVR5cGUgPT09IDEgJiYgaXNDaHJvbWUoYykpIHsgbm9kZS5yZW1vdmVDaGlsZChjKTsgY29udGludWU7IH0KICAgICAgICBpZiAoYy5ub2RlVHlwZSA9PT0gMSkgc3RyaXAoYyk7CiAgICAgIH0KICAgIH0pKGNsb25lKTsKICAgIHJldHVybiBjbG9uZTsKICB9CgogIGZ1bmN0aW9uIGNsYXNzaWZ5QnViYmxlKG5vZGUpIHsKICAgIGlmIChub2RlLm5vZGVUeXBlICE9PSAxKSByZXR1cm4gbnVsbDsKICAgIGlmIChoYXNQcmVmaXgobm9kZSwgInVzZXJNZXNzYWdlQ29udGFpbmVyXyIpKSByZXR1cm4gInVzZXIiOwogICAgaWYgKG5vZGUuZ2V0QXR0cmlidXRlICYmIG5vZGUuZ2V0QXR0cmlidXRlKCJkYXRhLXRlc3RpZCIpID09PSAiYXNzaXN0YW50LW1lc3NhZ2UiKSByZXR1cm4gImFzc2lzdGFudCI7CiAgICByZXR1cm4gbnVsbDsKICB9CgogIC8vIEJ1aWxkIHRoZSB3aG9sZS1jb252ZXJzYXRpb24gbWFya2Rvd24gZnJvbSBhbiBvcmRlcmVkIGxpc3Qgb2YgYnViYmxlcy4KICAvLyBgY29udGVudE9mKGJ1YmJsZSlgIHJlc29sdmVzIHRoZSBjb250ZW50IG5vZGUgKGRlZmF1bHQ6IHRoZSBidWJibGUgaXRzZWxmLCBzbwogIC8vIGV2ZXJ5IGNvbnRlbnQgYmxvY2sgaXMgaW5jbHVkZWQ7IHNhbml0aXplQ2xvbmUgZHJvcHMgY2hyb21lKTsgYSBkZWZhdWx0IGlzCiAgLy8gcHJvdmlkZWQgZm9yIHRlc3RzLgogIGZ1bmN0aW9uIGNvbnZlcnNhdGlvblRvTWFya2Rvd24oYnViYmxlcywgY29udGVudE9mKSB7CiAgICBjb250ZW50T2YgPSBjb250ZW50T2YgfHwgZnVuY3Rpb24gKGIpIHsgcmV0dXJuIGI7IH07CiAgICB2YXIgcGFydHMgPSBbXTsKICAgIGZvciAodmFyIGkgPSAwOyBpIDwgYnViYmxlcy5sZW5ndGg7IGkrKykgewogICAgICB2YXIgcm9sZSA9IGNsYXNzaWZ5QnViYmxlKGJ1YmJsZXNbaV0pOwogICAgICBpZiAoIXJvbGUpIGNvbnRpbnVlOwogICAgICB2YXIgY2xlYW4gPSBzYW5pdGl6ZUNsb25lKGNvbnRlbnRPZihidWJibGVzW2ldKSk7CiAgICAgIHZhciBib2R5ID0gcm9sZSA9PT0gImFzc2lzdGFudCIgPyBodG1sVG9NYXJrZG93bihjbGVhbikgOiAoY2xlYW4udGV4dENvbnRlbnQgfHwgIiIpLnRyaW0oKTsKICAgICAgaWYgKCFib2R5KSBjb250aW51ZTsKICAgICAgcGFydHMucHVzaCgocm9sZSA9PT0gInVzZXIiID8gIiMjIFVzZXIiIDogIiMjIEFzc2lzdGFudCIpICsgIlxuXG4iICsgYm9keSk7CiAgICB9CiAgICByZXR1cm4gcGFydHMuam9pbigiXG5cbiIpICsgKHBhcnRzLmxlbmd0aCA/ICJcbiIgOiAiIik7CiAgfQoKICAvLyAtLS0tIGV4cG9ydHMgKG5vZGUgdGVzdHMpIC8gYm9vdCAocmVhbCB3ZWJ2aWV3KSAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tCiAgaWYgKHR5cGVvZiBkb2N1bWVudCAhPT0gInVuZGVmaW5lZCIpIHsKICAgIGJvb3QoKTsKICB9IGVsc2UgaWYgKHR5cGVvZiBtb2R1bGUgIT09ICJ1bmRlZmluZWQiICYmIG1vZHVsZS5leHBvcnRzKSB7CiAgICBtb2R1bGUuZXhwb3J0cyA9IHsgaHRtbFRvTWFya2Rvd246IGh0bWxUb01hcmtkb3duLCBzYW5pdGl6ZUNsb25lOiBzYW5pdGl6ZUNsb25lLAogICAgICAgICAgICAgICAgICAgICAgIGNsYXNzaWZ5QnViYmxlOiBjbGFzc2lmeUJ1YmJsZSwgY29udmVyc2F0aW9uVG9NYXJrZG93bjogY29udmVyc2F0aW9uVG9NYXJrZG93biB9OwogIH0KCiAgLy8gLS0tLSBsaXZlLXdlYnZpZXcgd2lyaW5nIChydW5zIG9ubHkgd2hlbiBhIGRvY3VtZW50IGV4aXN0cykgLS0tLS0tLS0tLS0tLS0tLQogIGZ1bmN0aW9uIHFzKG5vZGUsIHNlbCkgeyB0cnkgeyByZXR1cm4gc2VsICYmIG5vZGUucXVlcnlTZWxlY3RvciA/IG5vZGUucXVlcnlTZWxlY3RvcihzZWwpIDogbnVsbDsgfSBjYXRjaCAoXykgeyByZXR1cm4gbnVsbDsgfSB9CiAgZnVuY3Rpb24gcXNhKHNlbCkgeyB0cnkgeyByZXR1cm4gQXJyYXkucHJvdG90eXBlLnNsaWNlLmNhbGwoZG9jdW1lbnQucXVlcnlTZWxlY3RvckFsbChzZWwpKTsgfSBjYXRjaCAoXykgeyByZXR1cm4gW107IH0gfQoKICAvLyBUaGUgY29udGVudCBub2RlIHRvIGNvbnZlcnQvY29weTogdGhlIG9wdGlvbmFsIEFTU0lTVEFOVF9DT05URU5UIHdyYXBwZXIgaWYKICAvLyBwaW5uZWQgYW5kIHByZXNlbnQsIGVsc2UgdGhlIGJ1YmJsZSBpdHNlbGYuIFRoZSBidWJibGUgYWxyZWFkeSBjb250YWlucyBldmVyeQogIC8vIGNvbnRlbnQtYmxvY2sgc2libGluZyBvZiBhIG11bHRpLWJsb2NrIHR1cm4sIGFuZCBzYW5pdGl6ZUNsb25lIHN0cmlwcyB0aGUKICAvLyBjaHJvbWUgKHJhdGluZyB3aWRnZXQsIHRvb2wvdGhpbmtpbmcvdW5rbm93biBibG9ja3MsIGJ1dHRvbnMsIG91ciBjb250cm9scykKICAvLyBlaXRoZXIgd2F5IC0tIHNvIHRoaXMgaXMgYSBuYXJyb3dpbmcsIG5ldmVyIHRoZSB0aGluZyB0aGF0IGd1YXJhbnRlZXMKICAvLyBjb3JyZWN0bmVzcy4KICBmdW5jdGlvbiBjb250ZW50Tm9kZU9mKGJ1YmJsZSwgcm9sZSkgewogICAgaWYgKHJvbGUgPT09ICJhc3Npc3RhbnQiICYmIEFTU0lTVEFOVF9DT05URU5UKSB7CiAgICAgIHZhciBuID0gcXMoYnViYmxlLCBBU1NJU1RBTlRfQ09OVEVOVCk7CiAgICAgIGlmIChuKSByZXR1cm4gbjsKICAgIH0KICAgIHJldHVybiBidWJibGU7CiAgfQoKICBmdW5jdGlvbiBjb3B5VGV4dCh0ZXh0KSB7CiAgICB0cnkgewogICAgICBpZiAobmF2aWdhdG9yLmNsaXBib2FyZCAmJiBuYXZpZ2F0b3IuY2xpcGJvYXJkLndyaXRlVGV4dCkgcmV0dXJuIG5hdmlnYXRvci5jbGlwYm9hcmQud3JpdGVUZXh0KHRleHQpOwogICAgfSBjYXRjaCAoXykge30KICAgIHJldHVybiBQcm9taXNlLnJlc29sdmUoKTsgLy8gYmVzdC1lZmZvcnQ7IG5ldmVyIHRocm93IGludG8gdGhlIGFwcAogIH0KCiAgZnVuY3Rpb24gZmxhc2hGZWVkYmFjayhob3N0KSB7CiAgICB0cnkgewogICAgICB2YXIgZmIgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCJzcGFuIik7CiAgICAgIGZiLmNsYXNzTmFtZSA9IENPTlRST0xfUFJFRklYICsgIi1mZWVkYmFjayI7CiAgICAgIGZiLnRleHRDb250ZW50ID0gIkNvcGllZCI7CiAgICAgIGhvc3QuYXBwZW5kQ2hpbGQoZmIpOwogICAgICBzZXRUaW1lb3V0KGZ1bmN0aW9uICgpIHsgaWYgKGZiICYmIGZiLnBhcmVudE5vZGUpIGZiLnBhcmVudE5vZGUucmVtb3ZlQ2hpbGQoZmIpOyB9LCBGRUVEQkFDS19NUyk7CiAgICB9IGNhdGNoIChfKSB7fQogIH0KCiAgZnVuY3Rpb24gYnViYmxlTWFya2Rvd24oYnViYmxlLCByb2xlKSB7CiAgICB2YXIgY2xlYW4gPSBzYW5pdGl6ZUNsb25lKGNvbnRlbnROb2RlT2YoYnViYmxlLCByb2xlKSk7CiAgICByZXR1cm4gcm9sZSA9PT0gImFzc2lzdGFudCIgPyBodG1sVG9NYXJrZG93bihjbGVhbikgOiAoY2xlYW4udGV4dENvbnRlbnQgfHwgIiIpLnRyaW0oKTsKICB9CiAgZnVuY3Rpb24gYnViYmxlUGxhaW4oYnViYmxlLCByb2xlKSB7CiAgICByZXR1cm4gKHNhbml0aXplQ2xvbmUoY29udGVudE5vZGVPZihidWJibGUsIHJvbGUpKS50ZXh0Q29udGVudCB8fCAiIikudHJpbSgpOwogIH0KCiAgLy8gQnVpbGQgYSBzaW5nbGUgY29udHJvbDogYSBwcmltYXJ5ICJDb3B5IiAobWFya2Rvd24pIHBsdXMgYSBzbWFsbCBjYXJldCB0aGF0CiAgLy8gdG9nZ2xlcyBhIG1lbnUgd2l0aCAiQ29weSBhcyBwbGFpbiB0ZXh0Ii4gQWxsIG5vZGVzIGNhcnJ5IHRoZSBDT05UUk9MX1BSRUZJWAogIC8vIGNsYXNzIHNvIHNhbml0aXplQ2xvbmUgcmVtb3ZlcyB0aGVtIGZyb20gYW55IGNvcGllZCBjb250ZW50LgogIGZ1bmN0aW9uIGJ1aWxkQ29udHJvbChvbk1hcmtkb3duLCBvblBsYWluKSB7CiAgICB2YXIgd3JhcCA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoInNwYW4iKTsKICAgIHdyYXAuY2xhc3NOYW1lID0gQ09OVFJPTF9QUkVGSVg7CiAgICB2YXIgcHJpbWFyeSA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoImJ1dHRvbiIpOwogICAgcHJpbWFyeS50eXBlID0gImJ1dHRvbiI7CiAgICBwcmltYXJ5LmNsYXNzTmFtZSA9IENPTlRST0xfUFJFRklYICsgIi1idG4iOwogICAgcHJpbWFyeS50aXRsZSA9ICJDb3B5IGFzIE1hcmtkb3duIjsKICAgIHByaW1hcnkudGV4dENvbnRlbnQgPSAiQ29weSI7CiAgICBwcmltYXJ5LmFkZEV2ZW50TGlzdGVuZXIoImNsaWNrIiwgZnVuY3Rpb24gKGUpIHsgZS5zdG9wUHJvcGFnYXRpb24oKTsgb25NYXJrZG93bihwcmltYXJ5KTsgfSk7CiAgICB2YXIgY2FyZXQgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCJidXR0b24iKTsKICAgIGNhcmV0LnR5cGUgPSAiYnV0dG9uIjsKICAgIGNhcmV0LmNsYXNzTmFtZSA9IENPTlRST0xfUFJFRklYICsgIi1jYXJldCI7CiAgICBjYXJldC50aXRsZSA9ICJDb3B5IG9wdGlvbnMiOwogICAgY2FyZXQudGV4dENvbnRlbnQgPSAi4pa+IjsgLy8gYmxhY2sgZG93bi1wb2ludGluZyBzbWFsbCB0cmlhbmdsZQogICAgdmFyIG1lbnUgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCJzcGFuIik7CiAgICBtZW51LmNsYXNzTmFtZSA9IENPTlRST0xfUFJFRklYICsgIi1tZW51IjsKICAgIG1lbnUuc3R5bGUuZGlzcGxheSA9ICJub25lIjsKICAgIHZhciBwbGFpbiA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoImJ1dHRvbiIpOwogICAgcGxhaW4udHlwZSA9ICJidXR0b24iOwogICAgcGxhaW4uY2xhc3NOYW1lID0gQ09OVFJPTF9QUkVGSVggKyAiLWJ0biI7CiAgICBwbGFpbi50ZXh0Q29udGVudCA9ICJDb3B5IGFzIHBsYWluIHRleHQiOwogICAgcGxhaW4uYWRkRXZlbnRMaXN0ZW5lcigiY2xpY2siLCBmdW5jdGlvbiAoZSkgeyBlLnN0b3BQcm9wYWdhdGlvbigpOyBtZW51LnN0eWxlLmRpc3BsYXkgPSAibm9uZSI7IG9uUGxhaW4ocGxhaW4pOyB9KTsKICAgIG1lbnUuYXBwZW5kQ2hpbGQocGxhaW4pOwogICAgY2FyZXQuYWRkRXZlbnRMaXN0ZW5lcigiY2xpY2siLCBmdW5jdGlvbiAoZSkgewogICAgICBlLnN0b3BQcm9wYWdhdGlvbigpOwogICAgICBtZW51LnN0eWxlLmRpc3BsYXkgPSBtZW51LnN0eWxlLmRpc3BsYXkgPT09ICJub25lIiA/ICJpbmxpbmUtYmxvY2siIDogIm5vbmUiOwogICAgfSk7CiAgICB3cmFwLmFwcGVuZENoaWxkKHByaW1hcnkpOwogICAgd3JhcC5hcHBlbmRDaGlsZChjYXJldCk7CiAgICB3cmFwLmFwcGVuZENoaWxkKG1lbnUpOwogICAgcmV0dXJuIHdyYXA7CiAgfQoKICBmdW5jdGlvbiBkZWNvcmF0ZShidWJibGUpIHsKICAgIHRyeSB7CiAgICAgIHZhciByb2xlID0gY2xhc3NpZnlCdWJibGUoYnViYmxlKTsKICAgICAgaWYgKCFyb2xlKSByZXR1cm47CiAgICAgIGlmIChxcyhidWJibGUsICIuIiArIENPTlRST0xfUFJFRklYKSkgcmV0dXJuOyAvLyBhbHJlYWR5IGRlY29yYXRlZAogICAgICB2YXIgY29udHJvbCA9IGJ1aWxkQ29udHJvbCgKICAgICAgICBmdW5jdGlvbiAoaG9zdCkgeyBjb3B5VGV4dChidWJibGVNYXJrZG93bihidWJibGUsIHJvbGUpKS50aGVuKGZ1bmN0aW9uICgpIHsgZmxhc2hGZWVkYmFjayhjb250cm9sKTsgfSk7IH0sCiAgICAgICAgZnVuY3Rpb24gKGhvc3QpIHsgY29weVRleHQoYnViYmxlUGxhaW4oYnViYmxlLCByb2xlKSkudGhlbihmdW5jdGlvbiAoKSB7IGZsYXNoRmVlZGJhY2soY29udHJvbCk7IH0pOyB9CiAgICAgICk7CiAgICAgIGJ1YmJsZS5hcHBlbmRDaGlsZChjb250cm9sKTsKICAgIH0gY2F0Y2ggKF8pIHt9CiAgfQoKICBmdW5jdGlvbiBjb3B5Q29udmVyc2F0aW9uKGZvcm1hdCkgewogICAgdmFyIGJ1YmJsZXMgPSBxc2EoVVNFUl9CVUJCTEUgKyAiLCIgKyBBU1NJU1RBTlRfQlVCQkxFKTsKICAgIGlmIChmb3JtYXQgPT09ICJ0ZXh0IikgewogICAgICB2YXIgbGluZXMgPSBbXTsKICAgICAgZm9yICh2YXIgaSA9IDA7IGkgPCBidWJibGVzLmxlbmd0aDsgaSsrKSB7CiAgICAgICAgdmFyIHJvbGUgPSBjbGFzc2lmeUJ1YmJsZShidWJibGVzW2ldKTsKICAgICAgICBpZiAoIXJvbGUpIGNvbnRpbnVlOwogICAgICAgIHZhciBib2R5ID0gYnViYmxlUGxhaW4oYnViYmxlc1tpXSwgcm9sZSk7CiAgICAgICAgaWYgKGJvZHkpIGxpbmVzLnB1c2goYm9keSk7CiAgICAgIH0KICAgICAgcmV0dXJuIGNvcHlUZXh0KGxpbmVzLmpvaW4oIlxuXG4iKSArIChsaW5lcy5sZW5ndGggPyAiXG4iIDogIiIpKTsKICAgIH0KICAgIHJldHVybiBjb3B5VGV4dChjb252ZXJzYXRpb25Ub01hcmtkb3duKGJ1YmJsZXMsIGZ1bmN0aW9uIChiKSB7CiAgICAgIHJldHVybiBjb250ZW50Tm9kZU9mKGIsIGNsYXNzaWZ5QnViYmxlKGIpKTsKICAgIH0pKTsKICB9CgogIGZ1bmN0aW9uIGluc3RhbGxDb252ZXJzYXRpb25Db250cm9sKCkgewogICAgdHJ5IHsKICAgICAgaWYgKHFzKGRvY3VtZW50LCAiLiIgKyBDT05UUk9MX1BSRUZJWCArICItY29udmVyc2F0aW9uIikpIHJldHVybjsKICAgICAgdmFyIGJhciA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoImRpdiIpOwogICAgICBiYXIuY2xhc3NOYW1lID0gQ09OVFJPTF9QUkVGSVggKyAiLWNvbnZlcnNhdGlvbiI7CiAgICAgIHZhciBjb250cm9sID0gYnVpbGRDb250cm9sKAogICAgICAgIGZ1bmN0aW9uICgpIHsgY29weUNvbnZlcnNhdGlvbigibWFya2Rvd24iKS50aGVuKGZ1bmN0aW9uICgpIHsgZmxhc2hGZWVkYmFjayhiYXIpOyB9KTsgfSwKICAgICAgICBmdW5jdGlvbiAoKSB7IGNvcHlDb252ZXJzYXRpb24oInRleHQiKS50aGVuKGZ1bmN0aW9uICgpIHsgZmxhc2hGZWVkYmFjayhiYXIpOyB9KTsgfQogICAgICApOwogICAgICBjb250cm9sLnRpdGxlID0gIkNvcHkgZW50aXJlIGNvbnZlcnNhdGlvbiI7CiAgICAgIGJhci5hcHBlbmRDaGlsZChjb250cm9sKTsKICAgICAgZG9jdW1lbnQuYm9keS5hcHBlbmRDaGlsZChiYXIpOyAvLyBmaXhlZC1wb3NpdGlvbiB2aWEgQ1NTOyBwbGFjZW1lbnQgcmVmaW5lZCBpbiBUYXNrIDYKICAgIH0gY2F0Y2ggKF8pIHt9CiAgfQoKICBmdW5jdGlvbiBzd2VlcCgpIHsgdmFyIGIgPSBxc2EoVVNFUl9CVUJCTEUgKyAiLCIgKyBBU1NJU1RBTlRfQlVCQkxFKTsgZm9yICh2YXIgaSA9IDA7IGkgPCBiLmxlbmd0aDsgaSsrKSBkZWNvcmF0ZShiW2ldKTsgfQoKICBmdW5jdGlvbiBib290KCkgewogICAgdHJ5IHsKICAgICAgdmFyIHRhcmdldCA9IChNRVNTQUdFU19DT05UQUlORVIgJiYgcXMoZG9jdW1lbnQsIE1FU1NBR0VTX0NPTlRBSU5FUikpIHx8IGRvY3VtZW50LmJvZHk7CiAgICAgIHN3ZWVwKCk7CiAgICAgIGluc3RhbGxDb252ZXJzYXRpb25Db250cm9sKCk7CiAgICAgIGlmICh0eXBlb2YgTXV0YXRpb25PYnNlcnZlciA9PT0gInVuZGVmaW5lZCIpIHJldHVybjsKICAgICAgdmFyIG9icyA9IG5ldyBNdXRhdGlvbk9ic2VydmVyKGZ1bmN0aW9uICgpIHsgc3dlZXAoKTsgfSk7CiAgICAgIG9icy5vYnNlcnZlKHRhcmdldCwgeyBjaGlsZExpc3Q6IHRydWUsIHN1YnRyZWU6IHRydWUgfSk7CiAgICB9IGNhdGNoIChfKSB7fQogIH0KfSkoKTsK").decode("utf-8") +INJECT_JS = base64.b64decode("LyogY2MtbWQtY29weTogcGVyLW1lc3NhZ2UgYW5kIHdob2xlLWNvbnZlcnNhdGlvbiBjb3B5IChtYXJrZG93bi9wbGFpbikgZm9yIHRoZQogKiBDbGF1ZGUgQ29kZSBWUyBDb2RlIHdlYnZpZXcuIFNlbGYtY29udGFpbmVkIElJRkUgYXBwZW5kZWQgdG8gd2Vidmlldy9pbmRleC5qcy4KICogQWRkaXRpdmUgYW5kIHJlYWQtb25seSB3LnIudC4gYXBwIHN0YXRlOyBrZXllZCBvbiBzdGFibGUgQ1NTLW1vZHVsZSBjbGFzcwogKiBwcmVmaXhlcywgc28gaXQgZmFpbHMgc2FmZSAoY29udHJvbHMgc2ltcGx5IGRvIG5vdCBhcHBlYXIpIGlmIGEgcHJlZml4IG1vdmVzLgogKiBFeHBvc2VzIGl0cyBwdXJlIGZ1bmN0aW9ucyBmb3Igbm9kZSB1bml0IHRlc3RzOyBib290KClzIG9ubHkgaW4gYSByZWFsIHdlYnZpZXcuICovCihmdW5jdGlvbiAoKSB7CiAgInVzZSBzdHJpY3QiOwoKICB2YXIgQ09OVFJPTF9QUkVGSVggPSAiY2MtbWQtY29weSI7IC8vIGV2ZXJ5IGluamVjdGVkIG5vZGUncyBjbGFzcyBzdGFydHMgd2l0aCB0aGlzCiAgdmFyIFVTRVJfQlVCQkxFID0gJ1tjbGFzcyo9InVzZXJNZXNzYWdlQ29udGFpbmVyXyJdJzsKICAvLyBBc3Npc3RhbnQgbWVzc2FnZSB3cmFwcGVyLiBWZXJpZmllZCBvbiAyLjEuMTcwOiB0aGUgcmVuZGVyIGVtaXRzIGV4YWN0bHkgb25lCiAgLy8gYGRhdGEtdGVzdGlkPSJhc3Npc3RhbnQtbWVzc2FnZSJgIGRpdiBwZXIgYXNzaXN0YW50IHR1cm4sIHdpdGggdGhlIHJhdGluZwogIC8vIHdpZGdldCBhbmQgY29udGVudCBibG9ja3MgYXMgaXRzIGNoaWxkcmVuLiAoVGhlIGVhcmxpZXIgYFtkYXRhLW1lc3NhZ2UtcmF0aW5nXWAKICAvLyB3YXMgV1JPTkc6IHRoYXQgYXR0cmlidXRlIHNpdHMgb24gdGhlIG5lc3RlZCByYXRpbmcgY29udHJvbCwgd2hpY2ggaXMgYWxzbyBvbmx5CiAgLy8gcmVuZGVyZWQgYmVoaW5kIGFuIGV4cGVyaW1lbnQrYW5hbHl0aWNzIGdhdGUuKSBSZS1waW5uZWQgaW4gVGFzayA2LgogIHZhciBBU1NJU1RBTlRfQlVCQkxFID0gJ1tkYXRhLXRlc3RpZD0iYXNzaXN0YW50LW1lc3NhZ2UiXSc7CiAgdmFyIE1FU1NBR0VTX0NPTlRBSU5FUiA9ICdbY2xhc3MqPSJtZXNzYWdlc0NvbnRhaW5lcl8iXSc7IC8vIGUuZy4gJ1tjbGFzcyo9InRpbWVsaW5lXyJdJzsgIiIgLT4gb2JzZXJ2ZSBkb2N1bWVudC5ib2R5CiAgLy8gT3B0aW9uYWwgbmFycm93aW5nIG9ubHkuIE1VU1QgYmUgYSBzaW5nbGUgd3JhcHBlciBhcm91bmQgQUxMIGNvbnRlbnQgYmxvY2tzLAogIC8vIG5vdCBhIHBlci1ibG9jayBjbGFzcyAoYSB0dXJuIGhhcyBtdWx0aXBsZSBibG9ja3MpLiAiIiAtPiB1c2UgdGhlIGJ1YmJsZSBpdHNlbGYKICAvLyAoYWxyZWFkeSBhZ2dyZWdhdGVzIGFsbCBibG9ja3M7IHNhbml0aXplQ2xvbmUgaXMgdGhlIGNvcnJlY3RuZXNzIGdhdGUpLgogIHZhciBBU1NJU1RBTlRfQ09OVEVOVCA9ICIiOwogIHZhciBGRUVEQkFDS19NUyA9IDE4MDA7CgogIC8vIC0tLS0gSFRNTCAtPiBNYXJrZG93biAoRE9NIHdhbGspIC0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0KICAvLyBVc2VzIG9ubHk6IG5vZGVUeXBlLCB0YWdOYW1lLCBjaGlsZE5vZGVzLCB0ZXh0Q29udGVudCwgZ2V0QXR0cmlidXRlLCBjbGFzc05hbWUuCiAgZnVuY3Rpb24gaHRtbFRvTWFya2Rvd24ocm9vdCkgewogICAgLy8gTG9uZ2VzdCBydW4gb2YgY29uc2VjdXRpdmUgYmFja3RpY2tzIGluIHMsIHNvIGEgY29kZSBkZWxpbWl0ZXIvZmVuY2UgY2FuIGJlCiAgICAvLyBjaG9zZW4gbG9uZ2VyIHRoYW4gYW55dGhpbmcgaW5zaWRlIGl0IChlbHNlIGBgYCBpbiB0aGUgY29udGVudCBjbG9zZXMgZWFybHkpLgogICAgZnVuY3Rpb24gYmFja3RpY2tSdW4ocykgewogICAgICB2YXIgbWF4ID0gMCwgY3VyID0gMDsKICAgICAgZm9yICh2YXIgaSA9IDA7IGkgPCBzLmxlbmd0aDsgaSsrKSB7CiAgICAgICAgaWYgKHMuY2hhckF0KGkpID09PSAiYCIpIHsgY3VyKys7IGlmIChjdXIgPiBtYXgpIG1heCA9IGN1cjsgfSBlbHNlIGN1ciA9IDA7CiAgICAgIH0KICAgICAgcmV0dXJuIG1heDsKICAgIH0KICAgIGZ1bmN0aW9uIGZlbmNlKHMsIG1pbikgeyB2YXIgbiA9IGJhY2t0aWNrUnVuKHMpICsgMTsgaWYgKG4gPCBtaW4pIG4gPSBtaW47IHJldHVybiBuZXcgQXJyYXkobiArIDEpLmpvaW4oImAiKTsgfQogICAgZnVuY3Rpb24gaW5saW5lKG5vZGUpIHsKICAgICAgdmFyIG91dCA9ICIiOwogICAgICB2YXIga2lkcyA9IG5vZGUuY2hpbGROb2RlcyB8fCBbXTsKICAgICAgZm9yICh2YXIgaSA9IDA7IGkgPCBraWRzLmxlbmd0aDsgaSsrKSB7CiAgICAgICAgdmFyIGMgPSBraWRzW2ldOwogICAgICAgIGlmIChjLm5vZGVUeXBlID09PSAzKSB7IG91dCArPSBjLnRleHRDb250ZW50IHx8ICIiOyBjb250aW51ZTsgfQogICAgICAgIGlmIChjLm5vZGVUeXBlICE9PSAxKSBjb250aW51ZTsKICAgICAgICB2YXIgdGFnID0gKGMudGFnTmFtZSB8fCAiIikudG9VcHBlckNhc2UoKTsKICAgICAgICBpZiAodGFnID09PSAiQlIiKSBvdXQgKz0gIlxuIjsKICAgICAgICBlbHNlIGlmICh0YWcgPT09ICJTVFJPTkciIHx8IHRhZyA9PT0gIkIiKSBvdXQgKz0gIioqIiArIGlubGluZShjKSArICIqKiI7CiAgICAgICAgZWxzZSBpZiAodGFnID09PSAiRU0iIHx8IHRhZyA9PT0gIkkiKSBvdXQgKz0gIioiICsgaW5saW5lKGMpICsgIioiOwogICAgICAgIGVsc2UgaWYgKHRhZyA9PT0gIkRFTCIgfHwgdGFnID09PSAiUyIpIG91dCArPSAifn4iICsgaW5saW5lKGMpICsgIn5+IjsKICAgICAgICBlbHNlIGlmICh0YWcgPT09ICJDT0RFIikgewogICAgICAgICAgdmFyIGN0ID0gYy50ZXh0Q29udGVudCB8fCAiIjsKICAgICAgICAgIHZhciBkID0gZmVuY2UoY3QsIDEpOwogICAgICAgICAgLy8gQ29tbW9uTWFyayBzdHJpcHMgb25lIGxlYWRpbmcrdHJhaWxpbmcgc3BhY2UsIHNvIHBhZCB3aGVuIGFuIGVkZ2UgaXMgYQogICAgICAgICAgLy8gYmFja3RpY2sgdG8ga2VlcCBpdCBmcm9tIG1lcmdpbmcgd2l0aCB0aGUgZGVsaW1pdGVyLgogICAgICAgICAgdmFyIHAgPSAoY3QuY2hhckF0KDApID09PSAiYCIgfHwgY3QuY2hhckF0KGN0Lmxlbmd0aCAtIDEpID09PSAiYCIpID8gIiAiIDogIiI7CiAgICAgICAgICBvdXQgKz0gZCArIHAgKyBjdCArIHAgKyBkOwogICAgICAgIH0KICAgICAgICBlbHNlIGlmICh0YWcgPT09ICJBIikgewogICAgICAgICAgdmFyIGhyZWYgPSBjLmdldEF0dHJpYnV0ZSA/IGMuZ2V0QXR0cmlidXRlKCJocmVmIikgOiBudWxsOwogICAgICAgICAgdmFyIHQgPSBpbmxpbmUoYyk7CiAgICAgICAgICBvdXQgKz0gaHJlZiA/ICJbIiArIHQgKyAiXSgiICsgaHJlZiArICIpIiA6IHQ7CiAgICAgICAgfSBlbHNlIG91dCArPSBpbmxpbmUoYyk7IC8vIHVua25vd24gaW5saW5lIHdyYXBwZXI6IGtlZXAgdGV4dCwgZHJvcCB0YWcKICAgICAgfQogICAgICByZXR1cm4gb3V0OwogICAgfQogICAgZnVuY3Rpb24gbGFuZ09mKGNvZGVFbCkgewogICAgICB2YXIgY2xzID0gIiI7CiAgICAgIGlmIChjb2RlRWwpIGNscyA9IChjb2RlRWwuZ2V0QXR0cmlidXRlICYmIGNvZGVFbC5nZXRBdHRyaWJ1dGUoImNsYXNzIikpIHx8IGNvZGVFbC5jbGFzc05hbWUgfHwgIiI7CiAgICAgIHZhciBtID0gL2xhbmd1YWdlLShbQS1aYS16MC05KyMuXC1dKykvLmV4ZWMoY2xzIHx8ICIiKTsKICAgICAgcmV0dXJuIG0gPyBtWzFdIDogIiI7CiAgICB9CiAgICBmdW5jdGlvbiBmaW5kQ2hpbGRUYWcobm9kZSwgdGFnKSB7CiAgICAgIHZhciBraWRzID0gbm9kZS5jaGlsZE5vZGVzIHx8IFtdOwogICAgICBmb3IgKHZhciBpID0gMDsgaSA8IGtpZHMubGVuZ3RoOyBpKyspIHsKICAgICAgICBpZiAoa2lkc1tpXS5ub2RlVHlwZSA9PT0gMSAmJiAoa2lkc1tpXS50YWdOYW1lIHx8ICIiKS50b1VwcGVyQ2FzZSgpID09PSB0YWcpIHJldHVybiBraWRzW2ldOwogICAgICB9CiAgICAgIHJldHVybiBudWxsOwogICAgfQogICAgZnVuY3Rpb24gbGlzdChub2RlLCBvcmRlcmVkLCBkZXB0aCkgewogICAgICB2YXIgb3V0ID0gIiIsIG4gPSAxOwogICAgICB2YXIga2lkcyA9IG5vZGUuY2hpbGROb2RlcyB8fCBbXTsKICAgICAgZm9yICh2YXIgaSA9IDA7IGkgPCBraWRzLmxlbmd0aDsgaSsrKSB7CiAgICAgICAgdmFyIGxpID0ga2lkc1tpXTsKICAgICAgICBpZiAobGkubm9kZVR5cGUgIT09IDEgfHwgKGxpLnRhZ05hbWUgfHwgIiIpLnRvVXBwZXJDYXNlKCkgIT09ICJMSSIpIGNvbnRpbnVlOwogICAgICAgIHZhciBtYXJrZXIgPSBvcmRlcmVkID8gbisrICsgIi4gIiA6ICItICI7CiAgICAgICAgdmFyIGluZGVudCA9IG5ldyBBcnJheShkZXB0aCArIDEpLmpvaW4oIiAgIik7CiAgICAgICAgdmFyIGxlYWQgPSAiIiwgbmVzdGVkID0gIiI7CiAgICAgICAgdmFyIGxrID0gbGkuY2hpbGROb2RlcyB8fCBbXTsKICAgICAgICBmb3IgKHZhciBqID0gMDsgaiA8IGxrLmxlbmd0aDsgaisrKSB7CiAgICAgICAgICB2YXIgY2ggPSBsa1tqXTsKICAgICAgICAgIHZhciBjdCA9IGNoLm5vZGVUeXBlID09PSAxID8gKGNoLnRhZ05hbWUgfHwgIiIpLnRvVXBwZXJDYXNlKCkgOiAiIjsKICAgICAgICAgIGlmIChjdCA9PT0gIlVMIikgbmVzdGVkICs9IGxpc3QoY2gsIGZhbHNlLCBkZXB0aCArIDEpOwogICAgICAgICAgZWxzZSBpZiAoY3QgPT09ICJPTCIpIG5lc3RlZCArPSBsaXN0KGNoLCB0cnVlLCBkZXB0aCArIDEpOwogICAgICAgICAgZWxzZSBpZiAoY2gubm9kZVR5cGUgPT09IDMpIGxlYWQgKz0gY2gudGV4dENvbnRlbnQgfHwgIiI7CiAgICAgICAgICBlbHNlIGxlYWQgKz0gaW5saW5lKGNoKTsKICAgICAgICB9CiAgICAgICAgb3V0ICs9IGluZGVudCArIG1hcmtlciArIGxlYWQudHJpbSgpICsgIlxuIiArIG5lc3RlZDsKICAgICAgfQogICAgICByZXR1cm4gb3V0OwogICAgfQogICAgZnVuY3Rpb24gdGFibGUobm9kZSkgewogICAgICB2YXIgcm93cyA9IFtdOwogICAgICAoZnVuY3Rpb24gY29sbGVjdChjb250YWluZXIpIHsKICAgICAgICB2YXIga2lkcyA9IGNvbnRhaW5lci5jaGlsZE5vZGVzIHx8IFtdOwogICAgICAgIGZvciAodmFyIGkgPSAwOyBpIDwga2lkcy5sZW5ndGg7IGkrKykgewogICAgICAgICAgdmFyIGMgPSBraWRzW2ldOwogICAgICAgICAgaWYgKGMubm9kZVR5cGUgIT09IDEpIGNvbnRpbnVlOwogICAgICAgICAgdmFyIHQgPSAoYy50YWdOYW1lIHx8ICIiKS50b1VwcGVyQ2FzZSgpOwogICAgICAgICAgaWYgKHQgPT09ICJUSEVBRCIgfHwgdCA9PT0gIlRCT0RZIiB8fCB0ID09PSAiVEZPT1QiKSBjb2xsZWN0KGMpOwogICAgICAgICAgZWxzZSBpZiAodCA9PT0gIlRSIikgewogICAgICAgICAgICB2YXIgY2VsbHMgPSBbXSwgY2MgPSBjLmNoaWxkTm9kZXMgfHwgW107CiAgICAgICAgICAgIGZvciAodmFyIGogPSAwOyBqIDwgY2MubGVuZ3RoOyBqKyspIHsKICAgICAgICAgICAgICB2YXIgZCA9IGNjW2pdOwogICAgICAgICAgICAgIGlmIChkLm5vZGVUeXBlICE9PSAxKSBjb250aW51ZTsKICAgICAgICAgICAgICB2YXIgZHQgPSAoZC50YWdOYW1lIHx8ICIiKS50b1VwcGVyQ2FzZSgpOwogICAgICAgICAgICAgIGlmIChkdCA9PT0gIlRIIiB8fCBkdCA9PT0gIlREIikgY2VsbHMucHVzaChpbmxpbmUoZCkudHJpbSgpKTsKICAgICAgICAgICAgfQogICAgICAgICAgICByb3dzLnB1c2goY2VsbHMpOwogICAgICAgICAgfQogICAgICAgIH0KICAgICAgfSkobm9kZSk7CiAgICAgIGlmICghcm93cy5sZW5ndGgpIHJldHVybiAiIjsKICAgICAgdmFyIGhlYWQgPSByb3dzWzBdLCBib2R5ID0gcm93cy5zbGljZSgxKTsKICAgICAgdmFyIHNlcCA9IGhlYWQubWFwKGZ1bmN0aW9uICgpIHsgcmV0dXJuICItLS0iOyB9KTsKICAgICAgdmFyIG91dCA9ICJ8ICIgKyBoZWFkLmpvaW4oIiB8ICIpICsgIiB8XG58ICIgKyBzZXAuam9pbigiIHwgIikgKyAiIHxcbiI7CiAgICAgIGZvciAodmFyIGsgPSAwOyBrIDwgYm9keS5sZW5ndGg7IGsrKykgb3V0ICs9ICJ8ICIgKyBib2R5W2tdLmpvaW4oIiB8ICIpICsgIiB8XG4iOwogICAgICByZXR1cm4gb3V0OwogICAgfQogICAgZnVuY3Rpb24gYmxvY2sobm9kZSkgewogICAgICB2YXIgb3V0ID0gIiI7CiAgICAgIHZhciBraWRzID0gbm9kZS5jaGlsZE5vZGVzIHx8IFtdOwogICAgICBmb3IgKHZhciBpID0gMDsgaSA8IGtpZHMubGVuZ3RoOyBpKyspIHsKICAgICAgICB2YXIgYyA9IGtpZHNbaV07CiAgICAgICAgaWYgKGMubm9kZVR5cGUgPT09IDMpIHsgaWYgKChjLnRleHRDb250ZW50IHx8ICIiKS50cmltKCkpIG91dCArPSBjLnRleHRDb250ZW50OyBjb250aW51ZTsgfQogICAgICAgIGlmIChjLm5vZGVUeXBlICE9PSAxKSBjb250aW51ZTsKICAgICAgICB2YXIgdGFnID0gKGMudGFnTmFtZSB8fCAiIikudG9VcHBlckNhc2UoKTsKICAgICAgICBpZiAoL15IWzEtNl0kLy50ZXN0KHRhZykpIG91dCArPSBuZXcgQXJyYXkoK3RhZ1sxXSArIDEpLmpvaW4oIiMiKSArICIgIiArIGlubGluZShjKS50cmltKCkgKyAiXG5cbiI7CiAgICAgICAgZWxzZSBpZiAodGFnID09PSAiUCIpIG91dCArPSBpbmxpbmUoYykudHJpbSgpICsgIlxuXG4iOwogICAgICAgIGVsc2UgaWYgKHRhZyA9PT0gIlVMIikgb3V0ICs9IGxpc3QoYywgZmFsc2UsIDApICsgIlxuIjsKICAgICAgICBlbHNlIGlmICh0YWcgPT09ICJPTCIpIG91dCArPSBsaXN0KGMsIHRydWUsIDApICsgIlxuIjsKICAgICAgICBlbHNlIGlmICh0YWcgPT09ICJQUkUiKSB7CiAgICAgICAgICB2YXIgY29kZSA9IGZpbmRDaGlsZFRhZyhjLCAiQ09ERSIpOwogICAgICAgICAgdmFyIGxhbmcgPSBsYW5nT2YoY29kZSB8fCBjKTsKICAgICAgICAgIHZhciBib2R5ID0gKGNvZGUgfHwgYykudGV4dENvbnRlbnQgfHwgIiI7CiAgICAgICAgICB2YXIgZiA9IGZlbmNlKGJvZHksIDMpOwogICAgICAgICAgb3V0ICs9IGYgKyBsYW5nICsgIlxuIiArIGJvZHkucmVwbGFjZSgvXG4kLywgIiIpICsgIlxuIiArIGYgKyAiXG5cbiI7CiAgICAgICAgfSBlbHNlIGlmICh0YWcgPT09ICJCTE9DS1FVT1RFIikgewogICAgICAgICAgdmFyIGlubmVyID0gYmxvY2soYykudHJpbSgpLnNwbGl0KCJcbiIpLm1hcChmdW5jdGlvbiAobCkgeyByZXR1cm4gIj4gIiArIGw7IH0pLmpvaW4oIlxuIik7CiAgICAgICAgICBvdXQgKz0gaW5uZXIgKyAiXG5cbiI7CiAgICAgICAgfSBlbHNlIGlmICh0YWcgPT09ICJIUiIpIG91dCArPSAiLS0tXG5cbiI7CiAgICAgICAgZWxzZSBpZiAodGFnID09PSAiVEFCTEUiKSBvdXQgKz0gdGFibGUoYykgKyAiXG4iOwogICAgICAgIGVsc2UgaWYgKHRhZyA9PT0gIkJSIikgb3V0ICs9ICJcbiI7CiAgICAgICAgZWxzZSBpZiAodGFnID09PSAiU1RST05HIiB8fCB0YWcgPT09ICJCIiB8fCB0YWcgPT09ICJFTSIgfHwgdGFnID09PSAiSSIgfHwKICAgICAgICAgICAgICAgICB0YWcgPT09ICJBIiB8fCB0YWcgPT09ICJDT0RFIiB8fCB0YWcgPT09ICJERUwiIHx8IHRhZyA9PT0gIlMiKQogICAgICAgICAgb3V0ICs9IGlubGluZShjKSArICJcblxuIjsKICAgICAgICBlbHNlIG91dCArPSBibG9jayhjKTsgLy8gdW5rbm93biB3cmFwcGVyOiByZWN1cnNlIChkcm9wIHRhZywga2VlcCBjb250ZW50KQogICAgICB9CiAgICAgIHJldHVybiBvdXQ7CiAgICB9CiAgICAvLyBibG9jaygpIGRpc3BhdGNoZXMgb24gZWFjaCBDSElMRCdzIHRhZywgdHJlYXRpbmcgdGhlIHBhc3NlZCBub2RlIGFzIGEgcGxhaW4KICAgIC8vIGNvbnRhaW5lci4gV3JhcCByb290IGluIGEgb25lLW9mZiBjb250YWluZXIgc28gcm9vdCdzIE9XTiB0YWcgaXMgZGlzcGF0Y2hlZAogICAgLy8gdG9vOiBjYWxsZXJzIHBhc3MgZWl0aGVyIHRoZSBidWJibGUgY29udGFpbmVyIChpdHMgYmxvY2sgY2hpbGRyZW4gcmVuZGVyKSBvcgogICAgLy8gYSBzaW5nbGUgYmxvY2sgZWxlbWVudCBsaWtlIDxwcmU+Lzx1bD4vPHRhYmxlPiAobm93IGhhbmRsZWQsIG5vdCBmbGF0dGVuZWQpLgogICAgcmV0dXJuIGJsb2NrKHsgY2hpbGROb2RlczogW3Jvb3RdIH0pLnJlcGxhY2UoL1xuezMsfS9nLCAiXG5cbiIpLnRyaW0oKTsKICB9CgogIC8vIC0tLS0gcHVyZSBoZWxwZXJzIC0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0KICBmdW5jdGlvbiBoYXNQcmVmaXgobm9kZSwgcHJlZml4KSB7CiAgICBpZiAobm9kZS5ub2RlVHlwZSAhPT0gMSB8fCB0eXBlb2Ygbm9kZS5jbGFzc05hbWUgIT09ICJzdHJpbmciKSByZXR1cm4gZmFsc2U7CiAgICB2YXIgcGFydHMgPSBub2RlLmNsYXNzTmFtZS5zcGxpdCgvXHMrLyk7CiAgICBmb3IgKHZhciBpID0gMDsgaSA8IHBhcnRzLmxlbmd0aDsgaSsrKSBpZiAocGFydHNbaV0uaW5kZXhPZihwcmVmaXgpID09PSAwKSByZXR1cm4gdHJ1ZTsKICAgIHJldHVybiBmYWxzZTsKICB9CgogIC8vIENsYXNzLXByZWZpeCBob29rcyBmb3Igbm9uLWNvbnRlbnQgY2hyb21lIHRoYXQgcmVuZGVycyAqaW5zaWRlKiBhbiBhc3Npc3RhbnQKICAvLyBidWJibGUgKHZlcmlmaWVkIG9uIDIuMS4xNzA7IFRhc2sgNiByZS1waW5zIHRoZXNlKS4gdG9vbCovdGhpbmtpbmdfIGFyZSB0aGUgdjEKICAvLyBleGNsdXNpb25zOyB1bmtub3duQ29udGVudF8gaXMgdGhlIHJlbmRlcmVyJ3MgZmFsbGJhY2sgZm9yIHVucmVjb2duaXplZCBibG9jawogIC8vIHR5cGVzLCBzbyBzdHJpcHBpbmcgaXQgbWFrZXMgYSAqZnV0dXJlKiBibG9jayB0eXBlIGZhaWwgc2FmZSB0byBleGNsdWRlZCByYXRoZXIKICAvLyB0aGFuIGxlYWtpbmcgIlVuc3VwcG9ydGVkIGNvbnRlbnQiIGludG8gdGhlIGNvcHkuIFJlLXBpbiBpZiBhIHByZWZpeCBtb3Zlcy4KICB2YXIgQ0hST01FX1BSRUZJWEVTID0gWyJ0b29sVXNlXyIsICJ0b29sUmVzdWx0XyIsICJ0b29sUmVmZXJlbmNlXyIsICJ0aGlua2luZ18iLCAidW5rbm93bkNvbnRlbnRfIl07CgogIC8vIFRydWUgZm9yIGFueSBub2RlIHRoYXQgbXVzdCBuZXZlciBhcHBlYXIgaW4gY29waWVkIG91dHB1dDogb3VyIG93biBjb250cm9scywKICAvLyB0aGUgcmF0aW5nIHdpZGdldCAoYGRhdGEtbWVzc2FnZS1yYXRpbmdgICsgaXRzICJUaGFua3MgZm9yIHlvdXIgZmVlZGJhY2siCiAgLy8gdGV4dCksIGFueSBidXR0b24gKGNvcHktY29kZSBjaHJvbWUpLCBhbmQgdGhlIGV4Y2x1ZGVkIGNvbnRlbnQgYmxvY2tzIGFib3ZlLgogIGZ1bmN0aW9uIGlzQ2hyb21lKG5vZGUpIHsKICAgIGlmIChub2RlLm5vZGVUeXBlICE9PSAxKSByZXR1cm4gZmFsc2U7CiAgICBpZiAoKG5vZGUudGFnTmFtZSB8fCAiIikudG9VcHBlckNhc2UoKSA9PT0gIkJVVFRPTiIpIHJldHVybiB0cnVlOwogICAgaWYgKG5vZGUuZ2V0QXR0cmlidXRlICYmIG5vZGUuZ2V0QXR0cmlidXRlKCJkYXRhLW1lc3NhZ2UtcmF0aW5nIikgIT09IG51bGwpIHJldHVybiB0cnVlOwogICAgaWYgKGhhc1ByZWZpeChub2RlLCBDT05UUk9MX1BSRUZJWCkpIHJldHVybiB0cnVlOwogICAgZm9yICh2YXIgaSA9IDA7IGkgPCBDSFJPTUVfUFJFRklYRVMubGVuZ3RoOyBpKyspIGlmIChoYXNQcmVmaXgobm9kZSwgQ0hST01FX1BSRUZJWEVTW2ldKSkgcmV0dXJuIHRydWU7CiAgICByZXR1cm4gZmFsc2U7CiAgfQoKICAvLyBEZWVwLWNsb25lIGBjb250ZW50Tm9kZWAsIHRoZW4gc3RyaXAgZXZlcnkgY2hyb21lIG5vZGUgc28gY29waWVkIG91dHB1dCBpcyB0aGUKICAvLyBtZXNzYWdlJ3MgdGV4dCBjb250ZW50IG9ubHkuIFRoaXMgaXMgYSBDT1JSRUNUTkVTUyBHQVRFLCBub3QgY29zbWV0aWM6IHRoZQogIC8vIGRlZmF1bHQgY29udGVudCBub2RlIGlzIHRoZSB3aG9sZSBidWJibGUgKGFsbCBjb250ZW50LWJsb2NrIHNpYmxpbmdzLCBzbyBtdWx0aS0KICAvLyBibG9jayBhc3Npc3RhbnQgdHVybnMgYXJlIGNhcHR1cmVkKSwgYW5kIHRoaXMgc3RyaXAtbGlzdCBpcyB0aGUgb25seSB0aGluZwogIC8vIGtlZXBpbmcgdGhlIHJhdGluZyB3aWRnZXQgYW5kIHYxLWV4Y2x1ZGVkIGJsb2NrcyBvdXQgb2YgdGhlIGNvcHkuCiAgZnVuY3Rpb24gc2FuaXRpemVDbG9uZShjb250ZW50Tm9kZSkgewogICAgdmFyIGNsb25lID0gY29udGVudE5vZGUuY2xvbmVOb2RlKHRydWUpOwogICAgKGZ1bmN0aW9uIHN0cmlwKG5vZGUpIHsKICAgICAgdmFyIGtpZHMgPSAobm9kZS5jaGlsZE5vZGVzIHx8IFtdKS5zbGljZSgpOwogICAgICBmb3IgKHZhciBpID0gMDsgaSA8IGtpZHMubGVuZ3RoOyBpKyspIHsKICAgICAgICB2YXIgYyA9IGtpZHNbaV07CiAgICAgICAgaWYgKGMubm9kZVR5cGUgPT09IDEgJiYgaXNDaHJvbWUoYykpIHsgbm9kZS5yZW1vdmVDaGlsZChjKTsgY29udGludWU7IH0KICAgICAgICBpZiAoYy5ub2RlVHlwZSA9PT0gMSkgc3RyaXAoYyk7CiAgICAgIH0KICAgIH0pKGNsb25lKTsKICAgIHJldHVybiBjbG9uZTsKICB9CgogIGZ1bmN0aW9uIGNsYXNzaWZ5QnViYmxlKG5vZGUpIHsKICAgIGlmIChub2RlLm5vZGVUeXBlICE9PSAxKSByZXR1cm4gbnVsbDsKICAgIGlmIChoYXNQcmVmaXgobm9kZSwgInVzZXJNZXNzYWdlQ29udGFpbmVyXyIpKSByZXR1cm4gInVzZXIiOwogICAgaWYgKG5vZGUuZ2V0QXR0cmlidXRlICYmIG5vZGUuZ2V0QXR0cmlidXRlKCJkYXRhLXRlc3RpZCIpID09PSAiYXNzaXN0YW50LW1lc3NhZ2UiKSByZXR1cm4gImFzc2lzdGFudCI7CiAgICByZXR1cm4gbnVsbDsKICB9CgogIC8vIEJ1aWxkIHRoZSB3aG9sZS1jb252ZXJzYXRpb24gbWFya2Rvd24gZnJvbSBhbiBvcmRlcmVkIGxpc3Qgb2YgYnViYmxlcy4KICAvLyBgY29udGVudE9mKGJ1YmJsZSlgIHJlc29sdmVzIHRoZSBjb250ZW50IG5vZGUgKGRlZmF1bHQ6IHRoZSBidWJibGUgaXRzZWxmLCBzbwogIC8vIGV2ZXJ5IGNvbnRlbnQgYmxvY2sgaXMgaW5jbHVkZWQ7IHNhbml0aXplQ2xvbmUgZHJvcHMgY2hyb21lKTsgYSBkZWZhdWx0IGlzCiAgLy8gcHJvdmlkZWQgZm9yIHRlc3RzLgogIGZ1bmN0aW9uIGNvbnZlcnNhdGlvblRvTWFya2Rvd24oYnViYmxlcywgY29udGVudE9mKSB7CiAgICBjb250ZW50T2YgPSBjb250ZW50T2YgfHwgZnVuY3Rpb24gKGIpIHsgcmV0dXJuIGI7IH07CiAgICB2YXIgcGFydHMgPSBbXTsKICAgIGZvciAodmFyIGkgPSAwOyBpIDwgYnViYmxlcy5sZW5ndGg7IGkrKykgewogICAgICB2YXIgcm9sZSA9IGNsYXNzaWZ5QnViYmxlKGJ1YmJsZXNbaV0pOwogICAgICBpZiAoIXJvbGUpIGNvbnRpbnVlOwogICAgICB2YXIgY2xlYW4gPSBzYW5pdGl6ZUNsb25lKGNvbnRlbnRPZihidWJibGVzW2ldKSk7CiAgICAgIHZhciBib2R5ID0gcm9sZSA9PT0gImFzc2lzdGFudCIgPyBodG1sVG9NYXJrZG93bihjbGVhbikgOiAoY2xlYW4udGV4dENvbnRlbnQgfHwgIiIpLnRyaW0oKTsKICAgICAgaWYgKCFib2R5KSBjb250aW51ZTsKICAgICAgcGFydHMucHVzaCgocm9sZSA9PT0gInVzZXIiID8gIiMjIFVzZXIiIDogIiMjIEFzc2lzdGFudCIpICsgIlxuXG4iICsgYm9keSk7CiAgICB9CiAgICByZXR1cm4gcGFydHMuam9pbigiXG5cbiIpICsgKHBhcnRzLmxlbmd0aCA/ICJcbiIgOiAiIik7CiAgfQoKICAvLyAtLS0tIGV4cG9ydHMgKG5vZGUgdGVzdHMpIC8gYm9vdCAocmVhbCB3ZWJ2aWV3KSAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tCiAgaWYgKHR5cGVvZiBkb2N1bWVudCAhPT0gInVuZGVmaW5lZCIpIHsKICAgIGJvb3QoKTsKICB9IGVsc2UgaWYgKHR5cGVvZiBtb2R1bGUgIT09ICJ1bmRlZmluZWQiICYmIG1vZHVsZS5leHBvcnRzKSB7CiAgICBtb2R1bGUuZXhwb3J0cyA9IHsgaHRtbFRvTWFya2Rvd246IGh0bWxUb01hcmtkb3duLCBzYW5pdGl6ZUNsb25lOiBzYW5pdGl6ZUNsb25lLAogICAgICAgICAgICAgICAgICAgICAgIGNsYXNzaWZ5QnViYmxlOiBjbGFzc2lmeUJ1YmJsZSwgY29udmVyc2F0aW9uVG9NYXJrZG93bjogY29udmVyc2F0aW9uVG9NYXJrZG93biB9OwogIH0KCiAgLy8gLS0tLSBsaXZlLXdlYnZpZXcgd2lyaW5nIChydW5zIG9ubHkgd2hlbiBhIGRvY3VtZW50IGV4aXN0cykgLS0tLS0tLS0tLS0tLS0tLQogIGZ1bmN0aW9uIHFzKG5vZGUsIHNlbCkgeyB0cnkgeyByZXR1cm4gc2VsICYmIG5vZGUucXVlcnlTZWxlY3RvciA/IG5vZGUucXVlcnlTZWxlY3RvcihzZWwpIDogbnVsbDsgfSBjYXRjaCAoXykgeyByZXR1cm4gbnVsbDsgfSB9CiAgZnVuY3Rpb24gcXNhKHNlbCkgeyB0cnkgeyByZXR1cm4gQXJyYXkucHJvdG90eXBlLnNsaWNlLmNhbGwoZG9jdW1lbnQucXVlcnlTZWxlY3RvckFsbChzZWwpKTsgfSBjYXRjaCAoXykgeyByZXR1cm4gW107IH0gfQoKICAvLyBUaGUgY29udGVudCBub2RlIHRvIGNvbnZlcnQvY29weTogdGhlIG9wdGlvbmFsIEFTU0lTVEFOVF9DT05URU5UIHdyYXBwZXIgaWYKICAvLyBwaW5uZWQgYW5kIHByZXNlbnQsIGVsc2UgdGhlIGJ1YmJsZSBpdHNlbGYuIFRoZSBidWJibGUgYWxyZWFkeSBjb250YWlucyBldmVyeQogIC8vIGNvbnRlbnQtYmxvY2sgc2libGluZyBvZiBhIG11bHRpLWJsb2NrIHR1cm4sIGFuZCBzYW5pdGl6ZUNsb25lIHN0cmlwcyB0aGUKICAvLyBjaHJvbWUgKHJhdGluZyB3aWRnZXQsIHRvb2wvdGhpbmtpbmcvdW5rbm93biBibG9ja3MsIGJ1dHRvbnMsIG91ciBjb250cm9scykKICAvLyBlaXRoZXIgd2F5IC0tIHNvIHRoaXMgaXMgYSBuYXJyb3dpbmcsIG5ldmVyIHRoZSB0aGluZyB0aGF0IGd1YXJhbnRlZXMKICAvLyBjb3JyZWN0bmVzcy4KICBmdW5jdGlvbiBjb250ZW50Tm9kZU9mKGJ1YmJsZSwgcm9sZSkgewogICAgaWYgKHJvbGUgPT09ICJhc3Npc3RhbnQiICYmIEFTU0lTVEFOVF9DT05URU5UKSB7CiAgICAgIHZhciBuID0gcXMoYnViYmxlLCBBU1NJU1RBTlRfQ09OVEVOVCk7CiAgICAgIGlmIChuKSByZXR1cm4gbjsKICAgIH0KICAgIHJldHVybiBidWJibGU7CiAgfQoKICBmdW5jdGlvbiBjb3B5VGV4dCh0ZXh0KSB7CiAgICB0cnkgewogICAgICBpZiAobmF2aWdhdG9yLmNsaXBib2FyZCAmJiBuYXZpZ2F0b3IuY2xpcGJvYXJkLndyaXRlVGV4dCkgcmV0dXJuIG5hdmlnYXRvci5jbGlwYm9hcmQud3JpdGVUZXh0KHRleHQpOwogICAgfSBjYXRjaCAoXykge30KICAgIHJldHVybiBQcm9taXNlLnJlc29sdmUoKTsgLy8gYmVzdC1lZmZvcnQ7IG5ldmVyIHRocm93IGludG8gdGhlIGFwcAogIH0KCiAgZnVuY3Rpb24gZmxhc2hGZWVkYmFjayhob3N0KSB7CiAgICB0cnkgewogICAgICB2YXIgZmIgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCJzcGFuIik7CiAgICAgIGZiLmNsYXNzTmFtZSA9IENPTlRST0xfUFJFRklYICsgIi1mZWVkYmFjayI7CiAgICAgIGZiLnRleHRDb250ZW50ID0gIkNvcGllZCI7CiAgICAgIGhvc3QuYXBwZW5kQ2hpbGQoZmIpOwogICAgICBzZXRUaW1lb3V0KGZ1bmN0aW9uICgpIHsgaWYgKGZiICYmIGZiLnBhcmVudE5vZGUpIGZiLnBhcmVudE5vZGUucmVtb3ZlQ2hpbGQoZmIpOyB9LCBGRUVEQkFDS19NUyk7CiAgICB9IGNhdGNoIChfKSB7fQogIH0KCiAgZnVuY3Rpb24gYnViYmxlTWFya2Rvd24oYnViYmxlLCByb2xlKSB7CiAgICB2YXIgY2xlYW4gPSBzYW5pdGl6ZUNsb25lKGNvbnRlbnROb2RlT2YoYnViYmxlLCByb2xlKSk7CiAgICByZXR1cm4gcm9sZSA9PT0gImFzc2lzdGFudCIgPyBodG1sVG9NYXJrZG93bihjbGVhbikgOiAoY2xlYW4udGV4dENvbnRlbnQgfHwgIiIpLnRyaW0oKTsKICB9CiAgZnVuY3Rpb24gYnViYmxlUGxhaW4oYnViYmxlLCByb2xlKSB7CiAgICByZXR1cm4gKHNhbml0aXplQ2xvbmUoY29udGVudE5vZGVPZihidWJibGUsIHJvbGUpKS50ZXh0Q29udGVudCB8fCAiIikudHJpbSgpOwogIH0KCiAgLy8gQnVpbGQgYSBzaW5nbGUgY29udHJvbDogYSBwcmltYXJ5ICJDb3B5IiAobWFya2Rvd24pIHBsdXMgYSBzbWFsbCBjYXJldCB0aGF0CiAgLy8gdG9nZ2xlcyBhIG1lbnUgd2l0aCAiQ29weSBhcyBwbGFpbiB0ZXh0Ii4gQWxsIG5vZGVzIGNhcnJ5IHRoZSBDT05UUk9MX1BSRUZJWAogIC8vIGNsYXNzIHNvIHNhbml0aXplQ2xvbmUgcmVtb3ZlcyB0aGVtIGZyb20gYW55IGNvcGllZCBjb250ZW50LgogIGZ1bmN0aW9uIGJ1aWxkQ29udHJvbChvbk1hcmtkb3duLCBvblBsYWluKSB7CiAgICB2YXIgd3JhcCA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoInNwYW4iKTsKICAgIHdyYXAuY2xhc3NOYW1lID0gQ09OVFJPTF9QUkVGSVg7CiAgICB2YXIgcHJpbWFyeSA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoImJ1dHRvbiIpOwogICAgcHJpbWFyeS50eXBlID0gImJ1dHRvbiI7CiAgICBwcmltYXJ5LmNsYXNzTmFtZSA9IENPTlRST0xfUFJFRklYICsgIi1idG4iOwogICAgcHJpbWFyeS50aXRsZSA9ICJDb3B5IGFzIE1hcmtkb3duIjsKICAgIHByaW1hcnkudGV4dENvbnRlbnQgPSAiQ29weSI7CiAgICBwcmltYXJ5LmFkZEV2ZW50TGlzdGVuZXIoImNsaWNrIiwgZnVuY3Rpb24gKGUpIHsgZS5zdG9wUHJvcGFnYXRpb24oKTsgb25NYXJrZG93bihwcmltYXJ5KTsgfSk7CiAgICB2YXIgY2FyZXQgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCJidXR0b24iKTsKICAgIGNhcmV0LnR5cGUgPSAiYnV0dG9uIjsKICAgIGNhcmV0LmNsYXNzTmFtZSA9IENPTlRST0xfUFJFRklYICsgIi1jYXJldCI7CiAgICBjYXJldC50aXRsZSA9ICJDb3B5IG9wdGlvbnMiOwogICAgY2FyZXQudGV4dENvbnRlbnQgPSAi4pa+IjsgLy8gYmxhY2sgZG93bi1wb2ludGluZyBzbWFsbCB0cmlhbmdsZQogICAgdmFyIG1lbnUgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCJzcGFuIik7CiAgICBtZW51LmNsYXNzTmFtZSA9IENPTlRST0xfUFJFRklYICsgIi1tZW51IjsKICAgIG1lbnUuc3R5bGUuZGlzcGxheSA9ICJub25lIjsKICAgIHZhciBwbGFpbiA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoImJ1dHRvbiIpOwogICAgcGxhaW4udHlwZSA9ICJidXR0b24iOwogICAgcGxhaW4uY2xhc3NOYW1lID0gQ09OVFJPTF9QUkVGSVggKyAiLWJ0biI7CiAgICBwbGFpbi50ZXh0Q29udGVudCA9ICJDb3B5IGFzIHBsYWluIHRleHQiOwogICAgcGxhaW4uYWRkRXZlbnRMaXN0ZW5lcigiY2xpY2siLCBmdW5jdGlvbiAoZSkgeyBlLnN0b3BQcm9wYWdhdGlvbigpOyBtZW51LnN0eWxlLmRpc3BsYXkgPSAibm9uZSI7IG9uUGxhaW4ocGxhaW4pOyB9KTsKICAgIG1lbnUuYXBwZW5kQ2hpbGQocGxhaW4pOwogICAgY2FyZXQuYWRkRXZlbnRMaXN0ZW5lcigiY2xpY2siLCBmdW5jdGlvbiAoZSkgewogICAgICBlLnN0b3BQcm9wYWdhdGlvbigpOwogICAgICBtZW51LnN0eWxlLmRpc3BsYXkgPSBtZW51LnN0eWxlLmRpc3BsYXkgPT09ICJub25lIiA/ICJpbmxpbmUtYmxvY2siIDogIm5vbmUiOwogICAgfSk7CiAgICB3cmFwLmFwcGVuZENoaWxkKHByaW1hcnkpOwogICAgd3JhcC5hcHBlbmRDaGlsZChjYXJldCk7CiAgICB3cmFwLmFwcGVuZENoaWxkKG1lbnUpOwogICAgcmV0dXJuIHdyYXA7CiAgfQoKICBmdW5jdGlvbiBkZWNvcmF0ZShidWJibGUpIHsKICAgIHRyeSB7CiAgICAgIHZhciByb2xlID0gY2xhc3NpZnlCdWJibGUoYnViYmxlKTsKICAgICAgaWYgKCFyb2xlKSByZXR1cm47CiAgICAgIGlmIChxcyhidWJibGUsICIuIiArIENPTlRST0xfUFJFRklYKSkgcmV0dXJuOyAvLyBhbHJlYWR5IGRlY29yYXRlZAogICAgICB2YXIgY29udHJvbCA9IGJ1aWxkQ29udHJvbCgKICAgICAgICBmdW5jdGlvbiAoaG9zdCkgeyBjb3B5VGV4dChidWJibGVNYXJrZG93bihidWJibGUsIHJvbGUpKS50aGVuKGZ1bmN0aW9uICgpIHsgZmxhc2hGZWVkYmFjayhjb250cm9sKTsgfSk7IH0sCiAgICAgICAgZnVuY3Rpb24gKGhvc3QpIHsgY29weVRleHQoYnViYmxlUGxhaW4oYnViYmxlLCByb2xlKSkudGhlbihmdW5jdGlvbiAoKSB7IGZsYXNoRmVlZGJhY2soY29udHJvbCk7IH0pOyB9CiAgICAgICk7CiAgICAgIGJ1YmJsZS5hcHBlbmRDaGlsZChjb250cm9sKTsKICAgIH0gY2F0Y2ggKF8pIHt9CiAgfQoKICBmdW5jdGlvbiBjb3B5Q29udmVyc2F0aW9uKGZvcm1hdCkgewogICAgdmFyIGJ1YmJsZXMgPSBxc2EoVVNFUl9CVUJCTEUgKyAiLCIgKyBBU1NJU1RBTlRfQlVCQkxFKTsKICAgIGlmIChmb3JtYXQgPT09ICJ0ZXh0IikgewogICAgICB2YXIgbGluZXMgPSBbXTsKICAgICAgZm9yICh2YXIgaSA9IDA7IGkgPCBidWJibGVzLmxlbmd0aDsgaSsrKSB7CiAgICAgICAgdmFyIHJvbGUgPSBjbGFzc2lmeUJ1YmJsZShidWJibGVzW2ldKTsKICAgICAgICBpZiAoIXJvbGUpIGNvbnRpbnVlOwogICAgICAgIHZhciBib2R5ID0gYnViYmxlUGxhaW4oYnViYmxlc1tpXSwgcm9sZSk7CiAgICAgICAgaWYgKGJvZHkpIGxpbmVzLnB1c2goYm9keSk7CiAgICAgIH0KICAgICAgcmV0dXJuIGNvcHlUZXh0KGxpbmVzLmpvaW4oIlxuXG4iKSArIChsaW5lcy5sZW5ndGggPyAiXG4iIDogIiIpKTsKICAgIH0KICAgIHJldHVybiBjb3B5VGV4dChjb252ZXJzYXRpb25Ub01hcmtkb3duKGJ1YmJsZXMsIGZ1bmN0aW9uIChiKSB7CiAgICAgIHJldHVybiBjb250ZW50Tm9kZU9mKGIsIGNsYXNzaWZ5QnViYmxlKGIpKTsKICAgIH0pKTsKICB9CgogIGZ1bmN0aW9uIGluc3RhbGxDb252ZXJzYXRpb25Db250cm9sKCkgewogICAgdHJ5IHsKICAgICAgaWYgKHFzKGRvY3VtZW50LCAiLiIgKyBDT05UUk9MX1BSRUZJWCArICItY29udmVyc2F0aW9uIikpIHJldHVybjsKICAgICAgdmFyIGJhciA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoImRpdiIpOwogICAgICBiYXIuY2xhc3NOYW1lID0gQ09OVFJPTF9QUkVGSVggKyAiLWNvbnZlcnNhdGlvbiI7CiAgICAgIHZhciBjb250cm9sID0gYnVpbGRDb250cm9sKAogICAgICAgIGZ1bmN0aW9uICgpIHsgY29weUNvbnZlcnNhdGlvbigibWFya2Rvd24iKS50aGVuKGZ1bmN0aW9uICgpIHsgZmxhc2hGZWVkYmFjayhiYXIpOyB9KTsgfSwKICAgICAgICBmdW5jdGlvbiAoKSB7IGNvcHlDb252ZXJzYXRpb24oInRleHQiKS50aGVuKGZ1bmN0aW9uICgpIHsgZmxhc2hGZWVkYmFjayhiYXIpOyB9KTsgfQogICAgICApOwogICAgICBjb250cm9sLnRpdGxlID0gIkNvcHkgZW50aXJlIGNvbnZlcnNhdGlvbiI7CiAgICAgIGJhci5hcHBlbmRDaGlsZChjb250cm9sKTsKICAgICAgZG9jdW1lbnQuYm9keS5hcHBlbmRDaGlsZChiYXIpOyAvLyBmaXhlZC1wb3NpdGlvbiB2aWEgQ1NTOyBwbGFjZW1lbnQgcmVmaW5lZCBpbiBUYXNrIDYKICAgIH0gY2F0Y2ggKF8pIHt9CiAgfQoKICBmdW5jdGlvbiBzd2VlcCgpIHsgdmFyIGIgPSBxc2EoVVNFUl9CVUJCTEUgKyAiLCIgKyBBU1NJU1RBTlRfQlVCQkxFKTsgZm9yICh2YXIgaSA9IDA7IGkgPCBiLmxlbmd0aDsgaSsrKSBkZWNvcmF0ZShiW2ldKTsgfQoKICBmdW5jdGlvbiBib290KCkgewogICAgdHJ5IHsKICAgICAgdmFyIHRhcmdldCA9IChNRVNTQUdFU19DT05UQUlORVIgJiYgcXMoZG9jdW1lbnQsIE1FU1NBR0VTX0NPTlRBSU5FUikpIHx8IGRvY3VtZW50LmJvZHk7CiAgICAgIHN3ZWVwKCk7CiAgICAgIGluc3RhbGxDb252ZXJzYXRpb25Db250cm9sKCk7CiAgICAgIGlmICh0eXBlb2YgTXV0YXRpb25PYnNlcnZlciA9PT0gInVuZGVmaW5lZCIpIHJldHVybjsKICAgICAgdmFyIG9icyA9IG5ldyBNdXRhdGlvbk9ic2VydmVyKGZ1bmN0aW9uICgpIHsgc3dlZXAoKTsgfSk7CiAgICAgIG9icy5vYnNlcnZlKHRhcmdldCwgeyBjaGlsZExpc3Q6IHRydWUsIHN1YnRyZWU6IHRydWUgfSk7CiAgICB9IGNhdGNoIChfKSB7fQogIH0KfSkoKTsK").decode("utf-8") INJECT_CSS = base64.b64decode("LmNjLW1kLWNvcHkgewogIGRpc3BsYXk6IGlubGluZS1mbGV4OwogIGFsaWduLWl0ZW1zOiBjZW50ZXI7CiAgZ2FwOiAycHg7CiAgdmVydGljYWwtYWxpZ246IG1pZGRsZTsKICBtYXJnaW4tbGVmdDogNnB4Owp9Ci5jYy1tZC1jb3B5LWJ0biwKLmNjLW1kLWNvcHktY2FyZXQgewogIGZvbnQ6IGluaGVyaXQ7CiAgZm9udC1zaXplOiAxMXB4OwogIGxpbmUtaGVpZ2h0OiAxLjQ7CiAgcGFkZGluZzogMXB4IDZweDsKICBjb2xvcjogdmFyKC0tdnNjb2RlLWZvcmVncm91bmQpOwogIGJhY2tncm91bmQ6IHRyYW5zcGFyZW50OwogIGJvcmRlcjogMXB4IHNvbGlkIHZhcigtLXZzY29kZS13aWRnZXQtYm9yZGVyLCB0cmFuc3BhcmVudCk7CiAgYm9yZGVyLXJhZGl1czogNHB4OwogIGN1cnNvcjogcG9pbnRlcjsKICBvcGFjaXR5OiAwLjY1Owp9Ci5jYy1tZC1jb3B5LWJ0bjpob3ZlciwKLmNjLW1kLWNvcHktY2FyZXQ6aG92ZXIgewogIG9wYWNpdHk6IDE7CiAgYmFja2dyb3VuZDogdmFyKC0tdnNjb2RlLXRvb2xiYXItaG92ZXJCYWNrZ3JvdW5kLCByZ2JhKDEyOCwgMTI4LCAxMjgsIDAuMTUpKTsKfQouY2MtbWQtY29weS1tZW51IHsKICBwb3NpdGlvbjogcmVsYXRpdmU7CiAgbWFyZ2luLWxlZnQ6IDRweDsKICBwYWRkaW5nOiAycHg7CiAgYmFja2dyb3VuZDogdmFyKC0tdnNjb2RlLW1lbnUtYmFja2dyb3VuZCwgdmFyKC0tdnNjb2RlLWVkaXRvcldpZGdldC1iYWNrZ3JvdW5kKSk7CiAgYm9yZGVyOiAxcHggc29saWQgdmFyKC0tdnNjb2RlLW1lbnUtYm9yZGVyLCB2YXIoLS12c2NvZGUtd2lkZ2V0LWJvcmRlciwgdHJhbnNwYXJlbnQpKTsKICBib3JkZXItcmFkaXVzOiA0cHg7CiAgei1pbmRleDogNTsKfQouY2MtbWQtY29weS1mZWVkYmFjayB7CiAgbWFyZ2luLWxlZnQ6IDZweDsKICBmb250LXNpemU6IDExcHg7CiAgb3BhY2l0eTogMC44NTsKICBjb2xvcjogdmFyKC0tdnNjb2RlLWZvcmVncm91bmQpOwp9Ci5jYy1tZC1jb3B5LWNvbnZlcnNhdGlvbiB7CiAgcG9zaXRpb246IGZpeGVkOwogIHJpZ2h0OiAxNnB4OwogIGJvdHRvbTogNTZweDsKICB6LWluZGV4OiAxMDsKICBwYWRkaW5nOiAycHg7CiAgYmFja2dyb3VuZDogdmFyKC0tdnNjb2RlLWVkaXRvcldpZGdldC1iYWNrZ3JvdW5kKTsKICBib3JkZXI6IDFweCBzb2xpZCB2YXIoLS12c2NvZGUtd2lkZ2V0LWJvcmRlciwgdHJhbnNwYXJlbnQpOwogIGJvcmRlci1yYWRpdXM6IDZweDsKICBvcGFjaXR5OiAwLjg1Owp9Ci5jYy1tZC1jb3B5LWNvbnZlcnNhdGlvbjpob3ZlciB7CiAgb3BhY2l0eTogMTsKfQo=").decode("utf-8") # << Markdown (DOM walk) ------------------------------------------- // Uses only: nodeType, tagName, childNodes, textContent, getAttribute, className. function htmlToMarkdown(root) { + // Longest run of consecutive backticks in s, so a code delimiter/fence can be + // chosen longer than anything inside it (else ``` in the content closes early). + function backtickRun(s) { + var max = 0, cur = 0; + for (var i = 0; i < s.length; i++) { + if (s.charAt(i) === "`") { cur++; if (cur > max) max = cur; } else cur = 0; + } + return max; + } + function fence(s, min) { var n = backtickRun(s) + 1; if (n < min) n = min; return new Array(n + 1).join("`"); } function inline(node) { var out = ""; var kids = node.childNodes || []; @@ -36,7 +46,14 @@ else if (tag === "STRONG" || tag === "B") out += "**" + inline(c) + "**"; else if (tag === "EM" || tag === "I") out += "*" + inline(c) + "*"; else if (tag === "DEL" || tag === "S") out += "~~" + inline(c) + "~~"; - else if (tag === "CODE") out += "`" + (c.textContent || "") + "`"; + else if (tag === "CODE") { + var ct = c.textContent || ""; + var d = fence(ct, 1); + // CommonMark strips one leading+trailing space, so pad when an edge is a + // backtick to keep it from merging with the delimiter. + var p = (ct.charAt(0) === "`" || ct.charAt(ct.length - 1) === "`") ? " " : ""; + out += d + p + ct + p + d; + } else if (tag === "A") { var href = c.getAttribute ? c.getAttribute("href") : null; var t = inline(c); @@ -124,7 +141,8 @@ var code = findChildTag(c, "CODE"); var lang = langOf(code || c); var body = (code || c).textContent || ""; - out += "```" + lang + "\n" + body.replace(/\n$/, "") + "\n```\n\n"; + var f = fence(body, 3); + out += f + lang + "\n" + body.replace(/\n$/, "") + "\n" + f + "\n\n"; } else if (tag === "BLOCKQUOTE") { var inner = block(c).trim().split("\n").map(function (l) { return "> " + l; }).join("\n"); out += inner + "\n\n"; diff --git a/launcher/claudemax b/launcher/claudemax index 0be4b1a..bbd23ad 100755 --- a/launcher/claudemax +++ b/launcher/claudemax @@ -323,6 +323,16 @@ _cc_md_copy_js() { cat <<'CCMDCOPYJS' // ---- HTML -> Markdown (DOM walk) ------------------------------------------- // Uses only: nodeType, tagName, childNodes, textContent, getAttribute, className. function htmlToMarkdown(root) { + // Longest run of consecutive backticks in s, so a code delimiter/fence can be + // chosen longer than anything inside it (else ``` in the content closes early). + function backtickRun(s) { + var max = 0, cur = 0; + for (var i = 0; i < s.length; i++) { + if (s.charAt(i) === "`") { cur++; if (cur > max) max = cur; } else cur = 0; + } + return max; + } + function fence(s, min) { var n = backtickRun(s) + 1; if (n < min) n = min; return new Array(n + 1).join("`"); } function inline(node) { var out = ""; var kids = node.childNodes || []; @@ -335,7 +345,14 @@ _cc_md_copy_js() { cat <<'CCMDCOPYJS' else if (tag === "STRONG" || tag === "B") out += "**" + inline(c) + "**"; else if (tag === "EM" || tag === "I") out += "*" + inline(c) + "*"; else if (tag === "DEL" || tag === "S") out += "~~" + inline(c) + "~~"; - else if (tag === "CODE") out += "`" + (c.textContent || "") + "`"; + else if (tag === "CODE") { + var ct = c.textContent || ""; + var d = fence(ct, 1); + // CommonMark strips one leading+trailing space, so pad when an edge is a + // backtick to keep it from merging with the delimiter. + var p = (ct.charAt(0) === "`" || ct.charAt(ct.length - 1) === "`") ? " " : ""; + out += d + p + ct + p + d; + } else if (tag === "A") { var href = c.getAttribute ? c.getAttribute("href") : null; var t = inline(c); @@ -423,7 +440,8 @@ _cc_md_copy_js() { cat <<'CCMDCOPYJS' var code = findChildTag(c, "CODE"); var lang = langOf(code || c); var body = (code || c).textContent || ""; - out += "```" + lang + "\n" + body.replace(/\n$/, "") + "\n```\n\n"; + var f = fence(body, 3); + out += f + lang + "\n" + body.replace(/\n$/, "") + "\n" + f + "\n\n"; } else if (tag === "BLOCKQUOTE") { var inner = block(c).trim().split("\n").map(function (l) { return "> " + l; }).join("\n"); out += inner + "\n\n"; diff --git a/launcher/claudemax.win.js b/launcher/claudemax.win.js index b63ba8a..9a9575f 100644 --- a/launcher/claudemax.win.js +++ b/launcher/claudemax.win.js @@ -284,7 +284,7 @@ function contextIconEnabled() { const MD_OPEN = "/* cc-md-copy v1 */"; const MD_CLOSE = "/* /cc-md-copy v1 */"; // >>>CCWA-MD-COPY-EMBED>>> (generated by tools/gen-embeds; do not edit) -const MD_COPY_JS = "/* cc-md-copy: per-message and whole-conversation copy (markdown/plain) for the\n * Claude Code VS Code webview. Self-contained IIFE appended to webview/index.js.\n * Additive and read-only w.r.t. app state; keyed on stable CSS-module class\n * prefixes, so it fails safe (controls simply do not appear) if a prefix moves.\n * Exposes its pure functions for node unit tests; boot()s only in a real webview. */\n(function () {\n \"use strict\";\n\n var CONTROL_PREFIX = \"cc-md-copy\"; // every injected node's class starts with this\n var USER_BUBBLE = '[class*=\"userMessageContainer_\"]';\n // Assistant message wrapper. Verified on 2.1.170: the render emits exactly one\n // `data-testid=\"assistant-message\"` div per assistant turn, with the rating\n // widget and content blocks as its children. (The earlier `[data-message-rating]`\n // was WRONG: that attribute sits on the nested rating control, which is also only\n // rendered behind an experiment+analytics gate.) Re-pinned in Task 6.\n var ASSISTANT_BUBBLE = '[data-testid=\"assistant-message\"]';\n var MESSAGES_CONTAINER = '[class*=\"messagesContainer_\"]'; // e.g. '[class*=\"timeline_\"]'; \"\" -> observe document.body\n // Optional narrowing only. MUST be a single wrapper around ALL content blocks,\n // not a per-block class (a turn has multiple blocks). \"\" -> use the bubble itself\n // (already aggregates all blocks; sanitizeClone is the correctness gate).\n var ASSISTANT_CONTENT = \"\";\n var FEEDBACK_MS = 1800;\n\n // ---- HTML -> Markdown (DOM walk) -------------------------------------------\n // Uses only: nodeType, tagName, childNodes, textContent, getAttribute, className.\n function htmlToMarkdown(root) {\n function inline(node) {\n var out = \"\";\n var kids = node.childNodes || [];\n for (var i = 0; i < kids.length; i++) {\n var c = kids[i];\n if (c.nodeType === 3) { out += c.textContent || \"\"; continue; }\n if (c.nodeType !== 1) continue;\n var tag = (c.tagName || \"\").toUpperCase();\n if (tag === \"BR\") out += \"\\n\";\n else if (tag === \"STRONG\" || tag === \"B\") out += \"**\" + inline(c) + \"**\";\n else if (tag === \"EM\" || tag === \"I\") out += \"*\" + inline(c) + \"*\";\n else if (tag === \"DEL\" || tag === \"S\") out += \"~~\" + inline(c) + \"~~\";\n else if (tag === \"CODE\") out += \"`\" + (c.textContent || \"\") + \"`\";\n else if (tag === \"A\") {\n var href = c.getAttribute ? c.getAttribute(\"href\") : null;\n var t = inline(c);\n out += href ? \"[\" + t + \"](\" + href + \")\" : t;\n } else out += inline(c); // unknown inline wrapper: keep text, drop tag\n }\n return out;\n }\n function langOf(codeEl) {\n var cls = \"\";\n if (codeEl) cls = (codeEl.getAttribute && codeEl.getAttribute(\"class\")) || codeEl.className || \"\";\n var m = /language-([A-Za-z0-9+#.\\-]+)/.exec(cls || \"\");\n return m ? m[1] : \"\";\n }\n function findChildTag(node, tag) {\n var kids = node.childNodes || [];\n for (var i = 0; i < kids.length; i++) {\n if (kids[i].nodeType === 1 && (kids[i].tagName || \"\").toUpperCase() === tag) return kids[i];\n }\n return null;\n }\n function list(node, ordered, depth) {\n var out = \"\", n = 1;\n var kids = node.childNodes || [];\n for (var i = 0; i < kids.length; i++) {\n var li = kids[i];\n if (li.nodeType !== 1 || (li.tagName || \"\").toUpperCase() !== \"LI\") continue;\n var marker = ordered ? n++ + \". \" : \"- \";\n var indent = new Array(depth + 1).join(\" \");\n var lead = \"\", nested = \"\";\n var lk = li.childNodes || [];\n for (var j = 0; j < lk.length; j++) {\n var ch = lk[j];\n var ct = ch.nodeType === 1 ? (ch.tagName || \"\").toUpperCase() : \"\";\n if (ct === \"UL\") nested += list(ch, false, depth + 1);\n else if (ct === \"OL\") nested += list(ch, true, depth + 1);\n else if (ch.nodeType === 3) lead += ch.textContent || \"\";\n else lead += inline(ch);\n }\n out += indent + marker + lead.trim() + \"\\n\" + nested;\n }\n return out;\n }\n function table(node) {\n var rows = [];\n (function collect(container) {\n var kids = container.childNodes || [];\n for (var i = 0; i < kids.length; i++) {\n var c = kids[i];\n if (c.nodeType !== 1) continue;\n var t = (c.tagName || \"\").toUpperCase();\n if (t === \"THEAD\" || t === \"TBODY\" || t === \"TFOOT\") collect(c);\n else if (t === \"TR\") {\n var cells = [], cc = c.childNodes || [];\n for (var j = 0; j < cc.length; j++) {\n var d = cc[j];\n if (d.nodeType !== 1) continue;\n var dt = (d.tagName || \"\").toUpperCase();\n if (dt === \"TH\" || dt === \"TD\") cells.push(inline(d).trim());\n }\n rows.push(cells);\n }\n }\n })(node);\n if (!rows.length) return \"\";\n var head = rows[0], body = rows.slice(1);\n var sep = head.map(function () { return \"---\"; });\n var out = \"| \" + head.join(\" | \") + \" |\\n| \" + sep.join(\" | \") + \" |\\n\";\n for (var k = 0; k < body.length; k++) out += \"| \" + body[k].join(\" | \") + \" |\\n\";\n return out;\n }\n function block(node) {\n var out = \"\";\n var kids = node.childNodes || [];\n for (var i = 0; i < kids.length; i++) {\n var c = kids[i];\n if (c.nodeType === 3) { if ((c.textContent || \"\").trim()) out += c.textContent; continue; }\n if (c.nodeType !== 1) continue;\n var tag = (c.tagName || \"\").toUpperCase();\n if (/^H[1-6]$/.test(tag)) out += new Array(+tag[1] + 1).join(\"#\") + \" \" + inline(c).trim() + \"\\n\\n\";\n else if (tag === \"P\") out += inline(c).trim() + \"\\n\\n\";\n else if (tag === \"UL\") out += list(c, false, 0) + \"\\n\";\n else if (tag === \"OL\") out += list(c, true, 0) + \"\\n\";\n else if (tag === \"PRE\") {\n var code = findChildTag(c, \"CODE\");\n var lang = langOf(code || c);\n var body = (code || c).textContent || \"\";\n out += \"```\" + lang + \"\\n\" + body.replace(/\\n$/, \"\") + \"\\n```\\n\\n\";\n } else if (tag === \"BLOCKQUOTE\") {\n var inner = block(c).trim().split(\"\\n\").map(function (l) { return \"> \" + l; }).join(\"\\n\");\n out += inner + \"\\n\\n\";\n } else if (tag === \"HR\") out += \"---\\n\\n\";\n else if (tag === \"TABLE\") out += table(c) + \"\\n\";\n else if (tag === \"BR\") out += \"\\n\";\n else if (tag === \"STRONG\" || tag === \"B\" || tag === \"EM\" || tag === \"I\" ||\n tag === \"A\" || tag === \"CODE\" || tag === \"DEL\" || tag === \"S\")\n out += inline(c) + \"\\n\\n\";\n else out += block(c); // unknown wrapper: recurse (drop tag, keep content)\n }\n return out;\n }\n // block() dispatches on each CHILD's tag, treating the passed node as a plain\n // container. Wrap root in a one-off container so root's OWN tag is dispatched\n // too: callers pass either the bubble container (its block children render) or\n // a single block element like
    /
      /
    (now handled, not flattened).\n return block({ childNodes: [root] }).replace(/\\n{3,}/g, \"\\n\\n\").trim();\n }\n\n // ---- pure helpers ----------------------------------------------------------\n function hasPrefix(node, prefix) {\n if (node.nodeType !== 1 || typeof node.className !== \"string\") return false;\n var parts = node.className.split(/\\s+/);\n for (var i = 0; i < parts.length; i++) if (parts[i].indexOf(prefix) === 0) return true;\n return false;\n }\n\n // Class-prefix hooks for non-content chrome that renders *inside* an assistant\n // bubble (verified on 2.1.170; Task 6 re-pins these). tool*/thinking_ are the v1\n // exclusions; unknownContent_ is the renderer's fallback for unrecognized block\n // types, so stripping it makes a *future* block type fail safe to excluded rather\n // than leaking \"Unsupported content\" into the copy. Re-pin if a prefix moves.\n var CHROME_PREFIXES = [\"toolUse_\", \"toolResult_\", \"toolReference_\", \"thinking_\", \"unknownContent_\"];\n\n // True for any node that must never appear in copied output: our own controls,\n // the rating widget (`data-message-rating` + its \"Thanks for your feedback\"\n // text), any button (copy-code chrome), and the excluded content blocks above.\n function isChrome(node) {\n if (node.nodeType !== 1) return false;\n if ((node.tagName || \"\").toUpperCase() === \"BUTTON\") return true;\n if (node.getAttribute && node.getAttribute(\"data-message-rating\") !== null) return true;\n if (hasPrefix(node, CONTROL_PREFIX)) return true;\n for (var i = 0; i < CHROME_PREFIXES.length; i++) if (hasPrefix(node, CHROME_PREFIXES[i])) return true;\n return false;\n }\n\n // Deep-clone `contentNode`, then strip every chrome node so copied output is the\n // message's text content only. This is a CORRECTNESS GATE, not cosmetic: the\n // default content node is the whole bubble (all content-block siblings, so multi-\n // block assistant turns are captured), and this strip-list is the only thing\n // keeping the rating widget and v1-excluded blocks out of the copy.\n function sanitizeClone(contentNode) {\n var clone = contentNode.cloneNode(true);\n (function strip(node) {\n var kids = (node.childNodes || []).slice();\n for (var i = 0; i < kids.length; i++) {\n var c = kids[i];\n if (c.nodeType === 1 && isChrome(c)) { node.removeChild(c); continue; }\n if (c.nodeType === 1) strip(c);\n }\n })(clone);\n return clone;\n }\n\n function classifyBubble(node) {\n if (node.nodeType !== 1) return null;\n if (hasPrefix(node, \"userMessageContainer_\")) return \"user\";\n if (node.getAttribute && node.getAttribute(\"data-testid\") === \"assistant-message\") return \"assistant\";\n return null;\n }\n\n // Build the whole-conversation markdown from an ordered list of bubbles.\n // `contentOf(bubble)` resolves the content node (default: the bubble itself, so\n // every content block is included; sanitizeClone drops chrome); a default is\n // provided for tests.\n function conversationToMarkdown(bubbles, contentOf) {\n contentOf = contentOf || function (b) { return b; };\n var parts = [];\n for (var i = 0; i < bubbles.length; i++) {\n var role = classifyBubble(bubbles[i]);\n if (!role) continue;\n var clean = sanitizeClone(contentOf(bubbles[i]));\n var body = role === \"assistant\" ? htmlToMarkdown(clean) : (clean.textContent || \"\").trim();\n if (!body) continue;\n parts.push((role === \"user\" ? \"## User\" : \"## Assistant\") + \"\\n\\n\" + body);\n }\n return parts.join(\"\\n\\n\") + (parts.length ? \"\\n\" : \"\");\n }\n\n // ---- exports (node tests) / boot (real webview) ----------------------------\n if (typeof document !== \"undefined\") {\n boot();\n } else if (typeof module !== \"undefined\" && module.exports) {\n module.exports = { htmlToMarkdown: htmlToMarkdown, sanitizeClone: sanitizeClone,\n classifyBubble: classifyBubble, conversationToMarkdown: conversationToMarkdown };\n }\n\n // ---- live-webview wiring (runs only when a document exists) ----------------\n function qs(node, sel) { try { return sel && node.querySelector ? node.querySelector(sel) : null; } catch (_) { return null; } }\n function qsa(sel) { try { return Array.prototype.slice.call(document.querySelectorAll(sel)); } catch (_) { return []; } }\n\n // The content node to convert/copy: the optional ASSISTANT_CONTENT wrapper if\n // pinned and present, else the bubble itself. The bubble already contains every\n // content-block sibling of a multi-block turn, and sanitizeClone strips the\n // chrome (rating widget, tool/thinking/unknown blocks, buttons, our controls)\n // either way -- so this is a narrowing, never the thing that guarantees\n // correctness.\n function contentNodeOf(bubble, role) {\n if (role === \"assistant\" && ASSISTANT_CONTENT) {\n var n = qs(bubble, ASSISTANT_CONTENT);\n if (n) return n;\n }\n return bubble;\n }\n\n function copyText(text) {\n try {\n if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(text);\n } catch (_) {}\n return Promise.resolve(); // best-effort; never throw into the app\n }\n\n function flashFeedback(host) {\n try {\n var fb = document.createElement(\"span\");\n fb.className = CONTROL_PREFIX + \"-feedback\";\n fb.textContent = \"Copied\";\n host.appendChild(fb);\n setTimeout(function () { if (fb && fb.parentNode) fb.parentNode.removeChild(fb); }, FEEDBACK_MS);\n } catch (_) {}\n }\n\n function bubbleMarkdown(bubble, role) {\n var clean = sanitizeClone(contentNodeOf(bubble, role));\n return role === \"assistant\" ? htmlToMarkdown(clean) : (clean.textContent || \"\").trim();\n }\n function bubblePlain(bubble, role) {\n return (sanitizeClone(contentNodeOf(bubble, role)).textContent || \"\").trim();\n }\n\n // Build a single control: a primary \"Copy\" (markdown) plus a small caret that\n // toggles a menu with \"Copy as plain text\". All nodes carry the CONTROL_PREFIX\n // class so sanitizeClone removes them from any copied content.\n function buildControl(onMarkdown, onPlain) {\n var wrap = document.createElement(\"span\");\n wrap.className = CONTROL_PREFIX;\n var primary = document.createElement(\"button\");\n primary.type = \"button\";\n primary.className = CONTROL_PREFIX + \"-btn\";\n primary.title = \"Copy as Markdown\";\n primary.textContent = \"Copy\";\n primary.addEventListener(\"click\", function (e) { e.stopPropagation(); onMarkdown(primary); });\n var caret = document.createElement(\"button\");\n caret.type = \"button\";\n caret.className = CONTROL_PREFIX + \"-caret\";\n caret.title = \"Copy options\";\n caret.textContent = \"\u25be\"; // black down-pointing small triangle\n var menu = document.createElement(\"span\");\n menu.className = CONTROL_PREFIX + \"-menu\";\n menu.style.display = \"none\";\n var plain = document.createElement(\"button\");\n plain.type = \"button\";\n plain.className = CONTROL_PREFIX + \"-btn\";\n plain.textContent = \"Copy as plain text\";\n plain.addEventListener(\"click\", function (e) { e.stopPropagation(); menu.style.display = \"none\"; onPlain(plain); });\n menu.appendChild(plain);\n caret.addEventListener(\"click\", function (e) {\n e.stopPropagation();\n menu.style.display = menu.style.display === \"none\" ? \"inline-block\" : \"none\";\n });\n wrap.appendChild(primary);\n wrap.appendChild(caret);\n wrap.appendChild(menu);\n return wrap;\n }\n\n function decorate(bubble) {\n try {\n var role = classifyBubble(bubble);\n if (!role) return;\n if (qs(bubble, \".\" + CONTROL_PREFIX)) return; // already decorated\n var control = buildControl(\n function (host) { copyText(bubbleMarkdown(bubble, role)).then(function () { flashFeedback(control); }); },\n function (host) { copyText(bubblePlain(bubble, role)).then(function () { flashFeedback(control); }); }\n );\n bubble.appendChild(control);\n } catch (_) {}\n }\n\n function copyConversation(format) {\n var bubbles = qsa(USER_BUBBLE + \",\" + ASSISTANT_BUBBLE);\n if (format === \"text\") {\n var lines = [];\n for (var i = 0; i < bubbles.length; i++) {\n var role = classifyBubble(bubbles[i]);\n if (!role) continue;\n var body = bubblePlain(bubbles[i], role);\n if (body) lines.push(body);\n }\n return copyText(lines.join(\"\\n\\n\") + (lines.length ? \"\\n\" : \"\"));\n }\n return copyText(conversationToMarkdown(bubbles, function (b) {\n return contentNodeOf(b, classifyBubble(b));\n }));\n }\n\n function installConversationControl() {\n try {\n if (qs(document, \".\" + CONTROL_PREFIX + \"-conversation\")) return;\n var bar = document.createElement(\"div\");\n bar.className = CONTROL_PREFIX + \"-conversation\";\n var control = buildControl(\n function () { copyConversation(\"markdown\").then(function () { flashFeedback(bar); }); },\n function () { copyConversation(\"text\").then(function () { flashFeedback(bar); }); }\n );\n control.title = \"Copy entire conversation\";\n bar.appendChild(control);\n document.body.appendChild(bar); // fixed-position via CSS; placement refined in Task 6\n } catch (_) {}\n }\n\n function sweep() { var b = qsa(USER_BUBBLE + \",\" + ASSISTANT_BUBBLE); for (var i = 0; i < b.length; i++) decorate(b[i]); }\n\n function boot() {\n try {\n var target = (MESSAGES_CONTAINER && qs(document, MESSAGES_CONTAINER)) || document.body;\n sweep();\n installConversationControl();\n if (typeof MutationObserver === \"undefined\") return;\n var obs = new MutationObserver(function () { sweep(); });\n obs.observe(target, { childList: true, subtree: true });\n } catch (_) {}\n }\n})();\n"; +const MD_COPY_JS = "/* cc-md-copy: per-message and whole-conversation copy (markdown/plain) for the\n * Claude Code VS Code webview. Self-contained IIFE appended to webview/index.js.\n * Additive and read-only w.r.t. app state; keyed on stable CSS-module class\n * prefixes, so it fails safe (controls simply do not appear) if a prefix moves.\n * Exposes its pure functions for node unit tests; boot()s only in a real webview. */\n(function () {\n \"use strict\";\n\n var CONTROL_PREFIX = \"cc-md-copy\"; // every injected node's class starts with this\n var USER_BUBBLE = '[class*=\"userMessageContainer_\"]';\n // Assistant message wrapper. Verified on 2.1.170: the render emits exactly one\n // `data-testid=\"assistant-message\"` div per assistant turn, with the rating\n // widget and content blocks as its children. (The earlier `[data-message-rating]`\n // was WRONG: that attribute sits on the nested rating control, which is also only\n // rendered behind an experiment+analytics gate.) Re-pinned in Task 6.\n var ASSISTANT_BUBBLE = '[data-testid=\"assistant-message\"]';\n var MESSAGES_CONTAINER = '[class*=\"messagesContainer_\"]'; // e.g. '[class*=\"timeline_\"]'; \"\" -> observe document.body\n // Optional narrowing only. MUST be a single wrapper around ALL content blocks,\n // not a per-block class (a turn has multiple blocks). \"\" -> use the bubble itself\n // (already aggregates all blocks; sanitizeClone is the correctness gate).\n var ASSISTANT_CONTENT = \"\";\n var FEEDBACK_MS = 1800;\n\n // ---- HTML -> Markdown (DOM walk) -------------------------------------------\n // Uses only: nodeType, tagName, childNodes, textContent, getAttribute, className.\n function htmlToMarkdown(root) {\n // Longest run of consecutive backticks in s, so a code delimiter/fence can be\n // chosen longer than anything inside it (else ``` in the content closes early).\n function backtickRun(s) {\n var max = 0, cur = 0;\n for (var i = 0; i < s.length; i++) {\n if (s.charAt(i) === \"`\") { cur++; if (cur > max) max = cur; } else cur = 0;\n }\n return max;\n }\n function fence(s, min) { var n = backtickRun(s) + 1; if (n < min) n = min; return new Array(n + 1).join(\"`\"); }\n function inline(node) {\n var out = \"\";\n var kids = node.childNodes || [];\n for (var i = 0; i < kids.length; i++) {\n var c = kids[i];\n if (c.nodeType === 3) { out += c.textContent || \"\"; continue; }\n if (c.nodeType !== 1) continue;\n var tag = (c.tagName || \"\").toUpperCase();\n if (tag === \"BR\") out += \"\\n\";\n else if (tag === \"STRONG\" || tag === \"B\") out += \"**\" + inline(c) + \"**\";\n else if (tag === \"EM\" || tag === \"I\") out += \"*\" + inline(c) + \"*\";\n else if (tag === \"DEL\" || tag === \"S\") out += \"~~\" + inline(c) + \"~~\";\n else if (tag === \"CODE\") {\n var ct = c.textContent || \"\";\n var d = fence(ct, 1);\n // CommonMark strips one leading+trailing space, so pad when an edge is a\n // backtick to keep it from merging with the delimiter.\n var p = (ct.charAt(0) === \"`\" || ct.charAt(ct.length - 1) === \"`\") ? \" \" : \"\";\n out += d + p + ct + p + d;\n }\n else if (tag === \"A\") {\n var href = c.getAttribute ? c.getAttribute(\"href\") : null;\n var t = inline(c);\n out += href ? \"[\" + t + \"](\" + href + \")\" : t;\n } else out += inline(c); // unknown inline wrapper: keep text, drop tag\n }\n return out;\n }\n function langOf(codeEl) {\n var cls = \"\";\n if (codeEl) cls = (codeEl.getAttribute && codeEl.getAttribute(\"class\")) || codeEl.className || \"\";\n var m = /language-([A-Za-z0-9+#.\\-]+)/.exec(cls || \"\");\n return m ? m[1] : \"\";\n }\n function findChildTag(node, tag) {\n var kids = node.childNodes || [];\n for (var i = 0; i < kids.length; i++) {\n if (kids[i].nodeType === 1 && (kids[i].tagName || \"\").toUpperCase() === tag) return kids[i];\n }\n return null;\n }\n function list(node, ordered, depth) {\n var out = \"\", n = 1;\n var kids = node.childNodes || [];\n for (var i = 0; i < kids.length; i++) {\n var li = kids[i];\n if (li.nodeType !== 1 || (li.tagName || \"\").toUpperCase() !== \"LI\") continue;\n var marker = ordered ? n++ + \". \" : \"- \";\n var indent = new Array(depth + 1).join(\" \");\n var lead = \"\", nested = \"\";\n var lk = li.childNodes || [];\n for (var j = 0; j < lk.length; j++) {\n var ch = lk[j];\n var ct = ch.nodeType === 1 ? (ch.tagName || \"\").toUpperCase() : \"\";\n if (ct === \"UL\") nested += list(ch, false, depth + 1);\n else if (ct === \"OL\") nested += list(ch, true, depth + 1);\n else if (ch.nodeType === 3) lead += ch.textContent || \"\";\n else lead += inline(ch);\n }\n out += indent + marker + lead.trim() + \"\\n\" + nested;\n }\n return out;\n }\n function table(node) {\n var rows = [];\n (function collect(container) {\n var kids = container.childNodes || [];\n for (var i = 0; i < kids.length; i++) {\n var c = kids[i];\n if (c.nodeType !== 1) continue;\n var t = (c.tagName || \"\").toUpperCase();\n if (t === \"THEAD\" || t === \"TBODY\" || t === \"TFOOT\") collect(c);\n else if (t === \"TR\") {\n var cells = [], cc = c.childNodes || [];\n for (var j = 0; j < cc.length; j++) {\n var d = cc[j];\n if (d.nodeType !== 1) continue;\n var dt = (d.tagName || \"\").toUpperCase();\n if (dt === \"TH\" || dt === \"TD\") cells.push(inline(d).trim());\n }\n rows.push(cells);\n }\n }\n })(node);\n if (!rows.length) return \"\";\n var head = rows[0], body = rows.slice(1);\n var sep = head.map(function () { return \"---\"; });\n var out = \"| \" + head.join(\" | \") + \" |\\n| \" + sep.join(\" | \") + \" |\\n\";\n for (var k = 0; k < body.length; k++) out += \"| \" + body[k].join(\" | \") + \" |\\n\";\n return out;\n }\n function block(node) {\n var out = \"\";\n var kids = node.childNodes || [];\n for (var i = 0; i < kids.length; i++) {\n var c = kids[i];\n if (c.nodeType === 3) { if ((c.textContent || \"\").trim()) out += c.textContent; continue; }\n if (c.nodeType !== 1) continue;\n var tag = (c.tagName || \"\").toUpperCase();\n if (/^H[1-6]$/.test(tag)) out += new Array(+tag[1] + 1).join(\"#\") + \" \" + inline(c).trim() + \"\\n\\n\";\n else if (tag === \"P\") out += inline(c).trim() + \"\\n\\n\";\n else if (tag === \"UL\") out += list(c, false, 0) + \"\\n\";\n else if (tag === \"OL\") out += list(c, true, 0) + \"\\n\";\n else if (tag === \"PRE\") {\n var code = findChildTag(c, \"CODE\");\n var lang = langOf(code || c);\n var body = (code || c).textContent || \"\";\n var f = fence(body, 3);\n out += f + lang + \"\\n\" + body.replace(/\\n$/, \"\") + \"\\n\" + f + \"\\n\\n\";\n } else if (tag === \"BLOCKQUOTE\") {\n var inner = block(c).trim().split(\"\\n\").map(function (l) { return \"> \" + l; }).join(\"\\n\");\n out += inner + \"\\n\\n\";\n } else if (tag === \"HR\") out += \"---\\n\\n\";\n else if (tag === \"TABLE\") out += table(c) + \"\\n\";\n else if (tag === \"BR\") out += \"\\n\";\n else if (tag === \"STRONG\" || tag === \"B\" || tag === \"EM\" || tag === \"I\" ||\n tag === \"A\" || tag === \"CODE\" || tag === \"DEL\" || tag === \"S\")\n out += inline(c) + \"\\n\\n\";\n else out += block(c); // unknown wrapper: recurse (drop tag, keep content)\n }\n return out;\n }\n // block() dispatches on each CHILD's tag, treating the passed node as a plain\n // container. Wrap root in a one-off container so root's OWN tag is dispatched\n // too: callers pass either the bubble container (its block children render) or\n // a single block element like
    /
      /
    (now handled, not flattened).\n return block({ childNodes: [root] }).replace(/\\n{3,}/g, \"\\n\\n\").trim();\n }\n\n // ---- pure helpers ----------------------------------------------------------\n function hasPrefix(node, prefix) {\n if (node.nodeType !== 1 || typeof node.className !== \"string\") return false;\n var parts = node.className.split(/\\s+/);\n for (var i = 0; i < parts.length; i++) if (parts[i].indexOf(prefix) === 0) return true;\n return false;\n }\n\n // Class-prefix hooks for non-content chrome that renders *inside* an assistant\n // bubble (verified on 2.1.170; Task 6 re-pins these). tool*/thinking_ are the v1\n // exclusions; unknownContent_ is the renderer's fallback for unrecognized block\n // types, so stripping it makes a *future* block type fail safe to excluded rather\n // than leaking \"Unsupported content\" into the copy. Re-pin if a prefix moves.\n var CHROME_PREFIXES = [\"toolUse_\", \"toolResult_\", \"toolReference_\", \"thinking_\", \"unknownContent_\"];\n\n // True for any node that must never appear in copied output: our own controls,\n // the rating widget (`data-message-rating` + its \"Thanks for your feedback\"\n // text), any button (copy-code chrome), and the excluded content blocks above.\n function isChrome(node) {\n if (node.nodeType !== 1) return false;\n if ((node.tagName || \"\").toUpperCase() === \"BUTTON\") return true;\n if (node.getAttribute && node.getAttribute(\"data-message-rating\") !== null) return true;\n if (hasPrefix(node, CONTROL_PREFIX)) return true;\n for (var i = 0; i < CHROME_PREFIXES.length; i++) if (hasPrefix(node, CHROME_PREFIXES[i])) return true;\n return false;\n }\n\n // Deep-clone `contentNode`, then strip every chrome node so copied output is the\n // message's text content only. This is a CORRECTNESS GATE, not cosmetic: the\n // default content node is the whole bubble (all content-block siblings, so multi-\n // block assistant turns are captured), and this strip-list is the only thing\n // keeping the rating widget and v1-excluded blocks out of the copy.\n function sanitizeClone(contentNode) {\n var clone = contentNode.cloneNode(true);\n (function strip(node) {\n var kids = (node.childNodes || []).slice();\n for (var i = 0; i < kids.length; i++) {\n var c = kids[i];\n if (c.nodeType === 1 && isChrome(c)) { node.removeChild(c); continue; }\n if (c.nodeType === 1) strip(c);\n }\n })(clone);\n return clone;\n }\n\n function classifyBubble(node) {\n if (node.nodeType !== 1) return null;\n if (hasPrefix(node, \"userMessageContainer_\")) return \"user\";\n if (node.getAttribute && node.getAttribute(\"data-testid\") === \"assistant-message\") return \"assistant\";\n return null;\n }\n\n // Build the whole-conversation markdown from an ordered list of bubbles.\n // `contentOf(bubble)` resolves the content node (default: the bubble itself, so\n // every content block is included; sanitizeClone drops chrome); a default is\n // provided for tests.\n function conversationToMarkdown(bubbles, contentOf) {\n contentOf = contentOf || function (b) { return b; };\n var parts = [];\n for (var i = 0; i < bubbles.length; i++) {\n var role = classifyBubble(bubbles[i]);\n if (!role) continue;\n var clean = sanitizeClone(contentOf(bubbles[i]));\n var body = role === \"assistant\" ? htmlToMarkdown(clean) : (clean.textContent || \"\").trim();\n if (!body) continue;\n parts.push((role === \"user\" ? \"## User\" : \"## Assistant\") + \"\\n\\n\" + body);\n }\n return parts.join(\"\\n\\n\") + (parts.length ? \"\\n\" : \"\");\n }\n\n // ---- exports (node tests) / boot (real webview) ----------------------------\n if (typeof document !== \"undefined\") {\n boot();\n } else if (typeof module !== \"undefined\" && module.exports) {\n module.exports = { htmlToMarkdown: htmlToMarkdown, sanitizeClone: sanitizeClone,\n classifyBubble: classifyBubble, conversationToMarkdown: conversationToMarkdown };\n }\n\n // ---- live-webview wiring (runs only when a document exists) ----------------\n function qs(node, sel) { try { return sel && node.querySelector ? node.querySelector(sel) : null; } catch (_) { return null; } }\n function qsa(sel) { try { return Array.prototype.slice.call(document.querySelectorAll(sel)); } catch (_) { return []; } }\n\n // The content node to convert/copy: the optional ASSISTANT_CONTENT wrapper if\n // pinned and present, else the bubble itself. The bubble already contains every\n // content-block sibling of a multi-block turn, and sanitizeClone strips the\n // chrome (rating widget, tool/thinking/unknown blocks, buttons, our controls)\n // either way -- so this is a narrowing, never the thing that guarantees\n // correctness.\n function contentNodeOf(bubble, role) {\n if (role === \"assistant\" && ASSISTANT_CONTENT) {\n var n = qs(bubble, ASSISTANT_CONTENT);\n if (n) return n;\n }\n return bubble;\n }\n\n function copyText(text) {\n try {\n if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(text);\n } catch (_) {}\n return Promise.resolve(); // best-effort; never throw into the app\n }\n\n function flashFeedback(host) {\n try {\n var fb = document.createElement(\"span\");\n fb.className = CONTROL_PREFIX + \"-feedback\";\n fb.textContent = \"Copied\";\n host.appendChild(fb);\n setTimeout(function () { if (fb && fb.parentNode) fb.parentNode.removeChild(fb); }, FEEDBACK_MS);\n } catch (_) {}\n }\n\n function bubbleMarkdown(bubble, role) {\n var clean = sanitizeClone(contentNodeOf(bubble, role));\n return role === \"assistant\" ? htmlToMarkdown(clean) : (clean.textContent || \"\").trim();\n }\n function bubblePlain(bubble, role) {\n return (sanitizeClone(contentNodeOf(bubble, role)).textContent || \"\").trim();\n }\n\n // Build a single control: a primary \"Copy\" (markdown) plus a small caret that\n // toggles a menu with \"Copy as plain text\". All nodes carry the CONTROL_PREFIX\n // class so sanitizeClone removes them from any copied content.\n function buildControl(onMarkdown, onPlain) {\n var wrap = document.createElement(\"span\");\n wrap.className = CONTROL_PREFIX;\n var primary = document.createElement(\"button\");\n primary.type = \"button\";\n primary.className = CONTROL_PREFIX + \"-btn\";\n primary.title = \"Copy as Markdown\";\n primary.textContent = \"Copy\";\n primary.addEventListener(\"click\", function (e) { e.stopPropagation(); onMarkdown(primary); });\n var caret = document.createElement(\"button\");\n caret.type = \"button\";\n caret.className = CONTROL_PREFIX + \"-caret\";\n caret.title = \"Copy options\";\n caret.textContent = \"\u25be\"; // black down-pointing small triangle\n var menu = document.createElement(\"span\");\n menu.className = CONTROL_PREFIX + \"-menu\";\n menu.style.display = \"none\";\n var plain = document.createElement(\"button\");\n plain.type = \"button\";\n plain.className = CONTROL_PREFIX + \"-btn\";\n plain.textContent = \"Copy as plain text\";\n plain.addEventListener(\"click\", function (e) { e.stopPropagation(); menu.style.display = \"none\"; onPlain(plain); });\n menu.appendChild(plain);\n caret.addEventListener(\"click\", function (e) {\n e.stopPropagation();\n menu.style.display = menu.style.display === \"none\" ? \"inline-block\" : \"none\";\n });\n wrap.appendChild(primary);\n wrap.appendChild(caret);\n wrap.appendChild(menu);\n return wrap;\n }\n\n function decorate(bubble) {\n try {\n var role = classifyBubble(bubble);\n if (!role) return;\n if (qs(bubble, \".\" + CONTROL_PREFIX)) return; // already decorated\n var control = buildControl(\n function (host) { copyText(bubbleMarkdown(bubble, role)).then(function () { flashFeedback(control); }); },\n function (host) { copyText(bubblePlain(bubble, role)).then(function () { flashFeedback(control); }); }\n );\n bubble.appendChild(control);\n } catch (_) {}\n }\n\n function copyConversation(format) {\n var bubbles = qsa(USER_BUBBLE + \",\" + ASSISTANT_BUBBLE);\n if (format === \"text\") {\n var lines = [];\n for (var i = 0; i < bubbles.length; i++) {\n var role = classifyBubble(bubbles[i]);\n if (!role) continue;\n var body = bubblePlain(bubbles[i], role);\n if (body) lines.push(body);\n }\n return copyText(lines.join(\"\\n\\n\") + (lines.length ? \"\\n\" : \"\"));\n }\n return copyText(conversationToMarkdown(bubbles, function (b) {\n return contentNodeOf(b, classifyBubble(b));\n }));\n }\n\n function installConversationControl() {\n try {\n if (qs(document, \".\" + CONTROL_PREFIX + \"-conversation\")) return;\n var bar = document.createElement(\"div\");\n bar.className = CONTROL_PREFIX + \"-conversation\";\n var control = buildControl(\n function () { copyConversation(\"markdown\").then(function () { flashFeedback(bar); }); },\n function () { copyConversation(\"text\").then(function () { flashFeedback(bar); }); }\n );\n control.title = \"Copy entire conversation\";\n bar.appendChild(control);\n document.body.appendChild(bar); // fixed-position via CSS; placement refined in Task 6\n } catch (_) {}\n }\n\n function sweep() { var b = qsa(USER_BUBBLE + \",\" + ASSISTANT_BUBBLE); for (var i = 0; i < b.length; i++) decorate(b[i]); }\n\n function boot() {\n try {\n var target = (MESSAGES_CONTAINER && qs(document, MESSAGES_CONTAINER)) || document.body;\n sweep();\n installConversationControl();\n if (typeof MutationObserver === \"undefined\") return;\n var obs = new MutationObserver(function () { sweep(); });\n obs.observe(target, { childList: true, subtree: true });\n } catch (_) {}\n }\n})();\n"; const MD_COPY_CSS = ".cc-md-copy {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n vertical-align: middle;\n margin-left: 6px;\n}\n.cc-md-copy-btn,\n.cc-md-copy-caret {\n font: inherit;\n font-size: 11px;\n line-height: 1.4;\n padding: 1px 6px;\n color: var(--vscode-foreground);\n background: transparent;\n border: 1px solid var(--vscode-widget-border, transparent);\n border-radius: 4px;\n cursor: pointer;\n opacity: 0.65;\n}\n.cc-md-copy-btn:hover,\n.cc-md-copy-caret:hover {\n opacity: 1;\n background: var(--vscode-toolbar-hoverBackground, rgba(128, 128, 128, 0.15));\n}\n.cc-md-copy-menu {\n position: relative;\n margin-left: 4px;\n padding: 2px;\n background: var(--vscode-menu-background, var(--vscode-editorWidget-background));\n border: 1px solid var(--vscode-menu-border, var(--vscode-widget-border, transparent));\n border-radius: 4px;\n z-index: 5;\n}\n.cc-md-copy-feedback {\n margin-left: 6px;\n font-size: 11px;\n opacity: 0.85;\n color: var(--vscode-foreground);\n}\n.cc-md-copy-conversation {\n position: fixed;\n right: 16px;\n bottom: 56px;\n z-index: 10;\n padding: 2px;\n background: var(--vscode-editorWidget-background);\n border: 1px solid var(--vscode-widget-border, transparent);\n border-radius: 6px;\n opacity: 0.85;\n}\n.cc-md-copy-conversation:hover {\n opacity: 1;\n}\n"; // << delimiter must be 2 so it can't close early + out = convert("el('p',{},[el('code',{},[txt('a`b')])])") + self.assertIn("``a`b``", out) + + def test_inline_code_edge_backtick_is_space_padded(self): + # content starts with a backtick -> CommonMark needs a space inside the delimiters + out = convert("el('p',{},[el('code',{},[txt('`x')])])") + self.assertIn("`` `x ``", out) + + def test_fenced_code_with_triple_backticks_uses_longer_fence(self): + # body contains a run of 3 backticks -> fence must be >=4 or it closes early + out = convert("el('pre',{},[el('code',{class:'language-md'},[txt('```\\nx\\n```')])])") + self.assertIn("````md", out) + self.assertTrue(out.strip().endswith("````")) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_md_export.py b/tests/test_md_export.py index 1bc5579..ed1390c 100644 --- a/tests/test_md_export.py +++ b/tests/test_md_export.py @@ -142,6 +142,18 @@ def test_open_invokes_editor_with_resolved_path(self): self.assertEqual(rc, 0) self.assertEqual(capture.read_text(encoding="utf-8"), str(sess)) + def test_fence_uses_longer_delimiter_when_body_has_backticks(self): + # a tool/thinking payload that itself contains a ``` run must not close the + # fence early: the delimiter grows to one more than the longest backtick run. + out = self.mod._fence("tool_result", "```\ncode\n```") + self.assertTrue(out.startswith("````tool_result\n")) + self.assertTrue(out.endswith("\n````")) + self.assertEqual(out.count("````"), 2) # exactly one opening + one closing fence + + def test_fence_default_three_backticks_when_no_backticks(self): + out = self.mod._fence("thinking", "plain text") + self.assertEqual(out, "```thinking\nplain text\n```") + if __name__ == "__main__": unittest.main() From e71e702c03f6f253f66515068093f6d84e1ab0b1 Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 15/15] docs(launcher): update header comments for the third fix (md-copy) Both launchers still said they combine "TWO" fixes and the toggle guidance omitted CC_PATCH_MD_COPY, so "thinking only" guidance would still leave the md-copy webview patch enabled. Document the third fix and CC_PATCH_MD_COPY in the headers and toggle lists. Comments only; the embed region is untouched (Codex audit, Low). Co-Authored-By: Claude Opus 4.8 (1M context) --- launcher/claudemax | 23 +++++++++++++++-------- launcher/claudemax.win.js | 25 ++++++++++++++++--------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/launcher/claudemax b/launcher/claudemax index bbd23ad..2b03efe 100755 --- a/launcher/claudemax +++ b/launcher/claudemax @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# claudemax - Claude Code launcher that combines TWO unofficial fixes: +# claudemax - Claude Code launcher that combines three unofficial fixes: # # 1. Restores extended-thinking summaries on Opus 4.7 / 4.8, where the # "Thinking" section otherwise renders empty in the VS Code extension and @@ -12,18 +12,24 @@ # fix #1) this wrapper idempotently patches the extension's webview bundle on # each launch, flipping the threshold so the icon shows at any usage level. # Because it re-applies every launch, it survives extension updates. +# 3. Adds per-message and whole-conversation "Copy" controls (Markdown / plain +# text) to the VS Code chat. Like fix #2 there is no env/CLI lever, so this +# wrapper idempotently appends a self-contained block to the webview bundle +# (index.js + index.css) each launch; it fails safe (the controls simply do +# not appear if the markup moves) and survives extension updates. # # This single launcher carries every fix, each independently switchable by an -# environment variable (all on by default). For thinking only, set -# CC_PATCH_CONTEXT_ICON=0; for the context-icon fix only, set -# CC_THINKING_DISPLAY=omitted. +# environment variable (all on by default): CC_THINKING_DISPLAY=omitted (fix 1), +# CC_PATCH_CONTEXT_ICON=0 (fix 2), CC_PATCH_MD_COPY=0 (fix 3). E.g. for thinking +# summaries only, set CC_PATCH_CONTEXT_ICON=0 AND CC_PATCH_MD_COPY=0. # -# NOTE: unlike fix #1, fix #2 DOES edit the extension's bundled webview/index.js. -# That edit is idempotent and ownership-marked, snapshotted once to +# NOTE: unlike fix #1, fixes #2 and #3 DO edit the extension's bundled webview +# files (#2 patches index.js in place; #3 appends a block to index.js + index.css). +# Those edits are idempotent and ownership-marked, snapshotted once to # index.js.bak-cc-workarounds (emergency restore only), written atomically (a # failed write leaves the original untouched), best-effort (it never blocks the # launch), reconciled per file every launch, and toggle-able with -# CC_PATCH_CONTEXT_ICON=0 (or CC_WORKAROUNDS=0 / CC_RECONCILE=0). +# CC_PATCH_CONTEXT_ICON=0 / CC_PATCH_MD_COPY=0 (or CC_WORKAROUNDS=0 / CC_RECONCILE=0). # # Use it: # - VS Code (official "Claude Code" extension): set "claudeCode.claudeProcessWrapper" @@ -35,7 +41,8 @@ # # Toggle off (defaults in parentheses): # export CC_THINKING_DISPLAY=omitted # hide thinking summaries (summarized) -# export CC_PATCH_CONTEXT_ICON=0 # leave the extension webview alone (1) +# export CC_PATCH_CONTEXT_ICON=0 # leave the context-usage icon as-is (1) +# export CC_PATCH_MD_COPY=0 # no copy controls / webview append (1) # export CC_WORKAROUNDS=0 # master: disable every fix (1) # export CC_RECONCILE=0 # do not touch the webview bundle (1) # diff --git a/launcher/claudemax.win.js b/launcher/claudemax.win.js index 9a9575f..6f38ffe 100644 --- a/launcher/claudemax.win.js +++ b/launcher/claudemax.win.js @@ -1,4 +1,4 @@ -// claudemax.win.js - Windows launcher for Claude Code that combines TWO +// claudemax.win.js - Windows launcher for Claude Code that combines three // unofficial fixes: // // 1. Restores extended-thinking summaries on Opus 4.7 / 4.8, where the @@ -13,18 +13,24 @@ // this wrapper idempotently patches the extension's webview bundle on each // launch, flipping the threshold so the icon shows at any usage level. // Because it re-applies every launch, it survives extension updates. +// 3. Adds per-message and whole-conversation "Copy" controls (Markdown / plain +// text) to the VS Code chat. Like fix #2 there is no env/CLI lever, so this +// wrapper idempotently appends a self-contained block to the webview bundle +// (index.js + index.css) each launch; it fails safe (the controls simply do +// not appear if the markup moves) and survives extension updates. // // This single launcher carries every fix, each independently switchable by an -// environment variable (all on by default). For thinking only, set -// CC_PATCH_CONTEXT_ICON=0; for the context-icon fix only, set -// CC_THINKING_DISPLAY=omitted. +// environment variable (all on by default): CC_THINKING_DISPLAY=omitted (fix 1), +// CC_PATCH_CONTEXT_ICON=0 (fix 2), CC_PATCH_MD_COPY=0 (fix 3). E.g. for thinking +// summaries only, set CC_PATCH_CONTEXT_ICON=0 AND CC_PATCH_MD_COPY=0. // -// NOTE: unlike fix #1, fix #2 DOES edit the extension's bundled webview/index.js. -// That edit is idempotent and ownership-marked, snapshotted once to +// NOTE: unlike fix #1, fixes #2 and #3 DO edit the extension's bundled webview +// files (#2 patches index.js in place; #3 appends a block to index.js + index.css). +// Those edits are idempotent and ownership-marked, snapshotted once to // index.js.bak-cc-workarounds (emergency restore only), written via a temp file + // rename, best-effort (it never blocks the launch), reconciled per file every -// launch, and toggle-able with CC_PATCH_CONTEXT_ICON=0 (or CC_WORKAROUNDS=0 / -// CC_RECONCILE=0). +// launch, and toggle-able with CC_PATCH_CONTEXT_ICON=0 / CC_PATCH_MD_COPY=0 (or +// CC_WORKAROUNDS=0 / CC_RECONCILE=0). // // Use it: set the official "Claude Code" extension's "claudeCode.claudeProcessWrapper" // setting (or the third-party "Claude Code Chat" extension's @@ -36,7 +42,8 @@ // // Toggle off: // set CC_THINKING_DISPLAY=omitted hide thinking summaries (default: summarized) -// set CC_PATCH_CONTEXT_ICON=0 leave the extension webview untouched (default: 1) +// set CC_PATCH_CONTEXT_ICON=0 leave the context-usage icon as-is (default: 1) +// set CC_PATCH_MD_COPY=0 no copy controls / webview append (default: 1) // set CC_WORKAROUNDS=0 master: disable every fix (default: 1) // set CC_RECONCILE=0 do not touch the webview bundle (default: 1) //