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
// -----------------------------------------------------------------------