diff --git a/src/components/dialogs/CommentInputDialog.ts b/src/components/dialogs/CommentInputDialog.ts new file mode 100644 index 00000000..1575a2ac --- /dev/null +++ b/src/components/dialogs/CommentInputDialog.ts @@ -0,0 +1,117 @@ +import React, {useState} from 'react'; +import {Box, Text, useInput} from 'ink'; +const h = React.createElement; + +type Props = { + fileName: string; + lineText: string; + initialComment?: string; + onSave: (comment: string) => void; + onCancel: () => void; +}; + +export default function CommentInputDialog({fileName, lineText, initialComment = '', onSave, onCancel}: Props) { + const [comment, setComment] = useState(initialComment); + const [cursorPosition, setCursorPosition] = useState(initialComment.length); + + useInput((input, key) => { + if (key.escape) { + onCancel(); + return; + } + + if (key.return && !key.shift) { + if (comment.trim()) { + onSave(comment.trim()); + } else { + onCancel(); + } + return; + } + + if (key.return && key.shift) { + const newComment = comment.slice(0, cursorPosition) + '\n' + comment.slice(cursorPosition); + setComment(newComment); + setCursorPosition(cursorPosition + 1); + return; + } + + if (key.backspace || key.delete) { + if (comment.length > 0 && cursorPosition > 0) { + const newComment = comment.slice(0, cursorPosition - 1) + comment.slice(cursorPosition); + setComment(newComment); + setCursorPosition(Math.max(0, cursorPosition - 1)); + } + return; + } + + if (key.leftArrow) { + setCursorPosition(Math.max(0, cursorPosition - 1)); + return; + } + + if (key.rightArrow) { + setCursorPosition(Math.min(comment.length, cursorPosition + 1)); + return; + } + + if (key.upArrow || key.downArrow) { + return; + } + + if (input && !key.ctrl && !key.meta) { + const newComment = comment.slice(0, cursorPosition) + input + comment.slice(cursorPosition); + setComment(newComment); + setCursorPosition(cursorPosition + input.length); + } + }); + + const displayComment = comment || ''; + const beforeCursor = displayComment.slice(0, cursorPosition); + const atCursor = displayComment.slice(cursorPosition, cursorPosition + 1) || ' '; + const afterCursor = displayComment.slice(cursorPosition + 1); + + const lines = displayComment.split('\n'); + const boxWidth = 70; // Fixed width for consistent appearance + + return h( + Box, + { + flexDirection: 'column', + borderStyle: 'round', + borderColor: 'blue', + padding: 1, + width: boxWidth + }, + h(Text, {bold: true, color: 'blue'}, 'Add Comment'), + h(Text, {color: 'gray'}, `File: ${fileName}`), + h(Text, {color: 'gray'}, `Line: ${lineText.slice(0, 60)}${lineText.length > 60 ? '...' : ''}`), + h( + Box, + { + flexDirection: 'column', + borderStyle: 'single', + borderColor: 'gray', + padding: 1, + minHeight: 3 + }, + ...lines.map((line, index) => { + if (index === 0 && lines.length === 1) { + return h( + Text, + {key: index}, + h(Text, {}, beforeCursor), + h(Text, {inverse: true}, atCursor), + h(Text, {}, afterCursor) + ); + } + return h(Text, {key: index}, line || ' '); + }) + ), + h( + Text, + {color: 'gray'}, + 'Enter: Save Shift+Enter: New Line Esc: Cancel' + ) + ); +} \ No newline at end of file diff --git a/src/components/views/DiffView.ts b/src/components/views/DiffView.ts index ee027d3a..13ac58c6 100644 --- a/src/components/views/DiffView.ts +++ b/src/components/views/DiffView.ts @@ -1,11 +1,16 @@ import React, {useEffect, useMemo, useState} from 'react'; -import {Box, Text, useInput, useStdin, Static} from 'ink'; +import {Box, Text, useInput, useStdin} from 'ink'; const h = React.createElement; import {runCommandAsync} from '../../utils.js'; import {findBaseBranch} from '../../utils.js'; import {BASE_BRANCH_CANDIDATES} from '../../constants.js'; +import {CommentStore} from '../../models.js'; +import {commentStoreManager} from '../../services/CommentStoreManager.js'; +import {TmuxService} from '../../services/TmuxService.js'; +import {runCommand} from '../../utils.js'; +import CommentInputDialog from '../dialogs/CommentInputDialog.js'; -type DiffLine = {type: 'added'|'removed'|'context'|'header'; text: string}; +type DiffLine = {type: 'added'|'removed'|'context'|'header'; text: string; fileName?: string}; async function loadDiff(worktreePath: string, diffType: 'full' | 'uncommitted' = 'full'): Promise { const lines: DiffLine[] = []; @@ -27,32 +32,34 @@ async function loadDiff(worktreePath: string, diffType: 'full' | 'uncommitted' = if (!diff) return lines; const raw = diff.split('\n'); + let currentFileName = ''; for (const line of raw) { if (line.startsWith('diff --git')) { const parts = line.split(' '); const fp = parts[3]?.slice(2) || parts[2]?.slice(2) || ''; - lines.push({type: 'header', text: `📁 ${fp}`}); + currentFileName = fp; + lines.push({type: 'header', text: `📁 ${fp}`, fileName: fp}); } else if (line.startsWith('@@')) { const ctx = line.replace(/^@@.*@@ ?/, ''); - if (ctx) lines.push({type: 'header', text: ` ▼ ${ctx}`}); + if (ctx) lines.push({type: 'header', text: ` ▼ ${ctx}`, fileName: currentFileName}); } else if (line.startsWith('+') && !line.startsWith('+++')) { - lines.push({type: 'added', text: line.slice(1)}); + lines.push({type: 'added', text: line.slice(1), fileName: currentFileName}); } else if (line.startsWith('-') && !line.startsWith('---')) { - lines.push({type: 'removed', text: line.slice(1)}); + lines.push({type: 'removed', text: line.slice(1), fileName: currentFileName}); } else if (line.startsWith(' ')) { - lines.push({type: 'context', text: line.slice(1)}); + lines.push({type: 'context', text: line.slice(1), fileName: currentFileName}); } else if (line === '') { - lines.push({type: 'context', text: ' '}); // Empty line gets a space so cursor is visible + lines.push({type: 'context', text: ' ', fileName: currentFileName}); // Empty line gets a space so cursor is visible } } // Append untracked files const untracked = await runCommandAsync(['git', '-C', worktreePath, 'ls-files', '--others', '--exclude-standard']); if (untracked) { for (const fp of untracked.split('\n').filter(Boolean)) { - lines.push({type: 'header', text: `📁 ${fp} (new file)`}); + lines.push({type: 'header', text: `📁 ${fp} (new file)`, fileName: fp}); try { const cat = await runCommandAsync(['bash', '-lc', `cd ${JSON.stringify(worktreePath)} && sed -n '1,200p' ${JSON.stringify(fp)}`]); - for (const l of (cat || '').split('\n').filter(Boolean)) lines.push({type: 'added', text: l}); + for (const l of (cat || '').split('\n').filter(Boolean)) lines.push({type: 'added', text: l, fileName: fp}); } catch {} } } @@ -70,6 +77,11 @@ export default function DiffView({worktreePath, title = 'Diff Viewer', onClose, const [animationId, setAnimationId] = useState(null); const [terminalHeight, setTerminalHeight] = useState(process.stdout.rows || 24); const [terminalWidth, setTerminalWidth] = useState(process.stdout.columns || 80); + const commentStore = useMemo(() => commentStoreManager.getStore(worktreePath), [worktreePath]); + const [tmuxService] = useState(() => new TmuxService()); + const [showCommentDialog, setShowCommentDialog] = useState(false); + const [showAllComments, setShowAllComments] = useState(false); + const [statusMessage, setStatusMessage] = useState(''); useEffect(() => { (async () => { @@ -168,6 +180,10 @@ export default function DiffView({worktreePath, title = 'Diff Viewer', onClose, useInput((input, key) => { if (!isRawModeSupported) return; + + // Don't handle inputs when comment dialog is open + if (showCommentDialog) return; + if (key.escape || input === 'q') return onClose(); if (key.upArrow || input === 'k') setPos((p) => Math.max(0, p - 1)); 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, if (input === 'g') setPos(0); if (input === 'G') setPos(Math.max(0, lines.length - 1)); + // Comment functionality + if (input === 'c') { + const currentLine = lines[pos]; + if (currentLine && currentLine.fileName && currentLine.type !== 'header') { + setShowCommentDialog(true); + } + } + + if (input === 'C') { + setShowAllComments(!showAllComments); + } + + if (input === 'd') { + const currentLine = lines[pos]; + if (currentLine && currentLine.fileName) { + commentStore.removeComment(pos, currentLine.fileName); + } + } + + if (input === 'S') { + if (commentStore.count > 0) { + sendCommentsToTmux(); + } + } + // Left arrow: jump to previous chunk (▼ header) if (key.leftArrow) { for (let i = pos - 1; i >= 0; i--) { @@ -236,6 +277,99 @@ export default function DiffView({worktreePath, title = 'Diff Viewer', onClose, } }, [pos, targetOffset, pageSize, lines.length]); + const sendCommentsToTmux = () => { + const comments = commentStore.getAllComments(); + if (comments.length === 0) { + setStatusMessage('No comments to send'); + setTimeout(() => setStatusMessage(''), 2000); + return; + } + + setStatusMessage(`Sending ${comments.length} comment${comments.length > 1 ? 's' : ''} to Claude...`); + + try { + // Extract project and feature correctly from worktree path + // Path format: /base/path/project-branches/feature + const pathParts = worktreePath.split('/'); + const feature = pathParts[pathParts.length - 1]; + const projectWithBranches = pathParts[pathParts.length - 2]; + const project = projectWithBranches.replace(/-branches$/, ''); + + // Construct proper session name: dev-project-feature + const sessionName = tmuxService.sessionName(project, feature); + + // Check if session exists + const sessionExists = tmuxService.listSessions().includes(sessionName); + + if (!sessionExists) { + // Create new detached session + runCommand(['tmux', 'new-session', '-ds', sessionName, '-c', worktreePath]); + + // Start Claude if available + const hasClaude = runCommand(['bash', '-lc', 'command -v claude || true']).trim(); + if (hasClaude) { + runCommand(['tmux', 'send-keys', '-t', `${sessionName}:0.0`, 'claude', 'C-m']); + } + } + + // Format the message as an array of lines + const messageLines: string[] = []; + messageLines.push("Please address the following code review comments:"); + messageLines.push(""); + + const commentsByFile: {[key: string]: typeof comments} = {}; + comments.forEach(comment => { + if (!commentsByFile[comment.fileName]) { + commentsByFile[comment.fileName] = []; + } + commentsByFile[comment.fileName].push(comment); + }); + + Object.entries(commentsByFile).forEach(([fileName, fileComments]) => { + messageLines.push(`File: ${fileName}`); + fileComments.forEach(comment => { + messageLines.push(` Line ${comment.lineIndex + 1}: ${comment.commentText}`); + }); + messageLines.push(""); + }); + + // Send all lines with Alt+Enter (Escape Enter) to avoid auto-submission + messageLines.forEach((line, index) => { + // Send the line text + runCommand(['tmux', 'send-keys', '-t', `${sessionName}:0.0`, line]); + + // Send Alt+Enter (Escape followed by Enter) to insert newline without submitting + // Don't send a newline after the last line + if (index < messageLines.length - 1) { + runCommand(['tmux', 'send-keys', '-t', `${sessionName}:0.0`, 'Escape', 'Enter']); + } + }); + + // Clear comments after sending + commentStore.clear(); + + setStatusMessage(`✓ Sent ${comments.length} comment${comments.length > 1 ? 's' : ''} to session: ${sessionName}`); + setTimeout(() => setStatusMessage(''), 3000); + + } catch (error) { + setStatusMessage('✗ Failed to send comments'); + setTimeout(() => setStatusMessage(''), 3000); + console.error('Failed to send comments to tmux:', error); + } + }; + + const handleCommentSave = (commentText: string) => { + const currentLine = lines[pos]; + if (currentLine && currentLine.fileName) { + commentStore.addComment(pos, currentLine.fileName, currentLine.text, commentText); + } + setShowCommentDialog(false); + }; + + const handleCommentCancel = () => { + setShowCommentDialog(false); + }; + // Truncate text to fit terminal width const truncateText = (text: string, maxWidth: number): string => { if (text.length <= maxWidth) return text; @@ -246,15 +380,34 @@ export default function DiffView({worktreePath, title = 'Diff Viewer', onClose, return lines.slice(offset, offset + pageSize); }, [lines, offset, pageSize]); + const statusText = `Terminal: ${terminalHeight}x${terminalWidth} | PageSize: ${pageSize} | Pos: ${pos}/${lines.length} | Offset: ${offset} | Visible: ${visible.length} | Comments: ${commentStore.count}`; + + // Create comment dialog if needed - render it instead of the main view when active + if (showCommentDialog) { + return h( + Box, + {flexDirection: 'column', height: terminalHeight, justifyContent: 'center', alignItems: 'center'}, + h(CommentInputDialog, { + fileName: lines[pos]?.fileName || '', + lineText: lines[pos]?.text || '', + initialComment: lines[pos]?.fileName ? commentStore.getComment(pos, lines[pos].fileName)?.commentText || '' : '', + onSave: handleCommentSave, + onCancel: handleCommentCancel + }) + ); + } + return h( Box, {flexDirection: 'column'}, - h(Text, {color: 'yellow'}, `Terminal: ${terminalHeight}x${terminalWidth} | PageSize: ${pageSize} | Pos: ${pos}/${lines.length} | Offset: ${offset} | Visible: ${visible.length}`), + h(Text, {color: 'yellow'}, statusText), h(Text, {bold: true}, title), ...visible.map((l, idx) => { const actualLineIndex = offset + idx; const isCurrentLine = actualLineIndex === pos; - const displayText = truncateText(l.text || ' ', terminalWidth - 2); // -2 for padding + const hasComment = l.fileName && commentStore.hasComment(actualLineIndex, l.fileName); + const commentIndicator = hasComment ? '[C] ' : ''; + const displayText = truncateText(commentIndicator + (l.text || ' '), terminalWidth - 2); // -2 for padding return h(Text, { key: idx, color: l.type === 'added' ? 'green' : l.type === 'removed' ? 'red' : l.type === 'header' ? 'cyan' : undefined, @@ -262,7 +415,16 @@ export default function DiffView({worktreePath, title = 'Diff Viewer', onClose, bold: isCurrentLine }, displayText); }), - h(Text, {color: 'gray'}, 'j/k move b/f PgUp/PgDn g/G top/bottom ←/→ chunk Shift+←/→ file q close') + showAllComments && commentStore.count > 0 ? h( + Box, + {flexDirection: 'column', borderStyle: 'single', borderColor: 'blue', padding: 1, marginTop: 1}, + h(Text, {bold: true, color: 'blue'}, `All Comments (${commentStore.count}):`), + ...commentStore.getAllComments().map((comment, idx) => + h(Text, {key: idx, color: 'gray'}, `${comment.fileName}:${comment.lineIndex} - ${comment.commentText}`) + ) + ) : null, + h(Text, {color: 'gray'}, 'j/k move c comment C show all d delete S send to Claude q close'), + statusMessage ? h(Text, {color: statusMessage.startsWith('✓') ? 'green' : statusMessage.startsWith('✗') ? 'red' : 'yellow', bold: true}, statusMessage) : null ); } diff --git a/src/models.ts b/src/models.ts index 8cfd26e0..298953f2 100644 --- a/src/models.ts +++ b/src/models.ts @@ -124,6 +124,71 @@ export class ProjectInfo { } } +export class DiffComment { + lineIndex: number; + fileName: string; + lineText: string; + commentText: string; + timestamp: number; + constructor(init: Partial = {}) { + this.lineIndex = 0; + this.fileName = ''; + this.lineText = ''; + this.commentText = ''; + this.timestamp = Date.now(); + Object.assign(this, init); + } +} + +export class CommentStore { + comments: DiffComment[]; + constructor() { + this.comments = []; + } + + addComment(lineIndex: number, fileName: string, lineText: string, commentText: string): DiffComment { + // Remove existing comment for this line if any + this.comments = this.comments.filter(c => c.lineIndex !== lineIndex || c.fileName !== fileName); + + const comment = new DiffComment({ + lineIndex, + fileName, + lineText, + commentText, + timestamp: Date.now() + }); + + this.comments.push(comment); + return comment; + } + + removeComment(lineIndex: number, fileName: string): boolean { + const initialLength = this.comments.length; + this.comments = this.comments.filter(c => !(c.lineIndex === lineIndex && c.fileName === fileName)); + return this.comments.length < initialLength; + } + + getComment(lineIndex: number, fileName: string): DiffComment | undefined { + return this.comments.find(c => c.lineIndex === lineIndex && c.fileName === fileName); + } + + hasComment(lineIndex: number, fileName: string): boolean { + return this.comments.some(c => c.lineIndex === lineIndex && c.fileName === fileName); + } + + getAllComments(): DiffComment[] { + return [...this.comments].sort((a, b) => a.lineIndex - b.lineIndex); + } + + clear(): void { + this.comments = []; + } + + get count(): number { + return this.comments.length; + } +} + export class AppState { worktrees: WorktreeInfo[]; selectedIndex: number; diff --git a/src/services/CommentStoreManager.ts b/src/services/CommentStoreManager.ts new file mode 100644 index 00000000..fca5b353 --- /dev/null +++ b/src/services/CommentStoreManager.ts @@ -0,0 +1,34 @@ +import {CommentStore} from '../models.js'; + +export class CommentStoreManager { + private stores: Map = new Map(); + + getStore(worktreePath: string): CommentStore { + if (!this.stores.has(worktreePath)) { + this.stores.set(worktreePath, new CommentStore()); + } + return this.stores.get(worktreePath)!; + } + + clearStore(worktreePath: string): void { + const store = this.stores.get(worktreePath); + if (store) { + store.clear(); + } + } + + removeStore(worktreePath: string): void { + this.stores.delete(worktreePath); + } + + getTotalComments(): number { + let total = 0; + for (const store of this.stores.values()) { + total += store.count; + } + return total; + } +} + +// Singleton instance +export const commentStoreManager = new CommentStoreManager(); \ No newline at end of file