diff --git a/CHANGELOG.md b/CHANGELOG.md index 70fab59937..ee7acd2d65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,14 @@ file is the human-readable summary of what changed and why. ### Sidebar +- **"Modified Files" shows real data** — the session-level diff powering the + sidebar's Modified Files section, the prompt-footer `ctrl+b N todos · M files` + hint, and the web app's session diff is now computed from session snapshots + (first `step-start` → last `step-finish`), and the session's + additions/deletions/files counters reflect it. Upstream publishes a + permanently empty session-level diff (only revert carries data), leaving all + of those surfaces dormant. + ([#71](https://github.com/quickbeard/codev-code/pull/71)) - **Hidden-sidebar hint** — when the sidebar is hidden but the session has active todos or modified files, the prompt footer shows a hint like `ctrl+b 3 todos · 5 files` (using your actual `sidebar_toggle` binding) while diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 5913c1b559..04b771b12a 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -96,6 +96,8 @@ from the squash message first, or use a plain merge. | `packages/tui/src/config/keybind.ts`, `packages/web/src/content/docs/keybinds.mdx` (+ `es/`), `packages/opencode/test/cli/run/footer.view.test.tsx` | `sidebar_toggle` default `"b"` → `"ctrl+b,b"` (ctrl+b first so single-binding displays — the prompt-footer sidebar hint, the sidebar tip — resolve to it); `ctrl+b`'s former jobs move to `ctrl+o`: `input_move_left` `"left,ctrl+b"` → `"left,ctrl+o"`, `session_background` `"ctrl+b"` → `"ctrl+o"`; docs defaults updated to match (docs don't list `session_background`); footer.view.test.tsx asserts the run-CLI footer background hint as `"ctrl+o background"` (was `"ctrl+b background"`) since it resolves the real `session_background` default | Single-chord sidebar toggle on `ctrl+b` (tmux/VS Code sidebar muscle memory); its displaced bindings land on `ctrl+o`, which was unbound in every scope and free of terminal-encoding collisions (unlike `ctrl+h`/`ctrl+i`/`ctrl+m`) and flow control (`ctrl+s`/`ctrl+q`). | Keep our defaults on all three keybinds, the doc lines, and the footer hint assertion; take upstream's surrounding changes. If upstream binds `ctrl+o` to something else, re-audit and pick a new free key. | | `packages/opencode/src/installation/index.ts` | Updater detects/queries/installs `codev-code` (npm-family); GitHub fallback → quickbeard/codev-code | `codev-code upgrade` and the update notification target OUR npm package, never stock opencode. brew/choco/scoop probes never match (no such packages). | Keep our package names. | | `packages/opencode/src/config/config.ts` | `share` defaults to `"disabled"` when unset | Sharing publishes sessions to upstream's share service; internal users must opt in via config. | Keep our default block. | +| `packages/opencode/src/session/summary.ts` | `summarize` computes the whole-session snapshot diff (`computeDiff` over all messages, reusing the turn diff when the session has one turn) and publishes it via `Session.Event.Diff` plus real `session.summary` aggregates, replacing upstream's unconditional zeros/`diff: []` (empty is still published on the clearing paths: snapshots disabled, no messages, target missing); `diff` without `messageID` returns the computed session-wide diff instead of `[]`; shared `unquoteDiffs` helper applies `unquoteGitPath` on every returned/published path | Upstream's session-level diff is permanently empty (only revert publishes data), leaving the TUI sidebar "Modified Files", the prompt-footer files hint, and the web app's session diff dormant. Deliberate fork behavior — upstream retired session-level diffs on purpose as a perf fix (sst/opencode#30127: a snapshot-heavy historical session recomputed a multi-megabyte full-session diff every turn); we accept the recompute cost, with the single-turn reuse as the common-case mitigation and counts-only publishing as the follow-up lever if it bites. | Keep our `summarize`/`diff` bodies and the `publish`/`unquoteDiffs` helpers; take upstream's changes to `computeDiff`/`unquoteGitPath` and surrounding code. Do not resurrect reads of the legacy `session_diff` storage key (regression #26574). | +| `packages/opencode/test/server/session-diff-missing-patch.test.ts` | Header comment updated: session-level diffs are now computed from message snapshots; legacy `session_diff` storage rows are still never read back | The comment said session-level diffs "are no longer surfaced", which the summary.ts row above makes untrue; assertions unchanged | Keep our comment; take upstream's test-body changes. | | Config-file rename: `packages/opencode/src/config/{config,managed,tui-migrate}.ts`, `packages/core/src/config.ts`, `packages/opencode/src/plugin/install.ts`, `packages/opencode/src/cli/cmd/{mcp,plug,providers,error}.ts`, user-facing strings (`packages/{tui,app}` dialogs/tips/i18n, skill descriptions in `packages/core/src/plugin/skill.ts` + `packages/opencode/src/skill/index.ts`), bundled prompts (`customize-opencode.md`, `initialize.txt`), tests seeding config fixtures, docs (`packages/web/src/content/docs` en + es) | Config file `opencode.json(c)` → `codev.json(c)` everywhere (global, project walk-up, config dirs, managed); global `config.json` support and the legacy TOML `config` migration removed; managed config dir `/Library/Application Support/opencode`→`…/codev`, `/etc/opencode`→`/etc/codev`, `%ProgramData%\opencode`→`…\codev` | The fork's config surface uses the fork's name; hard rename, no legacy fallback. NOT renamed: the `$schema` URL `https://opencode.ai/config.json`, `@opencode-ai/*` packages, theme asset files named `opencode.json`, brew/scoop manifest URLs. | Keep our file names; take upstream's surrounding logic. Rename any new upstream `opencode.json`/global-`config.json` reference to `codev.json` (watch the not-renamed list). | | `.opencode/` (repo root, whole directory) | Renamed to `.codev/`; inner `opencode.jsonc` → `codev.jsonc`; internal `.opencode/` path mentions in `glossary/README.md`, `skills/effect/SKILL.md`, `command/translate.md` updated; the `opencode-local` reference → `codev-local` pointing at `~/.local/share/codev` | This is the repo's own dogfooding config dir; after the config-dir rename row below, the agent only discovers `.codev/`. | Keep our directory; re-apply upstream's `.opencode/**` changes into `.codev/` (sync surfaces them as modify/delete or add conflicts). | | Config-dir rename: `packages/opencode/src/config/{paths,config,tui}.ts`, `packages/core/src/config.ts`, `packages/opencode/src/plugin/install.ts`, `packages/opencode/src/plugin/tui/runtime.ts`, `packages/tui/src/context/theme.tsx`, `packages/opencode/src/cli/cmd/{agent,mcp}.ts`, `packages/opencode/src/session/session.ts`, plan permission patterns (`packages/opencode/src/agent/agent.ts`, `packages/core/src/plugin/agent.ts`), TUI tips, skill descriptions, `customize-opencode.md`, `specs/tui-plugins.md`, tests creating `.opencode` fixtures, docs (en + es) | Config directory `.opencode` → `.codev` (project walk-up, `~/.codev`, plans dir, theme/plugin discovery) | The fork's config surface uses the fork's name; hard rename, no legacy fallback. NOT renamed: `~/.opencode/bin` install paths (binary location — see installation/uninstall rows), the `ai.opencode.managed` plist domain, identifiers like provider id `opencode` and theme asset keys. | Keep our dir name; take upstream's surrounding logic. Rename any new upstream `.opencode` dir reference to `.codev` (leave `~/.opencode/bin`). | diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 6484730d0b..6f69db13bd 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -63,6 +63,15 @@ function unquoteGitPath(input: string) { return Buffer.from(bytes).toString() } +function unquoteDiffs(diffs: Snapshot.FileDiff[]) { + return diffs.map((item) => { + if (item.file === undefined) return item + const file = unquoteGitPath(item.file) + if (file === item.file) return item + return { ...item, file } + }) +} + export interface Interface { readonly summarize: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect readonly diff: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect @@ -99,46 +108,46 @@ const layer = Layer.effect( return [] }) - const summarize = Effect.fn("SessionSummary.summarize")(function* (input: { - sessionID: SessionID - messageID: MessageID - }) { + const publish = Effect.fn("SessionSummary.publish")(function* (sessionID: SessionID, diffs: Snapshot.FileDiff[]) { yield* sessions.setSummary({ - sessionID: input.sessionID, + sessionID, summary: { - additions: 0, - deletions: 0, - files: 0, + additions: diffs.reduce((sum, x) => sum + x.additions, 0), + deletions: diffs.reduce((sum, x) => sum + x.deletions, 0), + files: diffs.length, }, }) - yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: [] }) - if ((yield* config.get()).snapshot === false) return + yield* events.publish(Session.Event.Diff, { sessionID, diff: unquoteDiffs(diffs) }) + }) + + const summarize = Effect.fn("SessionSummary.summarize")(function* (input: { + sessionID: SessionID + messageID: MessageID + }) { + if ((yield* config.get()).snapshot === false) return yield* publish(input.sessionID, []) const all = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie) - if (!all.length) return + if (!all.length) return yield* publish(input.sessionID, []) const messages = all.filter( (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), ) const target = messages.find((m) => m.info.id === input.messageID) - if (!target || target.info.role !== "user") return + if (!target || target.info.role !== "user") return yield* publish(input.sessionID, []) const msgDiffs = yield* computeDiff({ messages }) target.info.summary = { ...target.info.summary, diffs: msgDiffs } yield* sessions.updateMessage(target.info) + // The turn filter equals the full message list for single-turn sessions, + // so the session-wide diff is the turn diff — skip the second diffFull. + const diffs = messages.length === all.length ? msgDiffs : yield* computeDiff({ messages: all }) + yield* publish(input.sessionID, diffs) }) const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { - if (!input.messageID) return [] - const message = (yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie)).find( - (item) => item.info.id === input.messageID, - ) + const all = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie) + if (!input.messageID) return unquoteDiffs(yield* computeDiff({ messages: all })) + const message = all.find((item) => item.info.id === input.messageID) if (!message || message.info.role !== "user") return [] - const diffs = message.info.summary?.diffs ?? [] - return diffs.map((item) => { - if (item.file === undefined) return item - const file = unquoteGitPath(item.file) - if (file === item.file) return item - return { ...item, file } - }) + return unquoteDiffs(message.info.summary?.diffs ?? []) }) return Service.of({ summarize, diff, computeDiff }) diff --git a/packages/opencode/test/server/session-diff-missing-patch.test.ts b/packages/opencode/test/server/session-diff-missing-patch.test.ts index d2a4211ff1..ac466f1fb2 100644 --- a/packages/opencode/test/server/session-diff-missing-patch.test.ts +++ b/packages/opencode/test/server/session-diff-missing-patch.test.ts @@ -4,8 +4,9 @@ * the response was Schema-encoded against `Snapshot.FileDiff` with * `patch: Schema.String` (required), so any session whose stored * `summary_diffs` had a row without `patch` returned HTTP 400 and the - * session never loaded. Legacy session-level diffs are no longer surfaced, - * but the endpoint remains compatible and must still return successfully. + * session never loaded. Session-level diffs are computed from message + * snapshots; legacy `session_diff` storage rows are still never read back, + * and the endpoint must still return successfully. * * This test inserts a session row with a missing-patch diff entry and * asserts that GET /session//diff returns 200 with empty data. diff --git a/packages/opencode/test/session/summary.test.ts b/packages/opencode/test/session/summary.test.ts new file mode 100644 index 0000000000..4313f60dd3 --- /dev/null +++ b/packages/opencode/test/session/summary.test.ts @@ -0,0 +1,205 @@ +import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import fs from "fs/promises" +import path from "path" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect } from "effect" +import { Session } from "@/session/session" + +import { SessionSummary } from "../../src/session/summary" +import { Snapshot } from "../../src/snapshot" +import { MessageID, PartID, SessionID } from "../../src/session/schema" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" + +const it = testEffect( + LayerNode.compile( + LayerNode.group([Session.node, SessionSummary.node, Snapshot.node, SessionProjector.node, CrossSpawnSpawner.node]), + ), +) + +const user = Effect.fn("test.user")(function* (sessionID: SessionID) { + const session = yield* Session.Service + return yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user" as const, + sessionID, + agent: "default", + model: { providerID: ProviderV2.ID.make("openai"), modelID: ModelV2.ID.make("gpt-4") }, + time: { created: Date.now() }, + }) +}) + +const assistant = Effect.fn("test.assistant")(function* (sessionID: SessionID, parentID: MessageID, dir: string) { + const session = yield* Session.Service + return yield* session.updateMessage({ + id: MessageID.ascending(), + role: "assistant" as const, + sessionID, + mode: "default", + agent: "default", + path: { cwd: dir, root: dir }, + cost: 0, + tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ModelV2.ID.make("gpt-4"), + providerID: ProviderV2.ID.make("openai"), + parentID, + time: { created: Date.now() }, + finish: "end_turn", + }) +}) + +const write = (file: string, text: string) => Effect.promise(() => fs.writeFile(file, text)) + +const tokens = { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, +} + +describe("session summary diff", () => { + it.live( + "computes the session-wide diff across turns", + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const session = yield* Session.Service + const summary = yield* SessionSummary.Service + const snapshot = yield* Snapshot.Service + + yield* write(path.join(dir, "a.txt"), "a0") + yield* write(path.join(dir, "b.txt"), "b0") + + const info = yield* session.create({}) + const sid = info.id + + const turn = Effect.fn("test.turn")(function* (file: string, next: string) { + const u = yield* user(sid) + const a = yield* assistant(sid, u.id, dir) + const before = yield* snapshot.track() + if (!before) throw new Error("expected snapshot") + yield* write(path.join(dir, file), next) + const after = yield* snapshot.track() + if (!after) throw new Error("expected snapshot") + yield* session.updatePart({ + id: PartID.ascending(), + messageID: a.id, + sessionID: sid, + type: "step-start", + snapshot: before, + }) + yield* session.updatePart({ + id: PartID.ascending(), + messageID: a.id, + sessionID: sid, + type: "step-finish", + reason: "stop", + snapshot: after, + cost: 0, + tokens, + }) + return u.id + }) + + const first = yield* turn("a.txt", "a1") + const second = yield* turn("b.txt", "b2") + + yield* summary.summarize({ sessionID: sid, messageID: first }) + yield* summary.summarize({ sessionID: sid, messageID: second }) + + const diffs = yield* summary.diff({ sessionID: sid }) + expect(diffs.map((item) => item.file).toSorted()).toEqual(["a.txt", "b.txt"]) + for (const item of diffs) { + expect(item.status).toBe("modified") + expect(item.additions).toBe(1) + expect(item.deletions).toBe(1) + expect(item.patch?.length).toBeGreaterThan(0) + } + + expect((yield* session.get(sid)).summary).toMatchObject({ additions: 2, deletions: 2, files: 2 }) + + const turnDiffs = yield* summary.diff({ sessionID: sid, messageID: second }) + expect(turnDiffs.map((item) => item.file)).toEqual(["b.txt"]) + }), + { git: true }, + ), + ) + + it.live( + "matches the turn diff for single-turn sessions", + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const session = yield* Session.Service + const summary = yield* SessionSummary.Service + const snapshot = yield* Snapshot.Service + + yield* write(path.join(dir, "a.txt"), "a0") + + const info = yield* session.create({}) + const sid = info.id + + const u = yield* user(sid) + const a = yield* assistant(sid, u.id, dir) + const before = yield* snapshot.track() + if (!before) throw new Error("expected snapshot") + yield* write(path.join(dir, "a.txt"), "a1") + const after = yield* snapshot.track() + if (!after) throw new Error("expected snapshot") + yield* session.updatePart({ + id: PartID.ascending(), + messageID: a.id, + sessionID: sid, + type: "step-start", + snapshot: before, + }) + yield* session.updatePart({ + id: PartID.ascending(), + messageID: a.id, + sessionID: sid, + type: "step-finish", + reason: "stop", + snapshot: after, + cost: 0, + tokens, + }) + + yield* summary.summarize({ sessionID: sid, messageID: u.id }) + + const sessionDiffs = yield* summary.diff({ sessionID: sid }) + const turnDiffs = yield* summary.diff({ sessionID: sid, messageID: u.id }) + expect(sessionDiffs).toEqual(turnDiffs) + expect(sessionDiffs.map((item) => item.file)).toEqual(["a.txt"]) + expect((yield* session.get(sid)).summary).toMatchObject({ additions: 1, deletions: 1, files: 1 }) + }), + { git: true }, + ), + ) + + it.live( + "publishes zeros when the session has no snapshots", + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const session = yield* Session.Service + const summary = yield* SessionSummary.Service + + const info = yield* session.create({}) + const sid = info.id + + const u = yield* user(sid) + yield* assistant(sid, u.id, dir) + + yield* summary.summarize({ sessionID: sid, messageID: u.id }) + + expect(yield* summary.diff({ sessionID: sid })).toEqual([]) + expect((yield* session.get(sid)).summary).toMatchObject({ additions: 0, deletions: 0, files: 0 }) + }), + { git: true }, + ), + ) +})