diff --git a/README.md b/README.md index 9592158..8f592c5 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Integrate the [opencode](https://github.com/sst/opencode) AI assistant with Neov - Respond to permission requests. - Reload edited buffers in real-time. - Monitor state via statusline component. +- Diff changes across entire sessions using `codediff.nvim`. - Forward Server-Sent-Events as autocmds for automation. - Sensible defaults with well-documented, flexible configuration and API to fit your workflow. - _Vim-y_ — supports ranges and dot-repeat. @@ -262,6 +263,7 @@ Select from all `opencode.nvim` functionality. - Prompts - Commands - Fetches custom commands from `opencode` +- Session diffs (requires `codediff.nvim`) - Provider controls Highlights and previews items when using `snacks.picker`. @@ -301,6 +303,13 @@ Command `opencode`: | `prompt.clear` | Clear the TUI input | | `agent.cycle` | Cycle the selected agent | +### 🔍 Session Diff — `require("opencode").session_diff()` + +View all changes made during an `opencode` session. + +- Pick from a list of sessions (sorted by recent activity). +- Exports session snapshots and displays differences using [`codediff.nvim`](https://github.com/esmuellert/codediff.nvim). + ## 👀 Events `opencode.nvim` forwards `opencode`'s Server-Sent-Events as an `OpencodeEvent` autocmd: diff --git a/lua/opencode.lua b/lua/opencode.lua index 1e6880b..cbf23e3 100644 --- a/lua/opencode.lua +++ b/lua/opencode.lua @@ -12,6 +12,8 @@ M.toggle = require("opencode.provider").toggle M.start = require("opencode.provider").start M.stop = require("opencode.provider").stop +M.session_diff = require("opencode.diff").session_diff + M.statusline = require("opencode.status").statusline return M diff --git a/lua/opencode/cli/client.lua b/lua/opencode/cli/client.lua index 8553065..25a1696 100644 --- a/lua/opencode/cli/client.lua +++ b/lua/opencode/cli/client.lua @@ -214,6 +214,24 @@ function M.get_commands(port, callback) M.call(port, "/command", "GET", nil, callback) end +---@class opencode.cli.client.Session +---@field id string +---@field slug string +---@field version string +---@field projectID string +---@field directory string +---@field title string +---@field time { created: number, updated: number } +---@field summary { additions: number, deletions: number, files: number } + +---Get all sessions from `opencode`. +--- +---@param port number +---@param callback fun(sessions: opencode.cli.client.Session[]) +function M.get_sessions(port, callback) + M.call(port, "/session", "GET", nil, callback) +end + ---@class opencode.cli.client.PathResponse ---@field directory string ---@field worktree string diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 2b1a3d7..7fffa96 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -18,6 +18,9 @@ vim.g.opencode_opts = vim.g.opencode_opts ---If set, `opencode.nvim` will append `--port ` to `provider.cmd`. ---@field port? number --- +---The data directory where `opencode` stores its data. +---@field diff_data_dir? string +--- ---Contexts to inject into prompts, keyed by their placeholder. ---@field contexts? table --- @@ -45,6 +48,7 @@ vim.g.opencode_opts = vim.g.opencode_opts ---@type opencode.Opts local defaults = { port = nil, + diff_data_dir = "~/.local/share/opencode", -- stylua: ignore contexts = { ["@this"] = function(context) return context:this() end, @@ -98,6 +102,7 @@ local defaults = { ["prompt.submit"] = "Submit the current prompt", ["prompt.clear"] = "Clear the current prompt", }, + diff = true, provider = true, }, snacks = { diff --git a/lua/opencode/diff.lua b/lua/opencode/diff.lua new file mode 100644 index 0000000..a799e83 --- /dev/null +++ b/lua/opencode/diff.lua @@ -0,0 +1,275 @@ +---Session diff functionality for opencode.nvim +---Uses codediff.nvim to display diffs between session snapshots. +local M = {} +local data_dir = vim.fn.expand(require("opencode.config").opts.diff_data_dir) + +---@class opencode.diff.Snapshots +---@field first string|nil First snapshot hash (step-start) +---@field last string|nil Last snapshot hash (step-finish) +---@field project_id string|nil Project ID for the session + +---Read JSON file and parse it +---@param path string +---@return table|nil +local function read_json(path) + local file = io.open(path, "r") + if not file then + return nil + end + local content = file:read("*a") + file:close() + local ok, data = pcall(vim.fn.json_decode, content) + if ok then + return data + end + return nil +end + +---Get list of message IDs for a session (sorted) +---@param session_id string +---@return string[] +local function get_message_ids(session_id) + local msg_dir = data_dir .. "/storage/message/" .. session_id + local handle = vim.uv.fs_scandir(msg_dir) + if not handle then + return {} + end + + local messages = {} + while true do + local name, ftype = vim.uv.fs_scandir_next(handle) + if not name then + break + end + if ftype == "file" and name:match("%.json$") then + local msg_id = name:gsub("%.json$", "") + table.insert(messages, msg_id) + end + end + + table.sort(messages) + return messages +end + +---Get snapshots from part files for a message +---@param msg_id string +---@return { step_starts: string[], step_finishes: string[] } +local function get_message_snapshots(msg_id) + local part_dir = data_dir .. "/storage/part/" .. msg_id + local handle = vim.uv.fs_scandir(part_dir) + if not handle then + return { step_starts = {}, step_finishes = {} } + end + + local step_starts = {} + local step_finishes = {} + + -- Collect all part files + local parts = {} + while true do + local name, ftype = vim.uv.fs_scandir_next(handle) + if not name then + break + end + if ftype == "file" and name:match("%.json$") then + table.insert(parts, name) + end + end + table.sort(parts) + + for _, part_name in ipairs(parts) do + local part_file = part_dir .. "/" .. part_name + local data = read_json(part_file) + if data and data.snapshot then + if data.type == "step-start" then + table.insert(step_starts, data.snapshot) + elseif data.type == "step-finish" then + table.insert(step_finishes, data.snapshot) + end + end + end + + return { step_starts = step_starts, step_finishes = step_finishes } +end + +---Find snapshots for session-wide diff +---@param session_id string +---@param project_id string +---@return opencode.diff.Snapshots +local function find_session_snapshots(session_id, project_id) + local messages = get_message_ids(session_id) + local first = nil + local last = nil + + for _, msg_id in ipairs(messages) do + local snaps = get_message_snapshots(msg_id) + + -- First step-start across all messages + if not first and #snaps.step_starts > 0 then + first = snaps.step_starts[1] + end + + -- Last step-finish across all messages + if #snaps.step_finishes > 0 then + last = snaps.step_finishes[#snaps.step_finishes] + end + end + + return { + first = first, + last = last, + project_id = project_id, + } +end + +---Export a git tree to a temporary directory +---@param snapshot_git string Path to the snapshot git directory +---@param tree_hash string Git tree hash +---@param callback fun(err: string|nil, temp_dir: string|nil) +local function export_tree(snapshot_git, tree_hash, callback) + -- Create temp directory + local temp_dir = vim.fn.tempname() + vim.fn.mkdir(temp_dir, "p") + + -- Verify the object exists + vim.system({ "git", "--git-dir=" .. snapshot_git, "cat-file", "-t", tree_hash }, { text = true }, function(result) + if result.code ~= 0 then + vim.schedule(function() + callback("Snapshot object no longer exists (may have been garbage collected): " .. tree_hash, nil) + end) + return + end + + -- Export the tree using git archive + vim.system({ "git", "--git-dir=" .. snapshot_git, "archive", tree_hash }, { text = false }, function(archive_result) + if archive_result.code ~= 0 then + vim.schedule(function() + callback("Failed to archive tree: " .. (archive_result.stderr or ""), nil) + end) + return + end + + -- Extract the archive + vim.system( + { "tar", "-xf", "-", "-C", temp_dir }, + { stdin = archive_result.stdout, text = false }, + function(tar_result) + vim.schedule(function() + if tar_result.code ~= 0 then + callback("Failed to extract archive: " .. (tar_result.stderr or ""), nil) + else + callback(nil, temp_dir) + end + end) + end + ) + end) + end) +end + +---Show diff between two snapshots using codediff.nvim +---@param snapshots opencode.diff.Snapshots +local function show_diff(snapshots) + if not snapshots.first or not snapshots.last then + vim.notify("No snapshots found for session diff", vim.log.levels.WARN, { title = "opencode" }) + return + end + + if snapshots.first == snapshots.last then + vim.notify("No changes in session (same snapshot)", vim.log.levels.INFO, { title = "opencode" }) + return + end + + if not snapshots.project_id then + vim.notify("Could not determine project ID", vim.log.levels.ERROR, { title = "opencode" }) + return + end + + local snapshot_git = data_dir .. "/snapshot/" .. snapshots.project_id + + -- Check if snapshot git directory exists + if vim.fn.isdirectory(snapshot_git) == 0 then + vim.notify("Snapshot directory not found: " .. snapshot_git, vim.log.levels.ERROR, { title = "opencode" }) + return + end + + -- Export both trees and show diff + export_tree(snapshot_git, snapshots.first, function(err1, dir1) + if err1 or not dir1 then + vim.notify(err1 or "Failed to export first snapshot", vim.log.levels.ERROR, { title = "opencode" }) + return + end + + export_tree(snapshot_git, snapshots.last, function(err2, dir2) + if err2 or not dir2 then + vim.notify(err2 or "Failed to export second snapshot", vim.log.levels.ERROR, { title = "opencode" }) + return + end + + -- Show diff using codediff.nvim + vim.notify( + "Session diff: " .. snapshots.first:sub(1, 7) .. " → " .. snapshots.last:sub(1, 7), + vim.log.levels.INFO, + { title = "opencode" } + ) + + -- Use CodeDiff command to compare directories + local ok, cmd_err = pcall(function() + vim.cmd("CodeDiff " .. vim.fn.fnameescape(dir1) .. " " .. vim.fn.fnameescape(dir2)) + end) + if not ok then + vim.notify("Failed to run CodeDiff: " .. tostring(cmd_err), vim.log.levels.ERROR, { title = "opencode" }) + end + end) + end) +end + +-- Show session-wide diff (first step-start → last step-finish across all messages) +function M.session_diff() + local has_codediff = pcall(require, "codediff") + if not has_codediff then + vim.notify( + "codediff.nvim is required for session diff. Install it from https://github.com/esmuellert/codediff.nvim", + vim.log.levels.ERROR, + { title = "opencode" } + ) + return + end + + require("opencode.cli.server") + .get_port() + :next(function(port) + require("opencode.cli.client").get_sessions(port, function(sessions) + if not sessions or #sessions == 0 then + vim.notify("No sessions found", vim.log.levels.WARN, { title = "opencode" }) + return + end + + -- Sort by updated time, most recent first + table.sort(sessions, function(a, b) + local a_time = (a.time and a.time.updated) or 0 + local b_time = (b.time and b.time.updated) or 0 + return a_time > b_time + end) + + vim.ui.select(sessions, { + prompt = "Select session to diff:", + format_item = function(session) + return string.format("%-20s", session.title or session.slug or session.id) + end, + }, function(choice) + if not choice then + return + end + + local snapshots = find_session_snapshots(choice.id, choice.projectID) + show_diff(snapshots) + end) + end) + end) + :catch(function(err) + vim.notify(err, vim.log.levels.ERROR, { title = "opencode" }) + end) +end + +return M diff --git a/lua/opencode/ui/select.lua b/lua/opencode/ui/select.lua index 9e74829..a5724ad 100644 --- a/lua/opencode/ui/select.lua +++ b/lua/opencode/ui/select.lua @@ -16,6 +16,10 @@ local M = {} ---Or `false` to hide the commands section. ---@field commands? table|false --- +---Whether to show the diff section. +---Requires codediff.nvim to be installed. +---@field diff? boolean +--- ---Whether to show the provider section. ---Always `false` if no provider is available. ---@field provider? boolean @@ -140,6 +144,21 @@ function M.select(opts) end end + -- Diff section (requires codediff.nvim) + if opts.sections.diff then + local has_codediff = pcall(require, "codediff") + if has_codediff then + table.insert(items, { __group = true, name = "DIFF", preview = { text = "" } }) + table.insert(items, { + __type = "diff", + name = "session diff", + text = "Pick a session to view diff of", + highlights = { { "Pick a session to view diff of", "Comment" } }, + preview = { text = "" }, + }) + end + end + -- Provider section if opts.sections.provider then table.insert(items, { __group = true, name = "PROVIDER", preview = { text = "" } }) @@ -230,6 +249,8 @@ function M.select(opts) elseif choice.name == "stop" then require("opencode").stop() end + elseif choice.__type == "diff" then + require("opencode.diff").session_diff() end end) end)