Skip to content

Commit 396d2c4

Browse files
promptium-aiclaude
andauthored
Add comment functionality to diff view with Claude integration (#19)
* Add comment functionality with tmux session integration - Fixed comment dialog width to be consistent (70 chars) - Implemented smart tmux session management for sending comments - Added persistent comment storage across app lifetime - Comments now sent directly to Claude in tmux sessions - Auto-creates and attaches to sessions as needed - Waits for Claude to be ready before sending comments * Fix comment sending to use Alt+Enter to prevent auto-submission - Changed from sending plain Enter (C-m) after each line - Now sends Escape Enter (Alt+Enter) to insert newlines without submitting - Preserves formatting while allowing user to review before manual submission 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8ec14cc commit 396d2c4

4 files changed

Lines changed: 391 additions & 13 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import React, {useState} from 'react';
2+
import {Box, Text, useInput} from 'ink';
3+
const h = React.createElement;
4+
5+
type Props = {
6+
fileName: string;
7+
lineText: string;
8+
initialComment?: string;
9+
onSave: (comment: string) => void;
10+
onCancel: () => void;
11+
};
12+
13+
export default function CommentInputDialog({fileName, lineText, initialComment = '', onSave, onCancel}: Props) {
14+
const [comment, setComment] = useState(initialComment);
15+
const [cursorPosition, setCursorPosition] = useState(initialComment.length);
16+
17+
useInput((input, key) => {
18+
if (key.escape) {
19+
onCancel();
20+
return;
21+
}
22+
23+
if (key.return && !key.shift) {
24+
if (comment.trim()) {
25+
onSave(comment.trim());
26+
} else {
27+
onCancel();
28+
}
29+
return;
30+
}
31+
32+
if (key.return && key.shift) {
33+
const newComment = comment.slice(0, cursorPosition) + '\n' + comment.slice(cursorPosition);
34+
setComment(newComment);
35+
setCursorPosition(cursorPosition + 1);
36+
return;
37+
}
38+
39+
if (key.backspace || key.delete) {
40+
if (comment.length > 0 && cursorPosition > 0) {
41+
const newComment = comment.slice(0, cursorPosition - 1) + comment.slice(cursorPosition);
42+
setComment(newComment);
43+
setCursorPosition(Math.max(0, cursorPosition - 1));
44+
}
45+
return;
46+
}
47+
48+
if (key.leftArrow) {
49+
setCursorPosition(Math.max(0, cursorPosition - 1));
50+
return;
51+
}
52+
53+
if (key.rightArrow) {
54+
setCursorPosition(Math.min(comment.length, cursorPosition + 1));
55+
return;
56+
}
57+
58+
if (key.upArrow || key.downArrow) {
59+
return;
60+
}
61+
62+
if (input && !key.ctrl && !key.meta) {
63+
const newComment = comment.slice(0, cursorPosition) + input + comment.slice(cursorPosition);
64+
setComment(newComment);
65+
setCursorPosition(cursorPosition + input.length);
66+
}
67+
});
68+
69+
const displayComment = comment || '';
70+
const beforeCursor = displayComment.slice(0, cursorPosition);
71+
const atCursor = displayComment.slice(cursorPosition, cursorPosition + 1) || ' ';
72+
const afterCursor = displayComment.slice(cursorPosition + 1);
73+
74+
const lines = displayComment.split('\n');
75+
const boxWidth = 70; // Fixed width for consistent appearance
76+
77+
return h(
78+
Box,
79+
{
80+
flexDirection: 'column',
81+
borderStyle: 'round',
82+
borderColor: 'blue',
83+
padding: 1,
84+
width: boxWidth
85+
},
86+
h(Text, {bold: true, color: 'blue'}, 'Add Comment'),
87+
h(Text, {color: 'gray'}, `File: ${fileName}`),
88+
h(Text, {color: 'gray'}, `Line: ${lineText.slice(0, 60)}${lineText.length > 60 ? '...' : ''}`),
89+
h(
90+
Box,
91+
{
92+
flexDirection: 'column',
93+
borderStyle: 'single',
94+
borderColor: 'gray',
95+
padding: 1,
96+
minHeight: 3
97+
},
98+
...lines.map((line, index) => {
99+
if (index === 0 && lines.length === 1) {
100+
return h(
101+
Text,
102+
{key: index},
103+
h(Text, {}, beforeCursor),
104+
h(Text, {inverse: true}, atCursor),
105+
h(Text, {}, afterCursor)
106+
);
107+
}
108+
return h(Text, {key: index}, line || ' ');
109+
})
110+
),
111+
h(
112+
Text,
113+
{color: 'gray'},
114+
'Enter: Save Shift+Enter: New Line Esc: Cancel'
115+
)
116+
);
117+
}

src/components/views/DiffView.ts

Lines changed: 175 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import React, {useEffect, useMemo, useState} from 'react';
2-
import {Box, Text, useInput, useStdin, Static} from 'ink';
2+
import {Box, Text, useInput, useStdin} from 'ink';
33
const h = React.createElement;
44
import {runCommandAsync} from '../../utils.js';
55
import {findBaseBranch} from '../../utils.js';
66
import {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

1015
async 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(/-branches$/, '');
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

Comments
 (0)