diff --git a/lua/codecompanion/config.lua b/lua/codecompanion/config.lua index 37ad7798e..24e89df56 100644 --- a/lua/codecompanion/config.lua +++ b/lua/codecompanion/config.lua @@ -517,6 +517,21 @@ If you are providing code changes, use the insert_edit_into_file tool (if availa interactions = { "chat", "cli" }, }, }, + ["save"] = { + path = "interactions.chat.slash_commands.builtin.save", + description = "Save the chat as a persistent session", + ---@param opts { adapter: CodeCompanion.HTTPAdapter|CodeCompanion.ACPAdapter } + ---@return boolean + enabled = function(opts) + if opts.adapter and opts.adapter.type == "http" then + return true + end + return false + end, + opts = { + contains_code = false, + }, + }, ["share"] = { path = "interactions.chat.slash_commands.builtin.share", description = "Share the chat in a GitHub Gist", diff --git a/lua/codecompanion/interactions/chat/init.lua b/lua/codecompanion/interactions/chat/init.lua index 5dd577128..6d3e3b2e9 100644 --- a/lua/codecompanion/interactions/chat/init.lua +++ b/lua/codecompanion/interactions/chat/init.lua @@ -1931,6 +1931,56 @@ function Chat:clear() utils.fire("ChatCleared", { bufnr = self.bufnr, id = self.id }) end +---Creates a serializable snapshot of the chat for use in saving sessions to disk +---@return table +function Chat:snapshot() + local snapshot_messages = {} + for _, msg in ipairs(self.messages or {}) do + local entry = { + role = msg.role, + content = msg.content, + } + if msg.reasoning ~= nil then + entry.reasoning = vim.deepcopy(msg.reasoning) + end + if msg.tools ~= nil then + entry.tools = vim.deepcopy(msg.tools) + end + if msg.opts ~= nil then + entry.opts = { visible = msg.opts.visible } + end + if msg.context ~= nil then + entry.context = vim.deepcopy(msg.context) + end + if msg._meta and msg._meta.tag ~= nil then + entry._meta = { tag = msg._meta.tag } + end + table.insert(snapshot_messages, entry) + end + + local adapter = { + name = self.adapter and self.adapter.name, + type = self.adapter and self.adapter.type, + } + if self.adapter and self.adapter.schema and self.adapter.schema.model then + adapter.model = self.adapter.schema.model.default + end + + local winid = vim.fn.bufwinid(self.bufnr) + local cwd = winid ~= -1 and vim.fn.getcwd(winid) or vim.fn.getcwd() + + return { + adapter = adapter, + context_items = vim.deepcopy(self.context_items or {}), + cwd = cwd, + cycle = self.cycle, + id = self.id, + messages = snapshot_messages, + settings = self.settings and vim.deepcopy(self.settings) or nil, + title = self.title, + } +end + ---Display the chat buffer's settings and messages function Chat:debug() if vim.tbl_isempty(self.messages) then diff --git a/lua/codecompanion/interactions/chat/sessions/init.lua b/lua/codecompanion/interactions/chat/sessions/init.lua new file mode 100644 index 000000000..5e27c0bba --- /dev/null +++ b/lua/codecompanion/interactions/chat/sessions/init.lua @@ -0,0 +1,179 @@ +---Persistent chat sessions. +--- +---Lives outside the Chat class; interacts with it through: +--- - `chat:snapshot()` for state reads (the only supported read seam) +--- - `chat:add_callback()` for `on_completed` / `on_closed` lifecycle hooks +--- - `Chat.new()` for restoration +--- +---Chat has no knowledge of sessions. Tracking state (which chat is being +---auto-saved under which slug) lives here, keyed by chat id. + +local log = require("codecompanion.utils.log") +local serializer = require("codecompanion.interactions.chat.sessions.serializer") +local slug_utils = require("codecompanion.interactions.chat.sessions.slug") +local storage = require("codecompanion.interactions.chat.sessions.storage") +local utils = require("codecompanion.utils") + +local api = vim.api + +local M = {} + +---Per-chat tracking. Indexed by `chat.id`. +---@type table +local sessions = {} + +---@return string +local function now_iso() + return os.date("!%Y-%m-%dT%H:%M:%SZ") --[[@as string]] +end + +---@param chat CodeCompanion.Chat +---@param title string +---@return string slug +local function resolve_slug(chat, title) + local base = slug_utils.slugify(title) + local current = sessions[chat.id] and sessions[chat.id].slug + return slug_utils.disambiguate(base, storage.exists, current) +end + +---Write the current state of a tracked chat to disk. +---@param chat CodeCompanion.Chat +---@return boolean ok +local function write_session(chat) + local entry = sessions[chat.id] + if not entry then + return false + end + + local snapshot = chat:snapshot() + local data = serializer.encode(snapshot, { + slug = entry.slug, + created_at = entry.created_at, + updated_at = now_iso(), + }) + + local ui_lines = api.nvim_buf_is_valid(chat.bufnr) and api.nvim_buf_get_lines(chat.bufnr, 0, -1, false) or nil + + local ok = storage.write(entry.slug, data, ui_lines) + if ok then + log:debug("[sessions] Wrote session %s for chat %d", entry.slug, chat.id) + end + return ok +end + +---Attach the auto-save callbacks to a chat. +---@param chat CodeCompanion.Chat +local function attach_autosave(chat) + chat:add_callback("on_completed", function(c) + if sessions[c.id] and sessions[c.id].autosave then + write_session(c) + end + end) + chat:add_callback("on_closed", function(c) + if sessions[c.id] and sessions[c.id].autosave then + write_session(c) + sessions[c.id] = nil + end + end) +end + +---Save the chat as a session. On first call, prompts for a title if the chat +---has none; subsequent calls just write to disk. +---@param chat CodeCompanion.Chat +---@param opts? { title?: string } +---@return nil +function M.save(chat, opts) + opts = opts or {} + + if chat.adapter and chat.adapter.type ~= "http" then + return utils.notify("Sessions only support HTTP chats", vim.log.levels.WARN) + end + + -- Existing tracked session: just rewrite under the current slug. + if sessions[chat.id] then + if opts.title and opts.title ~= "" and opts.title ~= chat.title then + M._rename(chat, opts.title) + end + if write_session(chat) then + utils.notify("Session saved: " .. sessions[chat.id].slug) + end + return + end + + local function persist_with_title(title) + if not title or title == "" then + return + end + if chat.title ~= title then + chat:set_title(title) + end + + local slug = resolve_slug(chat, title) + sessions[chat.id] = { + autosave = true, + created_at = now_iso(), + slug = slug, + } + attach_autosave(chat) + + if write_session(chat) then + utils.notify("Session saved: " .. slug) + utils.fire("ChatSessionSaved", { bufnr = chat.bufnr, id = chat.id, slug = slug }) + end + end + + local prefill = opts.title or chat.title + if prefill and prefill ~= "" then + return persist_with_title(prefill) + end + + vim.ui.input({ prompt = " Session Title " }, function(input) + if input == nil then + return + end + persist_with_title(input) + end) +end + +---Rename a tracked session by moving its files on disk. +---@param chat CodeCompanion.Chat +---@param new_title string +---@return nil +function M._rename(chat, new_title) + local entry = sessions[chat.id] + if not entry then + return + end + + local new_slug = resolve_slug(chat, new_title) + if new_slug == entry.slug then + chat:set_title(new_title) + return + end + + -- Delete old files; the next write under new_slug creates fresh ones. + storage.delete(entry.slug) + entry.slug = new_slug + chat:set_title(new_title) +end + +---Disable auto-save for a chat (e.g. after `/fork` produces a new chat). +---@param chat_id number +function M.untrack(chat_id) + sessions[chat_id] = nil +end + +---Whether a chat is currently being tracked for auto-save. +---@param chat_id number +---@return boolean +function M.is_tracked(chat_id) + return sessions[chat_id] ~= nil +end + +---@param chat_id number +---@return { slug: string, created_at: string, autosave: boolean }|nil +function M.get(chat_id) + return sessions[chat_id] +end + +return M diff --git a/lua/codecompanion/interactions/chat/sessions/serializer.lua b/lua/codecompanion/interactions/chat/sessions/serializer.lua new file mode 100644 index 000000000..a56d42dd4 --- /dev/null +++ b/lua/codecompanion/interactions/chat/sessions/serializer.lua @@ -0,0 +1,68 @@ +---Convert a Chat snapshot to and from the on-disk JSON form. +--- +---The schema version is pinned here. Bump `SCHEMA_VERSION` when the on-disk +---shape changes incompatibly, and add a migrator. + +local M = {} + +M.SCHEMA_VERSION = 1 +M.UI_VERSION = 1 + +---Build the JSON-serializable table for a session. +---@param snapshot table A Chat snapshot (see Chat:snapshot) +---@param extra { slug: string, created_at?: string, updated_at: string } +---@return table +function M.encode(snapshot, extra) + return { + adapter = snapshot.adapter, + context_items = snapshot.context_items, + created_at = extra.created_at or extra.updated_at, + cwd = snapshot.cwd, + cycle = snapshot.cycle, + id = snapshot.id, + messages = snapshot.messages, + schema_version = M.SCHEMA_VERSION, + settings = snapshot.settings, + slug = extra.slug, + title = snapshot.title, + ui_version = M.UI_VERSION, + updated_at = extra.updated_at, + } +end + +---Convert a decoded JSON table into args usable by `Chat.new`. +---Runtime-only message fields (cycle, id, estimated_tokens, sent, index) are +---left for the chat constructor / backfill to recompute. +---@param data table The decoded JSON +---@return table args Suitable for passing to `Chat.new` +function M.to_chat_args(data) + local messages = {} + for _, msg in ipairs(data.messages or {}) do + local entry = { + role = msg.role, + content = msg.content, + reasoning = msg.reasoning, + } + if msg.tools then + entry.tools = msg.tools + end + if msg.opts then + entry.opts = { visible = msg.opts.visible } + end + if msg.context then + entry.context = msg.context + end + if msg._meta then + entry._meta = { tag = msg._meta.tag } + end + table.insert(messages, entry) + end + + return { + messages = messages, + settings = data.settings, + title = data.title, + } +end + +return M diff --git a/lua/codecompanion/interactions/chat/sessions/slug.lua b/lua/codecompanion/interactions/chat/sessions/slug.lua new file mode 100644 index 000000000..54a8f4fe7 --- /dev/null +++ b/lua/codecompanion/interactions/chat/sessions/slug.lua @@ -0,0 +1,47 @@ +---Slugify and disambiguate session titles for use as filenames. + +local M = {} + +---Convert a title into a filesystem-safe slug. +---Lowercase, ASCII alphanumerics + hyphens, collapsed and trimmed. +---@param title string +---@return string +function M.slugify(title) + if type(title) ~= "string" or title == "" then + return "untitled" + end + + local slug = title:lower() + slug = slug:gsub("[^%w%s%-_]", "") + slug = slug:gsub("[%s_]+", "-") + slug = slug:gsub("%-+", "-") + slug = slug:gsub("^%-+", ""):gsub("%-+$", "") + + if slug == "" then + return "untitled" + end + return slug +end + +---Resolve a slug against an existing-slug check, appending `-2`, `-3` etc. on collision. +---The current slug (if any) is exempt — a session re-saving under its own slug keeps it. +---@param base string Base slug from slugify() +---@param exists fun(slug: string): boolean Predicate: is this slug taken on disk? +---@param current_slug? string The session's own existing slug (treated as available) +---@return string +function M.disambiguate(base, exists, current_slug) + if base == current_slug or not exists(base) then + return base + end + + local n = 2 + while true do + local candidate = base .. "-" .. n + if candidate == current_slug or not exists(candidate) then + return candidate + end + n = n + 1 + end +end + +return M diff --git a/lua/codecompanion/interactions/chat/sessions/storage.lua b/lua/codecompanion/interactions/chat/sessions/storage.lua new file mode 100644 index 000000000..a98549402 --- /dev/null +++ b/lua/codecompanion/interactions/chat/sessions/storage.lua @@ -0,0 +1,137 @@ +---Read and write session files to disk. +--- +---Layout under the session directory: +--- {slug}_chat.json — schema-versioned session state +--- {slug}_ui.md — rendered chat buffer markdown + +local log = require("codecompanion.utils.log") + +local M = {} + +local DEFAULT_DIR = vim.fn.stdpath("data") .. "/codecompanion/sessions" + +---Override the storage directory (used by tests). +---@type string|nil +local override_dir = nil + +---@return string +function M.dir() + return override_dir or DEFAULT_DIR +end + +---@param path string|nil +function M.set_dir(path) + override_dir = path +end + +---@return nil +function M.ensure_dir() + vim.fn.mkdir(M.dir(), "p") +end + +---@param slug string +---@return string +function M.json_path(slug) + return M.dir() .. "/" .. slug .. "_chat.json" +end + +---@param slug string +---@return string +function M.ui_path(slug) + return M.dir() .. "/" .. slug .. "_ui.md" +end + +---@param slug string +---@return boolean +function M.exists(slug) + return vim.fn.filereadable(M.json_path(slug)) == 1 +end + +---@param path string +---@param contents string +---@return boolean ok +local function write_file(path, contents) + local fd, err = io.open(path, "w") + if not fd then + log:error("[sessions::storage] Could not open %s for write: %s", path, err or "unknown") + return false + end + fd:write(contents) + fd:close() + return true +end + +---@param path string +---@return string|nil +local function read_file(path) + local fd, err = io.open(path, "r") + if not fd then + log:debug("[sessions::storage] Could not read %s: %s", path, err or "unknown") + return nil + end + local contents = fd:read("*a") + fd:close() + return contents +end + +---@param slug string +---@param data table The encoded JSON-shaped table +---@param ui_lines string[] The rendered chat buffer lines +---@return boolean ok +function M.write(slug, data, ui_lines) + M.ensure_dir() + local ok, encoded = pcall(vim.json.encode, data) + if not ok then + log:error("[sessions::storage] Failed to encode session %s: %s", slug, encoded) + return false + end + if not write_file(M.json_path(slug), encoded) then + return false + end + if ui_lines and not write_file(M.ui_path(slug), table.concat(ui_lines, "\n")) then + return false + end + return true +end + +---@param slug string +---@return table|nil data, string[]|nil ui_lines +function M.read(slug) + local raw = read_file(M.json_path(slug)) + if not raw then + return nil, nil + end + local ok, data = pcall(vim.json.decode, raw) + if not ok then + log:error("[sessions::storage] Failed to decode session %s: %s", slug, data) + return nil, nil + end + + local ui_raw = read_file(M.ui_path(slug)) + local ui_lines = ui_raw and vim.split(ui_raw, "\n", { plain = true }) or nil + return data, ui_lines +end + +---@param slug string +---@return boolean ok +function M.delete(slug) + local ok_json = pcall(os.remove, M.json_path(slug)) + pcall(os.remove, M.ui_path(slug)) + return ok_json +end + +---List all session slugs on disk. +---@return string[] +function M.list_slugs() + local pattern = M.dir() .. "/*_chat.json" + local files = vim.fn.glob(pattern, true, true) + local slugs = {} + for _, file in ipairs(files) do + local name = vim.fn.fnamemodify(file, ":t:r") -- strip dir + extension + local slug = name:gsub("_chat$", "") + table.insert(slugs, slug) + end + return slugs +end + +return M diff --git a/lua/codecompanion/interactions/chat/slash_commands/builtin/save.lua b/lua/codecompanion/interactions/chat/slash_commands/builtin/save.lua new file mode 100644 index 000000000..66460998b --- /dev/null +++ b/lua/codecompanion/interactions/chat/slash_commands/builtin/save.lua @@ -0,0 +1,33 @@ +local sessions = require("codecompanion.interactions.chat.sessions") +local utils = require("codecompanion.utils") + +---@class CodeCompanion.SlashCommand.Save: CodeCompanion.SlashCommand +local SlashCommand = {} + +---@param args CodeCompanion.SlashCommandArgs +function SlashCommand.new(args) + return setmetatable({ + Chat = args.Chat, + config = args.config, + context = args.context, + }, { __index = SlashCommand }) +end + +---@param chat CodeCompanion.Chat +---@return boolean, string +function SlashCommand.enabled(chat) + if not chat.adapter or chat.adapter.type ~= "http" then + return false, "The /save command only supports HTTP chats" + end + return true, "" +end + +---@return nil +function SlashCommand:execute() + if vim.tbl_isempty(self.Chat.messages or {}) then + return utils.notify("Nothing to save — chat is empty", vim.log.levels.WARN) + end + sessions.save(self.Chat) +end + +return SlashCommand