From 40977cb95c976e3ac5f0c45ad71ad1ccbc2c9aa5 Mon Sep 17 00:00:00 2001 From: Naowal Rahman Date: Sun, 18 Jan 2026 18:27:17 -0500 Subject: [PATCH 1/5] reference files for later --- session-ses_42e1.md | 76 +++++++++++++++++ test.sh | 193 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 session-ses_42e1.md create mode 100755 test.sh diff --git a/session-ses_42e1.md b/session-ses_42e1.md new file mode 100644 index 0000000..f8285e5 --- /dev/null +++ b/session-ses_42e1.md @@ -0,0 +1,76 @@ +### What We Did + +**Goal:** Investigate how opencode stores snapshots for undo/redo functionality, and create a script to generate diffs between repo states at different points in a session. + +**Key Findings about Opencode's Snapshot System:** + +1. **Storage Location:** Snapshots are stored in a separate Git repository at `~/.local/share/opencode/snapshot//`. Snapshots are Git tree hashes (not commits). + +2. **Data Structure in `~/.local/share/opencode/storage/`:** + + ``` + storage/ + ├── session//.json # Session info (contains projectID) + ├── message//.json # Message metadata (role, parentID) + └── part//.json # Parts (contains snapshots!) + ``` + +3. **Snapshot Parts:** + - `step-start` parts contain `snapshot` field (state BEFORE a step) + - `step-finish` parts contain `snapshot` field (state AFTER a step) + - These are stored in `part//*.json` files + +4. **Turn Structure:** A "turn" in opencode consists of: + - One user message + - All assistant messages where `parentID` equals that user message's ID + - This is how opencode groups responses (see `/home/naowal/Desktop/code/opencode/packages/opencode/src/session/summary.ts` lines 65-66) + +### What We Built + +Created a bash script at `/home/naowal/Desktop/code/test/test.sh` that: + +- Lists available sessions when run without arguments +- Auto-detects project ID from session metadata +- Supports two modes: + - **Session-wide diff** (default): First `step-start` snapshot → last `step-finish` snapshot across all messages + - **Last turn diff** (`-t` flag): Finds the last user message, collects all assistant messages with matching `parentID`, then diffs first→last snapshot within that turn +- Validates snapshot objects exist in git before attempting diff +- Provides clear error messages + +**Usage:** + +```bash +./test.sh # List sessions +./test.sh # Session-wide diff +./test.sh -t # Last turn diff +./test.sh --turn # Last turn diff +./test.sh -h # Help +``` + +### Key Files + +- **Script we created:** `/home/naowal/Desktop/code/test/test.sh` +- **Opencode source (for reference):** + - `/home/naowal/Desktop/code/opencode/packages/opencode/src/snapshot/index.ts` - Core snapshot operations + - `/home/naowal/Desktop/code/opencode/packages/opencode/src/session/summary.ts` - Shows how turns are grouped (lines 65-66) + - `/home/naowal/Desktop/code/opencode/packages/opencode/src/session/message-v2.ts` - Message/part schemas + - `/home/naowal/Desktop/code/opencode/packages/opencode/src/session/processor.ts` - Creates snapshots during LLM streaming + +### Current State + +The script works correctly. When tested, it: + +- Successfully lists sessions +- Correctly identifies turns by finding user messages and their associated assistant responses via `parentID` +- Properly extracts snapshot hashes from part files +- Handles cases where snapshots were garbage collected + +**Note:** Some older sessions have snapshot hashes that no longer exist in the git repo (garbage collected), which the script handles gracefully with an error message. + +### Potential Next Steps + +1. Add support for diffing a specific turn by number (not just the last one) +2. Add `--stat` option for summary instead of full diff +3. Add option to output diff to a file +4. Add support for comparing current working tree to a specific snapshot +5. Create a version that outputs JSON for programmatic use diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..08fec72 --- /dev/null +++ b/test.sh @@ -0,0 +1,193 @@ +#!/bin/bash + +# Usage: ./test.sh [options] [session_id] +# -t, --turn Show diff for last turn only (user message + all assistant responses) +# -h, --help Show help +# +# A "turn" in opencode is: one user message + all assistant messages that respond to it +# Assistant messages have a "parentID" field pointing to the user message they respond to + +STORAGE="$HOME/.local/share/opencode/storage" +SNAPSHOT_DIR="$HOME/.local/share/opencode/snapshot" +LAST_TURN=false + +# Parse options +while [[ $# -gt 0 ]]; do + case $1 in + -t|--turn) + LAST_TURN=true + shift + ;; + -h|--help) + echo "Usage: $0 [options] [session_id]" + echo "" + echo "Options:" + echo " -t, --turn Show diff for last turn only (default: session-wide)" + echo " -h, --help Show this help" + echo "" + echo "A 'turn' = user message + all assistant messages responding to it." + echo "If no session_id provided, lists available sessions." + exit 0 + ;; + *) + SESSION="$1" + shift + ;; + esac +done + +# List sessions if no session provided +if [ -z "$SESSION" ]; then + echo "Available sessions:" + for proj_dir in "$STORAGE/session"/*; do + [ -d "$proj_dir" ] || continue + for sess_file in "$proj_dir"/*.json; do + [ -f "$sess_file" ] || continue + sess_id=$(basename "$sess_file" .json) + title=$(grep -o '"title": *"[^"]*"' "$sess_file" | head -1 | sed 's/"title": *"\([^"]*\)"/\1/') + echo " $sess_id - $title" + done + done + echo "" + echo "Usage: $0 [-t|--turn] " + exit 0 +fi + +# Find the session file to get project ID +SESS_FILE=$(find "$STORAGE/session" -name "${SESSION}.json" 2>/dev/null | head -1) +if [ -z "$SESS_FILE" ]; then + echo "Session not found: $SESSION" + exit 1 +fi + +PROJECT_ID=$(grep -o '"projectID": *"[^"]*"' "$SESS_FILE" | sed 's/"projectID": *"\([^"]*\)"/\1/') +SNAPSHOT_GIT="$SNAPSHOT_DIR/$PROJECT_ID" + +echo "Session: $SESSION" +echo "Project: $PROJECT_ID" +if [ "$LAST_TURN" = true ]; then + echo "Mode: Last turn diff" +else + echo "Mode: Session-wide diff" +fi +echo "" + +# Get all message IDs for this session (sorted by filename which includes timestamp) +MESSAGES=$(ls "$STORAGE/message/$SESSION/" 2>/dev/null | sed 's/\.json$//' | sort) + +if [ -z "$MESSAGES" ]; then + echo "No messages found for session $SESSION" + exit 1 +fi + +if [ "$LAST_TURN" = true ]; then + # Find the last user message + LAST_USER="" + for MSG in $MESSAGES; do + MSG_FILE="$STORAGE/message/$SESSION/${MSG}.json" + ROLE=$(grep -o '"role": *"[^"]*"' "$MSG_FILE" | sed 's/"role": *"\([^"]*\)"/\1/') + if [ "$ROLE" = "user" ]; then + LAST_USER="$MSG" + fi + done + + if [ -z "$LAST_USER" ]; then + echo "No user messages found" + exit 1 + fi + + echo "Last user message: $LAST_USER" + + # Find all assistant messages with parentID == LAST_USER + TURN_MESSAGES="" + for MSG in $MESSAGES; do + MSG_FILE="$STORAGE/message/$SESSION/${MSG}.json" + ROLE=$(grep -o '"role": *"[^"]*"' "$MSG_FILE" | sed 's/"role": *"\([^"]*\)"/\1/') + if [ "$ROLE" = "assistant" ]; then + PARENT_ID=$(grep -o '"parentID": *"[^"]*"' "$MSG_FILE" | sed 's/"parentID": *"\([^"]*\)"/\1/') + if [ "$PARENT_ID" = "$LAST_USER" ]; then + TURN_MESSAGES="$TURN_MESSAGES $MSG" + fi + fi + done + + if [ -z "$TURN_MESSAGES" ]; then + echo "No assistant responses found for this turn" + exit 1 + fi + + echo "Assistant messages in turn:$TURN_MESSAGES" + + # Find first step-start and last step-finish across all messages in the turn + FIRST="" + LAST="" + + for MSG in $TURN_MESSAGES; do + PART_DIR="$STORAGE/part/$MSG" + if [ -d "$PART_DIR" ]; then + for PART_FILE in "$PART_DIR"/*.json; do + [ -f "$PART_FILE" ] || continue + TYPE=$(grep -o '"type": *"[^"]*"' "$PART_FILE" | head -1 | sed 's/.*"\([^"]*\)"$/\1/') + SNAP=$(grep -o '"snapshot": *"[^"]*"' "$PART_FILE" | head -1 | sed 's/.*"\([^"]*\)"$/\1/') + + if [ "$TYPE" = "step-start" ] && [ -n "$SNAP" ] && [ -z "$FIRST" ]; then + FIRST="$SNAP" + fi + if [ "$TYPE" = "step-finish" ] && [ -n "$SNAP" ]; then + LAST="$SNAP" + fi + done + fi + done +else + # Session-wide: first step-start to last step-finish across all messages + FIRST="" + LAST="" + + for MSG in $MESSAGES; do + PART_DIR="$STORAGE/part/$MSG" + if [ -d "$PART_DIR" ]; then + for PART_FILE in "$PART_DIR"/*.json; do + [ -f "$PART_FILE" ] || continue + TYPE=$(grep -o '"type": *"[^"]*"' "$PART_FILE" | head -1 | sed 's/.*"\([^"]*\)"$/\1/') + SNAP=$(grep -o '"snapshot": *"[^"]*"' "$PART_FILE" | head -1 | sed 's/.*"\([^"]*\)"$/\1/') + + if [ "$TYPE" = "step-start" ] && [ -n "$SNAP" ] && [ -z "$FIRST" ]; then + FIRST="$SNAP" + fi + if [ "$TYPE" = "step-finish" ] && [ -n "$SNAP" ]; then + LAST="$SNAP" + fi + done + fi + done +fi + +echo "" +echo "First snapshot: $FIRST" +echo "Last snapshot: $LAST" + +if [ -z "$FIRST" ] || [ -z "$LAST" ]; then + echo "Could not find snapshots in session" + exit 1 +fi + +if [ "$FIRST" = "$LAST" ]; then + echo "No changes (same snapshot)" + exit 0 +fi + +# Verify objects exist +if ! git --git-dir="$SNAPSHOT_GIT" cat-file -t "$FIRST" >/dev/null 2>&1; then + echo "ERROR: First snapshot object no longer exists in git repo (may have been garbage collected)" + exit 1 +fi + +if ! git --git-dir="$SNAPSHOT_GIT" cat-file -t "$LAST" >/dev/null 2>&1; then + echo "ERROR: Last snapshot object no longer exists in git repo (may have been garbage collected)" + exit 1 +fi + +echo "" +echo "=== DIFF ===" +git --git-dir="$SNAPSHOT_GIT" diff "$FIRST" "$LAST" From a1aa764b5b5dd0aa8e838b7e676e3d954f96e265 Mon Sep 17 00:00:00 2001 From: Naowal Rahman Date: Sun, 18 Jan 2026 21:10:06 -0500 Subject: [PATCH 2/5] feat: add session diff support using codediff.nvim --- README.md | 9 ++ lua/opencode.lua | 2 + lua/opencode/cli/client.lua | 18 +++ lua/opencode/config.lua | 1 + lua/opencode/diff.lua | 285 ++++++++++++++++++++++++++++++++++++ lua/opencode/ui/select.lua | 21 +++ session-ses_42e1.md | 76 ---------- test.sh | 193 ------------------------ 8 files changed, 336 insertions(+), 269 deletions(-) create mode 100644 lua/opencode/diff.lua delete mode 100644 session-ses_42e1.md delete mode 100755 test.sh 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..cc12726 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -98,6 +98,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..0786484 --- /dev/null +++ b/lua/opencode/diff.lua @@ -0,0 +1,285 @@ +---Session diff functionality for opencode.nvim +---Uses codediff.nvim to display diffs between session snapshots. +local M = {} + +local STORAGE = vim.fn.expand("$HOME/.local/share/opencode/storage") +local SNAPSHOT_DIR = vim.fn.expand("$HOME/.local/share/opencode/snapshot") + +---@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 = 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 = 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 = SNAPSHOT_DIR .. "/" .. 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 then + vim.notify(err1, vim.log.levels.ERROR, { title = "opencode" }) + return + end + + export_tree(snapshot_git, snapshots.last, function(err2, dir2) + if err2 then + vim.notify(err2, vim.log.levels.ERROR, { title = "opencode" }) + -- Clean up first temp dir + vim.fn.delete(dir1, "rf") + 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 + vim.cmd("CodeDiff " .. vim.fn.fnameescape(dir1) .. " " .. vim.fn.fnameescape(dir2)) + + -- Schedule cleanup of temp directories when the tab is closed + local cleanup_group = vim.api.nvim_create_augroup("OpencodeDiffCleanup", { clear = false }) + vim.api.nvim_create_autocmd("TabClosed", { + group = cleanup_group, + once = true, + callback = function() + vim.fn.delete(dir1, "rf") + vim.fn.delete(dir2, "rf") + 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) diff --git a/session-ses_42e1.md b/session-ses_42e1.md deleted file mode 100644 index f8285e5..0000000 --- a/session-ses_42e1.md +++ /dev/null @@ -1,76 +0,0 @@ -### What We Did - -**Goal:** Investigate how opencode stores snapshots for undo/redo functionality, and create a script to generate diffs between repo states at different points in a session. - -**Key Findings about Opencode's Snapshot System:** - -1. **Storage Location:** Snapshots are stored in a separate Git repository at `~/.local/share/opencode/snapshot//`. Snapshots are Git tree hashes (not commits). - -2. **Data Structure in `~/.local/share/opencode/storage/`:** - - ``` - storage/ - ├── session//.json # Session info (contains projectID) - ├── message//.json # Message metadata (role, parentID) - └── part//.json # Parts (contains snapshots!) - ``` - -3. **Snapshot Parts:** - - `step-start` parts contain `snapshot` field (state BEFORE a step) - - `step-finish` parts contain `snapshot` field (state AFTER a step) - - These are stored in `part//*.json` files - -4. **Turn Structure:** A "turn" in opencode consists of: - - One user message - - All assistant messages where `parentID` equals that user message's ID - - This is how opencode groups responses (see `/home/naowal/Desktop/code/opencode/packages/opencode/src/session/summary.ts` lines 65-66) - -### What We Built - -Created a bash script at `/home/naowal/Desktop/code/test/test.sh` that: - -- Lists available sessions when run without arguments -- Auto-detects project ID from session metadata -- Supports two modes: - - **Session-wide diff** (default): First `step-start` snapshot → last `step-finish` snapshot across all messages - - **Last turn diff** (`-t` flag): Finds the last user message, collects all assistant messages with matching `parentID`, then diffs first→last snapshot within that turn -- Validates snapshot objects exist in git before attempting diff -- Provides clear error messages - -**Usage:** - -```bash -./test.sh # List sessions -./test.sh # Session-wide diff -./test.sh -t # Last turn diff -./test.sh --turn # Last turn diff -./test.sh -h # Help -``` - -### Key Files - -- **Script we created:** `/home/naowal/Desktop/code/test/test.sh` -- **Opencode source (for reference):** - - `/home/naowal/Desktop/code/opencode/packages/opencode/src/snapshot/index.ts` - Core snapshot operations - - `/home/naowal/Desktop/code/opencode/packages/opencode/src/session/summary.ts` - Shows how turns are grouped (lines 65-66) - - `/home/naowal/Desktop/code/opencode/packages/opencode/src/session/message-v2.ts` - Message/part schemas - - `/home/naowal/Desktop/code/opencode/packages/opencode/src/session/processor.ts` - Creates snapshots during LLM streaming - -### Current State - -The script works correctly. When tested, it: - -- Successfully lists sessions -- Correctly identifies turns by finding user messages and their associated assistant responses via `parentID` -- Properly extracts snapshot hashes from part files -- Handles cases where snapshots were garbage collected - -**Note:** Some older sessions have snapshot hashes that no longer exist in the git repo (garbage collected), which the script handles gracefully with an error message. - -### Potential Next Steps - -1. Add support for diffing a specific turn by number (not just the last one) -2. Add `--stat` option for summary instead of full diff -3. Add option to output diff to a file -4. Add support for comparing current working tree to a specific snapshot -5. Create a version that outputs JSON for programmatic use diff --git a/test.sh b/test.sh deleted file mode 100755 index 08fec72..0000000 --- a/test.sh +++ /dev/null @@ -1,193 +0,0 @@ -#!/bin/bash - -# Usage: ./test.sh [options] [session_id] -# -t, --turn Show diff for last turn only (user message + all assistant responses) -# -h, --help Show help -# -# A "turn" in opencode is: one user message + all assistant messages that respond to it -# Assistant messages have a "parentID" field pointing to the user message they respond to - -STORAGE="$HOME/.local/share/opencode/storage" -SNAPSHOT_DIR="$HOME/.local/share/opencode/snapshot" -LAST_TURN=false - -# Parse options -while [[ $# -gt 0 ]]; do - case $1 in - -t|--turn) - LAST_TURN=true - shift - ;; - -h|--help) - echo "Usage: $0 [options] [session_id]" - echo "" - echo "Options:" - echo " -t, --turn Show diff for last turn only (default: session-wide)" - echo " -h, --help Show this help" - echo "" - echo "A 'turn' = user message + all assistant messages responding to it." - echo "If no session_id provided, lists available sessions." - exit 0 - ;; - *) - SESSION="$1" - shift - ;; - esac -done - -# List sessions if no session provided -if [ -z "$SESSION" ]; then - echo "Available sessions:" - for proj_dir in "$STORAGE/session"/*; do - [ -d "$proj_dir" ] || continue - for sess_file in "$proj_dir"/*.json; do - [ -f "$sess_file" ] || continue - sess_id=$(basename "$sess_file" .json) - title=$(grep -o '"title": *"[^"]*"' "$sess_file" | head -1 | sed 's/"title": *"\([^"]*\)"/\1/') - echo " $sess_id - $title" - done - done - echo "" - echo "Usage: $0 [-t|--turn] " - exit 0 -fi - -# Find the session file to get project ID -SESS_FILE=$(find "$STORAGE/session" -name "${SESSION}.json" 2>/dev/null | head -1) -if [ -z "$SESS_FILE" ]; then - echo "Session not found: $SESSION" - exit 1 -fi - -PROJECT_ID=$(grep -o '"projectID": *"[^"]*"' "$SESS_FILE" | sed 's/"projectID": *"\([^"]*\)"/\1/') -SNAPSHOT_GIT="$SNAPSHOT_DIR/$PROJECT_ID" - -echo "Session: $SESSION" -echo "Project: $PROJECT_ID" -if [ "$LAST_TURN" = true ]; then - echo "Mode: Last turn diff" -else - echo "Mode: Session-wide diff" -fi -echo "" - -# Get all message IDs for this session (sorted by filename which includes timestamp) -MESSAGES=$(ls "$STORAGE/message/$SESSION/" 2>/dev/null | sed 's/\.json$//' | sort) - -if [ -z "$MESSAGES" ]; then - echo "No messages found for session $SESSION" - exit 1 -fi - -if [ "$LAST_TURN" = true ]; then - # Find the last user message - LAST_USER="" - for MSG in $MESSAGES; do - MSG_FILE="$STORAGE/message/$SESSION/${MSG}.json" - ROLE=$(grep -o '"role": *"[^"]*"' "$MSG_FILE" | sed 's/"role": *"\([^"]*\)"/\1/') - if [ "$ROLE" = "user" ]; then - LAST_USER="$MSG" - fi - done - - if [ -z "$LAST_USER" ]; then - echo "No user messages found" - exit 1 - fi - - echo "Last user message: $LAST_USER" - - # Find all assistant messages with parentID == LAST_USER - TURN_MESSAGES="" - for MSG in $MESSAGES; do - MSG_FILE="$STORAGE/message/$SESSION/${MSG}.json" - ROLE=$(grep -o '"role": *"[^"]*"' "$MSG_FILE" | sed 's/"role": *"\([^"]*\)"/\1/') - if [ "$ROLE" = "assistant" ]; then - PARENT_ID=$(grep -o '"parentID": *"[^"]*"' "$MSG_FILE" | sed 's/"parentID": *"\([^"]*\)"/\1/') - if [ "$PARENT_ID" = "$LAST_USER" ]; then - TURN_MESSAGES="$TURN_MESSAGES $MSG" - fi - fi - done - - if [ -z "$TURN_MESSAGES" ]; then - echo "No assistant responses found for this turn" - exit 1 - fi - - echo "Assistant messages in turn:$TURN_MESSAGES" - - # Find first step-start and last step-finish across all messages in the turn - FIRST="" - LAST="" - - for MSG in $TURN_MESSAGES; do - PART_DIR="$STORAGE/part/$MSG" - if [ -d "$PART_DIR" ]; then - for PART_FILE in "$PART_DIR"/*.json; do - [ -f "$PART_FILE" ] || continue - TYPE=$(grep -o '"type": *"[^"]*"' "$PART_FILE" | head -1 | sed 's/.*"\([^"]*\)"$/\1/') - SNAP=$(grep -o '"snapshot": *"[^"]*"' "$PART_FILE" | head -1 | sed 's/.*"\([^"]*\)"$/\1/') - - if [ "$TYPE" = "step-start" ] && [ -n "$SNAP" ] && [ -z "$FIRST" ]; then - FIRST="$SNAP" - fi - if [ "$TYPE" = "step-finish" ] && [ -n "$SNAP" ]; then - LAST="$SNAP" - fi - done - fi - done -else - # Session-wide: first step-start to last step-finish across all messages - FIRST="" - LAST="" - - for MSG in $MESSAGES; do - PART_DIR="$STORAGE/part/$MSG" - if [ -d "$PART_DIR" ]; then - for PART_FILE in "$PART_DIR"/*.json; do - [ -f "$PART_FILE" ] || continue - TYPE=$(grep -o '"type": *"[^"]*"' "$PART_FILE" | head -1 | sed 's/.*"\([^"]*\)"$/\1/') - SNAP=$(grep -o '"snapshot": *"[^"]*"' "$PART_FILE" | head -1 | sed 's/.*"\([^"]*\)"$/\1/') - - if [ "$TYPE" = "step-start" ] && [ -n "$SNAP" ] && [ -z "$FIRST" ]; then - FIRST="$SNAP" - fi - if [ "$TYPE" = "step-finish" ] && [ -n "$SNAP" ]; then - LAST="$SNAP" - fi - done - fi - done -fi - -echo "" -echo "First snapshot: $FIRST" -echo "Last snapshot: $LAST" - -if [ -z "$FIRST" ] || [ -z "$LAST" ]; then - echo "Could not find snapshots in session" - exit 1 -fi - -if [ "$FIRST" = "$LAST" ]; then - echo "No changes (same snapshot)" - exit 0 -fi - -# Verify objects exist -if ! git --git-dir="$SNAPSHOT_GIT" cat-file -t "$FIRST" >/dev/null 2>&1; then - echo "ERROR: First snapshot object no longer exists in git repo (may have been garbage collected)" - exit 1 -fi - -if ! git --git-dir="$SNAPSHOT_GIT" cat-file -t "$LAST" >/dev/null 2>&1; then - echo "ERROR: Last snapshot object no longer exists in git repo (may have been garbage collected)" - exit 1 -fi - -echo "" -echo "=== DIFF ===" -git --git-dir="$SNAPSHOT_GIT" diff "$FIRST" "$LAST" From b3036250e0fe31c68c7d449c6e9217bd7f1ee153 Mon Sep 17 00:00:00 2001 From: Naowal Rahman Date: Mon, 19 Jan 2026 21:38:33 -0500 Subject: [PATCH 3/5] fix: improve session diff robustness and configurability --- lua/opencode/config.lua | 4 ++++ lua/opencode/diff.lua | 25 +++++++++++++++++-------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index cc12726..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, diff --git a/lua/opencode/diff.lua b/lua/opencode/diff.lua index 0786484..654b7e3 100644 --- a/lua/opencode/diff.lua +++ b/lua/opencode/diff.lua @@ -1,9 +1,7 @@ ---Session diff functionality for opencode.nvim ---Uses codediff.nvim to display diffs between session snapshots. local M = {} - -local STORAGE = vim.fn.expand("$HOME/.local/share/opencode/storage") -local SNAPSHOT_DIR = vim.fn.expand("$HOME/.local/share/opencode/snapshot") +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) @@ -31,7 +29,7 @@ end ---@param session_id string ---@return string[] local function get_message_ids(session_id) - local msg_dir = STORAGE .. "/message/" .. session_id + local msg_dir = data_dir .. "/storage/message/" .. session_id local handle = vim.uv.fs_scandir(msg_dir) if not handle then return {} @@ -57,7 +55,7 @@ end ---@param msg_id string ---@return { step_starts: string[], step_finishes: string[] } local function get_message_snapshots(msg_id) - local part_dir = STORAGE .. "/part/" .. 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 = {} } @@ -137,6 +135,7 @@ local function export_tree(snapshot_git, tree_hash, callback) vim.system({ "git", "--git-dir=" .. snapshot_git, "cat-file", "-t", tree_hash }, { text = true }, function(result) if result.code ~= 0 then vim.schedule(function() + vim.fn.delete(temp_dir, "rf") callback("Snapshot object no longer exists (may have been garbage collected): " .. tree_hash, nil) end) return @@ -146,6 +145,7 @@ local function export_tree(snapshot_git, tree_hash, callback) vim.system({ "git", "--git-dir=" .. snapshot_git, "archive", tree_hash }, { text = false }, function(archive_result) if archive_result.code ~= 0 then vim.schedule(function() + vim.fn.delete(temp_dir, "rf") callback("Failed to archive tree: " .. (archive_result.stderr or ""), nil) end) return @@ -158,6 +158,7 @@ local function export_tree(snapshot_git, tree_hash, callback) function(tar_result) vim.schedule(function() if tar_result.code ~= 0 then + vim.fn.delete(temp_dir, "rf") callback("Failed to extract archive: " .. (tar_result.stderr or ""), nil) else callback(nil, temp_dir) @@ -187,7 +188,7 @@ local function show_diff(snapshots) return end - local snapshot_git = SNAPSHOT_DIR .. "/" .. snapshots.project_id + local snapshot_git = data_dir .. "/snapshot/" .. snapshots.project_id -- Check if snapshot git directory exists if vim.fn.isdirectory(snapshot_git) == 0 then @@ -218,12 +219,20 @@ local function show_diff(snapshots) ) -- Use CodeDiff command to compare directories - vim.cmd("CodeDiff " .. vim.fn.fnameescape(dir1) .. " " .. vim.fn.fnameescape(dir2)) + local ok, cmd_err = pcall(vim.cmd, "CodeDiff " .. vim.fn.fnameescape(dir1) .. " " .. vim.fn.fnameescape(dir2)) + if not ok then + vim.notify("Failed to run CodeDiff: " .. tostring(cmd_err), vim.log.levels.ERROR, { title = "opencode" }) + vim.fn.delete(dir1, "rf") + vim.fn.delete(dir2, "rf") + return + end -- Schedule cleanup of temp directories when the tab is closed + local tab_id = vim.api.nvim_get_current_tabpage() local cleanup_group = vim.api.nvim_create_augroup("OpencodeDiffCleanup", { clear = false }) vim.api.nvim_create_autocmd("TabClosed", { group = cleanup_group, + pattern = tostring(tab_id), once = true, callback = function() vim.fn.delete(dir1, "rf") @@ -234,7 +243,7 @@ local function show_diff(snapshots) end) end ----Show session-wide diff (first step-start → last step-finish across all messages) +-- 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 From 2befc2922226b61e02bdcdd44741889985a49c8c Mon Sep 17 00:00:00 2001 From: Naowal Rahman Date: Mon, 19 Jan 2026 21:48:28 -0500 Subject: [PATCH 4/5] fix: improve session diff error reporting and command execution --- lua/opencode/diff.lua | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lua/opencode/diff.lua b/lua/opencode/diff.lua index 654b7e3..e9ccd80 100644 --- a/lua/opencode/diff.lua +++ b/lua/opencode/diff.lua @@ -198,14 +198,14 @@ local function show_diff(snapshots) -- Export both trees and show diff export_tree(snapshot_git, snapshots.first, function(err1, dir1) - if err1 then - vim.notify(err1, vim.log.levels.ERROR, { title = "opencode" }) + 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 then - vim.notify(err2, vim.log.levels.ERROR, { title = "opencode" }) + if err2 or not dir2 then + vim.notify(err2 or "Failed to export second snapshot", vim.log.levels.ERROR, { title = "opencode" }) -- Clean up first temp dir vim.fn.delete(dir1, "rf") return @@ -219,7 +219,9 @@ local function show_diff(snapshots) ) -- Use CodeDiff command to compare directories - local ok, cmd_err = pcall(vim.cmd, "CodeDiff " .. vim.fn.fnameescape(dir1) .. " " .. vim.fn.fnameescape(dir2)) + 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" }) vim.fn.delete(dir1, "rf") From 037d99102560751ce734215d9648f6320a224814 Mon Sep 17 00:00:00 2001 From: Naowal Rahman Date: Mon, 19 Jan 2026 21:50:44 -0500 Subject: [PATCH 5/5] refactor: remove unnecessary temp directory cleanup in diff.lua --- lua/opencode/diff.lua | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/lua/opencode/diff.lua b/lua/opencode/diff.lua index e9ccd80..a799e83 100644 --- a/lua/opencode/diff.lua +++ b/lua/opencode/diff.lua @@ -135,7 +135,6 @@ local function export_tree(snapshot_git, tree_hash, callback) vim.system({ "git", "--git-dir=" .. snapshot_git, "cat-file", "-t", tree_hash }, { text = true }, function(result) if result.code ~= 0 then vim.schedule(function() - vim.fn.delete(temp_dir, "rf") callback("Snapshot object no longer exists (may have been garbage collected): " .. tree_hash, nil) end) return @@ -145,7 +144,6 @@ local function export_tree(snapshot_git, tree_hash, callback) vim.system({ "git", "--git-dir=" .. snapshot_git, "archive", tree_hash }, { text = false }, function(archive_result) if archive_result.code ~= 0 then vim.schedule(function() - vim.fn.delete(temp_dir, "rf") callback("Failed to archive tree: " .. (archive_result.stderr or ""), nil) end) return @@ -158,7 +156,6 @@ local function export_tree(snapshot_git, tree_hash, callback) function(tar_result) vim.schedule(function() if tar_result.code ~= 0 then - vim.fn.delete(temp_dir, "rf") callback("Failed to extract archive: " .. (tar_result.stderr or ""), nil) else callback(nil, temp_dir) @@ -206,8 +203,6 @@ local function show_diff(snapshots) 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" }) - -- Clean up first temp dir - vim.fn.delete(dir1, "rf") return end @@ -224,23 +219,7 @@ local function show_diff(snapshots) end) if not ok then vim.notify("Failed to run CodeDiff: " .. tostring(cmd_err), vim.log.levels.ERROR, { title = "opencode" }) - vim.fn.delete(dir1, "rf") - vim.fn.delete(dir2, "rf") - return end - - -- Schedule cleanup of temp directories when the tab is closed - local tab_id = vim.api.nvim_get_current_tabpage() - local cleanup_group = vim.api.nvim_create_augroup("OpencodeDiffCleanup", { clear = false }) - vim.api.nvim_create_autocmd("TabClosed", { - group = cleanup_group, - pattern = tostring(tab_id), - once = true, - callback = function() - vim.fn.delete(dir1, "rf") - vim.fn.delete(dir2, "rf") - end, - }) end) end) end