Skip to content
Closed
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `"<leader>b"` → `"ctrl+b,<leader>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`). |
Expand Down
55 changes: 32 additions & 23 deletions packages/opencode/src/session/summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
readonly diff: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Snapshot.FileDiff[]>
Expand Down Expand Up @@ -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 })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/diff returns 200 with empty data.
Expand Down
205 changes: 205 additions & 0 deletions packages/opencode/test/session/summary.test.ts
Original file line number Diff line number Diff line change
@@ -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 },
),
)
})
Loading