authorgravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-09 01:06:49-08:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-02-09 01:06:49-08:00
logeef57db955614e346091769bc6d4e80e0068e9b6
tree1b088f6d4b9ac2e5b6f2a440ede56b5c956eb5dd
parent7a68e570eb821e5e905d5c8cee71a8aceff7be95
signature Signed by PGP key B5690EEEBB952194

Add open-in-editor feature with Cmd+O shortcut (#2)

* 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 <noreply@anthropic.com> * Move editor definitions to contracts for strict IPC typing EDITORS config and EditorId type now live in @acme/contracts so the shell.openInEditor IPC channel is strictly typed end-to-end. The main process uses the contract's command field (null = file manager via shell.openPath). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix biome formatting issues Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * 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 <juliusmarminge@users.noreply.github.com> --------- Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>

4 files changed, 135 insertions(+), 1 deletions(-)

apps/desktop/src/main.ts+25-1
......@@ -3,9 +3,10 @@ fixPath();
33
44import { spawn } from "node:child_process";
55import path from "node:path";
6import { BrowserWindow, app, dialog, ipcMain, session } from "electron";
6import { BrowserWindow, app, dialog, ipcMain, session, shell } from "electron";
77
88import {
9 EDITORS,
910 IPC_CHANNELS,
1011 type TerminalCommandInput,
1112 type TerminalCommandResult,
......@@ -126,6 +127,29 @@ function registerIpcHandlers(): void {
126127 }),
127128 );
128129
130 // Shell handlers
131 ipcMain.handle(
132 IPC_CHANNELS.shellOpenInEditor,
133 async (_event, cwd: string, editor: string) => {
134 if (!cwd) throw new Error("cwd is required");
135 const editorDef = EDITORS.find((e) => e.id === editor);
136 if (!editorDef) throw new Error(`Unknown editor: ${editor}`);
137 if (!editorDef.command) {
138 const error = await shell.openPath(cwd);
139 if (error) throw new Error(error);
140 return;
141 }
142 const child = spawn(editorDef.command, [cwd], {
143 detached: true,
144 stdio: "ignore",
145 });
146 child.on("error", () => {
147 /* ignore spawn failures for detached editors */
148 });
149 child.unref();
150 },
151 );
152
129153 // Agent handlers
130154 ipcMain.handle(
131155 IPC_CHANNELS.agentSpawn,
apps/desktop/src/preload.ts+4
......@@ -54,6 +54,10 @@ const nativeApi: NativeApi = {
5454 ipcRenderer.removeListener(IPC_CHANNELS.providerEvent, listener);
5555 },
5656 },
57 shell: {
58 openInEditor: (cwd: string, editor: string) =>
59 ipcRenderer.invoke(IPC_CHANNELS.shellOpenInEditor, cwd, editor),
60 },
5761};
5862
5963contextBridge.exposeInMainWorld("nativeApi", nativeApi);
apps/renderer/src/components/ChatView.tsx+95
......@@ -9,6 +9,7 @@ import {
99 useState,
1010} from "react";
1111
12import { EDITORS, type EditorId } from "@acme/contracts";
1213import {
1314 DEFAULT_MODEL,
1415 DEFAULT_REASONING,
......@@ -33,6 +34,18 @@ function formatMessageMeta(createdAt: string, duration: string | null): string {
3334 return `${formatTimestamp(createdAt)} • ${duration}`;
3435}
3536
37const FILE_MANAGER_LABEL = navigator.platform.includes("Mac")
38 ? "Finder"
39 : navigator.platform.includes("Win")
40 ? "Explorer"
41 : "Files";
42
43function editorLabel(editor: (typeof EDITORS)[number]): string {
44 return editor.command ? editor.label : FILE_MANAGER_LABEL;
45}
46
47const LAST_EDITOR_KEY = "codething:last-editor";
48
3649function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
3750 if (tone === "error") return "text-rose-300/50";
3851 if (tone === "tool") return "text-[#8a8a8a]";
......@@ -111,6 +124,13 @@ export default function ChatView() {
111124 const [isSending, setIsSending] = useState(false);
112125 const [isConnecting, setIsConnecting] = useState(false);
113126 const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
127 const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false);
128 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
129 const stored = localStorage.getItem(LAST_EDITOR_KEY);
130 return EDITORS.some((e) => e.id === stored)
131 ? (stored as EditorId)
132 : EDITORS[0].id;
133 });
114134 const [selectedEffort, setSelectedEffort] =
115135 useState<string>(DEFAULT_REASONING);
116136 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
......@@ -121,6 +141,7 @@ export default function ChatView() {
121141 const messagesEndRef = useRef<HTMLDivElement>(null);
122142 const textareaRef = useRef<HTMLTextAreaElement>(null);
123143 const modelMenuRef = useRef<HTMLDivElement>(null);
144 const editorMenuRef = useRef<HTMLDivElement>(null);
124145
125146 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
126147 const activeProject = state.projects.find(
......@@ -291,6 +312,47 @@ export default function ChatView() {
291312 };
292313 }, [isModelMenuOpen]);
293314
315 useEffect(() => {
316 if (!isEditorMenuOpen) return;
317
318 const handleClickOutside = (event: MouseEvent) => {
319 if (!editorMenuRef.current) return;
320 if (
321 event.target instanceof Node &&
322 !editorMenuRef.current.contains(event.target)
323 ) {
324 setIsEditorMenuOpen(false);
325 }
326 };
327
328 window.addEventListener("mousedown", handleClickOutside);
329 return () => {
330 window.removeEventListener("mousedown", handleClickOutside);
331 };
332 }, [isEditorMenuOpen]);
333
334 // Cmd+O / Ctrl+O to open in last-used editor
335 useEffect(() => {
336 const handler = (e: globalThis.KeyboardEvent) => {
337 if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
338 if (api && activeProject) {
339 e.preventDefault();
340 void api.shell.openInEditor(activeProject.cwd, lastEditor);
341 }
342 }
343 };
344 window.addEventListener("keydown", handler);
345 return () => window.removeEventListener("keydown", handler);
346 }, [api, activeProject, lastEditor]);
347
348 const openInEditor = (editorId: EditorId) => {
349 if (!api || !activeProject) return;
350 void api.shell.openInEditor(activeProject.cwd, editorId);
351 setLastEditor(editorId);
352 localStorage.setItem(LAST_EDITOR_KEY, editorId);
353 setIsEditorMenuOpen(false);
354 };
355
294356 const ensureSession = async (): Promise<string | null> => {
295357 if (!api || !activeThread || !activeProject) return null;
296358 if (activeThread.session && activeThread.session.status !== "closed") {
......@@ -458,6 +520,39 @@ export default function ChatView() {
458520 </h2>
459521 </div>
460522 <div className="flex items-center gap-3">
523 {/* Open in editor */}
524 {activeProject && (
525 <div className="relative" ref={editorMenuRef}>
526 <button
527 type="button"
528 className="rounded-md px-2 py-1 text-[10px] text-[#a0a0a0]/40 transition-colors duration-150 hover:text-[#a0a0a0]/60"
529 onClick={() => setIsEditorMenuOpen((v) => !v)}
530 >
531 Open in&hellip;
532 </button>
533 {isEditorMenuOpen && (
534 <div className="absolute right-0 top-full z-50 mt-1 min-w-[120px] rounded-md border border-white/[0.08] bg-[#1b1b1d] py-1 shadow-xl">
535 {EDITORS.map((editor) => (
536 <button
537 key={editor.id}
538 type="button"
539 className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-[#e0e0e0] hover:bg-white/[0.06]"
540 onClick={() => openInEditor(editor.id)}
541 >
542 {editorLabel(editor)}
543 {editor.id === lastEditor && (
544 <kbd className="ml-auto text-[9px] text-[#a0a0a0]/40">
545 {navigator.platform.includes("Mac")
546 ? "\u2318O"
547 : "Ctrl+O"}
548 </kbd>
549 )}
550 </button>
551 ))}
552 </div>
553 )}
554 </div>
555 )}
461556 {/* Diff toggle */}
462557 <button
463558 type="button"
packages/contracts/src/ipc.ts+11
......@@ -12,6 +12,13 @@ import type {
1212import type { TerminalCommandInput, TerminalCommandResult } from "./terminal";
1313import type { NewTodoInput, Todo } from "./todo";
1414
15export const EDITORS = [
16 { id: "cursor", label: "Cursor", command: "cursor" },
17 { id: "file-manager", label: "File Manager", command: null },
18] as const;
19
20export type EditorId = (typeof EDITORS)[number]["id"];
21
1522export const IPC_CHANNELS = {
1623 todosList: "todos:list",
1724 todosAdd: "todos:add",
......@@ -31,6 +38,7 @@ export const IPC_CHANNELS = {
3138 providerSessionList: "provider:session:list",
3239 providerRequestRespond: "provider:request:respond",
3340 providerEvent: "provider:event",
41 shellOpenInEditor: "shell:open-in-editor",
3442} as const;
3543
3644export interface NativeApi {
......@@ -66,4 +74,7 @@ export interface NativeApi {
6674 listSessions: () => Promise<ProviderSession[]>;
6775 onEvent: (callback: (event: ProviderEvent) => void) => () => void;
6876 };
77 shell: {
78 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;
79 };
6980}