11import React , { useEffect , useMemo , useState } from 'react' ;
2- import { Box , Text , useInput , useStdin , Static } from 'ink' ;
2+ import { Box , Text , useInput , useStdin } from 'ink' ;
33const h = React . createElement ;
44import { runCommandAsync } from '../../utils.js' ;
55import { findBaseBranch } from '../../utils.js' ;
66import { BASE_BRANCH_CANDIDATES } from '../../constants.js' ;
7+ import { CommentStore } from '../../models.js' ;
8+ import { commentStoreManager } from '../../services/CommentStoreManager.js' ;
9+ import { TmuxService } from '../../services/TmuxService.js' ;
10+ import { runCommand } from '../../utils.js' ;
11+ import CommentInputDialog from '../dialogs/CommentInputDialog.js' ;
712
8- type DiffLine = { type : 'added' | 'removed' | 'context' | 'header' ; text : string } ;
13+ type DiffLine = { type : 'added' | 'removed' | 'context' | 'header' ; text : string ; fileName ?: string } ;
914
1015async function loadDiff ( worktreePath : string , diffType : 'full' | 'uncommitted' = 'full' ) : Promise < DiffLine [ ] > {
1116 const lines : DiffLine [ ] = [ ] ;
@@ -27,32 +32,34 @@ async function loadDiff(worktreePath: string, diffType: 'full' | 'uncommitted' =
2732
2833 if ( ! diff ) return lines ;
2934 const raw = diff . split ( '\n' ) ;
35+ let currentFileName = '' ;
3036 for ( const line of raw ) {
3137 if ( line . startsWith ( 'diff --git' ) ) {
3238 const parts = line . split ( ' ' ) ;
3339 const fp = parts [ 3 ] ?. slice ( 2 ) || parts [ 2 ] ?. slice ( 2 ) || '' ;
34- lines . push ( { type : 'header' , text : `📁 ${ fp } ` } ) ;
40+ currentFileName = fp ;
41+ lines . push ( { type : 'header' , text : `📁 ${ fp } ` , fileName : fp } ) ;
3542 } else if ( line . startsWith ( '@@' ) ) {
3643 const ctx = line . replace ( / ^ @ @ .* @ @ ? / , '' ) ;
37- if ( ctx ) lines . push ( { type : 'header' , text : ` ▼ ${ ctx } ` } ) ;
44+ if ( ctx ) lines . push ( { type : 'header' , text : ` ▼ ${ ctx } ` , fileName : currentFileName } ) ;
3845 } else if ( line . startsWith ( '+' ) && ! line . startsWith ( '+++' ) ) {
39- lines . push ( { type : 'added' , text : line . slice ( 1 ) } ) ;
46+ lines . push ( { type : 'added' , text : line . slice ( 1 ) , fileName : currentFileName } ) ;
4047 } else if ( line . startsWith ( '-' ) && ! line . startsWith ( '---' ) ) {
41- lines . push ( { type : 'removed' , text : line . slice ( 1 ) } ) ;
48+ lines . push ( { type : 'removed' , text : line . slice ( 1 ) , fileName : currentFileName } ) ;
4249 } else if ( line . startsWith ( ' ' ) ) {
43- lines . push ( { type : 'context' , text : line . slice ( 1 ) } ) ;
50+ lines . push ( { type : 'context' , text : line . slice ( 1 ) , fileName : currentFileName } ) ;
4451 } else if ( line === '' ) {
45- lines . push ( { type : 'context' , text : ' ' } ) ; // Empty line gets a space so cursor is visible
52+ lines . push ( { type : 'context' , text : ' ' , fileName : currentFileName } ) ; // Empty line gets a space so cursor is visible
4653 }
4754 }
4855 // Append untracked files
4956 const untracked = await runCommandAsync ( [ 'git' , '-C' , worktreePath , 'ls-files' , '--others' , '--exclude-standard' ] ) ;
5057 if ( untracked ) {
5158 for ( const fp of untracked . split ( '\n' ) . filter ( Boolean ) ) {
52- lines . push ( { type : 'header' , text : `📁 ${ fp } (new file)` } ) ;
59+ lines . push ( { type : 'header' , text : `📁 ${ fp } (new file)` , fileName : fp } ) ;
5360 try {
5461 const cat = await runCommandAsync ( [ 'bash' , '-lc' , `cd ${ JSON . stringify ( worktreePath ) } && sed -n '1,200p' ${ JSON . stringify ( fp ) } ` ] ) ;
55- for ( const l of ( cat || '' ) . split ( '\n' ) . filter ( Boolean ) ) lines . push ( { type : 'added' , text : l } ) ;
62+ for ( const l of ( cat || '' ) . split ( '\n' ) . filter ( Boolean ) ) lines . push ( { type : 'added' , text : l , fileName : fp } ) ;
5663 } catch { }
5764 }
5865 }
@@ -70,6 +77,11 @@ export default function DiffView({worktreePath, title = 'Diff Viewer', onClose,
7077 const [ animationId , setAnimationId ] = useState < NodeJS . Timeout | null > ( null ) ;
7178 const [ terminalHeight , setTerminalHeight ] = useState < number > ( process . stdout . rows || 24 ) ;
7279 const [ terminalWidth , setTerminalWidth ] = useState < number > ( process . stdout . columns || 80 ) ;
80+ const commentStore = useMemo ( ( ) => commentStoreManager . getStore ( worktreePath ) , [ worktreePath ] ) ;
81+ const [ tmuxService ] = useState ( ( ) => new TmuxService ( ) ) ;
82+ const [ showCommentDialog , setShowCommentDialog ] = useState ( false ) ;
83+ const [ showAllComments , setShowAllComments ] = useState ( false ) ;
84+ const [ statusMessage , setStatusMessage ] = useState < string > ( '' ) ;
7385
7486 useEffect ( ( ) => {
7587 ( async ( ) => {
@@ -168,6 +180,10 @@ export default function DiffView({worktreePath, title = 'Diff Viewer', onClose,
168180
169181 useInput ( ( input , key ) => {
170182 if ( ! isRawModeSupported ) return ;
183+
184+ // Don't handle inputs when comment dialog is open
185+ if ( showCommentDialog ) return ;
186+
171187 if ( key . escape || input === 'q' ) return onClose ( ) ;
172188 if ( key . upArrow || input === 'k' ) setPos ( ( p ) => Math . max ( 0 , p - 1 ) ) ;
173189 if ( key . downArrow || input === 'j' ) setPos ( ( p ) => Math . min ( lines . length - 1 , p + 1 ) ) ;
@@ -176,6 +192,31 @@ export default function DiffView({worktreePath, title = 'Diff Viewer', onClose,
176192 if ( input === 'g' ) setPos ( 0 ) ;
177193 if ( input === 'G' ) setPos ( Math . max ( 0 , lines . length - 1 ) ) ;
178194
195+ // Comment functionality
196+ if ( input === 'c' ) {
197+ const currentLine = lines [ pos ] ;
198+ if ( currentLine && currentLine . fileName && currentLine . type !== 'header' ) {
199+ setShowCommentDialog ( true ) ;
200+ }
201+ }
202+
203+ if ( input === 'C' ) {
204+ setShowAllComments ( ! showAllComments ) ;
205+ }
206+
207+ if ( input === 'd' ) {
208+ const currentLine = lines [ pos ] ;
209+ if ( currentLine && currentLine . fileName ) {
210+ commentStore . removeComment ( pos , currentLine . fileName ) ;
211+ }
212+ }
213+
214+ if ( input === 'S' ) {
215+ if ( commentStore . count > 0 ) {
216+ sendCommentsToTmux ( ) ;
217+ }
218+ }
219+
179220 // Left arrow: jump to previous chunk (▼ header)
180221 if ( key . leftArrow ) {
181222 for ( let i = pos - 1 ; i >= 0 ; i -- ) {
@@ -236,6 +277,99 @@ export default function DiffView({worktreePath, title = 'Diff Viewer', onClose,
236277 }
237278 } , [ pos , targetOffset , pageSize , lines . length ] ) ;
238279
280+ const sendCommentsToTmux = ( ) => {
281+ const comments = commentStore . getAllComments ( ) ;
282+ if ( comments . length === 0 ) {
283+ setStatusMessage ( 'No comments to send' ) ;
284+ setTimeout ( ( ) => setStatusMessage ( '' ) , 2000 ) ;
285+ return ;
286+ }
287+
288+ setStatusMessage ( `Sending ${ comments . length } comment${ comments . length > 1 ? 's' : '' } to Claude...` ) ;
289+
290+ try {
291+ // Extract project and feature correctly from worktree path
292+ // Path format: /base/path/project-branches/feature
293+ const pathParts = worktreePath . split ( '/' ) ;
294+ const feature = pathParts [ pathParts . length - 1 ] ;
295+ const projectWithBranches = pathParts [ pathParts . length - 2 ] ;
296+ const project = projectWithBranches . replace ( / - b r a n c h e s $ / , '' ) ;
297+
298+ // Construct proper session name: dev-project-feature
299+ const sessionName = tmuxService . sessionName ( project , feature ) ;
300+
301+ // Check if session exists
302+ const sessionExists = tmuxService . listSessions ( ) . includes ( sessionName ) ;
303+
304+ if ( ! sessionExists ) {
305+ // Create new detached session
306+ runCommand ( [ 'tmux' , 'new-session' , '-ds' , sessionName , '-c' , worktreePath ] ) ;
307+
308+ // Start Claude if available
309+ const hasClaude = runCommand ( [ 'bash' , '-lc' , 'command -v claude || true' ] ) . trim ( ) ;
310+ if ( hasClaude ) {
311+ runCommand ( [ 'tmux' , 'send-keys' , '-t' , `${ sessionName } :0.0` , 'claude' , 'C-m' ] ) ;
312+ }
313+ }
314+
315+ // Format the message as an array of lines
316+ const messageLines : string [ ] = [ ] ;
317+ messageLines . push ( "Please address the following code review comments:" ) ;
318+ messageLines . push ( "" ) ;
319+
320+ const commentsByFile : { [ key : string ] : typeof comments } = { } ;
321+ comments . forEach ( comment => {
322+ if ( ! commentsByFile [ comment . fileName ] ) {
323+ commentsByFile [ comment . fileName ] = [ ] ;
324+ }
325+ commentsByFile [ comment . fileName ] . push ( comment ) ;
326+ } ) ;
327+
328+ Object . entries ( commentsByFile ) . forEach ( ( [ fileName , fileComments ] ) => {
329+ messageLines . push ( `File: ${ fileName } ` ) ;
330+ fileComments . forEach ( comment => {
331+ messageLines . push ( ` Line ${ comment . lineIndex + 1 } : ${ comment . commentText } ` ) ;
332+ } ) ;
333+ messageLines . push ( "" ) ;
334+ } ) ;
335+
336+ // Send all lines with Alt+Enter (Escape Enter) to avoid auto-submission
337+ messageLines . forEach ( ( line , index ) => {
338+ // Send the line text
339+ runCommand ( [ 'tmux' , 'send-keys' , '-t' , `${ sessionName } :0.0` , line ] ) ;
340+
341+ // Send Alt+Enter (Escape followed by Enter) to insert newline without submitting
342+ // Don't send a newline after the last line
343+ if ( index < messageLines . length - 1 ) {
344+ runCommand ( [ 'tmux' , 'send-keys' , '-t' , `${ sessionName } :0.0` , 'Escape' , 'Enter' ] ) ;
345+ }
346+ } ) ;
347+
348+ // Clear comments after sending
349+ commentStore . clear ( ) ;
350+
351+ setStatusMessage ( `✓ Sent ${ comments . length } comment${ comments . length > 1 ? 's' : '' } to session: ${ sessionName } ` ) ;
352+ setTimeout ( ( ) => setStatusMessage ( '' ) , 3000 ) ;
353+
354+ } catch ( error ) {
355+ setStatusMessage ( '✗ Failed to send comments' ) ;
356+ setTimeout ( ( ) => setStatusMessage ( '' ) , 3000 ) ;
357+ console . error ( 'Failed to send comments to tmux:' , error ) ;
358+ }
359+ } ;
360+
361+ const handleCommentSave = ( commentText : string ) => {
362+ const currentLine = lines [ pos ] ;
363+ if ( currentLine && currentLine . fileName ) {
364+ commentStore . addComment ( pos , currentLine . fileName , currentLine . text , commentText ) ;
365+ }
366+ setShowCommentDialog ( false ) ;
367+ } ;
368+
369+ const handleCommentCancel = ( ) => {
370+ setShowCommentDialog ( false ) ;
371+ } ;
372+
239373 // Truncate text to fit terminal width
240374 const truncateText = ( text : string , maxWidth : number ) : string => {
241375 if ( text . length <= maxWidth ) return text ;
@@ -246,23 +380,51 @@ export default function DiffView({worktreePath, title = 'Diff Viewer', onClose,
246380 return lines . slice ( offset , offset + pageSize ) ;
247381 } , [ lines , offset , pageSize ] ) ;
248382
383+ const statusText = `Terminal: ${ terminalHeight } x${ terminalWidth } | PageSize: ${ pageSize } | Pos: ${ pos } /${ lines . length } | Offset: ${ offset } | Visible: ${ visible . length } | Comments: ${ commentStore . count } ` ;
384+
385+ // Create comment dialog if needed - render it instead of the main view when active
386+ if ( showCommentDialog ) {
387+ return h (
388+ Box ,
389+ { flexDirection : 'column' , height : terminalHeight , justifyContent : 'center' , alignItems : 'center' } ,
390+ h ( CommentInputDialog , {
391+ fileName : lines [ pos ] ?. fileName || '' ,
392+ lineText : lines [ pos ] ?. text || '' ,
393+ initialComment : lines [ pos ] ?. fileName ? commentStore . getComment ( pos , lines [ pos ] . fileName ) ?. commentText || '' : '' ,
394+ onSave : handleCommentSave ,
395+ onCancel : handleCommentCancel
396+ } )
397+ ) ;
398+ }
399+
249400 return h (
250401 Box ,
251402 { flexDirection : 'column' } ,
252- h ( Text , { color : 'yellow' } , `Terminal: ${ terminalHeight } x ${ terminalWidth } | PageSize: ${ pageSize } | Pos: ${ pos } / ${ lines . length } | Offset: ${ offset } | Visible: ${ visible . length } ` ) ,
403+ h ( Text , { color : 'yellow' } , statusText ) ,
253404 h ( Text , { bold : true } , title ) ,
254405 ...visible . map ( ( l , idx ) => {
255406 const actualLineIndex = offset + idx ;
256407 const isCurrentLine = actualLineIndex === pos ;
257- const displayText = truncateText ( l . text || ' ' , terminalWidth - 2 ) ; // -2 for padding
408+ const hasComment = l . fileName && commentStore . hasComment ( actualLineIndex , l . fileName ) ;
409+ const commentIndicator = hasComment ? '[C] ' : '' ;
410+ const displayText = truncateText ( commentIndicator + ( l . text || ' ' ) , terminalWidth - 2 ) ; // -2 for padding
258411 return h ( Text , {
259412 key : idx ,
260413 color : l . type === 'added' ? 'green' : l . type === 'removed' ? 'red' : l . type === 'header' ? 'cyan' : undefined ,
261414 backgroundColor : isCurrentLine ? 'blue' : undefined ,
262415 bold : isCurrentLine
263416 } , displayText ) ;
264417 } ) ,
265- h ( Text , { color : 'gray' } , 'j/k move b/f PgUp/PgDn g/G top/bottom ←/→ chunk Shift+←/→ file q close' )
418+ showAllComments && commentStore . count > 0 ? h (
419+ Box ,
420+ { flexDirection : 'column' , borderStyle : 'single' , borderColor : 'blue' , padding : 1 , marginTop : 1 } ,
421+ h ( Text , { bold : true , color : 'blue' } , `All Comments (${ commentStore . count } ):` ) ,
422+ ...commentStore . getAllComments ( ) . map ( ( comment , idx ) =>
423+ h ( Text , { key : idx , color : 'gray' } , `${ comment . fileName } :${ comment . lineIndex } - ${ comment . commentText } ` )
424+ )
425+ ) : null ,
426+ h ( Text , { color : 'gray' } , 'j/k move c comment C show all d delete S send to Claude q close' ) ,
427+ statusMessage ? h ( Text , { color : statusMessage . startsWith ( '✓' ) ? 'green' : statusMessage . startsWith ( '✗' ) ? 'red' : 'yellow' , bold : true } , statusMessage ) : null
266428 ) ;
267429}
268430
0 commit comments