Skip to content
Draft
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
15 changes: 15 additions & 0 deletions lua/codecompanion/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
50 changes: 50 additions & 0 deletions lua/codecompanion/interactions/chat/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
179 changes: 179 additions & 0 deletions lua/codecompanion/interactions/chat/sessions/init.lua
Original file line number Diff line number Diff line change
@@ -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<number, { slug: string, created_at: string, autosave: boolean }>
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
68 changes: 68 additions & 0 deletions lua/codecompanion/interactions/chat/sessions/serializer.lua
Original file line number Diff line number Diff line change
@@ -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
47 changes: 47 additions & 0 deletions lua/codecompanion/interactions/chat/sessions/slug.lua
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading