Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions plugin/claude.vim
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ function! s:SetupClaudeKeybindings()

command! ClaudeCancel call s:CancelClaudeResponse()
execute "nnoremap " . g:claude_map_cancel_response . " :ClaudeCancel<CR>"

command! -nargs=? ClaudeChatSave call s:SaveChat(<q-args>)
command! -nargs=1 ClaudeChatLoad call s:LoadChat(<q-args>)
command! -nargs=? ClaudeChatList call s:ListChats(<q-args>)
command! -nargs=1 ClaudeChatArchive call s:ArchiveChat(<q-args>)
command! -nargs=1 ClaudeChatDelete call s:DeleteChat(<q-args>)
endfunction

augroup ClaudeKeybindings
Expand Down Expand Up @@ -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
170 changes: 170 additions & 0 deletions plugin/claude_db.py
Original file line number Diff line number Diff line change
@@ -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\<username>\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()