From 3303d9a7fa5fb09cc2a9fda033193520b087b9d4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 8 Feb 2026 20:49:16 -0800 Subject: [PATCH 1/4] Add open-in-editor feature with Cmd+O shortcut Add dropdown in header to open workspace in Cursor or system file manager (Finder/Explorer/Files). Include Cmd+O/Ctrl+O global keyboard shortcut to open in last-used editor, persisted to localStorage. Co-Authored-By: Claude Haiku 4.5 --- apps/desktop/src/main.ts | 30 +++++++- apps/desktop/src/preload.ts | 4 + apps/renderer/src/components/ChatView.tsx | 89 +++++++++++++++++++++++ packages/contracts/src/ipc.ts | 4 + 4 files changed, 126 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4f84aa55b7..657705fd01 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -3,7 +3,14 @@ fixPath(); import { spawn } from "node:child_process"; import path from "node:path"; -import { BrowserWindow, app, dialog, ipcMain, session } from "electron"; +import { + BrowserWindow, + app, + dialog, + ipcMain, + session, + shell, +} from "electron"; import { IPC_CHANNELS, @@ -125,6 +132,27 @@ function registerIpcHandlers(): void { }), ); + // Shell handlers + ipcMain.handle( + IPC_CHANNELS.shellOpenInEditor, + async (_event, cwd: string, editor: string) => { + if (editor === "file-manager") { + await shell.openPath(cwd); + return; + } + const EDITOR_COMMANDS: Record string[] }> = { + cursor: { command: "cursor", args: (p) => [p] }, + }; + const entry = EDITOR_COMMANDS[editor]; + if (!entry) throw new Error(`Unknown editor: ${editor}`); + const child = spawn(entry.command, entry.args(cwd), { + detached: true, + stdio: "ignore", + }); + child.unref(); + }, + ); + // Agent handlers ipcMain.handle( IPC_CHANNELS.agentSpawn, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index b72be5e13d..8fd7370d2b 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -52,6 +52,10 @@ const nativeApi: NativeApi = { ipcRenderer.removeListener(IPC_CHANNELS.providerEvent, listener); }, }, + shell: { + openInEditor: (cwd: string, editor: string) => + ipcRenderer.invoke(IPC_CHANNELS.shellOpenInEditor, cwd, editor), + }, }; contextBridge.exposeInMainWorld("nativeApi", nativeApi); diff --git a/apps/renderer/src/components/ChatView.tsx b/apps/renderer/src/components/ChatView.tsx index 641bc58164..2cf468ec70 100644 --- a/apps/renderer/src/components/ChatView.tsx +++ b/apps/renderer/src/components/ChatView.tsx @@ -30,6 +30,18 @@ function formatMessageMeta(createdAt: string, duration: string | null): string { return `${formatTimestamp(createdAt)} • ${duration}`; } +const FILE_MANAGER_LABEL = navigator.platform.includes("Mac") + ? "Finder" + : navigator.platform.includes("Win") + ? "Explorer" + : "Files"; + +const EDITORS = [ + { id: "cursor", label: "Cursor" }, + { id: "file-manager", label: FILE_MANAGER_LABEL }, +] as const; +const LAST_EDITOR_KEY = "codething:last-editor"; + function statusLabel(phase: string): string { if (phase === "running") return "Thinking / working"; if (phase === "connecting") return "Connecting"; @@ -51,12 +63,17 @@ export default function ChatView() { const [isSending, setIsSending] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); + const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false); + const [lastEditor, setLastEditor] = useState( + () => localStorage.getItem(LAST_EDITOR_KEY) ?? EDITORS[0].id, + ); const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); const [nowTick, setNowTick] = useState(() => Date.now()); const messagesEndRef = useRef(null); const textareaRef = useRef(null); const modelMenuRef = useRef(null); + const editorMenuRef = useRef(null); const activeThread = state.threads.find((t) => t.id === state.activeThreadId); const activeProject = state.projects.find( @@ -144,6 +161,47 @@ export default function ChatView() { }; }, [isModelMenuOpen]); + useEffect(() => { + if (!isEditorMenuOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + if (!editorMenuRef.current) return; + if ( + event.target instanceof Node && + !editorMenuRef.current.contains(event.target) + ) { + setIsEditorMenuOpen(false); + } + }; + + window.addEventListener("mousedown", handleClickOutside); + return () => { + window.removeEventListener("mousedown", handleClickOutside); + }; + }, [isEditorMenuOpen]); + + // Cmd+O / Ctrl+O to open in last-used editor + useEffect(() => { + const handler = (e: globalThis.KeyboardEvent) => { + if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) { + e.preventDefault(); + if (api && activeProject) { + void api.shell.openInEditor(activeProject.cwd, lastEditor); + } + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [api, activeProject, lastEditor]); + + const openInEditor = (editorId: string) => { + if (!api || !activeProject) return; + void api.shell.openInEditor(activeProject.cwd, editorId); + setLastEditor(editorId); + localStorage.setItem(LAST_EDITOR_KEY, editorId); + setIsEditorMenuOpen(false); + }; + const ensureSession = async (): Promise => { if (!api || !activeThread || !activeProject) return null; if (activeThread.session && activeThread.session.status !== "closed") { @@ -317,6 +375,37 @@ export default function ChatView() { {statusLabel(phase)} + {/* Open in editor */} + {activeProject && ( +
+ + {isEditorMenuOpen && ( +
+ {EDITORS.map((editor) => ( + + ))} +
+ )} +
+ )} {/* Diff toggle */} From 84b71e0a016acc71cf12e375a2b151ee20bc11b3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Feb 2026 05:23:09 +0000 Subject: [PATCH 4/4] fix: add error handling and address review feedback - Validate cwd is non-empty in shell:open-in-editor IPC handler - Check shell.openPath() return value and throw on error - Attach spawn error listener to prevent crashes from missing CLI - Validate localStorage lastEditor against known EDITORS list - Only call preventDefault() for Cmd+O when action will run Co-authored-by: Julius Marminge --- apps/desktop/src/main.ts | 7 ++++++- apps/renderer/src/components/ChatView.tsx | 11 +++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 7c2ce9baa8..868219fe68 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -130,16 +130,21 @@ function registerIpcHandlers(): void { ipcMain.handle( IPC_CHANNELS.shellOpenInEditor, async (_event, cwd: string, editor: string) => { + if (!cwd) throw new Error("cwd is required"); const editorDef = EDITORS.find((e) => e.id === editor); if (!editorDef) throw new Error(`Unknown editor: ${editor}`); if (!editorDef.command) { - await shell.openPath(cwd); + const error = await shell.openPath(cwd); + if (error) throw new Error(error); return; } const child = spawn(editorDef.command, [cwd], { detached: true, stdio: "ignore", }); + child.on("error", () => { + /* ignore spawn failures for detached editors */ + }); child.unref(); }, ); diff --git a/apps/renderer/src/components/ChatView.tsx b/apps/renderer/src/components/ChatView.tsx index a668510de5..56d996f23a 100644 --- a/apps/renderer/src/components/ChatView.tsx +++ b/apps/renderer/src/components/ChatView.tsx @@ -65,9 +65,12 @@ export default function ChatView() { const [isConnecting, setIsConnecting] = useState(false); const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false); - const [lastEditor, setLastEditor] = useState( - () => (localStorage.getItem(LAST_EDITOR_KEY) as EditorId) ?? EDITORS[0].id, - ); + const [lastEditor, setLastEditor] = useState(() => { + const stored = localStorage.getItem(LAST_EDITOR_KEY); + return EDITORS.some((e) => e.id === stored) + ? (stored as EditorId) + : EDITORS[0].id; + }); const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); const [nowTick, setNowTick] = useState(() => Date.now()); @@ -185,8 +188,8 @@ export default function ChatView() { useEffect(() => { const handler = (e: globalThis.KeyboardEvent) => { if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) { - e.preventDefault(); if (api && activeProject) { + e.preventDefault(); void api.shell.openInEditor(activeProject.cwd, lastEditor); } }