Skip to content

Commit 293b154

Browse files
committed
Add GIF export to tmux-viewer and fix workspace dependencies
- Add gif-exporter.ts with canvas-based terminal rendering - Add --export-gif, --frame-delay, --font-size CLI flags - Add gif-encoder-2.d.ts type declarations - Remove local node_modules/bun.lock from tmux-viewer (use workspace deps) - Add gif-encoder-2 and canvas to root package.json - Update .agents/tsconfig.json with JSX config for OpenTUI - Update README.md with GIF export documentation
1 parent 603e65e commit 293b154

File tree

8 files changed

+441
-23
lines changed

8 files changed

+441
-23
lines changed

.agents/tmux-viewer/README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ bun .agents/tmux-viewer/index.tsx <session-name> --replay
1414
# JSON output (for AIs)
1515
bun .agents/tmux-viewer/index.tsx <session-name> --json
1616

17+
# Export as animated GIF
18+
bun .agents/tmux-viewer/index.tsx <session-name> --export-gif output.gif
19+
20+
# Export with custom frame delay (default: 1500ms)
21+
bun .agents/tmux-viewer/index.tsx <session-name> --export-gif output.gif --frame-delay 2000
22+
23+
# Export with custom font size (default: 14px)
24+
bun .agents/tmux-viewer/index.tsx <session-name> --export-gif output.gif --font-size 16
25+
1726
# List available sessions
1827
bun .agents/tmux-viewer/index.tsx --list
1928

