Skip to content

Commit 603e65e

Browse files
committed
Add session replay functionality to tmux-viewer
- Add replay mode with play/pause (Space), speed controls (+/-), restart (r) - Support --replay flag to start in replay mode automatically - Auto-advance through captures chronologically like a video player - Speed options: 0.5s, 1.0s, 1.5s, 2.0s, 3.0s, 5.0s per capture - Fix session-loader to find captures in captures/ subdirectory - Update knowledge files with tmux helper scripts documentation
1 parent da9bcea commit 603e65e

File tree

7 files changed

+307
-24
lines changed

7 files changed

+307
-24
lines changed

.agents/tmux-viewer/README.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ Interactive TUI for viewing tmux session logs. Designed to work for **both human
88
# Interactive TUI (for humans)
99
bun .agents/tmux-viewer/index.tsx <session-name>
1010

11+
# Start in replay mode (auto-plays through captures like a video)
12+
bun .agents/tmux-viewer/index.tsx <session-name> --replay
13+
1114
# JSON output (for AIs)
1215
bun .agents/tmux-viewer/index.tsx <session-name> --json
1316

@@ -30,12 +33,38 @@ cd .agents && bun run view-session <session-name>
3033
- **Timeline panel**: Navigate through captures with ↑↓ arrows
3134
- **Capture panel**: View terminal output at each point in time
3235
- **Metadata display**: Session info, dimensions, command count
36+
- **Replay mode**: Auto-play through captures like a video player
3337
- **Keyboard shortcuts**:
34-
- `↑↓` or `jk`: Navigate captures
38+
- `Space`: Play/pause replay
39+
- `+` / `-`: Adjust playback speed (faster/slower)
40+
- `r`: Restart from beginning
41+
- `↑↓` or `jk`: Navigate captures (pauses replay)
3542
- `←→` or `hl`: Switch panels
3643
- `q` or Ctrl+C: Quit
3744
- Use the `--json` flag on the CLI entrypoint for JSON output
3845

46+
### Replay Mode
47+
48+
Replay mode auto-advances through captures chronologically, like a video player:
49+
50+
```bash
51+
# Start replay immediately
52+
bun .agents/tmux-viewer/index.tsx my-session --replay
53+
54+
# Or press Space in the TUI to start/stop replay
55+
```
56+
57+
**Playback controls:**
58+
- `Space` - Toggle play/pause
59+
- `+` or `=` - Speed up (shorter interval between captures)
60+
- `-` or `_` - Slow down (longer interval between captures)
61+
- `r` - Restart from the first capture
62+
- Arrow keys - Manual navigation (automatically pauses replay)
63+
64+
**Available speeds:** 0.5s, 1.0s, 1.5s (default), 2.0s, 3.0s, 5.0s per capture
65+
66+
The footer shows the current position (e.g., `3/10`), playback speed (e.g., `@1.5s`), and play/pause status.
67+
3968
### For AIs (JSON Output)
4069
Use the `--json` flag to get structured output:
4170

.agents/tmux-viewer/components/session-viewer.tsx

Lines changed: 137 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
* SessionViewer - Interactive TUI for viewing tmux session data
33
*
44
* Designed to be simple and predictable for both humans and AIs:
5-
* - Humans: navigate captures with arrow keys / vim keys
5+
* - Humans: navigate captures with arrow keys / vim keys, or use replay mode
66
* - AIs: typically use the --json flag on the CLI entrypoint instead of the TUI
77
*/
88

99
import { TextAttributes } from '@opentui/core'
10-
import React, { useEffect, useState } from 'react'
10+
import React, { useCallback, useEffect, useRef, useState } from 'react'
1111

1212
import { getTheme } from './theme'
1313

@@ -22,11 +22,20 @@ interface SessionViewerProps {
2222
* For now, AIs should call the CLI with --json instead.
2323
*/
2424
onJsonOutput?: () => void
25+
/**
26+
* Start in replay mode (auto-playing through captures)
27+
*/
28+
startInReplayMode?: boolean
2529
}
2630

