-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
fix(terminal): use Electron clipboard IPC for Ctrl+V paste on Windows #2008
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -208,6 +208,56 @@ Write a professional PR description. Output ONLY the Markdown body — no preamb | |
| } | ||
| } | ||
|
|
||
| // ============================================================================= | ||
| // Auto-Commit Uncommitted Changes | ||
| // ============================================================================= | ||
|
|
||
| /** | ||
| * Stage and commit any uncommitted changes in the worktree. | ||
| * Called before pushing to ensure the branch has commits to push. | ||
| * Returns an error string on failure, or undefined on success (or no changes). | ||
| */ | ||
| function autoCommitWorktreeChanges( | ||
| worktreePath: string, | ||
| gitPath: string, | ||
| specId: string, | ||
| ): string | undefined { | ||
| try { | ||
| // Check for uncommitted changes (staged or unstaged, tracked or untracked) | ||
| const status = execFileSync( | ||
| gitPath, | ||
| ['status', '--porcelain'], | ||
| { cwd: worktreePath, encoding: 'utf-8', stdio: 'pipe' }, | ||
| ).trim(); | ||
|
|
||
| if (!status) { | ||
| // No uncommitted changes — nothing to do | ||
| return undefined; | ||
| } | ||
|
|
||
| // Stage all changes | ||
| execFileSync( | ||
| gitPath, | ||
| ['add', '.'], | ||
| { cwd: worktreePath, encoding: 'utf-8', stdio: 'pipe' }, | ||
| ); | ||
|
|
||
| // Commit with a descriptive message | ||
| execFileSync( | ||
| gitPath, | ||
| ['commit', '-m', `auto-claude: implement ${specId}`], | ||
| { cwd: worktreePath, encoding: 'utf-8', stdio: 'pipe' }, | ||
| ); | ||
|
|
||
| return undefined; | ||
| } catch (err: unknown) { | ||
| const stderr = err instanceof Error && 'stderr' in err | ||
| ? String((err as NodeJS.ErrnoException & { stderr?: string }).stderr) | ||
| : String(err); | ||
| return stderr || 'Auto-commit failed'; | ||
| } | ||
| } | ||
|
|
||
| // ============================================================================= | ||
| // Push Branch | ||
| // ============================================================================= | ||
|
|
@@ -296,7 +346,17 @@ export async function createPR(config: CreatePRConfig): Promise<CreatePRResult> | |
| thinkingLevel = 'low', | ||
| } = config; | ||
|
|
||
| // Step 1: Push the branch to origin | ||
| // Strip remote prefix from base branch once — used in all steps below | ||
| const effectiveBase = baseBranch.startsWith('origin/') | ||
| ? baseBranch.slice('origin/'.length) | ||
| : baseBranch; | ||
|
|
||
| // Step 1a: Auto-commit any uncommitted changes in the worktree before pushing. | ||
| // This handles the case where the agent wrote files but didn't run `git commit`. | ||
| // A failure here is non-fatal — we still attempt the push in case prior commits exist. | ||
| autoCommitWorktreeChanges(worktreePath, gitPath, specId); | ||
|
|
||
| // Step 1b: Push the branch to origin | ||
| const pushError = pushBranch(worktreePath, gitPath, branchName); | ||
| if (pushError) { | ||
| // If it looks like the branch is already up-to-date, don't bail | ||
|
|
@@ -307,6 +367,26 @@ export async function createPR(config: CreatePRConfig): Promise<CreatePRResult> | |
| } | ||
| } | ||
|
|
||
| // Step 1c: Verify there is at least one commit ahead of the base branch. | ||
| // Without this check, `gh pr create` fails with a confusing GraphQL error. | ||
| try { | ||
| const commitCount = execFileSync( | ||
| gitPath, | ||
| ['rev-list', '--count', `origin/${effectiveBase}..HEAD`], | ||
| { cwd: worktreePath, encoding: 'utf-8', stdio: 'pipe' }, | ||
| ).trim(); | ||
| if (commitCount === '0') { | ||
| return { | ||
| success: false, | ||
| error: `No commits found between '${effectiveBase}' and '${branchName}'. ` + | ||
| 'The agent may not have committed any changes. ' + | ||
| 'Please verify the implementation completed successfully before creating a PR.', | ||
| }; | ||
| } | ||
| } catch { | ||
| // Unable to count commits — proceed and let `gh pr create` surface the error | ||
| } | ||
|
Comment on lines
+372
to
+388
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Consider using a fallback pattern similar to the one used in try {
let commitCount: string;
try {
commitCount = execFileSync(
gitPath,
['rev-list', '--count', `origin/${effectiveBase}..HEAD`],
{ cwd: worktreePath, encoding: 'utf-8', stdio: 'pipe' },
).trim();
} catch {
// Fallback to using the base branch directly if origin/ prefix fails
commitCount = execFileSync(
gitPath,
['rev-list', '--count', `${baseBranch}..HEAD`],
{ cwd: worktreePath, encoding: 'utf-8', stdio: 'pipe' },
).trim();
}
if (commitCount === '0') {
return {
success: false,
error: `No commits found between '${effectiveBase}' and '${branchName}'. ` +
'The agent may not have committed any changes. ' +
'Please verify the implementation completed successfully before creating a PR.',
};
}
} catch {
// Unable to count commits — proceed and let `gh pr create` surface the error
} |
||
|
|
||
| // Step 2: Gather context for AI description | ||
| const { diffSummary, commitLog } = gatherPRContext(worktreePath, gitPath, baseBranch); | ||
|
|
||
|
|
@@ -324,12 +404,7 @@ export async function createPR(config: CreatePRConfig): Promise<CreatePRResult> | |
|
|
||
| const prBody = aiBody || extractSpecSummary(projectDir, specId); | ||
|
|
||
| // Step 4: Strip remote prefix from base branch if present | ||
| const effectiveBase = baseBranch.startsWith('origin/') | ||
| ? baseBranch.slice('origin/'.length) | ||
| : baseBranch; | ||
|
|
||
| // Step 5: Build gh pr create command | ||
| // Step 4: Build gh pr create command | ||
| const ghArgs = [ | ||
| 'pr', 'create', | ||
| '--base', effectiveBase, | ||
|
|
@@ -342,7 +417,7 @@ export async function createPR(config: CreatePRConfig): Promise<CreatePRResult> | |
| ghArgs.push('--draft'); | ||
| } | ||
|
|
||
| // Step 6: Execute gh pr create with retry on network errors | ||
| // Step 5: Execute gh pr create with retry on network errors | ||
| for (let attempt = 0; attempt < 3; attempt++) { | ||
| try { | ||
| const output = execFileSync(ghPath, ghArgs, { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -169,7 +169,10 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea | |
| // Cap paste size to prevent GPU/memory pressure from extremely large clipboard contents. | ||
| const MAX_PASTE_BYTES = 1_048_576; // 1 MB | ||
| const handlePasteFromClipboard = (): void => { | ||
| navigator.clipboard.readText() | ||
| // Use Electron's main-process clipboard API via IPC, which is reliable on all | ||
| // platforms including Windows (where navigator.clipboard.readText() may silently | ||
| // fail due to renderer permission restrictions, causing paste to appear and vanish). | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: A synchronous Suggested FixUse optional chaining ( Prompt for AI AgentDid we get this right? 👍 / 👎 to inform future reviews. |
||
| window.electronAPI.readClipboardText() | ||
| .then((text) => { | ||
|
Comment on lines
+172
to
176
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard This call can throw synchronously when preload API is unavailable, which bypasses the 💡 Proposed fix const MAX_PASTE_BYTES = 1_048_576; // 1 MB
const handlePasteFromClipboard = (): void => {
+ const pasteText = (text: string) => {
+ if (!text) return;
+ if (text.length > MAX_PASTE_BYTES) {
+ console.warn(`[useXterm] Paste truncated from ${text.length} to ${MAX_PASTE_BYTES} bytes`);
+ xterm.paste(text.slice(0, MAX_PASTE_BYTES));
+ } else {
+ xterm.paste(text);
+ }
+ };
+
+ const readFromNavigator = () =>
+ navigator.clipboard.readText()
+ .then(pasteText)
+ .catch((err) => {
+ console.error('[useXterm] Failed to read clipboard:', err);
+ });
+
// Use Electron's main-process clipboard API via IPC, which is reliable on all
// platforms including Windows (where navigator.clipboard.readText() may silently
// fail due to renderer permission restrictions, causing paste to appear and vanish).
- window.electronAPI.readClipboardText()
- .then((text) => {
- if (text) {
- if (text.length > MAX_PASTE_BYTES) {
- console.warn(`[useXterm] Paste truncated from ${text.length} to ${MAX_PASTE_BYTES} bytes`);
- xterm.paste(text.slice(0, MAX_PASTE_BYTES));
- } else {
- xterm.paste(text);
- }
- }
- })
- .catch(() => {
- // Fall back to navigator.clipboard if IPC unavailable
- navigator.clipboard.readText()
- .then((text) => {
- if (text) {
- xterm.paste(text.length > MAX_PASTE_BYTES ? text.slice(0, MAX_PASTE_BYTES) : text);
- }
- })
- .catch((err) => {
- console.error('[useXterm] Failed to read clipboard:', err);
- });
- });
+ if (window.electronAPI?.readClipboardText) {
+ window.electronAPI.readClipboardText()
+ .then(pasteText)
+ .catch(() => {
+ // Fall back to navigator.clipboard if IPC is unavailable or fails
+ readFromNavigator();
+ });
+ } else {
+ readFromNavigator();
+ }
};Also applies to: 186-196 🤖 Prompt for AI Agents |
||
| if (text) { | ||
| if (text.length > MAX_PASTE_BYTES) { | ||
|
|
@@ -180,8 +183,17 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea | |
| } | ||
| } | ||
| }) | ||
| .catch((err) => { | ||
| console.error('[useXterm] Failed to read clipboard:', err); | ||
| .catch(() => { | ||
| // Fall back to navigator.clipboard if IPC unavailable | ||
| navigator.clipboard.readText() | ||
| .then((text) => { | ||
| if (text) { | ||
| xterm.paste(text.length > MAX_PASTE_BYTES ? text.slice(0, MAX_PASTE_BYTES) : text); | ||
| } | ||
| }) | ||
| .catch((err) => { | ||
| console.error('[useXterm] Failed to read clipboard:', err); | ||
| }); | ||
| }); | ||
| }; | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Handle auto-commit errors instead of discarding them.
autoCommitWorktreeChanges(...)can fail, but its return value is ignored. That can silently create a PR from stale commits while newly generated worktree changes remain uncommitted.Proposed fix
🤖 Prompt for AI Agents