From a2b3cc1600e9cab58147ef01c03f9889b5a8cc86 Mon Sep 17 00:00:00 2001 From: Neha Prasad Date: Wed, 1 Apr 2026 02:43:37 +0530 Subject: [PATCH] feat(opencode): add changed-files tree with change indicators (#815) * feat(opencode): add changed-files tree with change indicators * feat(opencode): address changed-files review feedback --------- Co-authored-by: Affaan Mustafa --- .opencode/index.ts | 1 + .opencode/opencode.json | 3 +- .opencode/plugins/ecc-hooks.ts | 76 ++++++++- .opencode/plugins/lib/changed-files-store.ts | 98 +++++++++++ .opencode/tools/changed-files.ts | 81 +++++++++ .opencode/tools/index.ts | 1 + tests/lib/changed-files-store.test.js | 163 +++++++++++++++++++ 7 files changed, 417 insertions(+), 6 deletions(-) create mode 100644 .opencode/plugins/lib/changed-files-store.ts create mode 100644 .opencode/tools/changed-files.ts create mode 100644 tests/lib/changed-files-store.test.js diff --git a/.opencode/index.ts b/.opencode/index.ts index c8736525..8ff13a0a 100644 --- a/.opencode/index.ts +++ b/.opencode/index.ts @@ -74,6 +74,7 @@ export const metadata = { "format-code", "lint-check", "git-summary", + "changed-files", ], }, } diff --git a/.opencode/opencode.json b/.opencode/opencode.json index 0c11ee27..947302f5 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -31,7 +31,8 @@ "write": true, "edit": true, "bash": true, - "read": true + "read": true, + "changed-files": true } }, "planner": { diff --git a/.opencode/plugins/ecc-hooks.ts b/.opencode/plugins/ecc-hooks.ts index 9193bb41..58a20928 100644 --- a/.opencode/plugins/ecc-hooks.ts +++ b/.opencode/plugins/ecc-hooks.ts @@ -14,6 +14,14 @@ */ import type { PluginInput } from "@opencode-ai/plugin" +import * as fs from "fs" +import * as path from "path" +import { + initStore, + recordChange, + clearChanges, +} from "./lib/changed-files-store.js" +import changedFilesTool from "../tools/changed-files.js" export const ECCHooksPlugin = async ({ client, @@ -23,9 +31,25 @@ export const ECCHooksPlugin = async ({ }: PluginInput) => { type HookProfile = "minimal" | "standard" | "strict" - // Track files edited in current session for console.log audit + const worktreePath = worktree || directory + initStore(worktreePath) + const editedFiles = new Set() + function resolvePath(p: string): string { + if (path.isAbsolute(p)) return p + return path.join(worktreePath, p) + } + + const pendingToolChanges = new Map() + let writeCounter = 0 + + function getFilePath(args: Record | undefined): string | null { + if (!args) return null + const p = (args.filePath ?? args.file_path ?? args.path) as string | undefined + return typeof p === "string" && p.trim() ? p : null + } + // Helper to call the SDK's log API with correct signature const log = (level: "debug" | "info" | "warn" | "error", message: string) => client.app.log({ body: { service: "ecc", level, message } }) @@ -73,8 +97,8 @@ export const ECCHooksPlugin = async ({ * Action: Runs prettier --write on the file */ "file.edited": async (event: { path: string }) => { - // Track edited files for console.log audit editedFiles.add(event.path) + recordChange(event.path, "modified") // Auto-format JS/TS files if (hookEnabled("post:edit:format", ["strict"]) && event.path.match(/\.(ts|tsx|js|jsx)$/)) { @@ -111,9 +135,24 @@ export const ECCHooksPlugin = async ({ * Action: Runs tsc --noEmit to check for type errors */ "tool.execute.after": async ( - input: { tool: string; args?: { filePath?: string } }, + input: { tool: string; callID?: string; args?: { filePath?: string; file_path?: string; path?: string } }, output: unknown ) => { + const filePath = getFilePath(input.args as Record) + if (input.tool === "edit" && filePath) { + recordChange(filePath, "modified") + } + if (input.tool === "write" && filePath) { + const key = input.callID ?? `write-${++writeCounter}-${filePath}` + const pending = pendingToolChanges.get(key) + if (pending) { + recordChange(pending.path, pending.type) + pendingToolChanges.delete(key) + } else { + recordChange(filePath, "modified") + } + } + // Check if a TypeScript file was edited if ( hookEnabled("post:edit:typecheck", ["strict"]) && @@ -152,8 +191,25 @@ export const ECCHooksPlugin = async ({ * Action: Warns about potential security issues */ "tool.execute.before": async ( - input: { tool: string; args?: Record } + input: { tool: string; callID?: string; args?: Record } ) => { + if (input.tool === "write") { + const filePath = getFilePath(input.args) + if (filePath) { + const absPath = resolvePath(filePath) + let type: "added" | "modified" = "modified" + try { + if (typeof fs.existsSync === "function") { + type = fs.existsSync(absPath) ? "modified" : "added" + } + } catch { + type = "modified" + } + const key = input.callID ?? `write-${++writeCounter}-${filePath}` + pendingToolChanges.set(key, { path: filePath, type }) + } + } + // Git push review reminder if ( hookEnabled("pre:bash:git-push-reminder", "strict") && @@ -293,6 +349,8 @@ export const ECCHooksPlugin = async ({ if (!hookEnabled("session:end-marker", ["minimal", "standard", "strict"])) return log("info", "[ECC] Session ended - cleaning up") editedFiles.clear() + clearChanges() + pendingToolChanges.clear() }, /** @@ -303,6 +361,10 @@ export const ECCHooksPlugin = async ({ * Action: Updates tracking */ "file.watcher.updated": async (event: { path: string; type: string }) => { + let changeType: "added" | "modified" | "deleted" = "modified" + if (event.type === "create" || event.type === "add") changeType = "added" + else if (event.type === "delete" || event.type === "remove") changeType = "deleted" + recordChange(event.path, changeType) if (event.type === "change" && event.path.match(/\.(ts|tsx|js|jsx)$/)) { editedFiles.add(event.path) } @@ -394,7 +456,7 @@ export const ECCHooksPlugin = async ({ "", "## Active Plugin: Everything Claude Code v1.8.0", "- Hooks: file.edited, tool.execute.before/after, session.created/idle/deleted, shell.env, compacting, permission.ask", - "- Tools: run-tests, check-coverage, security-audit, format-code, lint-check, git-summary", + "- Tools: run-tests, check-coverage, security-audit, format-code, lint-check, git-summary, changed-files", "- Agents: 13 specialized (planner, architect, tdd-guide, code-reviewer, security-reviewer, build-error-resolver, e2e-runner, refactor-cleaner, doc-updater, go-reviewer, go-build-resolver, database-reviewer, python-reviewer)", "", "## Key Principles", @@ -449,6 +511,10 @@ export const ECCHooksPlugin = async ({ // Everything else: let user decide return { approved: undefined } }, + + tool: { + "changed-files": changedFilesTool, + }, } } diff --git a/.opencode/plugins/lib/changed-files-store.ts b/.opencode/plugins/lib/changed-files-store.ts new file mode 100644 index 00000000..df22f812 --- /dev/null +++ b/.opencode/plugins/lib/changed-files-store.ts @@ -0,0 +1,98 @@ +import * as path from "path" + +export type ChangeType = "added" | "modified" | "deleted" + +const changes = new Map() +let worktreeRoot = "" + +export function initStore(worktree: string): void { + worktreeRoot = worktree || process.cwd() +} + +function toRelative(p: string): string { + if (!p) return "" + const normalized = path.normalize(p) + if (path.isAbsolute(normalized) && worktreeRoot) { + const rel = path.relative(worktreeRoot, normalized) + return rel.startsWith("..") ? normalized : rel + } + return normalized +} + +export function recordChange(filePath: string, type: ChangeType): void { + const rel = toRelative(filePath) + if (!rel) return + changes.set(rel, type) +} + +export function getChanges(): Map { + return new Map(changes) +} + +export function clearChanges(): void { + changes.clear() +} + +export type TreeNode = { + name: string + path: string + changeType?: ChangeType + children: TreeNode[] +} + +function addToTree(children: TreeNode[], segs: string[], fullPath: string, changeType: ChangeType): void { + if (segs.length === 0) return + const [head, ...rest] = segs + let child = children.find((c) => c.name === head) + + if (rest.length === 0) { + if (child) { + child.changeType = changeType + child.path = fullPath + } else { + children.push({ name: head, path: fullPath, changeType, children: [] }) + } + return + } + + if (!child) { + const dirPath = segs.slice(0, -rest.length).join(path.sep) + child = { name: head, path: dirPath, children: [] } + children.push(child) + } + addToTree(child.children, rest, fullPath, changeType) +} + +export function buildTree(filter?: ChangeType): TreeNode[] { + const root: TreeNode[] = [] + for (const [relPath, changeType] of changes) { + if (filter && changeType !== filter) continue + const segs = relPath.split(path.sep).filter(Boolean) + if (segs.length === 0) continue + addToTree(root, segs, relPath, changeType) + } + + function sortNodes(nodes: TreeNode[]): TreeNode[] { + return [...nodes].sort((a, b) => { + const aIsFile = a.changeType !== undefined + const bIsFile = b.changeType !== undefined + if (aIsFile !== bIsFile) return aIsFile ? 1 : -1 + return a.name.localeCompare(b.name) + }).map((n) => ({ ...n, children: sortNodes(n.children) })) + } + return sortNodes(root) +} + +export function getChangedPaths(filter?: ChangeType): Array<{ path: string; changeType: ChangeType }> { + const list: Array<{ path: string; changeType: ChangeType }> = [] + for (const [p, t] of changes) { + if (filter && t !== filter) continue + list.push({ path: p, changeType: t }) + } + list.sort((a, b) => a.path.localeCompare(b.path)) + return list +} + +export function hasChanges(): boolean { + return changes.size > 0 +} diff --git a/.opencode/tools/changed-files.ts b/.opencode/tools/changed-files.ts new file mode 100644 index 00000000..09cdacd3 --- /dev/null +++ b/.opencode/tools/changed-files.ts @@ -0,0 +1,81 @@ +import { tool } from "@opencode-ai/plugin/tool" +import { + buildTree, + getChangedPaths, + hasChanges, + type ChangeType, + type TreeNode, +} from "../plugins/lib/changed-files-store.js" + +const INDICATORS: Record = { + added: "+", + modified: "~", + deleted: "-", +} + +function renderTree(nodes: TreeNode[], indent: string): string { + const lines: string[] = [] + for (const node of nodes) { + const indicator = node.changeType ? ` (${INDICATORS[node.changeType]})` : "" + const name = node.changeType ? `${node.name}${indicator}` : `${node.name}/` + lines.push(`${indent}${name}`) + if (node.children.length > 0) { + lines.push(renderTree(node.children, `${indent} `)) + } + } + return lines.join("\n") +} + +export default tool({ + description: + "List files changed by agents in this session as a navigable tree. Shows added (+), modified (~), and deleted (-) indicators. Use filter to show only specific change types. Returns paths for git diff.", + args: { + filter: tool.schema + .enum(["all", "added", "modified", "deleted"]) + .optional() + .describe("Filter by change type (default: all)"), + format: tool.schema + .enum(["tree", "json"]) + .optional() + .describe("Output format: tree for terminal display, json for structured data (default: tree)"), + }, + async execute(args, context) { + const filter = args.filter === "all" || !args.filter ? undefined : (args.filter as ChangeType) + const format = args.format ?? "tree" + + if (!hasChanges()) { + return JSON.stringify({ changed: false, message: "No files changed in this session" }) + } + + const paths = getChangedPaths(filter) + + if (format === "json") { + return JSON.stringify( + { + changed: true, + filter: filter ?? "all", + files: paths.map((p) => ({ path: p.path, changeType: p.changeType })), + diffCommands: paths + .filter((p) => p.changeType !== "added") + .map((p) => `git diff ${p.path}`), + }, + null, + 2 + ) + } + + const tree = buildTree(filter) + const treeStr = renderTree(tree, "") + const diffHint = paths + .filter((p) => p.changeType !== "added") + .slice(0, 5) + .map((p) => ` git diff ${p.path}`) + .join("\n") + + let output = `Changed files (${paths.length}):\n\n${treeStr}` + if (diffHint) { + output += `\n\nTo view diff for a file:\n${diffHint}` + } + return output + }, +}) diff --git a/.opencode/tools/index.ts b/.opencode/tools/index.ts index dabacc4e..d05dc0bb 100644 --- a/.opencode/tools/index.ts +++ b/.opencode/tools/index.ts @@ -11,3 +11,4 @@ export { default as securityAudit } from "./security-audit.js" export { default as formatCode } from "./format-code.js" export { default as lintCheck } from "./lint-check.js" export { default as gitSummary } from "./git-summary.js" +export { default as changedFiles } from "./changed-files.js" diff --git a/tests/lib/changed-files-store.test.js b/tests/lib/changed-files-store.test.js new file mode 100644 index 00000000..c7c4e0fe --- /dev/null +++ b/tests/lib/changed-files-store.test.js @@ -0,0 +1,163 @@ +const assert = require('assert') +const path = require('path') +const { pathToFileURL } = require('url') + +const repoRoot = path.join(__dirname, '..', '..') +const storePath = path.join(repoRoot, '.opencode', 'dist', 'plugins', 'lib', 'changed-files-store.js') + +function test(name, fn) { + try { + fn() + console.log(` āœ“ ${name}`) + return true + } catch (err) { + console.log(` āœ— ${name}`) + console.log(` Error: ${err.message}`) + return false + } +} + +async function runTests() { + let passed = 0 + let failed = 0 + + let store + try { + store = await import(pathToFileURL(storePath).href) + } catch (err) { + console.log('\n⚠ Skipping: build .opencode first (cd .opencode && npm run build)\n') + process.exit(0) + } + + const { initStore, recordChange, buildTree, clearChanges, getChanges, getChangedPaths, hasChanges } = store + const worktree = path.join(repoRoot, '.opencode') + + console.log('\n=== Testing changed-files-store ===\n') + + if ( + test('initStore and recordChange store relative path', () => { + clearChanges() + initStore(worktree) + recordChange(path.join(worktree, 'src/foo.ts'), 'modified') + const m = getChanges() + assert.strictEqual(m.size, 1) + assert.ok(m.has('src/foo.ts') || m.has(path.join('src', 'foo.ts'))) + assert.strictEqual(m.get(m.keys().next().value), 'modified') + }) + ) + passed++ + else failed++ + + if ( + test('recordChange with relative path stores as-is when under worktree', () => { + clearChanges() + initStore(worktree) + recordChange('plugins/ecc-hooks.ts', 'modified') + const m = getChanges() + assert.strictEqual(m.size, 1) + const key = [...m.keys()][0] + assert.ok(key.includes('ecc-hooks')) + }) + ) + passed++ + else failed++ + + if ( + test('recordChange overwrites existing path with new type', () => { + clearChanges() + initStore(worktree) + recordChange('a.ts', 'modified') + recordChange('a.ts', 'added') + const m = getChanges() + assert.strictEqual(m.size, 1) + assert.strictEqual(m.get([...m.keys()][0]), 'added') + }) + ) + passed++ + else failed++ + + if ( + test('buildTree returns nested structure', () => { + clearChanges() + initStore(worktree) + recordChange('src/a.ts', 'modified') + recordChange('src/b.ts', 'added') + recordChange('src/sub/c.ts', 'deleted') + const tree = buildTree() + assert.strictEqual(tree.length, 1) + assert.strictEqual(tree[0].name, 'src') + assert.strictEqual(tree[0].children.length, 3) + const names = tree[0].children.map((n) => n.name).sort() + assert.deepStrictEqual(names, ['a.ts', 'b.ts', 'sub']) + }) + ) + passed++ + else failed++ + + if ( + test('buildTree filter restricts by change type', () => { + clearChanges() + initStore(worktree) + recordChange('a.ts', 'added') + recordChange('b.ts', 'modified') + recordChange('c.ts', 'deleted') + const added = buildTree('added') + assert.strictEqual(added.length, 1) + assert.strictEqual(added[0].changeType, 'added') + const modified = buildTree('modified') + assert.strictEqual(modified.length, 1) + assert.strictEqual(modified[0].changeType, 'modified') + }) + ) + passed++ + else failed++ + + if ( + test('getChangedPaths returns sorted list with filter', () => { + clearChanges() + initStore(worktree) + recordChange('z.ts', 'modified') + recordChange('a.ts', 'modified') + const paths = getChangedPaths('modified') + assert.strictEqual(paths.length, 2) + assert.ok(paths[0].path <= paths[1].path) + }) + ) + passed++ + else failed++ + + if ( + test('hasChanges reflects state', () => { + clearChanges() + initStore(worktree) + assert.strictEqual(hasChanges(), false) + recordChange('x.ts', 'modified') + assert.strictEqual(hasChanges(), true) + clearChanges() + assert.strictEqual(hasChanges(), false) + }) + ) + passed++ + else failed++ + + if ( + test('clearChanges clears all', () => { + clearChanges() + initStore(worktree) + recordChange('a.ts', 'modified') + recordChange('b.ts', 'added') + clearChanges() + assert.strictEqual(getChanges().size, 0) + }) + ) + passed++ + else failed++ + + console.log(`\n${passed} passed, ${failed} failed`) + process.exit(failed > 0 ? 1 : 0) +} + +runTests().catch((err) => { + console.error(err) + process.exit(1) +})