diff --git a/src/ipc-messages.ts b/src/ipc-messages.ts index e8c855b..3cd3998 100644 --- a/src/ipc-messages.ts +++ b/src/ipc-messages.ts @@ -40,6 +40,7 @@ export const IpcMessages = { CHAT_DELETE_CHAT: "clippy_chat_delete_chat", CHAT_DELETE_ALL_CHATS: "clippy_chat_delete_all_chats", CHAT_NEW_CHAT: "clippy_chat_new_chat", + CHAT_EXPORT_FOR_CLAUDE: "clippy_chat_export_for_claude", // Clipboard CLIPBOARD_WRITE: "clippy_clipboard_write", diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 5f31cca..9c2a0aa 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,4 +1,5 @@ -import { clipboard, Data, ipcMain } from "electron"; +import { clipboard, Data, dialog, ipcMain } from "electron"; +import fs from "fs"; import { toggleChatWindow, maximizeChatWindow, @@ -95,9 +96,49 @@ export function setupIpcListeners() { ipcMain.handle(IpcMessages.CHAT_DELETE_ALL_CHATS, () => getChatManager().deleteAllChats(), ); + ipcMain.handle( + IpcMessages.CHAT_EXPORT_FOR_CLAUDE, + async (_, chatWithMessages: ChatWithMessages) => { + const result = await dialog.showSaveDialog({ + title: "Export Chat for Claude Code", + defaultPath: "chat-context.md", + filters: [{ name: "Markdown", extensions: ["md"] }], + }); + + if (result.canceled || !result.filePath) { + return false; + } + + const content = formatChatForClaude(chatWithMessages); + await fs.promises.writeFile(result.filePath, content, "utf8"); + return true; + }, + ); // Clipboard ipcMain.handle(IpcMessages.CLIPBOARD_WRITE, (_, data: Data) => clipboard.write(data, "clipboard"), ); } + +function formatChatForClaude(chatWithMessages: ChatWithMessages): string { + const date = new Date(chatWithMessages.chat.createdAt).toLocaleString(); + const lines: string[] = [ + "# Clippy Chat Context", + "", + `> Exported from Clippy on ${date}`, + "", + "## Conversation", + "", + ]; + + for (const message of chatWithMessages.messages) { + if (!message.content) { + continue; + } + const speaker = message.sender === "user" ? "**User**" : "**Clippy**"; + lines.push(`${speaker}: ${message.content}`, ""); + } + + return lines.join("\n"); +} diff --git a/src/renderer/clippyApi.tsx b/src/renderer/clippyApi.tsx index 2928374..badc676 100644 --- a/src/renderer/clippyApi.tsx +++ b/src/renderer/clippyApi.tsx @@ -52,6 +52,7 @@ export type ClippyApi = { deleteAllChats: () => Promise; onNewChat: (callback: () => void) => void; offNewChat: () => void; + exportChatForClaude: (chatWithMessages: ChatWithMessages) => Promise; // Clipboard clipboardWrite: (data: Data) => Promise; }; diff --git a/src/renderer/components/BubbleWindow.tsx b/src/renderer/components/BubbleWindow.tsx index 182feb1..96a21ca 100644 --- a/src/renderer/components/BubbleWindow.tsx +++ b/src/renderer/components/BubbleWindow.tsx @@ -5,10 +5,14 @@ import { Chat } from "./Chat"; import { Settings } from "./Settings"; import { useBubbleView } from "../contexts/BubbleViewContext"; import { Chats } from "./Chats"; +import { useChat } from "../contexts/ChatContext"; +import { MessageRecord } from "../../types/interfaces"; export function Bubble() { const { currentView, setCurrentView } = useBubbleView(); + const { messages, currentChatRecord } = useChat(); const [isMaximized, setIsMaximized] = useState(false); + const [isExporting, setIsExporting] = useState(false); const containerStyle = { width: "calc(100% - 6px)", @@ -57,6 +61,30 @@ export function Bubble() { } }, [setCurrentView, currentView]); + const handleExportForClaude = useCallback(async () => { + if (messages.length === 0) { + return; + } + + setIsExporting(true); + try { + const messageRecords: MessageRecord[] = messages.map((m) => ({ + id: m.id, + content: m.content, + sender: m.sender, + createdAt: m.createdAt, + })); + await clippyApi.exportChatForClaude({ + chat: currentChatRecord, + messages: messageRecords, + }); + } catch (error) { + console.error("Failed to export chat:", error); + } finally { + setIsExporting(false); + } + }, [messages, currentChatRecord]); + return (
@@ -82,6 +110,19 @@ export function Bubble() { > Settings + {currentView === "chat" && messages.length > 0 && ( + + )} +