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
99import { TextAttributes } from '@opentui/core'
10- import React , { useEffect , useState } from 'react'
10+ import React , { useCallback , useEffect , useRef , useState } from 'react'
1111
1212import { 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+
2735export 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}
0 commit comments