From 71eb7ea7710f45b30cc3fd2f453c3d3f00ecbf03 Mon Sep 17 00:00:00 2001 From: Promptium AI Date: Wed, 27 Aug 2025 15:46:58 +0000 Subject: [PATCH 1/2] 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 --- src/components/dialogs/CommentInputDialog.ts | 117 +++++++++++ src/components/views/DiffView.ts | 196 +++++++++++++++++-- src/models.ts | 65 ++++++ src/services/CommentStoreManager.ts | 34 ++++ 4 files changed, 399 insertions(+), 13 deletions(-) create mode 100644 src/components/dialogs/CommentInputDialog.ts create mode 100644 src/services/CommentStoreManager.ts 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 28857f94..f105c68d 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 {} } } @@ -68,6 +75,10 @@ export default function DiffView({worktreePath, title = 'Diff Viewer', onClose, const [offset, setOffset] = useState(0); 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); useEffect(() => { (async () => { @@ -89,6 +100,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)); @@ -97,6 +112,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--) { @@ -148,6 +188,109 @@ export default function DiffView({worktreePath, title = 'Diff Viewer', onClose, else if (pos >= offset + pageSize) setOffset(pos - pageSize + 1); }, [pos, offset, pageSize]); + const waitForClaudeReady = async (sessionName: string, maxWait: number = 10000): Promise => { + const startTime = Date.now(); + while (Date.now() - startTime < maxWait) { + const status = tmuxService.getClaudeStatus(sessionName); + if (status === 'idle' || status === 'waiting') { + return true; + } + await new Promise(resolve => setTimeout(resolve, 500)); + } + return false; + }; + + const formatCommentsMessage = (comments: ReturnType): string => { + let message = "Here are my comments on the diff for automatic fixes:\\n\\n"; + + 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]) => { + message += `File: ${fileName}\\n`; + fileComments.forEach(comment => { + message += ` Line: ${comment.lineText.trim() || '(empty line)'}\\n`; + message += ` Comment: ${comment.commentText}\\n\\n`; + }); + message += "\\n"; + }); + + message += "Please review these comments and implement the suggested fixes."; + return message; + }; + + const sendCommentsToTmux = async () => { + const comments = commentStore.getAllComments(); + if (comments.length === 0) return; + + try { + // Extract project and feature from worktreePath + const pathSegments = worktreePath.split('/'); + const sessionName = `dev-${pathSegments.slice(-2).join('-')}`; + + // 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']); + // Wait for Claude to start + await new Promise(resolve => setTimeout(resolve, 3000)); + } + } + + // Wait for Claude to be ready + const ready = await waitForClaudeReady(sessionName, 10000); + + if (ready) { + const message = formatCommentsMessage(comments); + // Send comments to tmux session + runCommand(['tmux', 'send-keys', '-t', `${sessionName}:0.0`, message, 'C-m']); + + // Switch to that session to show user what happened + try { + runCommand(['tmux', 'switch-client', '-t', sessionName]); + } catch { + // If switch-client fails (no active client), try to attach interactively + // This will exit the current app, but that's expected behavior + runCommand(['tmux', 'attach-session', '-t', sessionName]); + } + + // Clear comments after successful send + commentStore.clear(); + } else { + // If Claude isn't ready, still send the message but don't clear comments + const message = formatCommentsMessage(comments); + runCommand(['tmux', 'send-keys', '-t', `${sessionName}:0.0`, message, 'C-m']); + } + } catch (error) { + // If anything fails, silently continue + 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; @@ -156,15 +299,34 @@ export default function DiffView({worktreePath, title = 'Diff Viewer', onClose, const visible = useMemo(() => 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, @@ -172,7 +334,15 @@ 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') ); } 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 From 9f9420afa5391b24f6a4557bf6c2ca924a343242 Mon Sep 17 00:00:00 2001 From: Promptium AI Date: Wed, 27 Aug 2025 18:17:48 +0000 Subject: [PATCH 2/2] Fix comment sending to use Alt+Enter to prevent auto-submission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/views/DiffView.ts | 122 +++++++++++++++---------------- 1 file changed, 57 insertions(+), 65 deletions(-) diff --git a/src/components/views/DiffView.ts b/src/components/views/DiffView.ts index aa27b808..13ac58c6 100644 --- a/src/components/views/DiffView.ts +++ b/src/components/views/DiffView.ts @@ -81,6 +81,7 @@ export default function DiffView({worktreePath, title = 'Diff Viewer', onClose, const [tmuxService] = useState(() => new TmuxService()); const [showCommentDialog, setShowCommentDialog] = useState(false); const [showAllComments, setShowAllComments] = useState(false); + const [statusMessage, setStatusMessage] = useState(''); useEffect(() => { (async () => { @@ -276,50 +277,26 @@ export default function DiffView({worktreePath, title = 'Diff Viewer', onClose, } }, [pos, targetOffset, pageSize, lines.length]); - const waitForClaudeReady = async (sessionName: string, maxWait: number = 10000): Promise => { - const startTime = Date.now(); - while (Date.now() - startTime < maxWait) { - const status = tmuxService.getClaudeStatus(sessionName); - if (status === 'idle' || status === 'waiting') { - return true; - } - await new Promise(resolve => setTimeout(resolve, 500)); + const sendCommentsToTmux = () => { + const comments = commentStore.getAllComments(); + if (comments.length === 0) { + setStatusMessage('No comments to send'); + setTimeout(() => setStatusMessage(''), 2000); + return; } - return false; - }; - const formatCommentsMessage = (comments: ReturnType): string => { - let message = "Here are my comments on the diff for automatic fixes:\\n\\n"; - - 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]) => { - message += `File: ${fileName}\\n`; - fileComments.forEach(comment => { - message += ` Line: ${comment.lineText.trim() || '(empty line)'}\\n`; - message += ` Comment: ${comment.commentText}\\n\\n`; - }); - message += "\\n"; - }); - - message += "Please review these comments and implement the suggested fixes."; - return message; - }; - - const sendCommentsToTmux = async () => { - const comments = commentStore.getAllComments(); - if (comments.length === 0) return; + setStatusMessage(`Sending ${comments.length} comment${comments.length > 1 ? 's' : ''} to Claude...`); try { - // Extract project and feature from worktreePath - const pathSegments = worktreePath.split('/'); - const sessionName = `dev-${pathSegments.slice(-2).join('-')}`; + // 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); @@ -332,37 +309,51 @@ export default function DiffView({worktreePath, title = 'Diff Viewer', onClose, const hasClaude = runCommand(['bash', '-lc', 'command -v claude || true']).trim(); if (hasClaude) { runCommand(['tmux', 'send-keys', '-t', `${sessionName}:0.0`, 'claude', 'C-m']); - // Wait for Claude to start - await new Promise(resolve => setTimeout(resolve, 3000)); } } + + // 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); + }); - // Wait for Claude to be ready - const ready = await waitForClaudeReady(sessionName, 10000); + Object.entries(commentsByFile).forEach(([fileName, fileComments]) => { + messageLines.push(`File: ${fileName}`); + fileComments.forEach(comment => { + messageLines.push(` Line ${comment.lineIndex + 1}: ${comment.commentText}`); + }); + messageLines.push(""); + }); - if (ready) { - const message = formatCommentsMessage(comments); - // Send comments to tmux session - runCommand(['tmux', 'send-keys', '-t', `${sessionName}:0.0`, message, 'C-m']); + // 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]); - // Switch to that session to show user what happened - try { - runCommand(['tmux', 'switch-client', '-t', sessionName]); - } catch { - // If switch-client fails (no active client), try to attach interactively - // This will exit the current app, but that's expected behavior - runCommand(['tmux', 'attach-session', '-t', sessionName]); + // 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 successful send - commentStore.clear(); - } else { - // If Claude isn't ready, still send the message but don't clear comments - const message = formatCommentsMessage(comments); - runCommand(['tmux', 'send-keys', '-t', `${sessionName}:0.0`, message, 'C-m']); - } + }); + + // Clear comments after sending + commentStore.clear(); + + setStatusMessage(`✓ Sent ${comments.length} comment${comments.length > 1 ? 's' : ''} to session: ${sessionName}`); + setTimeout(() => setStatusMessage(''), 3000); + } catch (error) { - // If anything fails, silently continue + setStatusMessage('✗ Failed to send comments'); + setTimeout(() => setStatusMessage(''), 3000); console.error('Failed to send comments to tmux:', error); } }; @@ -432,7 +423,8 @@ export default function DiffView({worktreePath, title = 'Diff Viewer', onClose, 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') + 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 ); }