@@ -154,6 +163,47 @@ The `@cli-tmux-tester` agent can use this viewer to inspect session data:
154163
// bun .agents/tmux-viewer/index.tsx cli-test-123 --json
155164
```
156165

166+
## GIF Export
167+
168+
The `--export-gif` flag renders the session replay as an animated GIF, perfect for:
169+
- Sharing CLI demonstrations
170+
- Embedding in documentation
171+
- Bug reports and issue tracking
172+
- Creating tutorials
173+
174+
### GIF Export Options
175+
176+
| Option | Description | Default |
177+
|--------|-------------|--------|
178+
| `--export-gif [path]` | Output file path | `<session>-<timestamp>.gif` |
179+
| `--frame-delay <ms>` | Delay between frames in milliseconds | `1500` |
180+
| `--font-size <px>` | Font size for terminal text | `14` |
181+
182+
### Examples
183+
184+
```bash
185+
# Basic export (auto-names the file)
186+
bun .agents/tmux-viewer/index.tsx my-session --export-gif
187+
188+
# Specify output path
189+
bun .agents/tmux-viewer/index.tsx my-session --export-gif demo.gif
190+
191+
# Fast playback (500ms per frame)
192+
bun .agents/tmux-viewer/index.tsx my-session --export-gif fast.gif --frame-delay 500
193+
194+
# Larger text for readability
195+
bun .agents/tmux-viewer/index.tsx my-session --export-gif large.gif --font-size 18
196+
```
197+
198+
### GIF Output
199+
200+
The exported GIF includes:
201+
- Terminal content rendered as monospace text
202+
- Frame labels showing capture sequence number and label
203+
- Timestamps for each frame
204+
- Dark terminal-style background
205+
- Automatic sizing based on terminal dimensions
206+
157207
## Development
158208

159209
```bash
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Type declarations for gif-encoder-2
3+
* @see https://github.com/benjaminadk/gif-encoder-2
4+
*/
5+
6+
declare module 'gif-encoder-2' {
7+
import type { CanvasRenderingContext2D } from 'canvas'
8+
9+
export default class GIFEncoder {
10+
constructor(
11+
width: number,
12+
height: number,
13+
algorithm?: 'neuquant' | 'octree',
14+
useOptimizer?: boolean
15+
)
16+
start(): void
17+
setDelay(delay: number): void
18+
setRepeat(repeat: number): void
19+
setQuality(quality: number): void
20+
setTransparent(color: number): void
21+
addFrame(ctx: CanvasRenderingContext2D): void
22+
finish(): void
23+
out: {
24+
getData(): Buffer
25+
}
26+
createReadStream(): NodeJS.ReadableStream
27+
}
28+
}
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
/**
2+
* GIF Exporter - Renders tmux session captures as an animated GIF
3+
*
4+
* Uses node-canvas to render terminal content as frames and gif-encoder-2 to encode.
5+
*/
6+
7+
import { createCanvas } from 'canvas'
8+
import GIFEncoder from 'gif-encoder-2'
9+
import path from 'path'
10+
11+
import type { SessionData, Capture } from './types'
12+
import type { CanvasRenderingContext2D } from 'canvas'
13+
14+
// Types are in gif-encoder-2.d.ts
15+
16+
export interface GifExportOptions {
17+
/** Output file path for the GIF */
18+
outputPath: string
19+
/** Delay between frames in milliseconds (default: 1500) */
20+
frameDelay?: number
21+
/** Font size in pixels (default: 14) */
22+
fontSize?: number
23+
/** Background color (default: '#1e1e1e') */
24+
bgColor?: string
25+
/** Foreground/text color (default: '#d4d4d4') */
26+
fgColor?: string
27+
/** Canvas width in pixels (default: auto-calculated from terminal width) */
28+
width?: number
29+
/** Canvas height in pixels (default: auto-calculated from terminal height) */
30+
height?: number
31+
/** Number of times to loop (0 = infinite, default: 0) */
32+
loop?: number
33+
/** Quality setting 1-20, lower = better quality (default: 10) */
34+
quality?: number
35+
/** Show frame label/timestamp overlay (default: true) */
36+
showLabel?: boolean
37+
}
38+
39+
interface RenderContext {
40+
fontSize: number
41+
lineHeight: number
42+
charWidth: number
43+
bgColor: string
44+
fgColor: string
45+
labelColor: string
46+
showLabel: boolean
47+
}
48+
49+
/**
50+
* Calculate canvas dimensions based on terminal size and font metrics
51+
*/
52+
function calculateDimensions(
53+
sessionData: SessionData,
54+
options: GifExportOptions
55+
): { width: number; height: number; charWidth: number; lineHeight: number } {
56+
const fontSize = options.fontSize ?? 14
57+
// Approximate character dimensions for monospace font
58+
const charWidth = fontSize * 0.6
59+
const lineHeight = fontSize * 1.2
60+
61+
// Get terminal dimensions from session info or first capture
62+
let termWidth = 80
63+
let termHeight = 24
64+
65+
if (sessionData.sessionInfo.dimensions) {
66+
termWidth =
67+
typeof sessionData.sessionInfo.dimensions.width === 'number'
68+
? sessionData.sessionInfo.dimensions.width
69+
: 80
70+
termHeight =
71+
typeof sessionData.sessionInfo.dimensions.height === 'number'
72+
? sessionData.sessionInfo.dimensions.height
73+
: 24
74+
}
75+
76+
// Calculate canvas size with padding
77+
const padding = 20
78+
const labelHeight = options.showLabel !== false ? 30 : 0
79+
80+
const width = options.width ?? Math.ceil(termWidth * charWidth + padding * 2)
81+
const height =
82+
options.height ?? Math.ceil(termHeight * lineHeight + padding * 2 + labelHeight)
83+
84+
return { width, height, charWidth, lineHeight }
85+
}
86+
87+
/**
88+
* Render a single capture frame to the canvas
89+
*/
90+
function renderFrame(
91+
ctx: CanvasRenderingContext2D,
92+
capture: Capture,
93+
canvasWidth: number,
94+
canvasHeight: number,
95+
renderCtx: RenderContext
96+
): void {
97+
const { fontSize, lineHeight, bgColor, fgColor, labelColor, showLabel } = renderCtx
98+
99+
// Clear and fill background
100+
ctx.fillStyle = bgColor
101+
ctx.fillRect(0, 0, canvasWidth, canvasHeight)
102+
103+
// Set up text rendering
104+
ctx.font = `${fontSize}px monospace`
105+
ctx.textBaseline = 'top'
106+
ctx.fillStyle = fgColor
107+
108+
// Calculate content area
109+
const padding = 10
110+
const labelHeight = showLabel ? 30 : 0
111+
const contentStartY = padding + labelHeight
112+
113+
// Render label if enabled
114+
if (showLabel) {
115+
const label = capture.frontMatter.label || `Capture ${capture.frontMatter.sequence}`
116+
const time = formatTimestamp(capture.frontMatter.timestamp)
117+
118+
ctx.fillStyle = labelColor
119+
ctx.font = `bold ${fontSize - 2}px sans-serif`
120+
ctx.fillText(`[${capture.frontMatter.sequence}] ${label}`, padding, padding)
121+
122+
// Time on the right
123+
const timeText = time
124+
const timeWidth = ctx.measureText(timeText).width
125+
ctx.fillText(timeText, canvasWidth - padding - timeWidth, padding)
126+
127+
// Draw separator line
128+
ctx.strokeStyle = labelColor
129+
ctx.lineWidth = 1
130+
ctx.beginPath()
131+
ctx.moveTo(padding, padding + fontSize + 5)
132+
ctx.lineTo(canvasWidth - padding, padding + fontSize + 5)
133+
ctx.stroke()
134+
}
135+
136+
// Render terminal content
137+
ctx.font = `${fontSize}px monospace`
138+
ctx.fillStyle = fgColor
139+
140+
const lines = capture.content.split('\n')
141+
const maxLines = Math.floor((canvasHeight - contentStartY - padding) / lineHeight)
142+
143+
for (let i = 0; i < Math.min(lines.length, maxLines); i++) {
144+
const line = lines[i]
145+
// Strip ANSI codes for now (basic support)
146+
const cleanLine = stripAnsiCodes(line)
147+
ctx.fillText(cleanLine, padding, contentStartY + i * lineHeight)
148+
}
149+
150+
// Show truncation indicator if content was cut off
151+
if (lines.length > maxLines) {
152+
ctx.fillStyle = labelColor
153+
ctx.font = `italic ${fontSize - 2}px sans-serif`
154+
ctx.fillText(
155+
`... ${lines.length - maxLines} more lines`,
156+
padding,
157+
canvasHeight - padding - fontSize
158+
)
159+
}
160+
}
161+
162+
/**
163+
* Strip ANSI escape codes from text
164+
*/
165+
function stripAnsiCodes(text: string): string {
166+
// eslint-disable-next-line no-control-regex
167+
return text.replace(/\x1b\[[0-9;]*m/g, '').replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
168+
}
169+
170+
/**
171+
* Format ISO timestamp into readable time
172+
*/
173+
function formatTimestamp(isoTimestamp: string): string {
174+
try {
175+
const date = new Date(isoTimestamp)
176+
return date.toLocaleTimeString('en-US', {
177+
hour: '2-digit',
178+
minute: '2-digit',
179+
second: '2-digit',
180+
hour12: false,
181+
})
182+
} catch {
183+
return isoTimestamp.slice(11, 19)
184+
}
185+
}
186+
187+
/**
188+
* Export session replay as an animated GIF
189+
*
190+
* @param sessionData - The loaded session data
191+
* @param options - Export options
192+
* @returns Promise that resolves to the output path when complete
193+
*/
194+
export async function renderSessionToGif(
195+
sessionData: SessionData,
196+
options: GifExportOptions
197+
): Promise<string> {
198+
const captures = sessionData.captures
199+
200+
if (captures.length === 0) {
201+
throw new Error('No captures to export - session has no captured frames')
202+
}
203+
204+
// Apply defaults
205+
const frameDelay = options.frameDelay ?? 1500
206+
const fontSize = options.fontSize ?? 14
207+
const bgColor = options.bgColor ?? '#1e1e1e'
208+
const fgColor = options.fgColor ?? '#d4d4d4'
209+
const loop = options.loop ?? 0
210+
const quality = options.quality ?? 10
211+
const showLabel = options.showLabel !== false
212+
213+
// Calculate dimensions
214+
const { width, height, charWidth, lineHeight } = calculateDimensions(sessionData, {
215+
...options,
216+
fontSize,
217+
showLabel,
218+
})
219+
220+
// Create canvas
221+
const canvas = createCanvas(width, height)
222+
const ctx = canvas.getContext('2d')
223+
224+
// Create GIF encoder
225+
const encoder = new GIFEncoder(width, height)
226+
227+
encoder.start()
228+
encoder.setDelay(frameDelay)
229+
encoder.setRepeat(loop)
230+
encoder.setQuality(quality)
231+
232+
// Render context for frames
233+
const renderCtx: RenderContext = {
234+
fontSize,
235+
lineHeight,
236+
charWidth,
237+
bgColor,
238+
fgColor,
239+
labelColor: '#888888',
240+
showLabel,
241+
}
242+
243+
// Render each capture as a frame
244+
for (const capture of captures) {
245+
renderFrame(ctx, capture, width, height, renderCtx)
246+
encoder.addFrame(ctx)
247+
}
248+
249+
encoder.finish()
250+
251+
// Write to file
252+
const outputPath = path.resolve(options.outputPath)
253+
const buffer = encoder.out.getData()
254+
255+
await Bun.write(outputPath, buffer)
256+
257+
return outputPath
258+
}
259+
260+
/**
261+
* Get suggested output filename based on session name
262+
*/
263+
export function getSuggestedFilename(sessionData: SessionData): string {
264+
const sessionName = sessionData.sessionInfo.session
265+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
266+
return `${sessionName}-${timestamp}.gif`
267+
}

0 commit comments

Comments
 (0)