Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
3dfcd2c
feat(md-copy): standalone session exporter cc-export.py (markdown/tex…
phase3dev Jun 10, 2026
340c6e4
feat(md-copy): DOM-walk HTML->markdown converter + node unit tests
phase3dev Jun 10, 2026
2a599a3
test(md-copy): sanitizer/classify/conversation-join scoping coverage
phase3dev Jun 10, 2026
ea95e7c
fix(md-copy): htmlToMarkdown handles a block element passed as root
phase3dev Jun 10, 2026
d3db52d
feat(md-copy): webview boot() - inject copy controls, observer, conve…
phase3dev Jun 10, 2026
534edbf
feat(md-copy): webview CSS (themed, namespaced under .cc-md-copy)
phase3dev Jun 10, 2026
358b36d
feat(md-copy): tools/gen-embeds payload generator + drift check
phase3dev Jun 10, 2026
ae03b9f
feat(md-copy): standalone add-md-copy.py (sentinel append, reverse-tr…
phase3dev Jun 10, 2026
b7f43b0
feat(launcher): md-copy bash feature - multi-file reconcile (index.js…
phase3dev Jun 10, 2026
ba44460
feat(launcher): md-copy node parity - per-file feature map (index.js …
phase3dev Jun 10, 2026
edcc6c4
docs(md-copy): fix README, toggle table + migration line, TECHNICAL c…
phase3dev Jun 10, 2026
f8e1f19
ci(md-copy): node/py checks for the new files + embed drift check
phase3dev Jun 10, 2026
40dd8f9
refactor(md-copy): drop unused import os from tools/gen-embeds
phase3dev Jun 10, 2026
e790c45
fix(md-copy): delimiter-safe code fences in converter and exporter
phase3dev Jun 10, 2026
e71e702
docs(launcher): update header comments for the third fix (md-copy)
phase3dev Jun 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand All @@ -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.

---
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions TECHNICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions fixes/markdown-copy-export/README.md
Original file line number Diff line number Diff line change
@@ -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`.
165 changes: 165 additions & 0 deletions fixes/markdown-copy-export/add-md-copy.py

Large diffs are not rendered by default.

195 changes: 195 additions & 0 deletions fixes/markdown-copy-export/cc-export.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
#!/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):
# Pick a fence longer than the longest backtick run in the body, so content
# that itself contains ``` cannot close the fence early.
text = text.rstrip()
longest = run = 0
for ch in text:
if ch == "`":
run += 1
longest = max(longest, run)
else:
run = 0
fence = "`" * max(3, longest + 1)
return f"{fence}{label}\n{text}\n{fence}"


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())
Loading
Loading