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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,4 @@ Thumbs.db
test-*.html
example-*.html
*.jsonl
nul
15 changes: 15 additions & 0 deletions src/claude_notes/__main__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
"""Entry point for the claude-notes CLI."""

import os
import sys

from claude_notes.cli import cli


def main():
"""Main entry point."""
# Fix Windows console encoding issues
if sys.platform == "win32":
# Try to set UTF-8 encoding for stdout/stderr
try:
if sys.stdout.encoding.lower() != "utf-8":
sys.stdout.reconfigure(encoding="utf-8")
if sys.stderr.encoding.lower() != "utf-8":
sys.stderr.reconfigure(encoding="utf-8")
except (AttributeError, OSError):
# Fallback: set environment variable
os.environ.setdefault("PYTHONIOENCODING", "utf-8")

cli()


Expand Down
87 changes: 76 additions & 11 deletions src/claude_notes/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,47 @@ def get_claude_projects_dir() -> Path:
return Path.home() / ".claude" / "projects"


def _decode_segments(encoded: str, separator: str) -> str:
"""Decode dash-separated segments, where '--' represents a literal dash."""
decoded_parts: list[str] = []
i = 0
while i < len(encoded):
char = encoded[i]
if char == "-":
if i + 1 < len(encoded) and encoded[i + 1] == "-":
decoded_parts.append("-")
i += 2
else:
decoded_parts.append(separator)
i += 1
else:
decoded_parts.append(char)
i += 1
return "".join(decoded_parts)


def _encode_segments(path: str) -> str:
"""Encode path segments by escaping literal dashes."""
return path.replace("-", "--").replace("/", "-")


def decode_project_path(encoded_name: str) -> str:
"""Decode the project folder name to actual path."""
# Remove leading dash and replace dashes with slashes
# Windows path (e.g., "C--Users-projects-my--project")
if len(encoded_name) >= 3 and encoded_name[1:3] == "--" and encoded_name[0].isalpha():
drive = encoded_name[0]
rest_encoded = encoded_name[3:]
rest = _decode_segments(rest_encoded, "/")
return f"{drive}:/{rest}" if rest else f"{drive}:/"

# Unix/Linux path (e.g., "-home-user-my--project")
if encoded_name.startswith("-"):
encoded_name = encoded_name[1:]
encoded_body = encoded_name[1:]
decoded_body = _decode_segments(encoded_body, "/")
return "/" + decoded_body

return "/" + encoded_name.replace("-", "/")
# Fallback: return as-is if it doesn't match expected encodings
return encoded_name


def list_projects() -> list[tuple[str, Path, int]]:
Expand All @@ -37,9 +71,18 @@ def list_projects() -> list[tuple[str, Path, int]]:
projects = []

for project_folder in projects_dir.iterdir():
if project_folder.is_dir() and project_folder.name.startswith("-"):
if not project_folder.is_dir():
continue

# Check if it's a valid project folder
# Unix/Linux: starts with "-" (e.g., "-home-user-project")
# Windows: starts with drive letter (e.g., "c--Users-Jack-project" or "C--Users-Jack-project")
name = project_folder.name
is_valid = name.startswith("-") or (len(name) >= 3 and name[1:3] == "--" and name[0].isalpha())

if is_valid:
# Decode the project path
actual_path = decode_project_path(project_folder.name)
actual_path = decode_project_path(name)

# Count JSONL files (transcripts)
jsonl_files = list(project_folder.glob("*.jsonl"))
Expand Down Expand Up @@ -84,10 +127,21 @@ def list_projects_cmd():

def encode_project_path(path: str) -> str:
"""Encode a project path to Claude folder name format."""
# Remove leading slash and replace slashes with dashes
if path.startswith("/"):
path = path[1:]
return "-" + path.replace("/", "-")
normalized = path.replace("\\", "/")

# Windows path with drive letter (e.g., C:/Users/...)
if len(normalized) >= 2 and normalized[1] == ":" and normalized[0].isalpha():
drive = normalized[0]
rest = normalized[2:]
if rest.startswith("/"):
rest = rest[1:]
encoded_rest = _encode_segments(rest)
return f"{drive}--{encoded_rest}"

# Unix/Linux path (leading slash)
normalized = normalized.lstrip("/")
encoded_body = _encode_segments(normalized)
return "-" + encoded_body


def find_project_folder(project_path: Path) -> Path | None:
Expand All @@ -96,8 +150,19 @@ def find_project_folder(project_path: Path) -> Path | None:
encoded_name = encode_project_path(str(project_path))
project_folder = projects_dir / encoded_name

# Try exact match first
if project_folder.exists() and project_folder.is_dir():
return project_folder

# On Windows, try case-insensitive match (drive letter might be uppercase or lowercase)
if not projects_dir.exists():
return None

encoded_lower = encoded_name.lower()
for folder in projects_dir.iterdir():
if folder.is_dir() and folder.name.lower() == encoded_lower:
return folder

