Last active
July 8, 2026 11:49
-
-
Save phase3dev/1f7ee1cb17151c19d538e053f4f548ca to your computer and use it in GitHub Desktop.
Launcher to run GLM 5.2 in Claude Code harness (full launcher with all features: thinking summaries, context icon, markdown copy)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # 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 | |
| # headless -p/SDK. Done by injecting `--thinking-display summarized` into the | |
| # launch args - the one lever that is NOT interactivity-gated. Edits nothing. | |
| # 2026-07-07: cannot override the server-side experiment that blanks Opus 4.8 | |
| # summaries (x-cc-atis header); see the README's 2026-07-07 update. The | |
| # launcher can apply that update's mitigation - opt in with CC_ATIS_OPTOUT=1 | |
| # (OFF by default; see the section below and the README for its caveats). | |
| # 2. Restores the always-visible context-usage icon in the VS Code chat input. | |
| # Recent extension builds (2.1.165+) hide that icon until you have used | |
| # >50% of the context window; with the 1M window that is ~500k tokens, so it | |
| # is effectively never shown. There is no env/CLI lever for this, so (unlike | |
| # 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 a single-click "Copy as Markdown" icon to every message (and a floating | |
| # "copy conversation" icon) in the VS Code chat; the icon flips to a checkmark | |
| # only when the copy truly lands. 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): 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, 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 / CC_PATCH_MD_COPY=0 (or CC_WORKAROUNDS=0 / CC_RECONCILE=0). | |
| # | |
| # Use it: | |
| # - VS Code (official "Claude Code" extension): set "claudeCode.claudeProcessWrapper" | |
| # to the FULL path of this file, then reload the window. In a multi-root | |
| # .code-workspace this setting is window-scoped, so put it in the workspace | |
| # file's "settings" block (or User settings), not a folder .vscode/settings.json. | |
| # - VS Code (third-party "Claude Code Chat"): set "claudeCodeChat.executable.path". | |
| # - Terminal: run `claudemax` in place of `claude`. | |
| # | |
| # Toggle off (defaults in parentheses): | |
| # export CC_THINKING_DISPLAY=omitted # hide thinking summaries (summarized) | |
| # 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) | |
| # export CC_SCRUB_ROUTING=1 # force the default Anthropic account (0) | |
| # export CC_ATIS_OPTOUT=1 # purge cached x-cc-atis experiment | |
| # # assignment + essential-traffic-only env (0) | |
| # | |
| # The real `claude` must be installed. This wrapper finds it automatically; if it | |
| # cannot, set CLAUDE_REAL_BIN to the full path of your real claude binary. | |
| set -euo pipefail | |
| # --- Locate the real claude binary ----------------------------------------- | |
| self="$(readlink -f "$0" 2>/dev/null || echo "$0")" | |
| # Process-wrapper convention: the official VS Code extension invokes the wrapper | |
| # as <wrapper> <REAL_CLAUDE...> <args...>, passing the real CLI ahead of the | |
| # args. <REAL_CLAUDE...> is either a single native-binary path (".../claude") or | |
| # a node interpreter followed by the bundled cli.js (".../node .../cli.js"). | |
| # Peel that off so it is not forwarded as a stray positional argument, and | |
| # prefer it as the real claude. (Plain "claudemax <args>" use is unaffected: | |
| # <args> never begins with an existing claude/node binary path.) | |
| wrapper_bin="" | |
| if [ "$#" -gt 0 ] \ | |
| && printf '%s' "$1" | grep -Eqi '/claude(\.exe|\.cmd|\.bat)?$' \ | |
| && [ -e "$1" ]; then | |
| wrapper_bin="$1" | |
| shift | |
| elif [ "$#" -ge 2 ] \ | |
| && printf '%s' "$1" | grep -Eqi '/node(\.exe)?$' && [ -e "$1" ] \ | |
| && printf '%s' "$2" | grep -Eqi '\.(c?js|mjs)$' && [ -e "$2" ]; then | |
| # node + cli.js: exec node directly and keep cli.js as the first forwarded arg. | |
| wrapper_bin="$1" | |
| shift | |
| fi | |
| REAL_CLAUDE="${CLAUDE_REAL_BIN:-}" | |
| if [ -z "$REAL_CLAUDE" ] && [ -n "$wrapper_bin" ]; then | |
| REAL_CLAUDE="$wrapper_bin" | |
| fi | |
| if [ -z "$REAL_CLAUDE" ]; then | |
| for c in \ | |
| "$HOME/.local/bin/claude" \ | |
| /usr/local/bin/claude \ | |
| /usr/bin/claude \ | |
| /opt/homebrew/bin/claude \ | |
| "$(command -v claude 2>/dev/null || true)"; do | |
| [ -n "$c" ] && [ -x "$c" ] || continue | |
| [ "$(readlink -f "$c" 2>/dev/null || echo "$c")" = "$self" ] && continue | |
| REAL_CLAUDE="$c" | |
| break | |
| done | |
| fi | |
| [ -n "$REAL_CLAUDE" ] || { | |
| echo "claudemax: could not find the real 'claude' binary; set CLAUDE_REAL_BIN" >&2 | |
| exit 1 | |
| } | |
| # --- Behavior --------------------------------------------------------------- | |
| # Set CC_THINKING_DISPLAY=omitted to hide thinking; default shows summaries. | |
| DISPLAY_VALUE="${CC_THINKING_DISPLAY:-summarized}" | |
| case "$DISPLAY_VALUE" in | |
| summarized|omitted) ;; | |
| *) | |
| echo "claudemax: invalid CC_THINKING_DISPLAY=$DISPLAY_VALUE; using summarized" >&2 | |
| DISPLAY_VALUE="summarized" | |
| ;; | |
| esac | |
| # ===== FEATURE DEFAULTS (edit to taste; environment variables override) ===== | |
| # Master switch: 0 disables every workaround (argument injection AND bundle | |
| # patches) and reconcile reverts the webview to clean on this launch. When 1, | |
| # the per-feature toggles below govern. | |
| CC_WORKAROUNDS="${CC_WORKAROUNDS:-1}" | |
| # Emergency bundle bypass: 0 means do NOT read or write the webview bundle at all | |
| # this launch (argument injection is unaffected). Leaves any existing patches in | |
| # place without uninstalling. | |
| 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.) | |
| # markdown copy/export bundle patch: 0 leaves the webview without the copy controls. | |
| CC_PATCH_MD_COPY="${CC_PATCH_MD_COPY:-1}" | |
| # ============================================================================ | |
| # --- Optional customizations ------------------------------------------------ | |
| # | |
| # Raise reasoning effort - longer, more detailed summaries. Uses more tokens: | |
| # export CLAUDE_CODE_EFFORT_LEVEL="${CLAUDE_CODE_EFFORT_LEVEL:-xhigh}" | |
| # | |
| # Auto mode - let a classifier pick the effort level per task. This is an | |
| # ALTERNATIVE to a fixed effort level above (when auto mode is on, a fixed | |
| # CLAUDE_CODE_EFFORT_LEVEL may be ignored). Another frequently-requested feature: | |
| # export CLAUDE_CODE_ENABLE_AUTO_MODE="${CLAUDE_CODE_ENABLE_AUTO_MODE:-1}" | |
| # | |
| # Longer network timeout for large requests: | |
| # export API_TIMEOUT_MS="${API_TIMEOUT_MS:-600000}" | |
| # --- Routing scrub + local environment -------------------------------------- | |
| # | |
| # CC_SCRUB_ROUTING=1 clears third-party model-routing variables before launch so | |
| # Claude Code always uses the default Anthropic account. Useful when you also run | |
| # wrappers (e.g. a GLM launcher) that export ANTHROPIC_BASE_URL / | |
| # ANTHROPIC_AUTH_TOKEN / *_MODEL to point Claude Code at a non-Anthropic model. | |
| # Default 0: leave the environment as-is. | |
| CC_SCRUB_ROUTING="${CC_SCRUB_ROUTING:-0}" | |
| if [ "$CC_SCRUB_ROUTING" != "0" ]; then | |
| unset CLAUDE_CONFIG_DIR \ | |
| ANTHROPIC_BASE_URL ANTHROPIC_AUTH_TOKEN ANTHROPIC_MODEL \ | |
| ANTHROPIC_DEFAULT_OPUS_MODEL ANTHROPIC_DEFAULT_SONNET_MODEL \ | |
| ANTHROPIC_DEFAULT_HAIKU_MODEL CLAUDE_CODE_SUBAGENT_MODEL \ | |
| ANTHROPIC_DEFAULT_OPUS_MODEL_NAME ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION \ | |
| ANTHROPIC_DEFAULT_SONNET_MODEL_NAME ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION \ | |
| ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION \ | |
| ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES \ | |
| ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES \ | |
| ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES 2>/dev/null || true | |
| fi | |
| # Personal/local exports go between the markers below. They are a stable splice | |
| # point: the Linux deploy step and the Windows build.ps1 inject a private env | |
| # file here, so a personal build never hand-merges into the launcher body. | |
| # Anything set here (effort level, API timeout, even routing) applies this launch | |
| # and, coming after the scrub above, wins over it. | |
| # >>> ccwa-local-env >>> | |
| # --- Connection --- | |
| export ANTHROPIC_BASE_URL="https://api.z.ai/api/anthropic" | |
| export ANTHROPIC_AUTH_TOKEN="your token" | |
| # --- Model mapping --- | |
| export ANTHROPIC_MODEL="glm-5.2" | |
| export ANTHROPIC_DEFAULT_OPUS_MODEL="glm-5.2" | |
| export ANTHROPIC_DEFAULT_SONNET_MODEL="glm-5.2" | |
| export ANTHROPIC_DEFAULT_HAIKU_MODEL="glm-5.2" | |
| export CLAUDE_CODE_SUBAGENT_MODEL="glm-5.2" | |
| # --- Session isolation (run this alongside the default `claude` at the same time) --- | |
| export CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude-glm}" | |
| # <<< ccwa-local-env <<< | |
| # --- x-cc-atis experiment opt-out (OFF by default) --------------------------- | |
| # | |
| # CC_ATIS_OPTOUT=1 applies the verified 2026-07-07 mitigation for the | |
| # server-side experiment that blanks Opus 4.8 thinking summaries (README | |
| # "2026-07-07 update"): | |
| # 1. purge any cached x-cc-atis experiment assignment from ~/.claude.json, and | |
| # 2. launch with CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 so the CLI cannot | |
| # re-enroll this install. (2026-07-08 correction: DISABLE_GROWTHBOOK=1 does | |
| # NOT stop re-enrollment - the assignment arrives via the CLI's startup | |
| # bootstrap fetch, which only the essential-traffic-only switch blocks; | |
| # measured. DISABLE_GROWTHBOOK stays exported as harmless defense in depth.) | |
| # Both halves are required - the env var alone still sends the already-cached | |
| # token, and the purge alone lasts only until the next refresh (both measured). | |
| # | |
| # OFF by default, unlike the other fixes, because it is not free: it edits the | |
| # CLI's own config file, and CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 restricts | |
| # the CLI to essential traffic ENTIRELY - it also disables claude.ai Projects | |
| # sync, DesignSync, /feedback, --enable-live-preview, and telemetry (local | |
| # sessions, transcripts, and resume are unaffected). Enable it deliberately. | |
| # | |
| # Safety model matches the bundle patches: idempotent (no rewrite when nothing | |
| # is cached), one-time backup (~/.claude.json.bak-cc-workarounds, only if | |
| # absent), atomic temp+mv write (jq exits non-zero on malformed input, so a bad | |
| # parse never replaces the file), best-effort (never blocks the launch). The | |
| # purge half needs jq; without it the launcher warns and still sets the env | |
| # vars (an already-cached assignment is still sent until purged by hand - see | |
| # the README's manual recipe). | |
| CC_ATIS_OPTOUT="${CC_ATIS_OPTOUT:-0}" | |
| _cc_atis_optout() { | |
| [ "$CC_WORKAROUNDS" != "0" ] || return 0 | |
| [ "$CC_ATIS_OPTOUT" != "0" ] || return 0 | |
| export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 | |
| export DISABLE_GROWTHBOOK=1 | |
| local cfg="$HOME/.claude.json" tmp | |
| [ -f "$cfg" ] && [ -r "$cfg" ] || return 0 | |
| if ! command -v jq >/dev/null 2>&1; then | |
| echo "claudemax: CC_ATIS_OPTOUT=1 but jq is not installed; cached x-cc-atis assignment (if any) NOT purged" >&2 | |
| return 0 | |
| fi | |
| # Idempotent: rewrite only when an assignment is actually cached. | |
| jq -e '((.clientDataCacheSlots // {}) | any(.[]?; .data.atis? != null)) | |
| or ((.clientDataCache // {}) | has("atis"))' "$cfg" >/dev/null 2>&1 || return 0 | |
| if [ ! -e "${cfg}.bak-cc-workarounds" ]; then | |
| cp -p "$cfg" "${cfg}.bak-cc-workarounds" 2>/dev/null || true | |
| fi | |
| tmp="${cfg}.ccatis.$$" | |
| if jq '.clientDataCacheSlots |= (if . then with_entries(select((.value.data.atis? // null) == null)) else . end) | |
| | del(.clientDataCache.atis)' "$cfg" > "$tmp" 2>/dev/null \ | |
| && [ -s "$tmp" ]; then | |
| mv -f "$tmp" "$cfg" 2>/dev/null || rm -f "$tmp" 2>/dev/null || true | |
| else | |
| rm -f "$tmp" 2>/dev/null || true | |
| fi | |
| } | |
| _cc_atis_optout || true | |
| # --- Inject the thinking-display fix into the launch args ------------------- | |
| # | |
| # Fire on a real agent invocation. Surfaces signal a real run differently: | |
| # - the VS Code extension passes "--max-thinking-tokens N" (N > 0) plus the | |
| # stream-json I/O flags, and does NOT pass "--thinking adaptive" or "-p"; | |
| # - the SDK / older extensions pass "--thinking adaptive" (or "enabled"); | |
| # - headless passes "-p" / "--print". | |
| # | |
| # Skip injection when: | |
| # - thinking is explicitly disabled | |
| # - --thinking-display is already present (no double-inject vs a patched extension) | |
| # - CC_THINKING_DISPLAY=omitted | |
| # - the command is a subcommand/probe such as mcp, config, or --version, | |
| # which carries none of these markers | |
| args=("$@") | |
| have_display=false | |
| thinking_adaptive=false | |
| thinking_disabled=false | |
| print_mode=false | |
| max_thinking_on=false | |
| prev="" | |
| for a in "$@"; do | |
| case "$a" in | |
| --thinking-display|--thinking-display=*) | |
| have_display=true | |
| ;; | |
| --thinking=adaptive|--thinking=enabled) | |
| thinking_adaptive=true | |
| ;; | |
| --thinking=disabled) | |
| thinking_disabled=true | |
| ;; | |
| --max-thinking-tokens=*) | |
| v="${a#*=}" | |
| if [ -n "$v" ] && [ "$v" != "0" ]; then | |
| max_thinking_on=true | |
| fi | |
| ;; | |
| -p|--print) | |
| print_mode=true | |
| ;; | |
| esac | |
| if [ "$prev" = "--thinking" ]; then | |
| case "$a" in | |
| adaptive|enabled) | |
| thinking_adaptive=true | |
| ;; | |
| disabled) | |
| thinking_disabled=true | |
| ;; | |
| esac | |
| fi | |
| if [ "$prev" = "--max-thinking-tokens" ] && [ "$a" != "0" ]; then | |
| max_thinking_on=true | |
| fi | |
| prev="$a" | |
| done | |
| if [ "$CC_WORKAROUNDS" != "0" ] \ | |
| && [ "$have_display" = false ] \ | |
| && [ "$thinking_disabled" = false ] \ | |
| && [ "$DISPLAY_VALUE" != "omitted" ] \ | |
| && { [ "$thinking_adaptive" = true ] || [ "$print_mode" = true ] || [ "$max_thinking_on" = true ]; }; then | |
| args+=(--thinking-display "$DISPLAY_VALUE") | |
| fi | |
| # --- Reconcile the webview bundle: apply enabled bundle-patch features, undo | |
| # disabled ones, PER FILE, every launch --------------------------------- | |
| # | |
| # Generic engine (replaces the single hard-coded context-icon sed). Each | |
| # bundle-patch feature registers, per target file, an idempotent + reversible | |
| # (apply, undo) pair. Every applied edit carries an ownership MARKER, and undo | |
| # keys off our own fingerprints (the MARKER, plus any legacy unmarked form an | |
| # older version of this tool wrote), so the launcher reverses ONLY its own edits | |
| # and never touches upstream code that merely resembles a patched value. | |
| # | |
| # Per-file reconcile (see TECHNICAL.md "patch composition"): | |
| # C = current bytes with every KNOWN feature's undo applied in REVERSE order | |
| # (the pristine bundle, regardless of which of our patches were present) | |
| # D = C with every ENABLED feature's apply applied in FORWARD order | |
| # write D only when it differs from the current bytes (idempotent) | |
| # | |
| # Best-effort: every step is guarded and the whole pass runs under `|| true`, so | |
| # it can never block the launch. Writes go through a metadata-preserving temp | |
| # (`cp -p`, portable - the GNU-only `--reference` is avoided so this also works | |
| # on macOS/BSD) and an atomic `mv -f`; a failed step leaves the original | |
| # untouched. | |
| # | |
| # context-icon feature - component `FJe` in webview/index.js: | |
| # if(t===0)return null;if(c>=50)return null} | |
| # -> if(c>=101)return null}/*ccwa-context-icon:t:c*/ | |
| # `c` is "% of context remaining" (maxes at 100), so >=101 never fires. Removing | |
| # the t===0 guard keeps the icon visible across a reload gap; it may briefly show | |
| # 0% until the webview receives fresh usage data. The trailing | |
| # /*ccwa-context-icon:<first-var>:<remaining-var>*/ is our ownership marker. | |
| # Maintenance: this keys off the minified guard pair shape above, not the | |
| # component name or exact minified variable names; if a future build changes that | |
| # shape, apply no-ops loudly (a one-line warning) until the anchor here is updated. | |
| # A bundle feature is enabled when the master switch is on AND its own toggle is | |
| # on. CC_WORKAROUNDS=0 forces every feature off, so reconcile reverts to clean. | |
| _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 | |
| } | |
| # apply/undo operate on a path in place. Each is a no-op when its target state is | |
| # already present/absent, so chaining them is safe and idempotent. | |
| _cc_apply_context_icon() { | |
| local f="$1" tmp count | |
| if grep -q '/\*ccwa-context-icon' "$f" 2>/dev/null; then return 0; fi # already marked | |
| count="$( (grep -E -o 'if\([A-Za-z_$][A-Za-z0-9_$]*===0\)return null;if\([A-Za-z_$][A-Za-z0-9_$]*>=50\)return null\}' "$f" 2>/dev/null || true) | wc -l | tr -d ' ')" | |
| if [ "$count" = "0" ]; then | |
| echo "claudemax: context-icon anchor not found in $f (extension changed?); skipping" >&2 | |
| return 0 | |
| fi | |
| if [ "$count" != "1" ]; then return 0; fi # ambiguous (version changed) - skip | |
| tmp="${f}.ccapply.$$" | |
| if sed 's#if(\([A-Za-z_$][A-Za-z0-9_$]*\)===0)return null;if(\([A-Za-z_$][A-Za-z0-9_$]*\)>=50)return null}#if(\2>=101)return null}/*ccwa-context-icon:\1:\2*/#' "$f" > "$tmp" 2>/dev/null \ | |
| && [ -s "$tmp" ] && grep -q '/\*ccwa-context-icon' "$tmp" 2>/dev/null; then | |
| cat "$tmp" > "$f" 2>/dev/null || true | |
| fi | |
| rm -f "$tmp" 2>/dev/null || true | |
| } | |
| _cc_undo_context_icon() { | |
| # Revert our edit to the pristine upstream form. Recognized fingerprints are: | |
| # the current metadata-marked form; the legacy bare (metadata-less) marker on | |
| # arbitrary guard names (older var-agnostic write); and legacy bare/unmarked | |
| # forms that older t/c-only versions wrote. Marked substitutions run first | |
| # because bare strings are prefixes of marked strings; a final pass strips any | |
| # leftover bare marker so apply (which exits early on ANY marker) is never | |
| # wedged by an unrecognized form. We deliberately do NOT do a generic | |
| # >=101->=50 rewrite: a bare >=101 guard with no marker is not necessarily ours, | |
| # and rewriting it would corrupt upstream code that merely resembles a patched | |
| # value (the ownership invariant above). Every form we actually write is covered | |
| # by the scoped substitutions below. | |
| local f="$1" tmp | |
| # Nothing of ours: no >=101 guard AND no leftover marker. The marker check is | |
| # load-bearing - a file an older buggy undo left wedged (gate already reverted to | |
| # >=50 but the bare marker still appended) has no >=101, yet the orphan strip | |
| # below must still run or apply stays wedged on the surviving marker. | |
| grep -qF '>=101)return null}' "$f" 2>/dev/null \ | |
| || grep -qF '/*ccwa-context-icon' "$f" 2>/dev/null \ | |
| || return 0 | |
| tmp="${f}.ccundo.$$" | |
| if sed -e 's#if(\([A-Za-z_$][A-Za-z0-9_$]*\)>=101)return null}/\*ccwa-context-icon:\([A-Za-z_$][A-Za-z0-9_$]*\):\1\*/#if(\2===0)return null;if(\1>=50)return null}#g' \ | |
| -e 's#if(\([A-Za-z_$][A-Za-z0-9_$]*\)===0)return null;if(\([A-Za-z_$][A-Za-z0-9_$]*\)>=101)return null}/\*ccwa-context-icon\*/#if(\1===0)return null;if(\2>=50)return null}#g' \ | |
| -e 's#if(c>=101)return null}/\*ccwa-context-icon\*/#if(t===0)return null;if(c>=50)return null}#g' \ | |
| -e 's#if(t===0)return null;if(c>=101)return null}#if(t===0)return null;if(c>=50)return null}#g' \ | |
| -e 's#if(c>=101)return null}#if(t===0)return null;if(c>=50)return null}#g' \ | |
| -e 's#)return null}/\*ccwa-context-icon\*/#)return null}#g' "$f" > "$tmp" 2>/dev/null \ | |
| && [ -s "$tmp" ]; then | |
| cat "$tmp" > "$f" 2>/dev/null || true | |
| fi | |
| rm -f "$tmp" 2>/dev/null || true | |
| } | |
| # 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) for the | |
| * Claude Code VS Code webview. Self-contained IIFE appended to webview/index.js. | |
| * Each control is a single clipboard icon that flips to a checkmark for ~2s when a | |
| * copy actually succeeds (no text label, no menu). 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. */ | |
| /* Leading ';' so that, appended after the bundle, this IIFE can never be parsed as | |
| * a call on the bundle's final expression if it lacks a trailing semicolon (ASI | |
| * safety across extension builds). */ | |
| ;(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 = 2000; // how long the checkmark shows after a successful copy | |
| // ---- 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 || []; | |
| 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") { | |
| 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); | |
| 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 || ""; | |
| 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"; | |
| } else if (tag === "DETAILS") out += block(c).trim() + "\n\n"; | |
| else if (tag === "SUMMARY") out += inline(c).trim() + "\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 <pre>/<ul>/<table> (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 blocks are excluded | |
| // from message copy; thinking summaries are visible content and must remain | |
| // copyable. 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_", "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 excluded tool/fallback blocks out of the copy. | |
| function sanitizeClone(contentNode) { | |
| var clone = contentNode.cloneNode(true); | |
| (function strip(node) { | |
| var kids = Array.prototype.slice.call(node.childNodes || []); | |
| 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 hasCopyableContent(contentNode, role) { | |
| function walk(node) { | |
| if (!node) return false; | |
| if (node.nodeType === 3) return !!(node.textContent || "").trim(); | |
| if (node.nodeType !== 1) return false; | |
| if (isChrome(node)) return false; | |
| var kids = node.childNodes || []; | |
| for (var i = 0; i < kids.length; i++) if (walk(kids[i])) return true; | |
| return false; | |
| } | |
| return walk(contentNode); | |
| } | |
| 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, | |
| hasCopyableContent: hasCopyableContent, copyText: copyText }; | |
| } | |
| // ---- 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/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; | |
| } | |
| // Copy `s` via a synchronous execCommand("copy") on an off-screen textarea, and | |
| // report whether it actually happened. Done first (and synchronously) because it | |
| // runs inside the click gesture and works whether or not the page is a secure | |
| // context -- so it covers remote / code-server, where the async Clipboard API is | |
| // simply absent. Restores the prior selection/focus so it is invisible. | |
| function execCopy(s) { | |
| try { | |
| if (typeof document === "undefined" || !document.createElement) return false; | |
| var prev = document.activeElement || null; | |
| var sel = document.getSelection ? document.getSelection() : null; | |
| var saved = (sel && sel.rangeCount) ? sel.getRangeAt(0) : null; | |
| var ta = document.createElement("textarea"); | |
| ta.value = s; | |
| ta.setAttribute("readonly", ""); | |
| ta.style.position = "fixed"; | |
| ta.style.top = "-1000px"; | |
| ta.style.left = "0"; | |
| ta.style.opacity = "0"; | |
| (document.body || document.documentElement).appendChild(ta); | |
| ta.focus(); | |
| ta.select(); | |
| var ok = false; | |
| try { ok = document.execCommand("copy"); } catch (_) { ok = false; } | |
| if (ta.parentNode) ta.parentNode.removeChild(ta); | |
| if (saved && sel) { try { sel.removeAllRanges(); sel.addRange(saved); } catch (_) {} } | |
| if (prev && prev.focus) { try { prev.focus(); } catch (_) {} } | |
| return !!ok; | |
| } catch (_) { return false; } | |
| } | |
| // Copy `text` and resolve to whether the copy ACTUALLY happened, so callers only | |
| // show success on a real copy -- never a false "copied" (the original bug: | |
| // navigator.clipboard was undefined in the webview, the code fell through to | |
| // Promise.resolve(), and the UI claimed success while nothing was written). Empty | |
| // text is a non-copy -> false. execCommand first (gesture-safe, secure-context- | |
| // independent); the async Clipboard API is the fallback. Never throws. | |
| function copyText(text) { | |
| var s = (text == null) ? "" : String(text); | |
| if (!s) return Promise.resolve(false); | |
| if (execCopy(s)) return Promise.resolve(true); | |
| try { | |
| if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) { | |
| return navigator.clipboard.writeText(s).then( | |
| function () { return true; }, | |
| function () { return false; } | |
| ); | |
| } | |
| } catch (_) {} | |
| return Promise.resolve(false); | |
| } | |
| function bubbleMarkdown(bubble, role) { | |
| var clean = sanitizeClone(contentNodeOf(bubble, role)); | |
| return role === "assistant" ? htmlToMarkdown(clean) : (clean.textContent || "").trim(); | |
| } | |
| // Inline SVG icons (currentColor, ~14px). Set via innerHTML on our own buttons | |
| // only; the markup never reaches copied content (sanitizeClone drops our nodes). | |
| var ICON_COPY = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>'; | |
| var ICON_CHECK = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>'; | |
| // Flip the button to a checkmark for FEEDBACK_MS, then restore. Idempotent across | |
| // rapid clicks (any pending restore is cleared first). | |
| function showCopied(btn) { | |
| try { | |
| if (btn.__ccTimer) clearTimeout(btn.__ccTimer); | |
| btn.classList.add(CONTROL_PREFIX + "-ok"); | |
| btn.innerHTML = ICON_CHECK; | |
| btn.__ccTimer = setTimeout(function () { | |
| try { btn.classList.remove(CONTROL_PREFIX + "-ok"); btn.innerHTML = ICON_COPY; } catch (_) {} | |
| btn.__ccTimer = null; | |
| }, FEEDBACK_MS); | |
| } catch (_) {} | |
| } | |
| // Build a single control: one clipboard-icon button. `onCopy()` is invoked | |
| // synchronously on click (so the copy stays inside the user gesture) and must | |
| // return a Promise<boolean>; the checkmark shows only when it resolves true. All | |
| // nodes carry the CONTROL_PREFIX class so sanitizeClone strips them from copies. | |
| function buildControl(onCopy, title) { | |
| var wrap = document.createElement("span"); | |
| wrap.className = CONTROL_PREFIX; | |
| var btn = document.createElement("button"); | |
| btn.type = "button"; | |
| btn.className = CONTROL_PREFIX + "-btn"; | |
| btn.title = title || "Copy as Markdown"; | |
| btn.setAttribute("aria-label", btn.title); | |
| btn.innerHTML = ICON_COPY; | |
| var busy = false; | |
| btn.addEventListener("click", function (e) { | |
| e.stopPropagation(); | |
| if (busy) return; | |
| busy = true; | |
| var p; | |
| try { p = onCopy(); } catch (_) { p = false; } | |
| Promise.resolve(p).then( | |
| function (ok) { busy = false; if (ok) showCopied(btn); }, | |
| function () { busy = false; } | |
| ); | |
| }); | |
| wrap.appendChild(btn); | |
| return wrap; | |
| } | |
| function decorate(bubble) { | |
| try { | |
| var role = classifyBubble(bubble); | |
| if (!role) return; | |
| // Idempotent: keep exactly one control. A React re-render of the bubble can | |
| // leave a stale control behind or transiently defeat an "already decorated" | |
| // guard, which is what produced duplicate rows of buttons; prune any extras | |
| // every sweep and only add one when none remain. | |
| var existing = bubble.querySelectorAll ? bubble.querySelectorAll("." + CONTROL_PREFIX) : null; | |
| if (!hasCopyableContent(contentNodeOf(bubble, role), role)) { | |
| if (existing && existing.length) { | |
| for (var j = existing.length - 1; j >= 0; j--) { | |
| if (existing[j] && existing[j].parentNode) existing[j].parentNode.removeChild(existing[j]); | |
| } | |
| } | |
| return; | |
| } | |
| if (existing && existing.length) { | |
| for (var i = existing.length - 1; i >= 1; i--) { | |
| if (existing[i] && existing[i].parentNode) existing[i].parentNode.removeChild(existing[i]); | |
| } | |
| return; | |
| } | |
| var control = buildControl(function () { | |
| return copyText(bubbleMarkdown(bubble, role)); | |
| }, "Copy as Markdown"); | |
| bubble.appendChild(control); | |
| } catch (_) {} | |
| } | |
| function copyConversation() { | |
| var bubbles = qsa(USER_BUBBLE + "," + ASSISTANT_BUBBLE); | |
| return copyText(conversationToMarkdown(bubbles, function (b) { | |
| return contentNodeOf(b, classifyBubble(b)); | |
| })); | |
| } | |
| // A single floating "Copy conversation" icon, present only while a conversation | |
| // is open (so it never clutters the history-list view). Pinned top-right by CSS, | |
| // clear of the chat input at the bottom; the most-recent-prompt sticky header | |
| // sits to its left. | |
| function installConversationControl() { | |
| try { | |
| var existing = qs(document, "." + CONTROL_PREFIX + "-conversation"); | |
| var hasMessages = qsa(USER_BUBBLE + "," + ASSISTANT_BUBBLE).length > 0; | |
| if (!hasMessages) { | |
| if (existing && existing.parentNode) existing.parentNode.removeChild(existing); | |
| return; | |
| } | |
| if (existing) return; | |
| var bar = document.createElement("div"); | |
| bar.className = CONTROL_PREFIX + "-conversation"; | |
| bar.appendChild(buildControl(copyConversation, "Copy conversation")); | |
| document.body.appendChild(bar); | |
| } catch (_) {} | |
| } | |
| function sweep() { | |
| var b = qsa(USER_BUBBLE + "," + ASSISTANT_BUBBLE); | |
| for (var i = 0; i < b.length; i++) decorate(b[i]); | |
| installConversationControl(); | |
| } | |
| function boot() { | |
| try { | |
| var target = (MESSAGES_CONTAINER && qs(document, MESSAGES_CONTAINER)) || document.body; | |
| sweep(); | |
| 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; | |
| vertical-align: middle; | |
| margin-left: 6px; | |
| } | |
| .cc-md-copy-btn { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 2px; | |
| color: var(--vscode-foreground); | |
| background: transparent; | |
| border: none; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| opacity: 0.6; | |
| } | |
| .cc-md-copy-btn svg { | |
| display: block; | |
| width: 14px; | |
| height: 14px; | |
| } | |
| .cc-md-copy-btn:hover { | |
| opacity: 1; | |
| background: var(--vscode-toolbar-hoverBackground, rgba(128, 128, 128, 0.15)); | |
| } | |
| /* Success state: the icon is a green checkmark for a moment after a real copy. */ | |
| .cc-md-copy-btn.cc-md-copy-ok, | |
| .cc-md-copy-btn.cc-md-copy-ok:hover { | |
| opacity: 1; | |
| color: var(--vscode-charts-green, var(--vscode-testing-iconPassed, #89d185)); | |
| background: transparent; | |
| } | |
| /* Whole-conversation copy: a single floating icon pinned to the top-right corner, | |
| clear of the chat input at the bottom. Shown only while a conversation is open | |
| (the IIFE adds/removes it). Nudge top/right here if it crowds the sticky header. */ | |
| .cc-md-copy-conversation { | |
| position: fixed; | |
| top: 26px; | |
| right: 4px; | |
| z-index: 30; | |
| display: inline-flex; | |
| 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 .cc-md-copy { | |
| margin-left: 0; | |
| } | |
| .cc-md-copy-conversation:hover { | |
| opacity: 1; | |
| } | |
| CCMDCOPYCSS | |
| } | |
| # <<<CCWA-MD-COPY-EMBED<<< | |
| _cc_md_copy_has() { grep -qF '/* cc-md-copy v1 */' "$1" 2>/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 | |
| } | |
| # 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 | |
| } | |
| # 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 | |
| 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 | |
| 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 | |
| else | |
| rm -f "$tmpmeta" 2>/dev/null || true | |
| fi | |
| 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 | |
| # Most precise target: walk up from REAL_CLAUDE to the extension root. | |
| d="$(dirname "$REAL_CLAUDE" 2>/dev/null || echo "")" | |
| extdir="" | |
| while [ -n "$d" ] && [ "$d" != "/" ] && [ "$d" != "." ]; do | |
| 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" | |
| _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 | |
| # fall through harmlessly - _cc_reconcile_index_js skips non-files. | |
| for f in \ | |
| "$HOME"/.vscode/extensions/anthropic.claude-code-*/webview/index.js \ | |
| "$HOME"/.vscode-insiders/extensions/anthropic.claude-code-*/webview/index.js \ | |
| "$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 | |
| } | |
| # Best-effort: invoking under `|| true` suspends `set -e` for the whole pass, so | |
| # nothing here can block the launch (matches the previous safety model). | |
| _cc_reconcile || true | |
| # The `${args[@]+...}` form guards the empty-array case under `set -u`, | |
| # including older Bash versions such as the default Bash on older macOS systems. | |
| exec "$REAL_CLAUDE" ${args[@]+"${args[@]}"} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment