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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 45 additions & 71 deletions README.md

Large diffs are not rendered by default.

20 changes: 12 additions & 8 deletions TECHNICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ The launcher intentionally sets no environment of its own by default; it only ad

### Option 2: extension.js patch

> **Unmaintained.** Documented for reference. VS Code only, and must be re-applied after every extension update; the launcher (Option 1) is the supported fix.

A one-line change so the non-interactive spawn always forwards the flag:

```js
Expand All @@ -113,6 +115,8 @@ Toggle idea (untested): changing the line to `l.display || (process.env.CC_THINK

### Option 3: local proxy (design)

> **Unmaintained and untested.** A design and a working starting point, not a turnkey fix; it sits in the path of your live auth token. The launcher (Option 1) is the supported fix.

The most thorough fix: a small localhost forward proxy that injects the field at the wire level, so it is surface-agnostic (VS Code + CLI + SDK), needs no install edits, and survives updates. It works because every Claude Code surface resolves the API host as:

```
Expand Down Expand Up @@ -172,17 +176,17 @@ function FJe({usedTokens:e, contextWindow:t, onCompact:i, buttonClassName:n}) {
}
```

`c` is the percent of context *remaining*, so `c >= 50` hides the icon whenever at least half the window is free, i.e. it only appears once more than 50% is used. With a 1M window that is 500k tokens. Older bundles (`2.1.131`, `2.1.128`) do not contain this gate; it appeared around `2.1.165`, which matches users' recollection that the icon used to be visible.
`c` is the percent of context *remaining*, so `c >= 50` hides the icon whenever at least half the window is free, i.e. it only appears once more than 50% is used. With a 1M window that is 500k tokens. The same guard shape is present in the locally inspected `2.1.131` (`Z/U`) and `2.1.170` (`t/c`) bundles, with different minified names.

The `CLAUDE_CODE_DISABLE_1M_CONTEXT=1` env var that circulates in the issue threads only shrinks the window to 200k so 50% (100k) is reached sooner. It does not touch the threshold and forces giving up the 1M window, so it is not a real fix.

## The fix

```text
if(c>=50)return null -> if(c>=101)return null}/*ccwa-context-icon*/
if(t===0)return null;if(c>=50)return null} -> if(c>=101)return null}/*ccwa-context-icon:t:c*/
```

`c` is in `[0, 100]`, so `c >= 101` is never true and the gate never hides the icon. The separate `if (t === 0) return null` guard is left intact, so nothing renders before a context window is known. Using `>=101` (rather than deleting the line) is the smallest, most legible, greppable, reversible change, and it preserves the surrounding structure for a clean string substitution. The patch is anchored on the literal `>=50)return null}`, which is stable across builds even though the minified names around it are not, and which occurs exactly once in `2.1.169`. The trailing `/*ccwa-context-icon*/` is an ownership marker: the launcher's reconcile reverses only the marked form, so it can never corrupt upstream code that merely matches a patched value, and a pre-existing unmarked patch from an older launcher is left as-is and re-marked on the next fresh bundle.
`c` is in `[0, 100]`, so `c >= 101` is never true and the gate never hides the icon. Removing `if (t === 0) return null` keeps the icon visible across window-reload gaps where the webview has not repopulated usage data yet; the tooltip can briefly read `0%` until the next usage event arrives. Using `>=101` (rather than deleting the line) is the smallest, most legible, greppable, reversible threshold change, while removing the adjacent startup guard addresses the reload case explicitly. The patch is anchored on the combined guard shape `if(<id>===0)return null;if(<id>>=50)return null}`, not the exact minified names. The trailing `/*ccwa-context-icon:<first-var>:<remaining-var>*/` is an ownership marker that stores the matched names: the launcher's reconcile reverses only known patch fingerprints, so it can never corrupt unrelated upstream code; pre-existing legacy patched forms are normalized to pristine and re-applied on the next launch.

There is no integrity or subresource check on the webview bundle (the only `sha256` references in `extension.js` belong to a bundled crypto library), so an edited `index.js` loads normally.

Expand All @@ -197,8 +201,8 @@ The wrapper discovers `index.js` two ways:

The edit is made safe:

* Idempotent: writes only when the recomputed bytes differ, and skips (rather than guesses) if the `>=50)return null}` anchor is absent because the extension changed.
* Ownership-marked: the edit carries a `/*ccwa-context-icon*/` marker; reconcile reverses only its own marked edit and never touches upstream code that merely resembles a patched value.
* Idempotent: writes only when the recomputed bytes differ, and skips (rather than guesses) if the combined guard shape is absent because the extension changed.
* Ownership-marked: the edit carries a `/*ccwa-context-icon:<first-var>:<remaining-var>*/` marker; reconcile reverses only its own marked edit and never touches upstream code that merely resembles a patched value. Older `/*ccwa-context-icon*/` forms are treated as legacy fingerprints.
* Atomic: written to a temp file and moved into place only after it is verified non-empty and actually patched, so a failed or partial write cannot corrupt the bundle.
* Metadata-preserving via `cp -p` (portable; the GNU-only `chmod`/`chown --reference` is avoided so it also works on macOS/BSD). The Windows launcher writes with `fs.writeFileSync` + `fs.renameSync`, inheriting the parent directory's ACLs (it does not preserve the file mode - the one intentional bash/node asymmetry).
* Fully guarded so it never blocks the launch (a read-only file, a renamed bundle, or a missing tool simply no-ops).
Expand Down Expand Up @@ -251,9 +255,9 @@ React.createElement(FJe, {
});
```

`usageData` initializes to all zeros and is only filled by usage events that arrive during an assistant turn. There is no seeding from `get_claude_state` on resume, so immediately after a window reload of a continued conversation, before any new turn, the store is still `{0,0,0,0}`: `usedTokens = 0` (the tooltip reads "0% used") and `contextWindow = 0 - 0 - 13000 = -13000` (negative, so the `t === 0` guard does not fire and, after the patch, the icon still renders at 0%). This self-corrects on the next turn, when a usage event fills the store with the real totals. `/context` does not use this store; it queries the CLI directly, so it always shows the true number.
`usageData` initializes to all zeros and is only filled by usage events that arrive during an assistant turn. There is no seeding from `get_claude_state` on resume, so immediately after a window reload of a continued conversation, before any new turn, the store is still empty: the tooltip can read "0% used" until a usage event fills the store with the real totals. The patch removes the `t === 0` hide guard so this state still renders an icon instead of hiding it for the whole reload gap. `/context` does not use this store; it queries the CLI directly, so it always shows the true number.

If showing a transient 0% is undesirable, changing `if(t===0)return null` to `if(t<=0)return null` hides the icon while the store is empty (`t` is negative then) and shows it with the correct value after the first turn. This is documented as an optional manual tweak and is not applied by default.
The transient `0%` is deliberate: it is the same stale-data symptom the extension can already show, and it is less confusing than no icon at all after a reload.

### The glyph is a coarse 3-state gauge

Expand All @@ -269,4 +273,4 @@ The pie button's `onClick` is `onCompact`: clicking the icon triggers compaction

## Compatibility

Confirmed on VS Code extension `2.1.169` (native-binary CLI) on Windows 11 and Ubuntu 24.04. The `>50% used` gate appeared around `2.1.165` (absent in `2.1.131` / `2.1.128`). The patch keys off the stable substring `>=50)return null}`, not the minified component name; if a future build changes that exact substring, the launcher safely no-ops (the icon goes missing again) until the anchor is updated. The standalone [`fix-context-icon.py`](fixes/context-icon/fix-context-icon.py) applies the same change directly and supports `--revert`.
Confirmed on VS Code extension `2.1.169` (native-binary CLI) on Windows 11 and Ubuntu 24.04. The same guard shape has been observed with different minified names (`Z/U` in `2.1.131`, `t/c` in `2.1.170`). The patch keys off that shape, not the minified component name or exact variable names; if a future build changes the shape, the launcher safely no-ops (the icon goes missing again) until the anchor is updated. The standalone [`fix-context-icon.py`](fixes/context-icon/fix-context-icon.py) applies the same change directly and supports `--revert`.
21 changes: 13 additions & 8 deletions fixes/context-icon/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
Restores the always-visible context-usage icon in the VS Code chat input.
Extension builds 2.1.165+ hide that icon until you have used more than 50% of the
context window. With the 1M context window that is ~500k tokens, so it is
effectively never shown. This fix flips the threshold so the icon renders
whenever a context window is known, at any usage level.
effectively never shown. This fix removes the startup hide guard and flips the
threshold so the icon renders at any usage level, including the reload gap before
fresh usage data arrives.

## Standalone usage

Expand All @@ -29,12 +30,16 @@ launcher reverts our edit on the next launch.

## Maintenance Contract

- Anchors / selectors: `>=50)return null}` (component `FJe` in `webview/index.js`).
- Ownership marker: `/*ccwa-context-icon*/`. Apply rewrites `>=50)return null}` ->
`>=101)return null}/*ccwa-context-icon*/`; undo reverses only that marked form.
- Failure mode if an anchor moves: if the anchor string changes, apply no-ops with
a one-line warning (the icon goes missing again) until the anchor is updated. A
bare upstream `>=101)return null}` with no marker is never touched.
- Anchors / selectors: `if(<id>===0)return null;if(<id>>=50)return null}`
(component `FJe` in `webview/index.js`). The identifier names are captured, not
hardcoded.
- Ownership marker: `/*ccwa-context-icon:<first-var>:<remaining-var>*/`. Apply
rewrites the combined guard to `if(<remaining-var>>=101)return null}<marker>`;
undo uses the marker metadata to restore the pristine combined guard with the
same variable names. Older `/*ccwa-context-icon*/` markers are recognized as
legacy fingerprints.
- Failure mode if an anchor moves: if the guard shape changes, apply no-ops with a
one-line warning (the icon goes missing again) until the anchor is updated.
- Launcher registry entry: feature id `context-icon`, file `webview/index.js`,
apply = marked swap, undo = reverse of the marked form only.
- Test fixture: `tests/test_reconcile.py` (launcher engine, both platforms) and
Expand Down
72 changes: 61 additions & 11 deletions fixes/context-icon/fix-context-icon.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,20 @@
}

With the 1M context window, 50% used = 500,000 tokens, so the icon stays hidden
for virtually an entire normal session. This script flips the threshold so the
icon is visible whenever a context window is known (t>0), at any usage level.
for virtually an entire normal session. This script removes the startup guard and
flips the threshold so the icon stays visible across reload gaps and then
self-corrects when fresh usage data arrives.

if(c>=50)return null -> if(c>=101)return null}/*ccwa-context-icon*/ (marked; c maxes at 100)
if(t===0)return null;if(c>=50)return null}
-> if(c>=101)return null}/*ccwa-context-icon:t:c*/ (marked; c maxes at 100)

The (t===0) guard is left intact. In a resumed window, the webview can still show
a transient 0% before the first fresh response updates context metadata. After
that first response the icon stays visible.
The minified variable names are not stable across builds; the patcher matches the
guard shape with any ASCII JS identifier pair and stores the matched names in the
marker so undo can restore the same pristine names.

In a resumed window, the webview can still show a transient 0% before the first
fresh response updates context metadata. After that first response the icon
corrects.

SAFE & REVERSIBLE
-----------------
Expand All @@ -48,14 +54,54 @@
"""
import glob
import os
import re
import shutil
import sys
import tempfile

OLD = ">=50)return null}"
NEW = ">=101)return null}/*ccwa-context-icon*/"
IDENT = r"[A-Za-z_$][A-Za-z0-9_$]*"
OLD_RE = re.compile(rf"if\(({IDENT})===0\)return null;if\(({IDENT})>=50\)return null\}}")
MARKED_RE = re.compile(
rf"if\(({IDENT})>=101\)return null\}}/\*ccwa-context-icon:({IDENT}):\1\*/"
)
# Legacy bare (metadata-less) marker on arbitrary guard names: an older
# var-agnostic write could leave the both-guards >=101 form + bare marker on a
# non-t/c build (e.g. Z/U). Recognize it by shape, not the fixed t/c.
LEGACY_NEW_RE = re.compile(
rf"if\(({IDENT})===0\)return null;if\(({IDENT})>=101\)return null\}}/\*ccwa-context-icon\*/"
)
# Strip any leftover bare marker so patch_file (which only re-applies on the
# pristine guard pair) is never wedged by an unrecognized bare-marked form.
ORPHAN_MARKER_RE = re.compile(r"\)return null\}/\*ccwa-context-icon\*/")
OLD = "if(t===0)return null;if(c>=50)return null}"
NEW_BARE = "if(c>=101)return null}"
NEW_LEGACY = NEW_BARE + "/*ccwa-context-icon*/"
NEW = "if(c>=101)return null}/*ccwa-context-icon:t:c*/"
LEGACY_BARE = "if(t===0)return null;if(c>=101)return null}"
BACKUP_SUFFIX = ".bak-context-icon"


def old_guard(first_var, remaining_var):
return f"if({first_var}===0)return null;if({remaining_var}>=50)return null}}"


def marked_guard(first_var, remaining_var):
return (
f"if({remaining_var}>=101)return null}}"
f"/*ccwa-context-icon:{first_var}:{remaining_var}*/"
)


def undo_known_patches(data):
data = MARKED_RE.sub(lambda m: old_guard(m.group(2), m.group(1)), data)
data = LEGACY_NEW_RE.sub(lambda m: old_guard(m.group(1), m.group(2)), data)
data = (
data.replace(NEW_LEGACY, OLD)
.replace(LEGACY_BARE, OLD)
.replace(NEW_BARE, OLD)
)
return ORPHAN_MARKER_RE.sub(")return null}", data)

DISCOVERY_GLOBS = [
os.path.expanduser("~/.vscode/extensions/anthropic.claude-code-*/webview/index.js"),
os.path.expanduser("~/.vscode-server/extensions/anthropic.claude-code-*/webview/index.js"),
Expand Down Expand Up @@ -103,18 +149,22 @@ def write_atomic_preserving_metadata(path, text):
def patch_file(path):
with open(path, "r", encoding="utf-8", newline="") as f:
data = f.read()
if NEW in data:
if MARKED_RE.search(data):
return "already-patched"
n = data.count(OLD)
data = undo_known_patches(data)
matches = list(OLD_RE.finditer(data))
n = len(matches)
if n == 0:
return "gate-not-found (extension version changed? re-inspect FJe in index.js)"
if n > 1:
return f"ambiguous ({n} matches) — skipped for safety"
match = matches[0]
patched = data[: match.start()] + marked_guard(match.group(1), match.group(2)) + data[match.end() :]
backup = path + BACKUP_SUFFIX
if not os.path.exists(backup):
with open(backup, "w", encoding="utf-8", newline="") as b:
b.write(data)
write_atomic_preserving_metadata(path, data.replace(OLD, NEW, 1))
write_atomic_preserving_metadata(path, patched)
return "PATCHED"


Expand Down
21 changes: 12 additions & 9 deletions fixes/markdown-copy-export/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@

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.
single-click copy icon (Markdown) on every user and assistant message - it flips
to a checkmark only when the copy actually lands - plus a floating "copy
conversation" icon, and a standalone CLI that exports a session transcript to
Markdown or plain text. Affects the VS Code extension webview; the CLI is
independent of it.

## Standalone usage

Expand All @@ -27,7 +28,6 @@ Session exporter (independent of the webview):
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

Expand All @@ -39,10 +39,13 @@ controls and reverts ours on the next launch. `CC_WORKAROUNDS=0` reverts it too.
- 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
chrome strip-prefixes `toolUse_`/`toolResult_`/`toolReference_`/
`unknownContent_` plus `[data-message-rating]` and `button`; visible
`thinking_` summaries are content and remain copyable;
clipboard write via a synchronous `document.execCommand("copy")` first
(gesture-safe and works without a secure context, e.g. remote / code-server),
falling back to `navigator.clipboard.writeText`; the icon only flips to a
checkmark when a copy actually succeeds. 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
Expand Down
4 changes: 2 additions & 2 deletions fixes/markdown-copy-export/add-md-copy.py

Large diffs are not rendered by default.

14 changes: 1 addition & 13 deletions fixes/markdown-copy-export/cc-export.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@
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.
calls are opt-in.

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
Expand All @@ -21,7 +20,6 @@
import glob
import json
import os
import subprocess
import sys
import unicodedata

Expand Down Expand Up @@ -157,7 +155,6 @@ def main(argv=None, config=None, env=None):
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)

Expand All @@ -168,15 +165,6 @@ def main(argv=None, config=None, env=None):
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,
Expand Down
Loading
Loading