Skip to content

Commit 9a451cc

Browse files
authored
Merge pull request #9 from pal-hexrays/feature/add-animated-formatter
feat: add animated formatter with MP4/GIF output and emoji fallbacks
2 parents c186228 + c923342 commit 9a451cc

File tree

7 files changed

+776
-11
lines changed

7 files changed

+776
-11
lines changed

CLAUDE.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,19 +45,19 @@ make test # Run tests and CLI functionality check
4545
uv run claude-notes list-projects
4646

4747
# Show transcripts for current directory (with pager)
48-
uv run claude-notes .
48+
uv run claude-notes show .
4949

5050
# Show transcripts for specific project
51-
uv run claude-notes /path/to/project
51+
uv run claude-notes show /path/to/project
5252

5353
# Show all content at once without pager
54-
uv run claude-notes . --no-pager
54+
uv run claude-notes show . --no-pager
5555

5656
# Show raw JSON data
57-
uv run claude-notes . --raw
57+
uv run claude-notes show . --raw
5858

5959
# Run with uvx (after publishing)
60-
uvx claude-notes .
60+
uvx claude-notes show .
6161
```
6262

6363
### Pager Controls

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ Documentation = "https://github.com/yourusername/claude-notes#readme"
3737
claude-notes = "claude_notes.__main__:main"
3838

3939
[project.optional-dependencies]
40+
animation = [
41+
"asciinema>=2.3.0",
42+
]
4043
dev = [
4144
"ruff>=0.1.0",
4245
"pytest>=7.0.0",

src/claude_notes/cli.py

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,8 @@ def order_messages(messages: list, message_order: str) -> list:
127127
@click.argument("path", type=click.Path(exists=True, path_type=Path), default=".")
128128
@click.option("--raw", is_flag=True, help="Show raw JSON data instead of formatted view")
129129
@click.option("--no-pager", is_flag=True, help="Disable pager and show all content at once")
130-
@click.option("--format", type=click.Choice(["terminal", "html"]), default="terminal", help="Output format")
131-
@click.option("--output", type=click.Path(), help="Output file for HTML format")
130+
@click.option("--format", type=click.Choice(["terminal", "html", "animated"]), default="terminal", help="Output format")
131+
@click.option("--output", type=click.Path(), help="Output file (HTML/GIF/MP4/cast format)")
132132
@click.option(
133133
"--session-order",
134134
type=click.Choice(["asc", "desc"]),
@@ -142,6 +142,20 @@ def order_messages(messages: list, message_order: str) -> list:
142142
help="Order messages within sessions (asc=oldest first, desc=newest first)",
143143
)
144144
@click.option("--style", type=click.Path(exists=True), help="Custom CSS file to include with HTML format")
145+
@click.option(
146+
"--typing-speed", type=float, default=0.05, help="Typing speed in seconds per character (animated format)"
147+
)
148+
@click.option(
149+
"--pause-duration", type=float, default=2.0, help="Pause duration between messages in seconds (animated format)"
150+
)
151+
@click.option("--cols", type=int, default=120, help="Terminal columns (animated format)")
152+
@click.option("--rows", type=int, default=30, help="Terminal rows (animated format)")
153+
@click.option("--max-duration", type=float, help="Maximum animation duration in seconds (animated format)")
154+
@click.option(
155+
"--emoji-fallbacks",
156+
is_flag=True,
157+
help="Replace emoji with text fallbacks for better GIF compatibility (animated format)",
158+
)
145159
def show(
146160
path: Path,
147161
raw: bool,
@@ -151,6 +165,12 @@ def show(
151165
session_order: str,
152166
message_order: str,
153167
style: str | None,
168+
typing_speed: float,
169+
pause_duration: float,
170+
cols: int,
171+
rows: int,
172+
max_duration: float | None,
173+
emoji_fallbacks: bool,
154174
):
155175
"""Show all conversations for a Claude project.
156176
@@ -276,6 +296,86 @@ def show(
276296
else:
277297
# Print to stdout
278298
print(html_output)
299+
elif format == "animated":
300+
# Generate animated GIF
301+
from claude_notes.formatters.factory import FormatterFactory
302+
303+
# Create animated formatter with options
304+
formatter_kwargs = {
305+
"typing_speed": typing_speed,
306+
"pause_duration": pause_duration,
307+
"cols": cols,
308+
"rows": rows,
309+
"max_duration": max_duration,
310+
"use_emoji_fallbacks": emoji_fallbacks,
311+
}
312+
313+
try:
314+
formatter = FormatterFactory.create_formatter("animated", **formatter_kwargs)
315+
except (ImportError, RuntimeError) as e:
316+
console.print(f"[red]Error:[/red] {e}")
317+
console.print("[dim]Hint: Install animation dependencies with: uv add --optional-deps animation[/dim]")
318+
return
319+
320+
# Collect all conversations into a single asciicast
321+
all_messages = []
322+
for conv in conversations:
323+
# Order the messages based on user preference
324+
ordered_messages = order_messages(conv["messages"], message_order)
325+
all_messages.extend(ordered_messages)
326+
327+
# Add separator between conversations if multiple
328+
if len(conversations) > 1:
329+
separator_msg = {
330+
"type": "assistant",
331+
"message": {
332+
"role": "assistant",
333+
"content": f"\n--- Conversation {conversations.index(conv) + 1} ---\n",
334+
},
335+
}
336+
all_messages.append(separator_msg)
337+
338+
# Generate asciicast
339+
try:
340+
cast_file = formatter.format_conversation(all_messages, conversation_info={})
341+
342+
# Handle output options
343+
if output:
344+
output_path = Path(output)
345+
base_name = output_path.stem
346+
output_dir = output_path.parent
347+
348+
# Always save the cast file alongside the output
349+
cast_output = output_dir / f"{base_name}.cast"
350+
import shutil
351+
352+
shutil.copy2(cast_file, cast_output)
353+
console.print(f"[cyan]Asciicast file saved: {cast_output}[/cyan]")
354+
355+
# Generate output based on file extension
356+
if output.endswith(".gif") or not output_path.suffix:
357+
gif_output = str(output_path.with_suffix(".gif"))
358+
formatter.generate_gif(cast_file, gif_output)
359+
console.print(f"[green]Animated GIF generated: {gif_output}[/green]")
360+
elif output.endswith(".mp4"):
361+
mp4_output = str(output_path.with_suffix(".mp4"))
362+
formatter.generate_mp4(cast_file, mp4_output)
363+
console.print(f"[green]MP4 video generated: {mp4_output}[/green]")
364+
elif output.endswith(".cast"):
365+
# User specifically requested just the cast file
366+
console.print(f"[green]Asciicast file saved: {cast_output}[/green]")
367+
else:
368+
# Unknown extension, assume they want GIF
369+
gif_output = str(output_path.with_suffix(".gif"))
370+
formatter.generate_gif(cast_file, gif_output)
371+
console.print(f"[green]Animated GIF generated: {gif_output}[/green]")
372+
else:
373+
console.print(f"[yellow]Asciicast file generated: {cast_file}[/yellow]")
374+
console.print("[dim]Use --output filename.cast/.gif/.mp4 to save in desired format[/dim]")
375+
376+
except Exception as e:
377+
console.print(f"[red]Error generating animation: {e}[/red]")
378+
279379
else:
280380
# Display formatted conversations in terminal
281381
from claude_notes.formatters.terminal import TerminalFormatter

0 commit comments

Comments
 (0)