diff --git a/plugin/claude.vim b/plugin/claude.vim index 3ba3bd3..db7816e 100644 --- a/plugin/claude.vim +++ b/plugin/claude.vim @@ -60,6 +60,12 @@ function! s:SetupClaudeKeybindings() command! ClaudeCancel call s:CancelClaudeResponse() execute "nnoremap " . g:claude_map_cancel_response . " :ClaudeCancel" + + command! -nargs=? ClaudeChatSave call s:SaveChat() + command! -nargs=1 ClaudeChatLoad call s:LoadChat() + command! -nargs=? ClaudeChatList call s:ListChats() + command! -nargs=1 ClaudeChatArchive call s:ArchiveChat() + command! -nargs=1 ClaudeChatDelete call s:DeleteChat() endfunction augroup ClaudeKeybindings @@ -1180,3 +1186,154 @@ function! s:CancelClaudeResponse() echo "No ongoing Claude response to cancel." endif endfunction + +" ============================================================================ +" Chat persistence +" ============================================================================ + +function! s:SaveChat(title) + let l:title = empty(a:title) ? input('Chat title: ') : a:title + if empty(l:title) + echom "Chat save cancelled" + return + endif + + let [l:messages, l:system_prompt] = s:ParseChatBuffer() + + " Ensure all message content is stringified before saving + let l:processed_messages = [] + for msg in l:messages + let l:msg_copy = copy(msg) + if type(l:msg_copy.content) == v:t_list + let l:msg_copy.content = json_encode(l:msg_copy.content) + endif + call add(l:processed_messages, l:msg_copy) + endfor + + let l:json_messages = json_encode(l:processed_messages) + + let l:cmd = ['python3', s:plugin_dir . '/claude_db.py', 'save', + \ '--title', shellescape(l:title), + \ '--messages', shellescape(l:json_messages)] + + let debug_file = expand('~/.vim/debug_claude.txt') + call writefile(l:cmd, debug_file, 'a') + + let l:output = system(join(l:cmd, " ")) + if v:shell_error + echohl ErrorMsg + echom "Failed to save chat: " . l:output + echohl None + return + endif + + let l:result = json_decode(l:output) + echom "Chat saved with ID " . l:result.chat_id +endfunction + +function! s:LoadChat(chat_id) + let l:cmd = ['python3', s:plugin_dir . '/claude_db.py', 'load', + \ '--chat-id', a:chat_id] + + let l:output = system(join(l:cmd, " ")) + if v:shell_error + echohl ErrorMsg + echom "Failed to load chat: " . l:output + echohl None + return + endif + + let l:chat = json_decode(l:output) + call s:OpenClaudeChat() + + " Clear the buffer + execute 'normal! ggdG' + + " Add system prompt + call setline(1, 'System prompt: ' . g:claude_default_system_prompt[0]) + call append('$', map(g:claude_default_system_prompt[1:], {_, v -> "\t" . v})) + + " Add chat messages + for msg in l:chat.messages + let l:content = type(msg.content) == v:t_string ? + \ (msg.content[0] ==# '{' ? json_decode(msg.content) : msg.content) : + \ msg.content + + if msg.role ==# 'user' + call append('$', 'You: ' . l:content) + else + call s:AppendResponse(l:content) + endif + endfor + + call s:PrepareNextInput() + echom "Loaded chat " . a:chat_id . ": " . l:chat.title +endfunction + +function! s:ListChats(args) + let l:cmd = ['python3', s:plugin_dir . '/claude_db.py', 'list'] + if a:args ==# '--archived' + let l:cmd += ['--include-archived'] + endif + + let l:output = system(join(l:cmd, " ")) + if v:shell_error + echohl ErrorMsg + echom "Failed to list chats: " . l:output + echohl None + return + endif + + let l:result = json_decode(l:output) + if empty(l:result.chats) + echom "No chats found" + return + endif + + echo "Chat ID | Title | Created At | Status" + echo "--------|----------------------|--------------------|---------" + for chat in l:result.chats + let l:status = empty(chat.archived_at) ? 'Active' : 'Archived' + echo printf("%-8d| %-20s | %-18s | %s", + \ chat.id, + \ chat.title, + \ split(chat.created_at, '\.')[0], + \ l:status) + endfor +endfunction + +function! s:ArchiveChat(chat_id) + let l:cmd = ['python3', s:plugin_dir . '/claude_db.py', 'archive', + \ '--chat-id', a:chat_id] + + let l:output = system(join(l:cmd, " ")) + if v:shell_error + echohl ErrorMsg + echom "Failed to archive chat: " . l:output + echohl None + return + endif + + echom "Chat " . a:chat_id . " archived" +endfunction + +function! s:DeleteChat(chat_id) + let l:confirm = input('Really delete chat ' . a:chat_id . '? (y/N) ') + if l:confirm !~? '^y' + echom "Chat deletion cancelled" + return + endif + + let l:cmd = ['python3', s:plugin_dir . '/claude_db.py', 'delete', + \ '--chat-id', a:chat_id] + + let l:output = system(join(l:cmd, " ")) + if v:shell_error + echohl ErrorMsg + echom "Failed to delete chat: " . l:output + echohl None + return + endif + + echom "Chat " . a:chat_id . " deleted" +endfunction diff --git a/plugin/claude_db.py b/plugin/claude_db.py new file mode 100644 index 0000000..d26194b --- /dev/null +++ b/plugin/claude_db.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 + +import argparse +import json +import sqlite3 +import sys +from datetime import datetime +from pathlib import Path + +def get_db_path(): + import os + import platform + + app_name = "claude-vim" + + # Get system-specific app data directory + system = platform.system() + if system == "Windows": + # Use %LOCALAPPDATA% (typically C:\Users\\AppData\Local) + base_dir = os.getenv("LOCALAPPDATA") + if not base_dir: + base_dir = os.path.expanduser("~\\AppData\\Local") + data_dir = Path(base_dir) / app_name + elif system == "Darwin": # macOS + # Use ~/Library/Application Support + data_dir = Path.home() / "Library" / "Application Support" / app_name + else: # Linux and other Unix-like systems + # Use XDG_DATA_HOME or ~/.local/share + xdg_data = os.getenv("XDG_DATA_HOME", str(Path.home() / ".local/share")) + data_dir = Path(xdg_data) / app_name + + # Create directory if it doesn't exist + data_dir.mkdir(parents=True, exist_ok=True) + return data_dir / "chats.db" + +def init_db(conn): + conn.executescript(""" + CREATE TABLE IF NOT EXISTS chats ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + archived_at TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY, + chat_id INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (chat_id) REFERENCES chats(id) + ); + """) + conn.commit() + +def save_chat(conn, title, messages): + cursor = conn.cursor() + cursor.execute("INSERT INTO chats (title) VALUES (?)", (title,)) + chat_id = cursor.lastrowid + + for msg in messages: + # Ensure content is properly serialized + content = msg["content"] + if isinstance(content, (list, dict)): + content = json.dumps(content) + elif not isinstance(content, str): + content = str(content) + + cursor.execute( + "INSERT INTO messages (chat_id, role, content) VALUES (?, ?, ?)", + (chat_id, msg["role"], content) + ) + + conn.commit() + return chat_id + +def load_chat(conn, chat_id): + cursor = conn.cursor() + chat = cursor.execute("SELECT title FROM chats WHERE id = ?", (chat_id,)).fetchone() + if not chat: + return None + + messages = cursor.execute( + "SELECT role, content FROM messages WHERE chat_id = ? ORDER BY created_at", + (chat_id,) + ).fetchall() + + return { + "title": chat[0], + "messages": [{"role": m[0], "content": m[1]} for m in messages] + } + +def list_chats(conn, include_archived=False): + cursor = conn.cursor() + if include_archived: + chats = cursor.execute( + "SELECT id, title, created_at, archived_at FROM chats ORDER BY created_at DESC" + ).fetchall() + else: + chats = cursor.execute( + "SELECT id, title, created_at, archived_at FROM chats WHERE archived_at IS NULL ORDER BY created_at DESC" + ).fetchall() + return [{"id": c[0], "title": c[1], "created_at": c[2], "archived_at": c[3]} for c in chats] + +def archive_chat(conn, chat_id): + cursor = conn.cursor() + cursor.execute( + "UPDATE chats SET archived_at = CURRENT_TIMESTAMP WHERE id = ?", + (chat_id,) + ) + conn.commit() + +def delete_chat(conn, chat_id): + cursor = conn.cursor() + cursor.execute("DELETE FROM messages WHERE chat_id = ?", (chat_id,)) + cursor.execute("DELETE FROM chats WHERE id = ?", (chat_id,)) + conn.commit() + +def main(): + parser = argparse.ArgumentParser(description="Claude Chat DB Helper") + parser.add_argument("action", choices=["save", "load", "list", "archive", "delete"]) + parser.add_argument("--title", help="Chat title for save action") + parser.add_argument("--messages", help="JSON messages for save action") + parser.add_argument("--chat-id", type=int, help="Chat ID for load/archive/delete actions") + parser.add_argument("--include-archived", action="store_true", help="Include archived chats in list") + args = parser.parse_args() + + conn = sqlite3.connect(get_db_path()) + init_db(conn) + + try: + if args.action == "save": + if not args.title or not args.messages: + print("Error: title and messages required for save", file=sys.stderr) + sys.exit(1) + chat_id = save_chat(conn, args.title, json.loads(args.messages)) + print(json.dumps({"chat_id": chat_id})) + + elif args.action == "load": + if not args.chat_id: + print("Error: chat_id required for load", file=sys.stderr) + sys.exit(1) + chat = load_chat(conn, args.chat_id) + if chat: + print(json.dumps(chat)) + else: + print("Error: chat not found", file=sys.stderr) + sys.exit(1) + + elif args.action == "list": + chats = list_chats(conn, args.include_archived) + print(json.dumps({"chats": chats})) + + elif args.action == "archive": + if not args.chat_id: + print("Error: chat_id required for archive", file=sys.stderr) + sys.exit(1) + archive_chat(conn, args.chat_id) + + elif args.action == "delete": + if not args.chat_id: + print("Error: chat_id required for delete", file=sys.stderr) + sys.exit(1) + delete_chat(conn, args.chat_id) + + finally: + conn.close() + +if __name__ == "__main__": + main()