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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions lua/opencode.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 18 additions & 0 deletions lua/opencode/cli/client.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions lua/opencode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ vim.g.opencode_opts = vim.g.opencode_opts
---If set, `opencode.nvim` will append `--port <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<string, fun(context: opencode.Context): string|nil>
---
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -98,6 +102,7 @@ local defaults = {
["prompt.submit"] = "Submit the current prompt",
["prompt.clear"] = "Clear the current prompt",
},
diff = true,
provider = true,
},
snacks = {
Expand Down
275 changes: 275 additions & 0 deletions lua/opencode/diff.lua
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +135 to +150
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the git cat-file or git archive commands fail after creating the temp directory, that directory is not cleaned up. The temp_dir created at line 133-134 should be deleted in the error paths at lines 140 and 149 to prevent temporary directory leaks.

Copilot uses AI. Check for mistakes.

-- 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
Comment on lines +129 to +168
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation uses Unix-specific commands (git, tar) and assumes Unix-style paths ($HOME/.local/share). This feature will likely not work on Windows systems. Consider adding a platform check and providing appropriate error messaging for Windows users, or implement Windows-compatible alternatives.

Copilot uses AI. Check for mistakes.

---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
Loading