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 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" diff --git a/src/main.rs b/src/main.rs index 6f760be..86bf4fc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -649,10 +649,15 @@ fn render_tool_args(name: &str, arguments: &str) -> String { ); } // Read / Glob / file_path / pattern → key: value + let display_val = if is_path_key(key) { + decode_path(s) + } else { + s.to_string() + }; return format!( "
{}: {}
", encode_text(key), - encode_text(s) + encode_text(&display_val) ); } } @@ -663,11 +668,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 = decode_path(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 +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 display_val = if is_path_key(key) { + decode_path(s) + } else { + s.to_string() + }; // Long strings get a code block - if s.contains('\n') || s.len() > 120 { + if display_val.contains('\n') || display_val.len() > 120 { format!( "
{}
{}
", encode_text(key), - encode_text(s) + encode_text(&display_val) ) } else { format!( "
{}: {}
", encode_text(key), - encode_text(s) + encode_text(&display_val) ) } } @@ -1657,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 { @@ -2771,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 { @@ -2933,7 +2979,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()) }) @@ -3398,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"); @@ -3609,6 +3684,83 @@ 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: path key 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 + ); + + // 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 + ); + } + // ----------------------------------------------------------------------- // Language detection from filenames // -----------------------------------------------------------------------