31+
// Available playback speeds (seconds per capture)
32+
const PLAYBACK_SPEEDS = [0.5, 1.0, 1.5, 2.0, 3.0, 5.0]
33+
const DEFAULT_SPEED_INDEX = 2 // 1.5 seconds
34+
2735
export const SessionViewer: React.FC<SessionViewerProps> = ({
2836
data,
2937
onExit,
38+
startInReplayMode = false,
3039
}) => {
3140
const theme = getTheme()
3241
const captures = data.captures
@@ -38,7 +47,57 @@ export const SessionViewer: React.FC<SessionViewerProps> = ({
3847
'timeline',
3948
)
4049

41-
// Keyboard input handling (q/Ctrl+C to quit, arrows + vim keys to navigate)
50+
// Replay state
51+
const [isPlaying, setIsPlaying] = useState(startInReplayMode)
52+
const [speedIndex, setSpeedIndex] = useState(DEFAULT_SPEED_INDEX)
53+
const playbackSpeed = PLAYBACK_SPEEDS[speedIndex]
54+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
55+
56+
// Auto-advance effect for replay mode
57+
useEffect(() => {
58+
if (!isPlaying || captures.length === 0) {
59+
return
60+
}
61+
62+
timerRef.current = setTimeout(() => {
63+
setSelectedIndex((prev) => {
64+
const next = prev + 1
65+
if (next >= captures.length) {
66+
// Reached the end, stop playing
67+
setIsPlaying(false)
68+
return prev
69+
}
70+
return next
71+
})
72+
}, playbackSpeed * 1000)
73+
74+
return () => {
75+
if (timerRef.current) {
76+
clearTimeout(timerRef.current)
77+
timerRef.current = null
78+
}
79+
}
80+
}, [isPlaying, selectedIndex, playbackSpeed, captures.length])
81+
82+
// Replay control functions
83+
const togglePlay = useCallback(() => {
84+
if (captures.length === 0) return
85+
// If at end and pressing play, restart from beginning
86+
if (!isPlaying && selectedIndex >= captures.length - 1) {
87+
setSelectedIndex(0)
88+
}
89+
setIsPlaying((prev) => !prev)
90+
}, [captures.length, isPlaying, selectedIndex])
91+
92+
const increaseSpeed = useCallback(() => {
93+
setSpeedIndex((prev) => Math.max(0, prev - 1)) // Lower index = faster
94+
}, [])
95+
96+
const decreaseSpeed = useCallback(() => {
97+
setSpeedIndex((prev) => Math.min(PLAYBACK_SPEEDS.length - 1, prev + 1))
98+
}, [])
99+
100+
// Keyboard input handling (q/Ctrl+C to quit, arrows + vim keys to navigate, space for play/pause)
42101
useEffect(() => {
43102
const handleKey = (key: string) => {
44103
// Quit: q or Ctrl+C
@@ -47,18 +106,49 @@ export const SessionViewer: React.FC<SessionViewerProps> = ({
47106
return
48107
}
49108

109+
// Space: toggle play/pause
110+
if (key === ' ') {
111+
togglePlay()
112+
return
113+
}
114+
115+
// +/= : increase speed (faster)
116+
if (key === '+' || key === '=') {
117+
increaseSpeed()
118+
return
119+
}
120+
121+
// -/_ : decrease speed (slower)
122+
if (key === '-' || key === '_') {
123+
decreaseSpeed()
124+
return
125+
}
126+
127+
// r: restart from beginning
128+
if (key === 'r') {
129+
setSelectedIndex(0)
130+
return
131+
}
132+
50133
if (captures.length === 0) {
51134
return
52135
}
53136

137+
// Stop playback on manual navigation
138+
const stopAndNavigate = () => {
139+
setIsPlaying(false)
140+
}
141+
54142
// Up: arrow up or k
55143
if (key === '\x1b[A' || key === 'k') {
144+
stopAndNavigate()
56145
setSelectedIndex((prev) => Math.max(0, prev - 1))
57146
return
58147
}
59148

60149
// Down: arrow down or j
61150
if (key === '\x1b[B' || key === 'j') {
151+
stopAndNavigate()
62152
setSelectedIndex((prev) =>
63153
Math.min(captures.length - 1, Math.max(0, prev + 1)),
64154
)
@@ -94,7 +184,7 @@ export const SessionViewer: React.FC<SessionViewerProps> = ({
94184
stdin.removeListener('data', onData as any)
95185
}
96186
}
97-
}, [captures.length, onExit])
187+
}, [captures.length, onExit, togglePlay, increaseSpeed, decreaseSpeed])
98188

99189
const selectedCapture: Capture | undefined =
100190
selectedIndex >= 0 && selectedIndex < captures.length
@@ -136,8 +226,14 @@ export const SessionViewer: React.FC<SessionViewerProps> = ({
136226
/>
137227
</box>
138228

139-
{/* Footer / help text */}
140-
<Footer theme={theme} />
229+
{/* Footer / help text with replay controls */}
230+
<Footer
231+
theme={theme}
232+
isPlaying={isPlaying}
233+
playbackSpeed={playbackSpeed}
234+
currentIndex={selectedIndex}
235+
totalCaptures={captures.length}
236+
/>
141237
</box>
142238
)
143239
}
@@ -369,25 +465,52 @@ const CapturePanel: React.FC<{
369465
)
370466
}
371467

372-
// Footer component with help text
373-
const Footer: React.FC<{ theme: ViewerTheme }> = ({ theme }) => {
468+
// Footer component with help text and replay controls
469+
const Footer: React.FC<{
470+
theme: ViewerTheme
471+
isPlaying: boolean
472+
playbackSpeed: number
473+
currentIndex: number
474+
totalCaptures: number
475+
}> = ({ theme, isPlaying, playbackSpeed, currentIndex, totalCaptures }) => {
476+
const position = totalCaptures > 0 ? `${currentIndex + 1}/${totalCaptures}` : '0/0'
477+
const speedDisplay = `${playbackSpeed.toFixed(1)}s`
478+
const playIcon = isPlaying ? '⏸' : '▶'
479+
374480
return (
375481
<box
376482
style={{
377483
flexDirection: 'row',
378-
justifyContent: 'center',
484+
justifyContent: 'space-between',
379485
borderStyle: 'single',
380486
borderColor: theme.border,
381487
paddingLeft: 1,
382488
paddingRight: 1,
383-
gap: 2,
384489
}}
385490
border={['top']}
386491
>
387-
<text style={{ fg: theme.muted }}>↑↓ / jk navigate</text>
388-
<text style={{ fg: theme.muted }}>←→ / hl panels</text>
389-
<text style={{ fg: theme.muted }}>q or Ctrl+C: quit</text>
390-
<text style={{ fg: theme.muted }}>use --json for JSON output</text>
492+
{/* Left: Replay status */}
493+
<box style={{ flexDirection: 'row', gap: 1 }}>
494+
<text style={{ fg: isPlaying ? theme.success : theme.muted }}>
495+
{playIcon}
496+
</text>
497+
<text style={{ fg: theme.foreground }}>{position}</text>
498+
<text style={{ fg: theme.muted }}>@{speedDisplay}</text>
499+
</box>
500+
501+
{/* Center: Key hints */}
502+
<box style={{ flexDirection: 'row', gap: 2 }}>
503+
<text style={{ fg: theme.muted }}>space: play/pause</text>
504+
<text style={{ fg: theme.muted }}>+/-: speed</text>
505+
<text style={{ fg: theme.muted }}>↑↓: navigate</text>
506+
<text style={{ fg: theme.muted }}>r: restart</text>
507+
<text style={{ fg: theme.muted }}>q: quit</text>
508+
</box>
509+
510+
{/* Right: Mode indicator */}
511+
<box>
512+
<text style={{ fg: theme.muted }}>--json for AI</text>
513+
</box>
391514
</box>
392515
)
393516
}

.agents/tmux-viewer/index.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* Usage:
77
* bun .agents/tmux-viewer/index.tsx <session-name>
88
* bun .agents/tmux-viewer/index.tsx <session-name> --json
9+
* bun .agents/tmux-viewer/index.tsx <session-name> --replay
910
* bun .agents/tmux-viewer/index.tsx --list
1011
*
1112
* Both humans and AIs can use this tool:
@@ -26,6 +27,7 @@ interface ParsedArgs {
2627
session: string | null
2728
json: boolean
2829
list: boolean
30+
replay: boolean
2931
}
3032

3133
function parseArgs(): ParsedArgs {
@@ -37,6 +39,7 @@ function parseArgs(): ParsedArgs {
3739
.version('0.0.1')
3840
.option('--json', 'Output session data as JSON (for AI consumption)')
3941
.option('--list', 'List available sessions')
42+
.option('--replay', 'Start in replay mode (auto-playing through captures)')
4043
.argument('[session]', 'Session name to view')
4144
.parse(process.argv)
4245

@@ -47,11 +50,12 @@ function parseArgs(): ParsedArgs {
4750
session: args[0] ?? null,
4851
json: options.json ?? false,
4952
list: options.list ?? false,
53+
replay: options.replay ?? false,
5054
}
5155
}
5256

5357
async function main(): Promise<void> {
54-
const { session, json, list } = parseArgs()
58+
const { session, json, list, replay } = parseArgs()
5559
const projectRoot = process.cwd()
5660

5761
// List sessions mode
@@ -90,15 +94,16 @@ async function main(): Promise<void> {
9094
// Use the most recent session
9195
const mostRecent = sessions[0]
9296
console.log(dim(`Using most recent session: ${mostRecent}`))
93-
return runViewer(mostRecent, json, projectRoot)
97+
return runViewer(mostRecent, json, replay, projectRoot)
9498
}
9599

96-
return runViewer(session, json, projectRoot)
100+
return runViewer(session, json, replay, projectRoot)
97101
}
98102

99103
async function runViewer(
100104
sessionName: string,
101105
jsonMode: boolean,
106+
replayMode: boolean,
102107
projectRoot: string
103108
): Promise<void> {
104109
// Load session data
@@ -149,6 +154,7 @@ async function runViewer(
149154
data={data}
150155
onExit={handleExit}
151156
onJsonOutput={handleJsonOutput}
157+
startInReplayMode={replayMode}
152158
/>
153159
)
154160
}

0 commit comments

Comments
 (0)