From 5cba18aa14109fecd0764075be37f4042b95885a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 21:03:49 +0000 Subject: [PATCH 1/4] Fix percent-encoded file paths in tool args, add date fallback for filenames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Decode percent-encoded strings (e.g. %3A → :) in tool argument values before rendering. This fixes file paths like C%3A\Users\... showing encoded colons in Edit, Read, and multi-key tool arg displays. - Fall back to file modification date when session.date_created is None, so output filenames still get YYYY-MM-DD prefixes matching the HTML header. - Add test for percent-decode behavior in render_tool_args. https://claude.ai/code/session_01UJUBqBwFHqndJeTQJY25sd --- src/main.rs | 56 +++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/src/main.rs b/src/main.rs index 6f760be..281c4ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -649,10 +649,11 @@ fn render_tool_args(name: &str, arguments: &str) -> String { ); } // Read / Glob / file_path / pattern → key: value + let display_val = percent_decode(s); return format!( "
{}: {}
", encode_text(key), - encode_text(s) + encode_text(&display_val) ); } } @@ -663,11 +664,12 @@ fn render_tool_args(name: &str, arguments: &str) -> String { let old_str = obj.get("old_string").and_then(|v| v.as_str()).unwrap_or(""); let new_str = obj.get("new_string").and_then(|v| v.as_str()).unwrap_or(""); if !file_path.is_empty() && (!old_str.is_empty() || !new_str.is_empty()) { + let decoded_path = percent_decode(file_path); let mut html = String::new(); html.push_str("
"); html.push_str(&format!( "
file_path: {}
", - encode_text(file_path) + encode_text(&decoded_path) )); // Show replace_all flag if present and true if let Some(replace_all) = obj.get("replace_all").and_then(|v| v.as_bool()) { @@ -702,18 +704,19 @@ fn render_tool_args(name: &str, arguments: &str) -> String { for (key, val) in obj { let display = match val.as_str() { Some(s) => { + let decoded = percent_decode(s); // Long strings get a code block - if s.contains('\n') || s.len() > 120 { + if decoded.contains('\n') || decoded.len() > 120 { format!( "
{}
{}
", encode_text(key), - encode_text(s) + encode_text(&decoded) ) } else { format!( "
{}: {}
", encode_text(key), - encode_text(s) + encode_text(&decoded) ) } } @@ -2933,7 +2936,11 @@ fn main() { } else { session.title.clone() }; - let date = session.date_created.clone().unwrap_or_default(); + let date = session + .date_created + .clone() + .or_else(|| file_modified_date(file.as_path())) + .unwrap_or_default(); ProcessResult::Success(html, title, date, file.clone()) }) @@ -3609,6 +3616,43 @@ mod tests { assert!(result.contains("not json at all")); } + #[test] + fn test_render_tool_args_percent_decodes_file_paths() { + // Edit tool: file_path with percent-encoded colons (e.g. Windows C: drive) + let result = render_tool_args( + "Edit", + r#"{"file_path": "C%3A%5CUsers%5Ctest%5Cfile.rs", "old_string": "a", "new_string": "b"}"#, + ); + assert!( + result.contains(r"C:\Users\test\file.rs"), + "Edit file_path should be percent-decoded: {}", + result + ); + assert!(!result.contains("%3A"), "Should not contain encoded colons"); + + // Single-key (Read tool): file_path with percent-encoded colon + let result = render_tool_args( + "Read", + r#"{"file_path": "/home/user%3Aname/file.rs"}"#, + ); + assert!( + result.contains("/home/user:name/file.rs"), + "Read file_path should be percent-decoded: {}", + result + ); + + // Multi-key: values should be percent-decoded + let result = render_tool_args( + "Grep", + r#"{"pattern": "hello", "path": "/home/user%3Aname/project"}"#, + ); + assert!( + result.contains("/home/user:name/project"), + "Multi-key path values should be percent-decoded: {}", + result + ); + } + // ----------------------------------------------------------------------- // Language detection from filenames // ----------------------------------------------------------------------- From d23dbab3bcf6a6ae0e49fce43fb78d6c3eda393a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 21:11:00 +0000 Subject: [PATCH 2/4] Restrict percent-decoding to path keys only, add file:/// URI stripping The initial fix decoded ALL string values in tool args, which could corrupt non-path content (e.g. regex patterns containing %3A). Now: - Only keys identified as paths (file_path, path, directory, etc.) get decoded via the new is_path_key() check. - New decode_path() helper strips file:/// URI prefixes before decoding, handling both Unix (/home/user) and Windows (C:/Users) paths. - Workspace directory also uses decode_path() for consistency. - Added tests for is_path_key, decode_path, file URI stripping, and verifying non-path keys are left untouched. https://claude.ai/code/session_01UJUBqBwFHqndJeTQJY25sd --- src/main.rs | 124 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 116 insertions(+), 8 deletions(-) diff --git a/src/main.rs b/src/main.rs index 281c4ca..86bf4fc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -649,7 +649,11 @@ fn render_tool_args(name: &str, arguments: &str) -> String { ); } // Read / Glob / file_path / pattern → key: value - let display_val = percent_decode(s); + let display_val = if is_path_key(key) { + decode_path(s) + } else { + s.to_string() + }; return format!( "
{}: {}
", encode_text(key), @@ -664,7 +668,7 @@ fn render_tool_args(name: &str, arguments: &str) -> String { let old_str = obj.get("old_string").and_then(|v| v.as_str()).unwrap_or(""); let new_str = obj.get("new_string").and_then(|v| v.as_str()).unwrap_or(""); if !file_path.is_empty() && (!old_str.is_empty() || !new_str.is_empty()) { - let decoded_path = percent_decode(file_path); + let decoded_path = decode_path(file_path); let mut html = String::new(); html.push_str("
"); html.push_str(&format!( @@ -704,19 +708,23 @@ fn render_tool_args(name: &str, arguments: &str) -> String { for (key, val) in obj { let display = match val.as_str() { Some(s) => { - let decoded = percent_decode(s); + let display_val = if is_path_key(key) { + decode_path(s) + } else { + s.to_string() + }; // Long strings get a code block - if decoded.contains('\n') || decoded.len() > 120 { + if display_val.contains('\n') || display_val.len() > 120 { format!( "
{}
{}
", encode_text(key), - encode_text(&decoded) + encode_text(&display_val) ) } else { format!( "
{}: {}
", encode_text(key), - encode_text(&decoded) + encode_text(&display_val) ) } } @@ -1660,7 +1668,7 @@ fn render_session(session: &Session, source_path: Option<&Path>) -> String { .or_else(|| source_path.and_then(file_modified_date)) .unwrap_or_else(|| "Unknown date".to_string()); - let workspace_decoded = percent_decode(&session.workspace_directory); + let workspace_decoded = decode_path(&session.workspace_directory); let workspace = if workspace_decoded.is_empty() { "N/A".to_string() } else { @@ -2774,6 +2782,41 @@ fn unique_filename( unreachable!() } +/// Returns true if a JSON key name represents a file/directory path, +/// meaning its value should be percent-decoded for display. +fn is_path_key(key: &str) -> bool { + matches!( + key, + "file_path" + | "filepath" + | "path" + | "file" + | "directory" + | "dir" + | "notebook_path" + | "cwd" + ) +} + +/// Decode a value for display: strips `file:///` URI prefixes and +/// decodes percent-encoded bytes (e.g. `%3A` → `:`). +fn decode_path(input: &str) -> String { + let rest = input.strip_prefix("file:///").unwrap_or(input); + let decoded = percent_decode(rest); + // After stripping file:/// and decoding, decide whether to re-add + // the leading slash. file:///home/user → /home/user (Unix), + // file:///C:/Users → C:/Users (Windows drive letter). + if input.starts_with("file:///") { + if decoded.starts_with('/') || decoded.chars().nth(1) == Some(':') { + decoded + } else { + format!("/{decoded}") + } + } else { + decoded + } +} + /// Decode percent-encoded strings (e.g. `%3A` → `:`). /// Handles UTF-8 sequences that span multiple percent-encoded bytes. fn percent_decode(input: &str) -> String { @@ -3405,6 +3448,31 @@ mod tests { ); } + #[test] + fn test_is_path_key() { + assert!(is_path_key("file_path")); + assert!(is_path_key("filepath")); + assert!(is_path_key("path")); + assert!(is_path_key("directory")); + assert!(is_path_key("notebook_path")); + assert!(!is_path_key("pattern")); + assert!(!is_path_key("command")); + assert!(!is_path_key("old_string")); + assert!(!is_path_key("content")); + } + + #[test] + fn test_decode_path_file_uri() { + // Unix file URI + assert_eq!(decode_path("file:///home/user/file.rs"), "/home/user/file.rs"); + // Windows file URI + assert_eq!(decode_path("file:///C%3A/Users/test"), "C:/Users/test"); + // Percent-encoded without file:/// prefix + assert_eq!(decode_path("C%3A%5CUsers%5Ctest"), r"C:\Users\test"); + // Plain path — no change + assert_eq!(decode_path("/home/user/file.rs"), "/home/user/file.rs"); + } + #[test] fn test_markdown_to_html() { let html = markdown_to_html("**bold** text"); @@ -3641,7 +3709,7 @@ mod tests { result ); - // Multi-key: values should be percent-decoded + // Multi-key: path key should be percent-decoded let result = render_tool_args( "Grep", r#"{"pattern": "hello", "path": "/home/user%3Aname/project"}"#, @@ -3651,6 +3719,46 @@ mod tests { "Multi-key path values should be percent-decoded: {}", result ); + + // Multi-key: non-path keys should NOT be decoded + let result = render_tool_args( + "Grep", + r#"{"pattern": "match%3Athis", "path": "/home/user/project"}"#, + ); + assert!( + result.contains("match%3Athis"), + "Non-path keys should not be percent-decoded: {}", + result + ); + } + + #[test] + fn test_render_tool_args_file_uri_stripped() { + // file:/// URI with percent-encoded path + let result = render_tool_args( + "Read", + r#"{"file_path": "file:///home/user%3Aname/file.rs"}"#, + ); + assert!( + result.contains("/home/user:name/file.rs"), + "file:/// prefix should be stripped and path decoded: {}", + result + ); + assert!( + !result.contains("file:///"), + "file:/// prefix should be removed" + ); + + // file:/// URI with Windows path + let result = render_tool_args( + "Edit", + r#"{"file_path": "file:///C%3A/Users/test/file.rs", "old_string": "a", "new_string": "b"}"#, + ); + assert!( + result.contains("C:/Users/test/file.rs"), + "Windows file:/// URI should be decoded: {}", + result + ); } // ----------------------------------------------------------------------- From 3038f58440e0e19ffbdbc21d006a2856c1ec3864 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 21:15:39 +0000 Subject: [PATCH 3/4] Bump version to 0.7.0 https://claude.ai/code/session_01UJUBqBwFHqndJeTQJY25sd --- Cargo.toml | 2 +- README.md | 8 ++++---- pyproject.toml | 2 +- python/continue_transcripts/__init__.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b3f4f34..1afdac3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "continue-transcripts" -version = "0.6.0" +version = "0.7.0" edition = "2021" description = "Convert continue.dev session files to readable HTML transcripts" license = "MIT" diff --git a/README.md b/README.md index 30d38f5..946407d 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Install globally so the `continue-transcripts` command is always available: ```sh uv tool install continue-transcripts \ --no-index \ - --find-links https://github.com/curtisalexander/continue-transcripts/releases/expanded_assets/v0.6.0 + --find-links https://github.com/curtisalexander/continue-transcripts/releases/expanded_assets/v0.7.0 ``` The `continue-transcripts` command is then available on your `PATH`. @@ -83,7 +83,7 @@ To upgrade later (update the version in the URL): ```sh uv tool install --upgrade continue-transcripts \ --no-index \ - --find-links https://github.com/curtisalexander/continue-transcripts/releases/expanded_assets/v0.6.0 + --find-links https://github.com/curtisalexander/continue-transcripts/releases/expanded_assets/v0.7.0 ``` To uninstall: @@ -100,7 +100,7 @@ Run without installing: uvx \ --no-index \ --from continue-transcripts \ - --find-links https://github.com/curtisalexander/continue-transcripts/releases/expanded_assets/v0.6.0 \ + --find-links https://github.com/curtisalexander/continue-transcripts/releases/expanded_assets/v0.7.0 \ continue-transcripts ./sessions ``` @@ -115,7 +115,7 @@ uv venv .venv source .venv/bin/activate uv pip install continue-transcripts \ --no-index \ - --find-links https://github.com/curtisalexander/continue-transcripts/releases/expanded_assets/v0.6.0 + --find-links https://github.com/curtisalexander/continue-transcripts/releases/expanded_assets/v0.7.0 ``` ### Building from source diff --git a/pyproject.toml b/pyproject.toml index 4c1b9d5..c791bf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "continue-transcripts" -version = "0.6.0" +version = "0.7.0" description = "Convert continue.dev session files to readable HTML transcripts" readme = "README.md" license = { text = "MIT" } diff --git a/python/continue_transcripts/__init__.py b/python/continue_transcripts/__init__.py index 294f397..8325296 100644 --- a/python/continue_transcripts/__init__.py +++ b/python/continue_transcripts/__init__.py @@ -1,3 +1,3 @@ """continue-transcripts - Convert continue.dev session files to readable HTML transcripts.""" -__version__ = "0.6.0" +__version__ = "0.7.0" From 9e87699329cb7431ff442d2cba0c5e6c467f100a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 21:18:08 +0000 Subject: [PATCH 4/4] Clarify version bump instructions in CLAUDE.md List all four files that must be updated when bumping versions, and fix stale v0.4.0 example URL. https://claude.ai/code/session_01UJUBqBwFHqndJeTQJY25sd --- CLAUDE.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 987b182..4292c5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ cargo build --release # Or install as a Python tool via uv (recommended for end users) uv tool install continue-transcripts \ --no-index \ - --find-links https://github.com/curtisalexander/continue-transcripts/releases/expanded_assets/v0.4.0 + --find-links https://github.com/curtisalexander/continue-transcripts/releases/expanded_assets/v0.7.0 ``` ### Running tests @@ -94,13 +94,12 @@ Session JSON file ## Version management -Versions must be kept in sync across three files: +When asked to "bump version", update **all four** of these locations: -1. `Cargo.toml` — `version = "X.Y.Z"` (Rust package) -2. `pyproject.toml` — `version = "X.Y.Z"` (Python package) +1. `Cargo.toml` — `version = "X.Y.Z"` +2. `pyproject.toml` — `version = "X.Y.Z"` 3. `python/continue_transcripts/__init__.py` — `__version__ = "X.Y.Z"` - -Also update the `--find-links` URLs in `README.md` that reference the release version. +4. `README.md` — all `--find-links` URLs containing the release version (e.g. `expanded_assets/vX.Y.Z`) ## continue.dev session format