From 3246c89cb1793b9a4fb4de10b65269934b7a1f87 Mon Sep 17 00:00:00 2001 From: Hlib Koval Date: Thu, 29 Jan 2026 15:08:34 +0200 Subject: [PATCH 1/2] feat: add process group cleanup and drag-and-drop file support - PTY wrapper now kills entire process tree on terminal close using os.setpgrp() and os.killpg() for proper cleanup - Add drag-and-drop support for files onto terminal: - Obsidian internal files: MCP sends absolute path, stdin fallback uses vault-relative path - External files: uses absolute path for both MCP and stdin - Plain text: writes directly to stdin - Resolve Obsidian URLs through metadataCache to restore .md extension - Focus terminal automatically after drop Co-Authored-By: Claude Opus 4.5 --- scripts/terminal_pty.py | 57 ++++++++++++++++++++++- src/main.ts | 18 ++++++++ src/view/terminal-view.ts | 96 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+), 2 deletions(-) diff --git a/scripts/terminal_pty.py b/scripts/terminal_pty.py index 550ecec..bf12c36 100644 --- a/scripts/terminal_pty.py +++ b/scripts/terminal_pty.py @@ -8,6 +8,37 @@ import termios import select import signal +import time + +# Global to track child PID (also the process group ID) for signal handler +child_pid = None + +def kill_process_group(pgid, sig): + """Kill an entire process group.""" + try: + os.killpg(pgid, sig) + except (ProcessLookupError, PermissionError, OSError): + pass + +def cleanup_child(signum, frame): + """Kill the entire process group when we receive a signal.""" + global child_pid + if child_pid: + # Kill entire process group (child is group leader) + kill_process_group(child_pid, signal.SIGTERM) + # Give processes a moment to exit gracefully + for _ in range(10): + try: + pid, _ = os.waitpid(-child_pid, os.WNOHANG) + if pid != 0: + break + except ChildProcessError: + break + time.sleep(0.1) + else: + # Force kill the entire group if still running + kill_process_group(child_pid, signal.SIGKILL) + sys.exit(0) def set_size(fd, cols, rows): """Set the PTY window size.""" @@ -15,6 +46,8 @@ def set_size(fd, cols, rows): fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize) def main(): + global child_pid + # Parse args: terminal_pty.py [cols] [rows] [shell] [shell_args...] if len(sys.argv) < 4: print(f"Usage: {sys.argv[0]} cols rows shell [args...]", file=sys.stderr) @@ -25,10 +58,17 @@ def main(): shell = sys.argv[3] shell_args = sys.argv[3:] # Include shell as argv[0] + # Register signal handlers for cleanup + signal.signal(signal.SIGTERM, cleanup_child) + signal.signal(signal.SIGINT, cleanup_child) + pid, fd = pty.fork() + child_pid = pid # Store for signal handler if pid == 0: - # Child process - exec the shell + # Child process - create new process group so we can kill entire tree + os.setpgrp() + # exec the shell os.execvp(shell, shell_args) sys.exit(1) @@ -97,6 +137,19 @@ def main(): break finally: fcntl.fcntl(stdin_fd, fcntl.F_SETFL, old_flags) + # Ensure entire process group is terminated when we exit + if child_pid: + kill_process_group(child_pid, signal.SIGTERM) + for _ in range(10): + try: + wpid, _ = os.waitpid(-child_pid, os.WNOHANG) + if wpid != 0: + break + except ChildProcessError: + break + time.sleep(0.1) + else: + kill_process_group(child_pid, signal.SIGKILL) if __name__ == '__main__': - main() + main() \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 0073f7d..3d4837b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -225,6 +225,24 @@ export default class VaultCodePlugin extends Plugin implements IVaultContext { this.viewManager.focusTerminal(); } + /** + * Send a file path to Claude Code via MCP @-mention notification. + * Used by drag-and-drop handler for MCP integration. + * @param absolutePath - Absolute file path to send + * @returns true if MCP is enabled and notification was sent, false otherwise + */ + sendPathToClaudeCode(absolutePath: string): boolean { + if (!this.mcpIntegration) { + return false; + } + + this.mcpIntegration.sendNotification( + createAtMentionedNotification(this.formatFilePath(absolutePath), null, null) + ); + + return true; + } + async loadSettings(): Promise { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial); } diff --git a/src/view/terminal-view.ts b/src/view/terminal-view.ts index 165877f..d6259d5 100644 --- a/src/view/terminal-view.ts +++ b/src/view/terminal-view.ts @@ -18,6 +18,8 @@ export class TerminalView extends ItemView { private escapeScope: Scope | null = null; private fitTimeout: ReturnType | null = null; private plugin: VaultCodePlugin; + private dragOverHandler: ((e: DragEvent) => void) | null = null; + private dropHandler: ((e: DragEvent) => void) | null = null; constructor(leaf: WorkspaceLeaf, plugin: VaultCodePlugin) { super(leaf); @@ -138,6 +140,89 @@ export class TerminalView extends ItemView { // Watch for Obsidian layout changes (sidebar resize, etc.) this.registerEvent(this.app.workspace.on("layout-change", () => this.debouncedFit())); + + // Handle file drag-and-drop + this.setupDragAndDrop(); + } + + private setupDragAndDrop(): void { + if (!this.termHost) return; + + this.dragOverHandler = (e: DragEvent) => { + e.preventDefault(); + if (e.dataTransfer) { + e.dataTransfer.dropEffect = "copy"; + } + }; + + this.dropHandler = (e: DragEvent) => { + e.preventDefault(); + if (!this.termProcess) return; + + // Check for Obsidian internal file drag (obsidian:// URL) + const textData = e.dataTransfer?.getData("text/plain"); + if (textData && textData.startsWith("obsidian://")) { + try { + const url = new URL(textData); + const filePath = url.searchParams.get("file"); + if (filePath) { + const linkPath = decodeURIComponent(filePath); + // Obsidian URLs omit .md extension, resolve through metadataCache + const file = this.app.metadataCache.getFirstLinkpathDest(linkPath, ""); + const relativePath = file?.path ?? linkPath; + // FileSystemAdapter has getFullPath but it's not in the public API + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + const absolutePath: string = (this.app.vault.adapter as any).getFullPath(relativePath); + // MCP uses absolute path (CC resolves it), stdin fallback uses relative + if (!this.plugin.sendPathToClaudeCode(absolutePath)) { + this.writeAtMention(relativePath); + } + this.term?.focus(); + return; + } + } catch { + // Failed to parse URL, try external file handling + } + } + + // Handle external file drops + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment + const { webUtils } = require("electron"); + for (let i = 0; i < files.length; i++) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + const filePath: string = webUtils.getPathForFile(files[i]); + if (filePath) { + // Try MCP integration first, fallback to @-mention stdin + if (!this.plugin.sendPathToClaudeCode(filePath)) { + this.writeAtMention(filePath); + } + } + } + this.term?.focus(); + return; + } + + // Handle plain text drops (no files, not obsidian://) + if (textData && !textData.startsWith("obsidian://")) { + this.termProcess.write(textData); + this.term?.focus(); + } + }; + + this.termHost.addEventListener("dragover", this.dragOverHandler); + this.termHost.addEventListener("drop", this.dropHandler); + } + + /** + * Write @-mention syntax to terminal stdin. + * Used as fallback when MCP integration is not available. + */ + private writeAtMention(filePath: string): void { + // Quote path if it contains spaces (Claude Code @-mention syntax) + const formattedPath = filePath.includes(" ") ? `@'${filePath}' ` : `@${filePath} `; + this.termProcess?.write(formattedPath); } private updateTheme(): void { @@ -250,6 +335,17 @@ export class TerminalView extends ItemView { this.escapeScope = null; } + if (this.termHost) { + if (this.dragOverHandler) { + this.termHost.removeEventListener("dragover", this.dragOverHandler); + this.dragOverHandler = null; + } + if (this.dropHandler) { + this.termHost.removeEventListener("drop", this.dropHandler); + this.dropHandler = null; + } + } + this.termProcess?.stop(); this.termProcess = null; From 53f6a09b4c5a7d81087b1adceb400105706bff44 Mon Sep 17 00:00:00 2001 From: Hlib Koval Date: Thu, 29 Jan 2026 15:18:20 +0200 Subject: [PATCH 2/2] fix: use vault-relative paths for drag-and-drop MCP integration Match context menu behavior by using relative paths for internal vault files and folders. External files from Finder still use absolute paths. Also adds folder drag-and-drop support and auto-focus after drop. Co-Authored-By: Claude Opus 4.5 --- src/view/terminal-view.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/view/terminal-view.ts b/src/view/terminal-view.ts index d6259d5..6a88b7c 100644 --- a/src/view/terminal-view.ts +++ b/src/view/terminal-view.ts @@ -170,11 +170,8 @@ export class TerminalView extends ItemView { // Obsidian URLs omit .md extension, resolve through metadataCache const file = this.app.metadataCache.getFirstLinkpathDest(linkPath, ""); const relativePath = file?.path ?? linkPath; - // FileSystemAdapter has getFullPath but it's not in the public API - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - const absolutePath: string = (this.app.vault.adapter as any).getFullPath(relativePath); - // MCP uses absolute path (CC resolves it), stdin fallback uses relative - if (!this.plugin.sendPathToClaudeCode(absolutePath)) { + // Use relative path for MCP (matches context menu behavior) + if (!this.plugin.sendPathToClaudeCode(relativePath)) { this.writeAtMention(relativePath); } this.term?.focus(); @@ -194,7 +191,7 @@ export class TerminalView extends ItemView { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access const filePath: string = webUtils.getPathForFile(files[i]); if (filePath) { - // Try MCP integration first, fallback to @-mention stdin + // External files use absolute path if (!this.plugin.sendPathToClaudeCode(filePath)) { this.writeAtMention(filePath); } @@ -205,8 +202,18 @@ export class TerminalView extends ItemView { } // Handle plain text drops (no files, not obsidian://) + // Check if it's an Obsidian folder path (folders don't use obsidian:// URLs) if (textData && !textData.startsWith("obsidian://")) { - this.termProcess.write(textData); + const vaultItem = this.app.vault.getAbstractFileByPath(textData); + if (vaultItem) { + // Use relative path for MCP (matches context menu behavior) + if (!this.plugin.sendPathToClaudeCode(vaultItem.path)) { + this.writeAtMention(vaultItem.path); + } + } else { + // Plain text, not a vault path + this.termProcess.write(textData); + } this.term?.focus(); } };