return None


Expand Down Expand Up @@ -263,9 +328,9 @@ def show(
html_parts.append("<h2>Conversations</h2>")
html_parts.append('<ul class="conversation-toc">')
for i, conv in enumerate(conversations):
conv_id = conv["info"].get("conversation_id", f"conv-{i+1}")
conv_id = conv["info"].get("conversation_id", f"conv-{i + 1}")
start_time = conv["info"].get("start_time", "Unknown time")
html_parts.append(f'<li><a href="#conv-{conv_id}">📝 Conversation {i+1} ({start_time})</a></li>')
html_parts.append(f'<li><a href="#conv-{conv_id}">📝 Conversation {i + 1} ({start_time})</a></li>')
html_parts.append("</ul>")
html_parts.append("</div>")

Expand Down
138 changes: 96 additions & 42 deletions src/claude_notes/pager.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
"""Pager implementation for progressive content display like 'less' CLI."""

import sys
import termios
import tty
from typing import Any

from rich.console import Console
from rich.text import Text

from claude_notes.formatters.terminal import TerminalFormatter

# Handle Windows vs Unix terminal input
if sys.platform == "win32":
import msvcrt
else:
import termios
import tty


class Pager:
"""A pager that displays content progressively like the 'less' command."""
Expand Down Expand Up @@ -177,51 +182,100 @@ def _show_end_status(self) -> None:

def _get_user_input(self) -> str:
"""Get user input for pager controls."""
try:
# Save terminal settings
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
if sys.platform == "win32":
# Windows implementation using msvcrt
try:
# Read single character without echo
ch = msvcrt.getch()

# msvcrt.getch() returns bytes, decode to string
if isinstance(ch, bytes):
try:
ch = ch.decode("utf-8")
except UnicodeDecodeError:
# Handle special keys (arrow keys, etc.)
if ch == b"\xe0": # Special key prefix
ch2 = msvcrt.getch()
if ch2 == b"H": # Up arrow
return "prev_line"
elif ch2 == b"P": # Down arrow
return "next_line"
elif ch2 == b"I": # Page Up
return "prev_page"
elif ch2 == b"Q": # Page Down
return "next_page"
return "next_page"

# Handle different key presses
if ch == "\r" or ch == " ": # Enter or Space
return "next_page"
elif ch == "q":
return "quit"
elif ch == "j": # j for next line
return "next_line"
elif ch == "k": # k for previous line
return "prev_line"
elif ch == "b": # Back one page
return "prev_page"
elif ch == "g": # Go to top
return "top"
elif ch == "G": # Go to bottom (capital G)
return "bottom"
elif ch == "h": # Help
self._show_help()
return "help"
else:
# Default to next page for any other key
return "next_page"
except (OSError, KeyboardInterrupt):
return "quit"
else:
# Unix implementation using termios
try:
# Save terminal settings
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)

# Set terminal to raw mode for single character input
tty.setraw(sys.stdin.fileno())
# Set terminal to raw mode for single character input
tty.setraw(sys.stdin.fileno())

# Read single character
ch = sys.stdin.read(1)
# Read single character
ch = sys.stdin.read(1)

# Restore terminal settings
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
# Restore terminal settings
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)

# Handle different key presses
if ch == "\n" or ch == "\r" or ch == " ": # Enter or Space
return "next_page"
elif ch.lower() == "q":
return "quit"
elif ch.lower() == "j": # j for next line
return "next_line"
elif ch.lower() == "k": # k for previous line
return "prev_line"
elif ch.lower() == "b": # Back one page
return "prev_page"
elif ch.lower() == "g": # Go to top
return "top"
elif ch.lower() == "G": # Go to bottom
return "bottom"
elif ch.lower() == "h": # Help
self._show_help()
return "help"
else:
# Default to next page for any other key
return "next_page"

except (termios.error, OSError):
# Fallback for environments that don't support raw input
try:
line = input()
if line.lower().startswith("q"):
# Handle different key presses
if ch == "\n" or ch == "\r" or ch == " ": # Enter or Space
return "next_page"
elif ch == "q":
return "quit"
elif ch == "j": # j for next line
return "next_line"
elif ch == "k": # k for previous line
return "prev_line"
elif ch == "b": # Back one page
return "prev_page"
elif ch == "g": # Go to top
return "top"
elif ch == "G": # Go to bottom
return "bottom"
elif ch == "h": # Help
self._show_help()
return "help"
else:
# Default to next page for any other key
return "next_page"

except (termios.error, OSError):
# Fallback for environments that don't support raw input
try:
line = input()
if line.lower().startswith("q"):
return "quit"
return "next_page"
except (EOFError, KeyboardInterrupt):
return "quit"
return "next_page"
except (EOFError, KeyboardInterrupt):
return "quit"

def _show_help(self) -> None:
"""Show help message."""
Expand Down
4 changes: 2 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.