Compare commits

..
77 changed files with 2652 additions and 340 deletions
+35 -35
View File
@@ -31,7 +31,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -53,7 +53,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -112,7 +112,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.2",
"version": "2.0.3",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -176,7 +176,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -202,7 +202,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -216,7 +216,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -252,7 +252,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -279,7 +279,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -296,7 +296,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -320,7 +320,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -340,7 +340,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/anthropic": "3.0.82",
@@ -412,7 +412,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
@@ -464,7 +464,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -501,7 +501,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -517,7 +517,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -536,7 +536,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -549,7 +549,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -563,7 +563,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -578,7 +578,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -617,7 +617,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -647,7 +647,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -662,7 +662,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -686,7 +686,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -707,7 +707,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -729,7 +729,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -764,7 +764,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -784,7 +784,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -818,7 +818,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -837,7 +837,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -883,7 +883,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -897,7 +897,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -932,7 +932,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -967,7 +967,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -1000,7 +1000,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1041,7 +1041,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.2",
"version": "2.0.3",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.2",
"version": "2.0.3",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
@@ -0,0 +1,47 @@
import { expect, story } from "../../storybook/playwright/story"
story("updates the timeline from the production slider and advanced settings", async ({ mount }) => {
const component = await mount("app-real-timeline--preset-explorer")
const slider = component.getByRole("slider", { name: "Timeline detail" })
await expect(slider).toHaveAttribute("aria-valuetext", "Compact")
await expect(component.getByText(/^Used 10 /)).toBeVisible()
await slider.press("End")
await expect(slider).toHaveAttribute("aria-valuetext", "Everything")
await expect(component.getByText(/^Used 10 /)).toHaveCount(0)
await component.getByRole("button", { name: "Advanced", exact: true }).click()
const shellGrouped = component.getByRole("switch", { name: "Shell grouped", exact: true })
await expect(shellGrouped).not.toBeChecked()
await shellGrouped.press("Space")
await expect(slider).toHaveAttribute("aria-valuetext", "Custom")
await expect(component.getByText(/^Used /)).toHaveCount(2)
})
story("renders a representative session with the desktop compact preset", async ({ mount }) => {
const component = await mount("app-real-timeline--representative-compact")
await expect(
component.getByText(/Used 10 Skill, Read, Grep, Glob, Execute, Shell, Web Search, Webfetch, Patch, Agent/),
).toBeVisible()
await expect(component.getByRole("img", { name: "lorem-layout.png" })).toBeVisible()
await expect(component.locator('[data-component="session-timeline"]')).toContainText("Lorem ipsum")
})
story("renders every server message family with the everything preset", async ({ mount }) => {
const component = await mount("app-real-timeline--all-server-parts-everything")
await expect(component.getByText("Build → Review", { exact: true })).toBeVisible()
await expect(component.getByText("Switched to ipsum-3", { exact: true })).toBeVisible()
await expect(component.getByText("/workspace/lorem/ipsum/dolor/sit/amet", { exact: true })).toBeVisible()
await expect(component.getByText("Command timed out", { exact: true })).toBeVisible()
await expect(component.getByText("Session compaction started", { exact: true })).toHaveCount(4)
})
story("keeps failures and required notices visible in text-only mode", async ({ mount }) => {
const component = await mount("app-real-timeline--failures-text-only")
await expect(component.getByText("Grep", { exact: true })).toBeVisible()
await expect(component.getByText("todo", { exact: true })).toBeVisible()
await expect(component.getByText("Shell finished · Background shell failed", { exact: true })).toBeVisible()
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.2",
"version": "2.0.3",
"description": "",
"type": "module",
"exports": {
@@ -0,0 +1,777 @@
import type {
JsonValue,
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageInfo,
SessionMessageUser,
} from "@opencode/client/promise"
import type { SessionDocument } from "@opencode/session-ui/document"
import type { SessionUserPresentation } from "@opencode/session-ui/timeline"
const TIME = 1_789_171_200_000
const MODEL = { providerID: "lorem", id: "ipsum-2", variant: "balanced" }
const LOREM =
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. "
function lorem(length: number) {
return LOREM.repeat(Math.ceil(length / LOREM.length))
.slice(0, length)
.trimEnd()
}
function output(length: number) {
const line = `${lorem(72)}\n`
return line
.repeat(Math.ceil(length / line.length))
.slice(0, length)
.trimEnd()
}
function user(id: string, text: string, offset: number): SessionMessageUser {
return {
id,
type: "user",
text,
files: [],
agents: [],
skills: [],
time: { created: TIME + offset },
metadata: { agent: "build", model: MODEL },
}
}
function assistant(input: {
id: string
offset: number
content: SessionMessageAssistant["content"]
finish?: SessionMessageAssistant["finish"]
error?: SessionMessageAssistant["error"]
retry?: SessionMessageAssistant["retry"]
completed?: boolean
}): SessionMessageAssistant {
return {
id: input.id,
type: "assistant",
agent: "build",
model: MODEL,
content: input.content,
finish: input.finish,
error: input.error,
retry: input.retry,
time: {
created: TIME + input.offset,
...(input.completed === false ? {} : { completed: TIME + input.offset + 1_200 }),
},
}
}
function reasoning(text: string, offset: number) {
return {
type: "reasoning",
text,
time: { created: TIME + offset, completed: TIME + offset + 500 },
} satisfies SessionMessageAssistant["content"][number]
}
function completedTool(input: {
id: string
name: string
offset: number
args: Record<string, JsonValue>
result: string
metadata?: Record<string, JsonValue>
files?: { uri: string; mime: string; name?: string }[]
}): SessionMessageAssistantTool {
return {
type: "tool",
id: input.id,
name: input.name,
state: {
status: "completed",
input: input.args,
content: [
{ type: "text", text: input.result },
...(input.files ?? []).map((file) => ({ type: "file" as const, ...file })),
],
metadata: input.metadata,
},
time: { created: TIME + input.offset, ran: TIME + input.offset + 100, completed: TIME + input.offset + 900 },
}
}
function errorTool(input: {
id: string
name: string
offset: number
args: Record<string, JsonValue>
message: string
metadata?: Record<string, JsonValue>
result?: string
}): SessionMessageAssistantTool {
return {
type: "tool",
id: input.id,
name: input.name,
state: {
status: "error",
input: input.args,
error: { type: "ToolExecutionError", message: input.message },
content: input.result ? [{ type: "text", text: input.result }] : undefined,
metadata: input.metadata,
},
time: { created: TIME + input.offset, ran: TIME + input.offset + 100, completed: TIME + input.offset + 700 },
}
}
function runningTool(input: {
id: string
name: string
offset: number
args: Record<string, JsonValue>
metadata?: Record<string, JsonValue>
}): SessionMessageAssistantTool {
return {
type: "tool",
id: input.id,
name: input.name,
state: { status: "running", input: input.args, metadata: input.metadata ?? {} },
time: { created: TIME + input.offset, ran: TIME + input.offset + 100 },
}
}
function document(messages: SessionMessageInfo[], status: SessionDocument["status"] = { type: "idle" }) {
return { sessionID: "session_real_timeline", messages, status, diffs: [] } satisfies SessionDocument
}
const representativeUser = {
...user("msg_real_user", lorem(1_440), 1_000),
files: [
{
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
mime: "image/png",
name: "lorem-layout.png",
source: { type: "inline" },
},
],
agents: [{ name: "review", mention: { text: "@review", start: 0, end: 7 } }],
} satisfies SessionMessageInfo
export const realTimelineDocument = document([
representativeUser,
assistant({
id: "msg_real_skill",
offset: 3_000,
finish: "tool-calls",
content: [
reasoning(lorem(210), 3_050),
completedTool({
id: "tool_real_skill",
name: "skill",
offset: 3_300,
args: { name: "lorem-guidance" },
result: output(12_800),
metadata: { name: "lorem-guidance" },
}),
],
}),
assistant({
id: "msg_real_read",
offset: 5_000,
finish: "tool-calls",
content: [
reasoning(lorem(190), 5_050),
completedTool({
id: "tool_real_read",
name: "read",
offset: 5_300,
args: { path: "src/lorem/timeline-controller.tsx", offset: 1, limit: 240 },
result: output(21_700),
metadata: { loaded: ["src/lorem/timeline-controller.tsx"] },
}),
],
}),
assistant({
id: "msg_real_search",
offset: 7_000,
finish: "tool-calls",
content: [
reasoning(lorem(205), 7_050),
completedTool({
id: "tool_real_grep",
name: "grep",
offset: 7_300,
args: { pattern: "lorem", path: "src", include: "*.tsx" },
result: output(3_500),
metadata: { matches: 18 },
}),
completedTool({
id: "tool_real_glob",
name: "glob",
offset: 7_500,
args: { pattern: "src/**/*.tsx" },
result: output(1_800),
metadata: { count: 42 },
}),
],
}),
assistant({
id: "msg_real_code",
offset: 9_000,
finish: "tool-calls",
content: [
reasoning(lorem(180), 9_050),
completedTool({
id: "tool_real_execute",
name: "execute",
offset: 9_300,
args: { code: "const result = await tools.lorem.inspect({ limit: 24 })\nreturn result" },
result: output(6_300),
metadata: {
toolCalls: [
{ name: "lorem.inspect", status: "completed" },
{ name: "lorem.measure", status: "completed" },
],
},
}),
],
}),
assistant({
id: "msg_real_shell",
offset: 11_000,
finish: "tool-calls",
content: [
reasoning(lorem(195), 11_050),
completedTool({
id: "tool_real_shell",
name: "shell",
offset: 11_300,
args: { command: "bun test src/lorem/timeline.test.ts" },
result: output(4_700),
metadata: { exit: 0 },
}),
],
}),
assistant({
id: "msg_real_web",
offset: 13_000,
finish: "tool-calls",
content: [
reasoning(lorem(200), 13_050),
completedTool({
id: "tool_real_websearch",
name: "websearch",
offset: 13_300,
args: { query: "lorem ipsum timeline guidance" },
result: output(16_500),
metadata: { provider: "lorem" },
}),
completedTool({
id: "tool_real_webfetch",
name: "webfetch",
offset: 13_500,
args: { url: "https://example.com/lorem" },
result: output(15_100),
}),
],
}),
{
id: "msg_real_system",
type: "system",
text: lorem(920),
description: "Instructions updated: lorem/timeline",
time: { created: TIME + 15_000 },
},
assistant({
id: "msg_real_edit",
offset: 16_000,
finish: "tool-calls",
content: [
reasoning(lorem(185), 16_050),
completedTool({
id: "tool_real_patch",
name: "patch",
offset: 16_300,
args: { patchText: lorem(3_200) },
result: lorem(190),
metadata: {
files: [
{
file: "src/lorem/real-timeline.tsx",
status: "modified",
patch: "@@ -1 +1 @@\n-export const lorem = false\n+export const lorem = true",
additions: 1,
deletions: 1,
},
],
},
}),
],
}),
assistant({
id: "msg_real_subagent",
offset: 18_000,
finish: "tool-calls",
content: [
reasoning(lorem(200), 18_050),
completedTool({
id: "tool_real_subagent",
name: "subagent",
offset: 18_300,
args: { agent: "explore", description: "Lorem ipsum audit", prompt: lorem(1_400) },
result: lorem(480),
metadata: { sessionID: "session_lorem_child", status: "completed" },
}),
],
}),
{
id: "msg_real_background_complete",
type: "synthetic",
text: lorem(8_900),
description: "Background task “Lorem ipsum audit” completed",
metadata: {
source: "subagent",
state: "completed",
childID: "session_lorem_child",
agent: "explore",
},
time: { created: TIME + 20_000 },
},
assistant({
id: "msg_real_result",
offset: 21_000,
finish: "stop",
content: [reasoning(lorem(160), 21_050), { type: "text", text: lorem(560) }],
}),
{ id: "msg_real_idle", type: "idle", outcome: "succeeded", time: { created: TIME + 23_000 } },
] satisfies SessionMessageInfo[])
export const realTimelinePresentation = {
msg_real_user: {
displayText: lorem(420),
comments: [
{
path: "src/lorem/timeline-controller.tsx",
comment: lorem(180),
selection: { startLine: 118, endLine: 126 },
},
],
},
} satisfies Record<string, SessionUserPresentation>
const coveragePrelude = [
user("msg_coverage_user", lorem(620), 30_000),
{
id: "msg_coverage_agent",
type: "agent-switched",
agent: "review",
previous: "build",
time: { created: TIME + 31_000 },
},
{
id: "msg_coverage_model",
type: "model-switched",
model: { providerID: "lorem", id: "ipsum-3" },
previous: MODEL,
time: { created: TIME + 31_500 },
},
{
id: "msg_coverage_location",
type: "location-switched",
location: { directory: "/workspace/lorem/ipsum/dolor/sit/amet" },
projectID: "project_lorem",
subpath: "packages/timeline",
previous: {
location: { directory: "/workspace/lorem" },
projectID: "project_previous",
subpath: "packages/app",
},
time: { created: TIME + 32_000 },
},
{
id: "msg_coverage_skill",
type: "skill",
skill: "skill_lorem",
name: "lorem-audit",
text: lorem(12_800),
time: { created: TIME + 32_500 },
},
{
id: "msg_coverage_system",
type: "system",
text: lorem(2_400),
description: "Lorem ipsum system notice",
time: { created: TIME + 33_000 },
},
{
id: "msg_coverage_synthetic_blank",
type: "synthetic",
text: lorem(240),
description: " ",
time: { created: TIME + 33_500 },
},
] satisfies SessionMessageInfo[]
export const realTimelineCoverageDocument = document([
...coveragePrelude,
{
id: "msg_coverage_shell_success",
type: "shell",
shellID: "shell_lorem_success",
command: "bun test src/lorem",
status: "exited",
exit: 0,
output: { output: output(4_700), cursor: 4_700, size: 4_700, truncated: false },
time: { created: TIME + 34_000, completed: TIME + 35_000 },
},
{
id: "msg_coverage_shell_timeout",
type: "shell",
shellID: "shell_lorem_timeout",
command: "bun run lorem:slow",
status: "timeout",
exit: 124,
output: { output: output(2_000), cursor: 2_000, size: 2_000, truncated: true },
time: { created: TIME + 36_000, completed: TIME + 38_000 },
},
{
id: "msg_coverage_shell_killed",
type: "shell",
shellID: "shell_lorem_killed",
command: "bun run lorem:cancelled",
status: "killed",
exit: 137,
output: { output: output(1_200), cursor: 1_200, size: 1_200, truncated: false },
time: { created: TIME + 38_100, completed: TIME + 38_600 },
},
{
id: "msg_coverage_shell_failed",
type: "shell",
shellID: "shell_lorem_failed",
command: "bun test src/lorem/failing.test.ts",
status: "exited",
exit: 1,
output: { output: output(1_800), cursor: 1_800, size: 1_800, truncated: false },
time: { created: TIME + 38_700, completed: TIME + 38_900 },
},
assistant({
id: "msg_coverage_streaming",
offset: 39_000,
completed: false,
content: [
{ type: "reasoning", text: lorem(200), time: { created: TIME + 39_050 } },
{
type: "tool",
id: "tool_coverage_streaming",
name: "webfetch",
state: { status: "streaming", input: '{"url":"https://example.com/lorem' },
time: { created: TIME + 39_300 },
},
],
}),
assistant({
id: "msg_coverage_running",
offset: 41_000,
completed: false,
content: [
runningTool({
id: "tool_coverage_background_shell",
name: "shell",
offset: 41_300,
args: { command: "bun run lorem:background" },
metadata: { shellID: "shell_lorem_background", status: "running", output: output(900) },
}),
runningTool({
id: "tool_coverage_background_agent",
name: "subagent",
offset: 41_500,
args: { agent: "general", description: "Lorem background", prompt: lorem(700) },
metadata: { sessionID: "session_lorem_background", status: "running" },
}),
],
}),
assistant({
id: "msg_coverage_question",
offset: 43_000,
finish: "tool-calls",
content: [
completedTool({
id: "tool_coverage_question",
name: "question",
offset: 43_300,
args: { questions: [{ header: "Lorem", question: lorem(120), options: ["Ipsum", "Dolor"] }] },
result: "Ipsum",
metadata: { answers: [["Ipsum"]] },
}),
completedTool({
id: "tool_coverage_file_result",
name: "browser",
offset: 43_500,
args: { action: "screenshot" },
result: lorem(120),
files: [{ uri: "data:image/png;base64,iVBORw0KGgo=", mime: "image/png", name: "lorem.png" }],
}),
],
}),
assistant({
id: "msg_coverage_native",
offset: 45_000,
finish: "tool-calls",
content: [
completedTool({
id: "tool_coverage_native_read",
name: "read",
offset: 45_200,
args: { path: "src/lorem/native.ts" },
result: output(2_200),
}),
completedTool({
id: "tool_coverage_native_shell",
name: "shell",
offset: 45_400,
args: { command: "bun test src/lorem/native.test.ts" },
result: "1 pass\n0 fail",
metadata: { exit: 0 },
}),
],
}),
assistant({
id: "msg_coverage_code_mode",
offset: 47_000,
finish: "tool-calls",
content: [
completedTool({
id: "tool_coverage_code_mode",
name: "execute",
offset: 47_300,
args: {
code: "const data = await tools.read({ path: 'src/lorem/code.ts' })\nreturn tools.shell({ command: 'bun test' })",
},
result: output(6_300),
metadata: {
toolCalls: [
{ name: "read", status: "completed" },
{ name: "shell", status: "error" },
],
},
}),
],
}),
assistant({
id: "msg_coverage_tool_error",
offset: 49_000,
finish: "tool-calls",
content: [
errorTool({
id: "tool_coverage_error",
name: "mcp_lorem_lookup",
offset: 49_300,
args: { query: lorem(160) },
message: lorem(320),
result: lorem(180),
metadata: { provider: "lorem" },
}),
errorTool({
id: "tool_coverage_todo_error",
name: "todo",
offset: 49_500,
args: { todos: [{ content: lorem(80), status: "pending" }] },
message: lorem(240),
}),
],
}),
{
id: "msg_coverage_shell_notice",
type: "synthetic",
text: output(8_900),
description: "Background shell “Lorem ipsum” failed",
metadata: { source: "shell", state: "completed", shellID: "shell_lorem_background", exit: 1, truncated: false },
time: { created: TIME + 51_000 },
},
{
id: "msg_coverage_subagent_cancelled",
type: "synthetic",
text: lorem(1_200),
description: "Background task “Lorem cancelled” was cancelled",
metadata: { source: "subagent", state: "cancelled", childID: "session_lorem_cancelled", agent: "general" },
time: { created: TIME + 51_200 },
},
{
id: "msg_coverage_subagent_error",
type: "synthetic",
text: lorem(1_400),
description: "Background task “Lorem failed” failed",
metadata: { source: "subagent", state: "error", childID: "session_lorem_failed", agent: "general" },
time: { created: TIME + 51_400 },
},
assistant({
id: "msg_coverage_interrupted",
offset: 52_000,
finish: "error",
error: { type: "ExecutionInterrupted", message: "Lorem ipsum interrupted" },
content: [{ type: "text", text: lorem(280) }],
}),
assistant({
id: "msg_coverage_error",
offset: 54_000,
finish: "error",
error: { type: "provider.invalid-output", message: lorem(360) },
content: [],
}),
assistant({
id: "msg_coverage_retry",
offset: 56_000,
completed: false,
retry: {
attempt: 3,
at: TIME + 56_500,
error: { type: "provider.internal", message: lorem(420) },
},
content: [],
}),
{
id: "msg_coverage_compaction_running",
type: "compaction",
status: "running",
reason: "auto",
summary: lorem(18_000),
recent: lorem(107_000),
time: { created: TIME + 58_000 },
},
{
id: "msg_coverage_compaction_complete",
type: "compaction",
status: "completed",
reason: "manual",
model: MODEL,
summary: lorem(18_000),
recent: lorem(107_000),
providerContext: {
version: 1,
provenance: {
providerID: "lorem",
provider: "Lorem",
modelID: "ipsum-2",
route: "lorem-route",
protocol: "lorem-protocol",
endpoint: "lorem-endpoint-digest",
},
messages: [],
},
cost: 0.012,
tokens: { input: 3_200, output: 420, reasoning: 90, cache: { read: 8_700, write: 0 } },
time: { created: TIME + 60_000 },
},
{
id: "msg_coverage_compaction_failed",
type: "compaction",
status: "failed",
reason: "auto",
error: { type: "compaction.failed", message: lorem(320) },
cost: 0.004,
tokens: { input: 1_200, output: 90, reasoning: 20, cache: { read: 2_100, write: 0 } },
time: { created: TIME + 60_500 },
},
{
id: "msg_coverage_compaction_interrupted",
type: "compaction",
status: "failed",
reason: "manual",
error: { type: "compaction.interrupted", message: "Lorem ipsum compaction interrupted" },
time: { created: TIME + 61_000 },
},
assistant({
id: "msg_coverage_recovered",
offset: 62_000,
finish: "stop",
content: [{ type: "text", text: lorem(560) }],
}),
{ id: "msg_coverage_idle", type: "idle", outcome: "interrupted", time: { created: TIME + 64_000 } },
] satisfies SessionMessageInfo[])
export const realTimelineFailureDocument = document([
user("msg_failure_user", lorem(420), 70_000),
assistant({
id: "msg_failure_tools",
offset: 71_000,
finish: "tool-calls",
content: [
errorTool({
id: "tool_failure_hidden",
name: "grep",
offset: 71_200,
args: { pattern: "lorem", path: "src" },
message: lorem(360),
}),
errorTool({
id: "tool_failure_todo",
name: "todo",
offset: 71_400,
args: { todos: [{ content: lorem(120), status: "pending" }] },
message: lorem(280),
}),
],
}),
{
id: "msg_failure_required_notice",
type: "synthetic",
text: lorem(900),
description: "Background shell failed",
metadata: { source: "shell", state: "completed", shellID: "shell_failure", exit: 1 },
time: { created: TIME + 73_000 },
},
assistant({
id: "msg_failure_assistant",
offset: 74_000,
finish: "error",
error: { type: "provider.invalid-output", message: lorem(420) },
content: [],
}),
] satisfies SessionMessageInfo[])
export const realTimelineVerboseDocument = document([
{
...user("msg_verbose_user", lorem(24_500), 80_000),
files: [
{
data: "IyBMb3JlbSBpcHN1bQ==",
mime: "text/markdown",
name: `${lorem(110).replaceAll(" ", "-")}.md`,
source: { type: "inline" },
},
{
data: "JVBERi0xLjQK",
mime: "application/pdf",
name: "lorem-verbose.pdf",
source: { type: "inline" },
},
],
},
assistant({
id: "msg_verbose_assistant",
offset: 82_000,
finish: "tool-calls",
content: [
reasoning(lorem(1_100), 82_050),
completedTool({
id: "tool_verbose_read",
name: "read",
offset: 82_300,
args: { path: `/workspace/${lorem(220).replaceAll(" ", "/")}/timeline.tsx`, offset: 1, limit: 2_000 },
result: output(106_000),
}),
errorTool({
id: "tool_verbose_error",
name: "mcp_lorem_extremely_verbose_tool_name_for_overflow_validation",
offset: 82_500,
args: { query: lorem(500) },
message: lorem(670),
}),
],
}),
assistant({
id: "msg_verbose_result",
offset: 84_000,
finish: "stop",
content: [{ type: "text", text: lorem(28_000) }],
}),
] satisfies SessionMessageInfo[])
@@ -0,0 +1,150 @@
import { timelinePresets, type TimelineDetail } from "@opencode/session-ui/timeline/detail"
import { Show } from "solid-js"
import { createStore } from "solid-js/store"
import { SettingsList } from "@/settings/list"
import { TimelineDetailControl } from "@/settings/timeline-detail"
import { SessionPreview } from "./story-model"
import {
realTimelineCoverageDocument,
realTimelineDocument,
realTimelineFailureDocument,
realTimelinePresentation,
realTimelineVerboseDocument,
} from "./real-timeline-fixtures"
import "@/settings/settings.css"
const description = "anonymized seven-day session shape · lorem ipsum content"
const presets = Object.fromEntries(timelinePresets.map((preset) => [preset.id, preset.value])) as Record<
string,
TimelineDetail
>
function Representative(props: { preset: string }) {
return (
<SessionPreview
title="Real Timeline"
description={description}
document={realTimelineDocument}
presentation={realTimelinePresentation}
timelineDetail={presets[props.preset] ?? timelinePresets[2].value}
draft="Lorem ipsum dolor sit amet"
/>
)
}
function TimelineSettingsPlayground() {
const [state, setState] = createStore({ value: structuredClone(timelinePresets[2].value) as TimelineDetail })
return (
<div class="flex h-screen min-h-[640px] w-full flex-col bg-background-base lg:flex-row">
<aside class="max-h-[46vh] w-full shrink-0 overflow-y-auto border-b border-border-weak-base bg-background-stronger p-4 lg:max-h-none lg:w-[420px] lg:border-b-0 lg:border-r">
<SettingsList>
<div class="py-5">
<TimelineDetailControl value={state.value} onChange={(value) => setState("value", value)} />
</div>
</SettingsList>
</aside>
<section class="min-h-0 min-w-0 flex-1">
<Show when={state.value} keyed>
{(timelineDetail) => (
<SessionPreview
title="Real Timeline · settings playground"
description={description}
document={realTimelineDocument}
presentation={realTimelinePresentation}
timelineDetail={timelineDetail}
/>
)}
</Show>
</section>
</div>
)
}
export default {
title: "OpenCode/Session/Real Timeline",
id: "app-real-timeline",
component: SessionPreview,
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
"Production Session timeline rendering with synthetic Lorem ipsum payloads shaped from a seven-day aggregate of local Session data. No original prompts, outputs, paths, URLs, or identifiers are retained.",
},
},
},
}
export const PresetExplorer = {
render: () => <TimelineSettingsPlayground />,
}
export const RepresentativeCompact = {
render: () => <Representative preset="compact" />,
}
export const RepresentativeEverything = {
render: () => <Representative preset="everything" />,
}
export const AllServerPartsEverything = {
render: () => (
<SessionPreview
title="Real Timeline · all server parts"
description={description}
document={realTimelineCoverageDocument}
timelineDetail={timelinePresets[0].value}
/>
),
}
export const AllServerPartsCompact = {
render: () => (
<SessionPreview
title="Real Timeline · compact grouping"
description={description}
document={realTimelineCoverageDocument}
timelineDetail={timelinePresets[2].value}
/>
),
}
export const FailuresQuiet = {
render: () => (
<SessionPreview
title="Real Timeline · failures stay visible"
description={description}
document={realTimelineFailureDocument}
timelineDetail={timelinePresets[3].value}
/>
),
}
export const FailuresTextOnly = {
render: () => (
<SessionPreview
title="Real Timeline · text only"
description={description}
document={realTimelineFailureDocument}
timelineDetail={timelinePresets[4].value}
/>
),
}
export const ExtremelyVerboseCompact = {
render: () => (
<SessionPreview
title="Real Timeline · verbose values"
description={description}
document={realTimelineVerboseDocument}
timelineDetail={timelinePresets[2].value}
/>
),
}
export const MobileCompact = {
globals: { viewport: { value: "mobile1", isRotated: false } },
parameters: { viewport: { defaultViewport: "mobile1" } },
render: () => <Representative preset="compact" />,
}
+13
View File
@@ -9,6 +9,8 @@ import {
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode/client/promise"
import type { SessionDocument } from "@opencode/session-ui/document"
import type { TimelineDetail } from "@opencode/session-ui/timeline/detail"
import type { SessionUserPresentation } from "@opencode/session-ui/timeline"
import { CurrentSessionProviders, STORY_MODEL } from "@opencode/session-ui/storybook"
import { SessionTimeline } from "@opencode/session-ui/timeline"
import { SessionReviewEmptyChangesV2 } from "@opencode/session-ui/v2/session-review-empty-changes-v2"
@@ -87,6 +89,8 @@ export type SessionPreviewProps = {
reviewOpened?: boolean
child?: { parentID: string }
terminal?: { title: string; lines: string[] }
timelineDetail?: TimelineDetail
presentation?: Record<string, SessionUserPresentation | undefined>
}
export function SessionPreview(props: SessionPreviewProps) {
@@ -259,6 +263,15 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
<div class="min-h-0 flex-1 overflow-y-auto py-6">
<SessionTimeline
document={props.document}
presentation={props.presentation}
timelineDetail={props.timelineDetail}
reasoningMode={
props.timelineDetail?.thinking.placement === "hidden"
? "hidden"
: props.timelineDetail?.thinking.details === "expanded"
? "full"
: "compact"
}
editToolDefaultOpen
shellToolDefaultOpen
class="mx-auto w-full max-w-[840px]"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.2",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"bin": {
+14 -36
View File
@@ -1,7 +1,7 @@
/** @jsxImportSource @opentui/solid */
// Split-footer status shown while a freshly launched CLI replaces a
// version-mismatched background service before the TUI attaches.
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core"
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer } from "@opentui/core"
import { render, useTerminalDimensions } from "@opentui/solid"
import { OPENCODE_VERSION } from "../version"
import { registerOpencodeSpinner } from "@opencode/tui/component/register-spinner"
@@ -30,17 +30,11 @@ const completionHold = 650
export type Handle = {
readonly begin: (from?: string) => boolean
readonly loading: () => void
readonly finish: () => Promise<Handoff | undefined>
readonly finish: () => Promise<undefined>
readonly fail: (message: string) => Promise<void>
readonly close: () => Promise<void>
}
export type Handoff = {
readonly renderer: CliRenderer
readonly mode: ThemeMode | null
readonly complete: () => void
}
export const make = (): Handle => {
let session: Promise<Session | undefined> | undefined
return {
@@ -72,7 +66,7 @@ export const make = (): Handle => {
type Session = {
readonly loading: () => Promise<void>
readonly finish: () => Promise<Handoff>
readonly finish: () => Promise<undefined>
readonly fail: (message: string) => Promise<void>
readonly close: () => Promise<void>
}
@@ -83,7 +77,6 @@ async function open(from?: string): Promise<Session> {
const [outcome, setOutcome] = createSignal<"running" | "success" | "failure">("running")
const [failure, setFailure] = createSignal("")
const [animating, setAnimating] = createSignal(true)
const [visible, setVisible] = createSignal(true)
let resolveOutcome: (() => void) | undefined
const renderer = await createCliRenderer({
stdin: process.stdin,
@@ -101,20 +94,17 @@ async function open(from?: string): Promise<Session> {
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
})
const terminalMode = renderer.waitForThemeMode(1000).catch(() => null)
await render(
() => (
<Show when={visible()}>
<UpdateFooter
from={from}
active={active}
outcome={outcome}
failure={failure}
animating={animating}
renderer={renderer}
onOutcomeSettled={() => resolveOutcome?.()}
/>
</Show>
<UpdateFooter
from={from}
active={active}
outcome={outcome}
failure={failure}
animating={animating}
renderer={renderer}
onOutcomeSettled={() => resolveOutcome?.()}
/>
),
renderer,
).catch((error) => {
@@ -148,10 +138,8 @@ async function open(from?: string): Promise<Session> {
if (completed) await setTimeout(hold)
}
let closing: Promise<void> | undefined
let transferred = false
const close = () =>
(closing ??= (async () => {
if (transferred) return
setAnimating(false)
if (renderer.isDestroyed) return
renderer.pause()
@@ -174,18 +162,8 @@ async function open(from?: string): Promise<Session> {
await waitForStage()
await transitionTo("success", completionHold)
})
const mode = await terminalMode
renderer.externalOutputMode = "passthrough"
renderer.screenMode = "alternate-screen"
renderer.consoleMode = "console-overlay"
renderer.requestRender()
await Promise.race([renderer.idle(), setTimeout(500)])
transferred = true
return {
renderer,
mode,
complete: () => setVisible(false),
}
await close()
return undefined
},
fail: (message) =>
settle(async () => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.2",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"repository": {
+11 -1
View File
@@ -18,6 +18,7 @@ import type { PromptInput } from "@opencode/schema/prompt-input"
import type { AgentAttachment } from "@opencode/schema/prompt"
import type { Skill } from "@opencode/schema/skill"
import type { Event } from "@opencode/schema/event"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { InstructionEntry } from "@opencode/schema/instruction-entry"
import type { Schema } from "effect"
import type { EventLog } from "@opencode/schema/event-log"
@@ -36,7 +37,6 @@ import type { PtyTicket } from "@opencode/schema/pty-ticket"
import type { Reference } from "@opencode/schema/reference"
import type { Worktree } from "@opencode/schema/worktree"
import type { Vcs } from "@opencode/schema/vcs"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { WebSearch } from "@opencode/schema/websearch"
import type { Config } from "@opencode/schema/config"
@@ -361,6 +361,15 @@ export type SessionContextInput = { readonly sessionID: Session.ID }
export type SessionContextOutput = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: SessionContextInput) => Effect.Effect<SessionContextOutput, E>
export type SessionDiffInput = {
readonly sessionID: Session.ID
readonly from?: SessionMessage.ID | undefined
readonly to?: SessionMessage.ID | undefined
readonly context?: number | undefined
}
export type SessionDiffOutput = ReadonlyArray<FileDiff.Info>
export type SessionDiffOperation<E = never> = (input: SessionDiffInput) => Effect.Effect<SessionDiffOutput, E>
export type SessionInboxListInput = { readonly sessionID: Session.ID }
export type SessionInboxListOutput = ReadonlyArray<SessionInbox.Info>
export type SessionInboxListOperation<E = never> = (
@@ -1150,6 +1159,7 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E>
}
readonly context: SessionContextOperation<E>
readonly diff: SessionDiffOperation<E>
readonly inbox: {
readonly list: SessionInboxListOperation<E>
readonly cancel: SessionInboxCancelOperation<E>
@@ -68,6 +68,8 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionDiffInput,
SessionDiffOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
@@ -599,6 +601,17 @@ const EndpointSessionContext = (raw: RawClient["server.session"]) => (input: Ses
),
)
const EndpointSessionDiff = (raw: RawClient["server.session"]) => (input: SessionDiffInput) =>
preserveEffect<SessionDiffOutput>()(
raw["session.diff"]({
params: { sessionID: input["sessionID"] },
query: { from: input["from"], to: input["to"], context: input["context"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionInboxList = (raw: RawClient["server.session"]) => (input: SessionInboxListInput) =>
preserveEffect<SessionInboxListOutput>()(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
@@ -738,6 +751,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
commit: EndpointSessionRevertCommit(raw),
},
context: EndpointSessionContext(raw),
diff: EndpointSessionDiff(raw),
inbox: {
list: EndpointSessionInboxList(raw),
cancel: EndpointSessionInboxCancel(raw),
@@ -62,6 +62,8 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionDiffInput,
SessionDiffOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
@@ -849,6 +851,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
diff: (input: SessionDiffInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionDiffOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/diff`,
query: { from: input["from"], to: input["to"], context: input["context"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 500],
empty: false,
},
requestOptions,
).then((value) => value.data),
inbox: {
list: (input: SessionInboxListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionInboxListOutput }>(
@@ -147,6 +147,14 @@ export type SessionProviderContextProvenance = {
endpoint: string
}
export type SessionMessageIdle = {
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
type: "idle"
outcome: "succeeded" | "failed" | "interrupted"
}
export type SessionActive = { type: "running" }
export type SessionInboxDelivery = "steer" | "queue"
@@ -2210,6 +2218,7 @@ export type SessionMessageInfo =
| SessionMessageShell
| SessionMessageAssistant
| SessionMessageCompaction
| SessionMessageIdle
export type SessionMessageContentUpdated = {
id: string
@@ -3222,6 +3231,13 @@ export type SessionImportInput = {
}
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["info"]
@@ -3532,6 +3548,13 @@ export type SessionImportInput = {
}
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["messages"]
@@ -3842,6 +3865,13 @@ export type SessionImportInput = {
}
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["location"]
@@ -4331,6 +4361,27 @@ export type SessionContextInput = { readonly sessionID: { readonly sessionID: st
export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"]
export type SessionDiffInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly from?: {
readonly from?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["from"]
readonly to?: {
readonly from?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["to"]
readonly context?: {
readonly from?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["context"]
}
export type SessionDiffOutput = { data: Array<FileDiffInfo> }["data"]
export type SessionInboxListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"]
+12
View File
@@ -1028,6 +1028,18 @@ export function createData(config: CreateDataInput) {
if (currentAssistant) currentAssistant.retry = undefined
})
if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown") return
// Mirror the projected idle marker so turn boundaries match before the next message read.
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "idle",
outcome:
event.type === "session.execution.succeeded"
? "succeeded"
: event.type === "session.execution.failed"
? "failed"
: "interrupted",
time: { created: event.created },
})
// An event can overtake the first read; queue a revalidation when that read is still active.
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
result.session.invalidate(event.data.sessionID)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.2",
"version": "2.0.3",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.2",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.2",
"version": "2.0.3",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.2",
"version": "2.0.3",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.2",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.2",
"version": "2.0.3",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
+75 -64
View File
@@ -9,6 +9,7 @@ import { AppProcess } from "@opencode/util/process"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { File } from "./file.js"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { VcsPatch } from "./vcs/patch.js"
export class Repository extends Schema.Class<Repository>("Git.Repository")({
worktree: AbsolutePath,
@@ -308,7 +309,7 @@ const layer = Layer.effect(
operationName: OperationError["operation"],
repository: Repository,
args: string[],
options?: { stdin?: string; env?: Record<string, string> },
options?: { stdin?: string; env?: Record<string, string>; maxOutputBytes?: number },
) {
const result = yield* proc
.run(
@@ -317,7 +318,7 @@ const layer = Layer.effect(
env: options?.env,
extendEnv: true,
}),
{ stdin: options?.stdin },
{ stdin: options?.stdin, maxOutputBytes: options?.maxOutputBytes },
)
.pipe(
Effect.mapError(
@@ -331,7 +332,8 @@ const layer = Layer.effect(
),
)
const text = result.stdout.toString("utf8")
if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") }
if (result.exitCode === 0)
return { text, stderr: result.stderr.toString("utf8"), truncated: result.stdoutTruncated }
return yield* new OperationError({
operation: operationName,
directory: repository.worktree,
@@ -385,9 +387,7 @@ const layer = Layer.effect(
maximumUntrackedFileBytes?: number
}) {
const list = (args: string[]) =>
repositoryOperation("refresh", input.repository, args).pipe(
Effect.map((result) => result.text.split("\0").filter(Boolean)),
)
repositoryOperation("refresh", input.repository, args).pipe(Effect.map((result) => nuls(result.text)))
const [tracked, untracked] = yield* Effect.all(
[
list(["diff-files", "--name-only", "-z", "--", input.scope]),
@@ -464,13 +464,7 @@ const layer = Layer.effect(
directory: input.repository.worktree,
message: result.stderr.toString("utf8").trim() || "Failed to check ignored paths",
})
return new Set(
result.stdout
.toString("utf8")
.split("\0")
.filter(Boolean)
.map((file) => RelativePath.make(file)),
)
return new Set(nuls(result.stdout.toString("utf8")).map((file) => RelativePath.make(file)))
})
const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
@@ -499,19 +493,23 @@ const layer = Layer.effect(
to: TreeID
}) {
// Undo needs both paths of a rename, not only its destination.
return (yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
])).text
.split("\0")
.filter(Boolean)
.map((file) => RelativePath.make(file))
return nuls(
(yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
])).text,
).map((file) => RelativePath.make(file))
})
/**
* Three batched invocations over the tree pair instead of three per file. An
* explicit empty selection diffs nothing; an absent one diffs every changed path.
* Patch output is capped like VCS diffs: files past the cap get an empty patch.
*/
const treeDiff = Effect.fn("Git.tree.diff")(function* (input: {
repository: Repository
from: TreeID
@@ -519,49 +517,57 @@ const layer = Layer.effect(
context?: number
paths?: readonly RelativePath[]
}) {
const paths = input.paths ?? (yield* treeFiles(input))
return yield* Effect.forEach(paths, (file) =>
Effect.gen(function* () {
const statusText = (yield* repositoryOperation("diff", input.repository, [
if (input.paths?.length === 0) return []
const args = ["--no-renames", input.from, input.to, "--", ...(input.paths ?? [])]
// Patch headers have no -z form: unquoted paths keep chunksByFile matching non-ASCII names.
const [names, numbers, patch] = yield* Effect.all(
[
repositoryOperation("diff", input.repository, ["diff", "--name-status", "-z", ...args]),
repositoryOperation("diff", input.repository, ["diff", "--numstat", "-z", ...args]),
repositoryOperation(
"diff",
"--name-status",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.trim()
const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified"
const stats = (yield* repositoryOperation("diff", input.repository, [
"diff",
"--numstat",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.split("\t")
const binary = stats[0] === "-" || stats[1] === "-"
const patch = binary
? ""
: (yield* repositoryOperation("diff", input.repository, [
"diff",
`--unified=${input.context ?? 3}`,
"--no-renames",
input.from,
input.to,
"--",
file,
])).text
return {
file,
status,
additions: binary ? 0 : Number(stats[0] ?? 0),
deletions: binary ? 0 : Number(stats[1] ?? 0),
patch,
} satisfies File.Diff
input.repository,
["-c", "core.quotepath=false", "diff", "--no-ext-diff", `--unified=${input.context ?? 3}`, ...args],
{ maxOutputBytes: VcsPatch.MAX_TOTAL_PATCH_BYTES },
),
],
{ concurrency: 3 },
)
const statuses = nuls(names.text)
const files = statuses.flatMap((code, index) => {
const file = statuses[index + 1]
if (index % 2 !== 0 || !file) return []
return [
{
file: RelativePath.make(file),
status: code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified",
} as const,
]
})
const stats = new Map(
nuls(numbers.text).flatMap((line) => {
const [additions, deletions, ...file] = line.split("\t")
if (!additions || !deletions || file.length === 0) return []
return [
[
file.join("\t"),
additions === "-" || deletions === "-"
? { binary: true, additions: 0, deletions: 0 }
: { binary: false, additions: Number(additions), deletions: Number(deletions) },
] as const,
]
}),
)
const patches = VcsPatch.chunksByFile(patch, (index) => files[index]?.file)
return files.map((entry) => {
const stat = stats.get(entry.file)
return {
...entry,
additions: stat?.additions ?? 0,
deletions: stat?.deletions ?? 0,
patch: stat?.binary ? "" : (patches.get(entry.file) ?? VcsPatch.emptyPatch(entry.file)),
} satisfies File.Diff
})
})
const hasEntry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
@@ -733,6 +739,11 @@ function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
)
}
/** Split NUL-terminated git output into its records. */
function nuls(text: string) {
return text.split("\0").filter(Boolean)
}
function resolvePath(cwd: string, value: string) {
const trimmed = value.replace(/[\r\n]+$/, "")
if (!trimmed) return cwd
+24
View File
@@ -55,8 +55,11 @@ import { SessionModelTransport } from "./session/model-transport.js"
import { llmClient } from "./effect/app-node-platform.js"
import { Snapshot } from "./snapshot.js"
import { Session } from "./session/session.js"
import { SessionDiff, TurnRangeError } from "./session/diff.js"
import { LocationServiceMap } from "./location-service-map.js"
import { FSUtil } from "@opencode/util/fs-util"
import type { EventLog } from "@opencode/schema/event-log"
import type { FileDiff } from "@opencode/schema/file-diff"
import { Job } from "./job.js"
import type { Command } from "./command.js"
import { SessionEnvironment } from "./session/environment.js"
@@ -109,6 +112,7 @@ export {
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
export { DestinationNotFoundError, DestinationNotDirectoryError, DestinationUnavailableError }
export { TurnRangeError }
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<{
@@ -135,6 +139,13 @@ export interface Interface {
readonly context: (
sessionID: SessionSchema.ID,
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
/** Structured diffs of the files changed by a turn or range of turns; see `SessionDiff.turn`. */
readonly diff: (input: {
readonly sessionID: SessionSchema.ID
readonly from?: SessionMessage.ID
readonly to?: SessionMessage.ID
readonly context?: number
}) => Effect.Effect<readonly FileDiff.Info[], NotFoundError | MessageNotFoundError | TurnRangeError | Snapshot.Error>
/**
* Durable admitted session work not yet visible in projected history,
* ordered by admission. Includes unpromoted user and synthetic inputs and
@@ -227,6 +238,7 @@ const layer = Layer.effect(
const moves = yield* SessionMove.Service
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
const locations = yield* LocationServiceMap.Service
const sessions = yield* Session.make()
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
@@ -359,6 +371,17 @@ const layer = Layer.effect(
yield* result.get(sessionID)
return yield* store.context(sessionID)
}),
diff: Effect.fn("Session.diff")(function* (input) {
const session = yield* result.get(input.sessionID)
const active = yield* execution.isActive(input.sessionID)
return yield* SessionDiff.turn(db, locations, {
session,
active,
from: input.from,
to: input.to,
context: input.context,
})
}),
inbox: (sessionID) => sessions.forSession(sessionID).inbox(),
cancelInbox: (input) => sessions.forSession(input.sessionID).cancelInbox(input.inboxID),
steerInbox: (input) => sessions.forSession(input.sessionID).steerInbox(input.inboxID),
@@ -448,6 +471,7 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
SessionInbox.node,
SessionMove.node,
SessionProjector.node,
LocationServiceMap.node,
FSUtil.node,
App.node,
],
+138
View File
@@ -0,0 +1,138 @@
export * as SessionDiff from "./diff.js"
import { and, asc, eq, gt, inArray, lt, or, sql } from "drizzle-orm"
import { Context, Effect, Schema } from "effect"
import { Location } from "@opencode/schema/location"
import { Database } from "../database/database.js"
import { LocationServiceMap } from "../location-service-map.js"
import { Snapshot } from "../snapshot.js"
import { PATCH_CONTEXT_LINES } from "../vcs/patch.js"
import { MessageNotFoundError } from "./error.js"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionMessageTable } from "./sql.js"
export class TurnRangeError extends Schema.TaggedError<TurnRangeError>()("Session.TurnRangeError", {
sessionID: SessionSchema.ID,
field: Schema.Literals(["from", "to"]),
message: Schema.String,
}) {}
const decodeLocation = Schema.decodeUnknownSync(Schema.fromJsonString(Location.Ref))
/**
* Diff the files changed by the turn containing a user message. A turn runs from
* the first prompt after the Session was last idle until the next idle marker, so
* prompts steered in while it was busy belong to the same turn; `to` extends the
* range through the turn containing a later user message. Compares the range's
* first recorded start snapshot with its last recorded end snapshot; only a step
* still running in the active Session compares against the working copy. Like VCS
* diffs, an omitted `context` yields full-file patches.
*
* A Session without any idle marker predates them, so its prompts span until the
* next user message instead.
*
* Snapshot trees live in the repository of the Location that captured them, so a
* range spanning a location switch is rejected rather than diffed wrongly.
*/
export const turn = Effect.fn("SessionDiff.turn")(function* (
db: Database.Interface["db"],
locations: Context.Service.Shape<typeof LocationServiceMap.Service>,
input: {
readonly session: SessionSchema.Info
/** The process is currently executing this Session. */
readonly active: boolean
readonly from?: SessionMessage.ID
readonly to?: SessionMessage.ID
readonly context?: number
},
) {
const sessionID = input.session.id
const rows = yield* db
.select({ id: SessionMessageTable.id, type: SessionMessageTable.type, seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
or(
inArray(SessionMessageTable.type, ["user", "idle"]),
input.from ? eq(SessionMessageTable.id, input.from) : undefined,
input.to ? eq(SessionMessageTable.id, input.to) : undefined,
),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const users = rows.filter((row) => row.type === "user")
const markers = rows.filter((row) => row.type === "idle")
const resolve = Effect.fn(function* (field: "from" | "to", id: SessionMessage.ID) {
const row = rows.find((row) => row.id === id)
if (!row) return yield* new MessageNotFoundError({ sessionID, messageID: id })
if (row.type !== "user")
return yield* new TurnRangeError({ sessionID, field, message: `Message ${id} is not a user message` })
return row
})
const anchor = input.from ? yield* resolve("from", input.from) : users[users.length - 1]
if (!anchor) return []
const last = input.to ? yield* resolve("to", input.to) : anchor
if (last.seq < anchor.seq)
return yield* new TurnRangeError({ sessionID, field: "to", message: `Message ${last.id} precedes ${anchor.id}` })
// Without any marker, history predates idle markers and a prompt's turn ends at the next prompt.
const legacy = markers.length === 0
// The turn opens with the first prompt after the previous idle marker; the anchor itself is the latest candidate.
const opened = markers.findLast((row) => row.seq < anchor.seq)?.seq ?? -1
const start = legacy ? anchor.seq : (users.find((row) => row.seq > opened)?.seq ?? anchor.seq)
const end = legacy ? users.find((row) => row.seq > last.seq)?.seq : markers.find((row) => row.seq > last.seq)?.seq
const steps = yield* db
.select({
seq: SessionMessageTable.seq,
start: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.start')`,
end: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.end')`,
completed: sql<number | null>`json_extract(${SessionMessageTable.data}, '$.time.completed')`,
})
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, "assistant"),
gt(SessionMessageTable.seq, start),
end === undefined ? undefined : lt(SessionMessageTable.seq, end),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const first = steps[0]
const final = steps[steps.length - 1]
const from = steps.find((step) => step.start)?.start
if (!first || !final || !from) return []
const switches = yield* db
.select({
seq: SessionMessageTable.seq,
location: sql<string>`json_extract(${SessionMessageTable.data}, '$.location')`,
previous: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.previous.location')`,
})
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "location-switched")))
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
if (switches.some((row) => row.seq > first.seq && row.seq < final.seq))
return yield* new TurnRangeError({ sessionID, field: "to", message: "Turn range spans a location change" })
const before = switches.findLast((row) => row.seq < first.seq)?.location
const after = switches.find((row) => row.seq > first.seq)?.previous
const location = before ? decodeLocation(before) : after ? decodeLocation(after) : input.session.location
const recorded = steps.findLast((step) => step.end)?.end
return yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const running = input.active && final.completed === null
const to = running ? ((yield* snapshot.capture()) ?? recorded) : recorded
if (!to) return []
return yield* snapshot.diff({
from: Snapshot.ID.make(from),
to: Snapshot.ID.make(to),
context: input.context ?? PATCH_CONTEXT_LINES,
})
}).pipe(Effect.provide(locations.get(location)))
})
+20 -3
View File
@@ -60,6 +60,21 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
})
const idle = (outcome: SessionMessage.Idle["outcome"]) =>
clearCurrentRetry.pipe(
Effect.andThen(
adapter.appendMessage(
SessionMessage.Idle.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "idle",
outcome,
metadata: event.metadata,
time: { created },
}),
),
),
)
const project = pipe(
Match.type<SessionEvent.DurableEvent>(),
Match.discriminatorsExhaustive("type")({
@@ -124,9 +139,11 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.inbox.cancelled": () => Effect.void,
"session.inbox.delivery.changed": () => Effect.void,
"session.execution.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
"session.execution.interrupted": () => clearCurrentRetry,
"session.execution.succeeded": () => idle("succeeded"),
"session.execution.failed": () => idle("failed"),
// Shutdown keeps the execution claim and the resumed drain continues the turn.
"session.execution.interrupted": (event) =>
event.data.reason === "shutdown" ? clearCurrentRetry : idle("interrupted"),
"session.instructions.updated": (event) => {
if (event.data.text === undefined) return Effect.void
return adapter.appendMessage(
@@ -226,6 +226,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
switch (message.type) {
case "agent-switched":
case "model-switched":
case "idle":
return []
case "location-switched":
return [
+33 -16
View File
@@ -131,38 +131,55 @@ const layer = Layer.effect(
)
})
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const comparison = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
const comparison = {
return {
source: repo.source,
repository: repo.snapshotRepository,
from: Git.TreeID.make(input.from),
to: Git.TreeID.make(input.to),
}
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
const ignored = yield* git.index
.ignored({ repository: repo.source, paths: files })
})
// Snapshots track every scoped file; the source repository's ignore rules decide what callers see.
const ignored = Effect.fnUntraced(function* (
operation: "files" | "diff",
source: Git.Repository,
paths: readonly RelativePath[],
) {
return yield* git.index
.ignored({ repository: source, paths })
.pipe(Effect.mapError((cause) => failure(operation, cause)))
return {
input: comparison,
files,
ignored,
}
})
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
const comparison = yield* compare("files", input)
return comparison.files.filter((file) => !comparison.ignored.has(file))
const compared = yield* comparison("files", input)
const changed = yield* git.tree
.files({ repository: compared.repository, from: compared.from, to: compared.to })
.pipe(Effect.mapError((cause) => failure("files", cause)))
const skipped = yield* ignored("files", compared.source, changed)
return changed.filter((file) => !skipped.has(file))
})
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
const comparison = yield* compare("diff", input)
return yield* git.tree
if (input.paths?.length === 0) return []
const compared = yield* comparison("diff", input)
// Only an explicit selection becomes a pathspec; ignored paths are dropped from the result instead.
const diffs = yield* git.tree
.diff({
...comparison.input,
repository: compared.repository,
from: compared.from,
to: compared.to,
context: input.context,
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
paths: input.paths,
})
.pipe(Effect.mapError((cause) => failure("diff", cause)))
const skipped = yield* ignored(
"diff",
compared.source,
diffs.map((file) => RelativePath.make(file.file)),
)
return diffs.filter((file) => !skipped.has(RelativePath.make(file.file)))
})
const plan = Effect.fnUntraced(function* (worktree: AbsolutePath, input: RestoreInput) {
+37
View File
@@ -6,6 +6,7 @@ import { Effect } from "effect"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Git } from "@opencode/core/git"
import { AbsolutePath, RelativePath } from "@opencode/core/schema"
import { VcsPatch } from "@opencode/core/vcs/patch"
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -196,6 +197,42 @@ describe("Git trees", () => {
}),
)
it.live("caps batched tree patches, keeps per-file stats past the cap, and matches non-ASCII names", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const git = yield* Git.Service
const repository = yield* git.repo.discover(AbsolutePath.make(root.path))
if (!repository) throw new Error("Repository not found")
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
const lines = Math.ceil(VcsPatch.MAX_TOTAL_PATCH_BYTES / 80) + 1
yield* Effect.promise(async () => {
await Bun.write(path.join(root.path, "a-small.txt"), "small\n")
await Bun.write(path.join(root.path, "b-large.txt"), `${"x".repeat(79)}\n`.repeat(lines))
await Bun.write(path.join(root.path, "c-binary.bin"), new Uint8Array([0, 1, 2, 3]))
await Bun.write(path.join(root.path, "a-caf\u00e9.txt"), "caf\u00e9\n")
})
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 0 })
expect(diffs.map((item) => [item.file, item.status, item.additions, item.deletions])).toEqual([
["a-caf\u00e9.txt", "added", 1, 0],
["a-small.txt", "added", 1, 0],
["b-large.txt", "added", lines, 0],
["c-binary.bin", "added", 0, 0],
])
// Patch headers are not NUL-delimited; a quoted (octal-escaped) header would orphan this chunk.
expect(diffs[0]?.patch).toContain("+caf\u00e9\n")
expect(diffs[1]?.patch).toContain("+small\n")
expect(diffs[2]?.patch).toBe(VcsPatch.emptyPatch("b-large.txt"))
expect(diffs[3]?.patch).toBe("")
expect(yield* git.tree.diff({ repository, from: before, to: after, paths: [] })).toEqual([])
}),
)
it.live("captures, compares, previews, and restores scoped trees", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
+198
View File
@@ -0,0 +1,198 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { Agent } from "@opencode/core/agent"
import { Bus } from "@opencode/core/bus"
import { Database } from "@opencode/core/database/database"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LocationServiceMap } from "@opencode/core/location-service-map"
import { Model } from "@opencode/core/model"
import { Plugin } from "@opencode/core/plugin"
import { Provider } from "@opencode/core/provider"
import { AbsolutePath } from "@opencode/core/schema"
import { Session } from "@opencode/core/session"
import { SessionDiff } from "@opencode/core/session/diff"
import { SessionEvent } from "@opencode/core/session/event"
import { SessionExecution } from "@opencode/core/session/execution"
import { SessionInbox } from "@opencode/core/session/inbox"
import { SessionMessage } from "@opencode/core/session/message"
import { SessionProjector } from "@opencode/core/session/projector"
import { Snapshot } from "@opencode/core/snapshot"
import { Money } from "@opencode/schema/money"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Global } from "@opencode/util/global"
import { tempGlobalLayer } from "./fixture/global"
import { offlineModels } from "./fixture/models"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
[Global.node.replace(tempGlobalLayer), SessionExecution.node.replace(SessionExecution.noopLayer), offlineModels],
),
)
const summarize = (file: { file: string; status: string; additions: number; deletions: number }) => [
file.file,
file.status,
file.additions,
file.deletions,
]
describe("Session.diff", () => {
it.live(
"diffs the busy period containing a user message and ranges across later turns",
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const directory = path.join(tmp.path, "project")
const write = (name: string, content: string) => () => Bun.write(path.join(directory, name), content)
yield* Effect.promise(async () => {
await fs.mkdir(directory)
await write("first.txt", "first\n")()
await write("second.txt", "second\n")()
await write("manual.txt", "manual\n")()
await $`git init -q`.cwd(directory).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
})
const sessions = yield* Session.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const locations = yield* LocationServiceMap.Service
const created = yield* sessions.create({ location: { directory: AbsolutePath.make(directory) } })
const diff = (input?: { from?: SessionMessage.ID; to?: SessionMessage.ID }) =>
sessions
.diff({ sessionID: created.id, context: 0, ...input })
.pipe(Effect.map((files) => files.map(summarize)))
expect(yield* diff()).toEqual([])
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.awaitActivation
const snapshot = yield* Snapshot.Service
const usage = {
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
}
const prompt = Effect.fn(function* (text: string) {
const admitted = yield* sessions.prompt({ sessionID: created.id, text, resume: false })
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
return admitted.id
})
const step = Effect.fn(function* (edit: () => Promise<unknown>, end: "recorded" | "unrecorded" | "running") {
const before = yield* snapshot.capture()
if (!before) throw new Error("Start snapshot missing")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: created.id,
assistantMessageID,
agent: Agent.defaultID,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
snapshot: before,
})
yield* Effect.promise(edit)
if (end === "running") return assistantMessageID
const after = end === "recorded" ? yield* snapshot.capture() : undefined
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: created.id,
assistantMessageID,
finish: "stop",
...usage,
snapshot: after,
files: after && before ? yield* snapshot.files({ from: before, to: after }) : undefined,
})
return assistantMessageID
})
const idle = (outcome: "succeeded" | "failed") =>
outcome === "succeeded"
? bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
: bus.publish(SessionEvent.Execution.Failed, {
sessionID: created.id,
error: { type: "unknown", message: "failed" },
})
// Before any idle marker exists, a prompt's turn ends at the next prompt.
const first = yield* prompt("Edit the first file")
const firstStep = yield* step(write("first.txt", "first edited\n"), "recorded")
// Edits made while idle are not a turn's work, but a range spanning them still sees them.
yield* Effect.promise(write("manual.txt", "manual edited\n"))
const second = yield* prompt("Edit the second file")
yield* step(write("second.txt", "second edited\n"), "recorded")
expect(yield* diff()).toEqual([["second.txt", "modified", 1, 1]])
expect(yield* diff({ from: first })).toEqual([["first.txt", "modified", 1, 1]])
// Once markers exist, a turn spans a whole busy period, steers included; earlier history merges into the first one.
yield* idle("succeeded")
const third = yield* prompt("Add a third file")
yield* step(write("third.txt", "third\n"), "recorded")
const steer = yield* prompt("Also add a fourth file")
yield* step(write("fourth.txt", "fourth\n"), "recorded")
yield* idle("failed")
const busy = [
["fourth.txt", "added", 1, 0],
["third.txt", "added", 1, 0],
]
expect(yield* diff()).toEqual(busy)
expect(yield* diff({ from: steer })).toEqual(busy)
expect(yield* diff({ from: second })).toEqual([
["first.txt", "modified", 1, 1],
["manual.txt", "modified", 1, 1],
["second.txt", "modified", 1, 1],
])
expect(yield* diff({ from: first, to: third })).toEqual([
["first.txt", "modified", 1, 1],
["fourth.txt", "added", 1, 0],
["manual.txt", "modified", 1, 1],
["second.txt", "modified", 1, 1],
["third.txt", "added", 1, 0],
])
const full = yield* sessions.diff({ sessionID: created.id, from: first })
expect(full[0]?.patch).toContain("-first\n+first edited\n")
expect(yield* diff({ from: steer, to: second }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.TurnRangeError",
field: "to",
})
expect(yield* diff({ from: firstStep }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.TurnRangeError",
field: "from",
})
expect(yield* diff({ from: SessionMessage.ID.create() }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageNotFoundError",
})
// A completed step without an end snapshot falls back to the last recorded end.
yield* prompt("Edit both files again")
yield* step(write("first.txt", "first edited twice\n"), "recorded")
yield* step(write("second.txt", "second edited twice\n"), "unrecorded")
yield* idle("succeeded")
expect(yield* diff()).toEqual([["first.txt", "modified", 1, 1]])
// Only a step still running in the active session compares against the working copy.
yield* prompt("Delete the manual file")
yield* step(() => fs.rm(path.join(directory, "manual.txt")), "running")
expect(yield* diff()).toEqual([])
const session = yield* sessions.get(created.id)
const live = yield* SessionDiff.turn(database.db, locations, { session, active: true, context: 0 })
expect(live.map(summarize)).toEqual([["manual.txt", "deleted", 0, 1]])
// Reverting removes later history, markers included; a fork keeps the copied turns.
yield* sessions.revert.stage({ sessionID: created.id, messageID: steer, files: false })
yield* sessions.revert.commit(created.id)
expect(yield* diff()).toEqual([["third.txt", "added", 1, 0]])
expect(yield* diff({ from: steer }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageNotFoundError",
})
const forked = yield* sessions.fork({ sessionID: created.id, boundary: { type: "through" } })
expect((yield* sessions.diff({ sessionID: forked.id, context: 0 })).map(summarize)).toEqual([
["third.txt", "added", 1, 0],
])
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
}),
// Real Location/plugin startup and Git snapshots can exceed five seconds under CI load.
{ timeout: 30_000 },
)
})
+3 -2
View File
@@ -561,7 +561,9 @@ describe("SessionRestart background recovery", () => {
expect(yield* restarted.pendingBackground).toEqual([])
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
expect(yield* sessions.messages({ sessionID })).toMatchObject([
// Recovery ends a busy period, so an idle marker follows the notification.
const messages = (yield* sessions.messages({ sessionID })).filter((message) => message.type !== "idle")
expect(messages).toMatchObject([
{
id: background.notificationID,
type: "synthetic",
@@ -569,7 +571,6 @@ describe("SessionRestart background recovery", () => {
metadata: { state: "completed" },
},
])
expect(yield* sessions.messages({ sessionID })).toHaveLength(1)
}),
)
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.2",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.2",
"version": "2.0.3",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "2.0.2",
"version": "2.0.3",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.2",
"version": "2.0.3",
"name": "@opencode/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/httpapi-codegen",
"version": "2.0.2",
"version": "2.0.3",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/latex",
"version": "2.0.2",
"version": "2.0.3",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/merman",
"version": "2.0.2",
"version": "2.0.3",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-browser",
"version": "2.0.2",
"version": "2.0.3",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin",
"version": "2.0.2",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"scripts": {
+181
View File
@@ -3242,6 +3242,152 @@
"summary": "Get session context"
}
},
"/api/session/{sessionID}/diff": {
"get": {
"tags": ["session"],
"operationId": "v2.session.diff",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "from",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
},
"required": false
},
{
"name": "to",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Later user message whose turn ends the range. Defaults to the turn of `from` alone."
},
"required": false
},
{
"name": "context",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Unchanged lines around each hunk. Omit for full-file patches."
},
"required": false
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff.Info"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "MessageNotFoundError | SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
}
}
}
},
"500": {
"description": "UnknownError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnknownErrorEncoded"
}
}
}
}
},
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
"summary": "Diff session turns"
}
},
"/api/session/{sessionID}/inbox": {
"get": {
"tags": ["session"],
@@ -18421,6 +18567,38 @@
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
"additionalProperties": false
},
"Session.Message.Idle": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^msg_"
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["idle"]
},
"outcome": {
"type": "string",
"enum": ["succeeded", "failed", "interrupted"]
}
},
"required": ["id", "time", "type", "outcome"],
"additionalProperties": false
},
"Session.Message.Info": {
"anyOf": [
{
@@ -18452,6 +18630,9 @@
},
{
"$ref": "#/components/schemas/Session.Message.Compaction"
},
{
"$ref": "#/components/schemas/Session.Message.Idle"
}
]
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/protocol",
"version": "2.0.2",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"repository": {
+26
View File
@@ -31,6 +31,7 @@ import { Permission } from "@opencode/schema/permission"
import { Location } from "@opencode/schema/location"
import { SessionEvent } from "@opencode/schema/session-event"
import { EventLog } from "@opencode/schema/event-log"
import { FileDiff } from "@opencode/schema/file-diff"
const ParentIDFilter = Schema.Union([
Session.ID,
@@ -523,6 +524,31 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.get("session.diff", "/api/session/:sessionID/diff", {
params: { sessionID: Session.ID },
query: Schema.Struct({
from: Schema.optional(SessionMessage.ID).annotate({
description: "User message whose turn to diff. Defaults to the turn of the newest user message.",
}),
to: Schema.optional(SessionMessage.ID).annotate({
description: "Later user message whose turn ends the range. Defaults to the turn of `from` alone.",
}),
context: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional).annotate({
description: "Unchanged lines around each hunk. Omit for full-file patches.",
}),
}),
success: Schema.Struct({ data: Schema.Array(FileDiff.Info) }),
error: [InvalidRequestError, MessageNotFoundError, SessionNotFoundError, UnknownError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.diff",
summary: "Diff session turns",
description:
"Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
}),
),
)
.add(
HttpApiEndpoint.get("session.inbox.list", "/api/session/:sessionID/inbox", {
params: { sessionID: Session.ID },
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/schema",
"version": "2.0.2",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"repository": {
+14
View File
@@ -280,6 +280,18 @@ export const Compaction = Schema.Union([CompactionRunning, CompactionCompleted,
)
export type Compaction = CompactionRunning | CompactionCompleted | CompactionFailed
/**
* Marks the Session going idle: every step since the previous marker belongs to
* one turn, including prompts steered in while it was busy. A shutdown does not
* record one, since the resumed execution continues the same turn.
*/
export interface Idle extends Schema.Schema.Type<typeof Idle> {}
export const Idle = Schema.Struct({
...Base,
type: Schema.tag("idle"),
outcome: Schema.Literals(["succeeded", "failed", "interrupted"]),
}).annotate({ identifier: "Session.Message.Idle" })
export const Info = Schema.Union([
AgentSelected,
ModelSelected,
@@ -291,6 +303,7 @@ export const Info = Schema.Union([
Shell,
Assistant,
Compaction,
Idle,
]).annotate({ identifier: "Session.Message.Info" })
export type Info =
| AgentSelected
@@ -303,4 +316,5 @@ export type Info =
| Shell
| Assistant
| Compaction
| Idle
export type Type = Info["type"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.2",
"version": "2.0.3",
"name": "@opencode/sdk",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/server",
"version": "2.0.2",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"repository": {
+23 -1
View File
@@ -1,5 +1,6 @@
import { Session } from "@opencode/core/session"
import { SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
import type { Snapshot } from "@opencode/core/snapshot"
import { MessageNotFoundError, SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
import { Effect } from "effect"
export function missingSession(error: Session.NotFoundError) {
@@ -9,6 +10,14 @@ export function missingSession(error: Session.NotFoundError) {
})
}
export function missingMessage(error: Session.MessageNotFoundError) {
return new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
})
}
export function failedMessageDecode(error: Session.MessageDecodeError) {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode session message").pipe(
@@ -18,3 +27,16 @@ export function failedMessageDecode(error: Session.MessageDecodeError) {
),
)
}
/** Snapshot repositories are host state clients cannot repair, so surface only a log reference. */
export function failedSnapshot(operation: string, sessionID: Session.ID) {
return (error: Snapshot.Error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError(`failed to ${operation}`, { cause: error }).pipe(
Effect.annotateLogs({ ref, sessionID }),
Effect.andThen(
Effect.fail(new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref })),
),
)
}
}
+32 -53
View File
@@ -17,10 +17,9 @@ import {
ServiceUnavailableError,
SessionBusyError,
SkillNotFoundError,
UnknownError,
} from "@opencode/protocol/errors"
import { AbsolutePath } from "@opencode/core/schema"
import { failedMessageDecode, missingSession } from "./session-error"
import { failedMessageDecode, failedSnapshot, missingMessage, missingSession } from "./session-error"
const DefaultSessionsLimit = 50
@@ -213,15 +212,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
return {
data: yield* session.fork({ sessionID: ctx.params.sessionID, boundary: ctx.payload.boundary }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag(
"Session.MessageNotFoundError",
(error) =>
new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
}),
),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag(
"Session.ForkEmptyError",
(error) => new InvalidRequestError({ message: error.message, kind: "empty_session" }),
@@ -449,32 +440,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
files: ctx.payload.files,
})
return {
data: yield* session.revert.stage({ ...ctx.params, ...ctx.payload }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag(
"Session.MessageNotFoundError",
(error) =>
new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
}),
data: yield* session.revert
.stage({ ...ctx.params, ...ctx.payload })
.pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", failedSnapshot("stage session revert", ctx.params.sessionID)),
),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to stage session revert", { cause: error }).pipe(
Effect.andThen(
Effect.fail(
new UnknownError({
message: "Unexpected server error. Check server logs for details.",
ref,
}),
),
),
)
}),
),
}
}),
)
@@ -482,23 +455,13 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
"session.revert.clear",
Effect.fn(function* (ctx) {
yield* Effect.log("session.revert.clear", { sessionID: ctx.params.sessionID })
yield* session.revert.clear(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to clear session revert", { cause: error }).pipe(
Effect.andThen(
Effect.fail(
new UnknownError({
message: "Unexpected server error. Check server logs for details.",
ref,
}),
),
),
)
}),
)
yield* session.revert
.clear(ctx.params.sessionID)
.pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", failedSnapshot("clear session revert", ctx.params.sessionID)),
)
return HttpApiSchema.NoContent.make()
}),
)
@@ -528,6 +491,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.diff",
Effect.fn(function* (ctx) {
return {
data: yield* session.diff({ sessionID: ctx.params.sessionID, ...ctx.query }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag(
"Session.TurnRangeError",
(error) => new InvalidRequestError({ message: error.message, field: error.field }),
),
Effect.catchTag("Snapshot.Error", failedSnapshot("diff session turn", ctx.params.sessionID)),
),
}
}),
)
.handle(
"session.inbox.list",
Effect.fn(function* (ctx) {
+98
View File
@@ -0,0 +1,98 @@
import { expect, setDefaultTimeout } from "bun:test"
import { Agent } from "@opencode/core/agent"
import { Bus } from "@opencode/core/bus"
import { Model } from "@opencode/core/model"
import { Provider } from "@opencode/core/provider"
import { Session } from "@opencode/core/session"
import { SessionEvent } from "@opencode/core/session/event"
import { SessionExecution } from "@opencode/core/session/execution"
import { SessionMessage } from "@opencode/core/session/message"
import { Money } from "@opencode/schema/money"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { Effect, Layer } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
setDefaultTimeout(30_000)
it.live("serves turn diffs by user message with range validation", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-session-diff-")))
const ids = { user: SessionMessage.ID.create(), assistant: SessionMessage.ID.create() }
// Deliver the prompt and one step the way the runner would, without a model.
const execution = Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
return SessionExecution.Service.of({
active: Effect.succeed(new Set()),
isActive: () => Effect.succeed(false),
resume: () => Effect.void,
wake: (sessionID) =>
Effect.gen(function* () {
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: ids.user })
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: ids.assistant,
agent: Agent.defaultID,
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
})
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID,
assistantMessageID: ids.assistant,
finish: "stop",
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
}),
interrupt: () => Effect.succeed(false),
awaitIdle: () => Effect.void,
})
}),
)
const handler = yield* ServerFetch.make(
{
app: { version: "test-version" },
database: { path: ":memory:" },
fs: { filewatcher: false },
models: { fetch: false },
},
{
overrides: [
SessionExecution.node.replace(
makeGlobalNode({ service: SessionExecution.Service, layer: execution, deps: [Bus.node] }),
),
],
},
)
const request = (path: string, body?: unknown) =>
Effect.promise(async () => {
const response = await handler(
new Request(`http://opencode.local${path}`, {
method: body === undefined ? "GET" : "POST",
headers: body === undefined ? undefined : { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
}),
)
return { status: response.status, body: (await response.json()) as Record<string, unknown> }
})
const created = yield* request("/api/session", { location: { directory: tmp.path } })
const sessionID = Session.ID.make((created.body.data as { id: string }).id)
const diff = (query = "") => request(`/api/session/${sessionID}/diff${query}`)
expect(yield* diff()).toEqual({ status: 200, body: { data: [] } })
expect((yield* request(`/api/session/${sessionID}/prompt`, { id: ids.user, text: "prompt" })).status).toBe(200)
// Not a git repository, so steps record no snapshots and the turn has no diff.
expect(yield* diff(`?from=${ids.user}&context=3`)).toEqual({ status: 200, body: { data: [] } })
expect(yield* diff(`?from=${ids.assistant}`)).toMatchObject({
status: 400,
body: { _tag: "InvalidRequestError", field: "from" },
})
expect(yield* diff(`?from=${SessionMessage.ID.create()}`)).toMatchObject({
status: 404,
body: { _tag: "MessageNotFoundError" },
})
expect((yield* request(`/api/session/${Session.ID.create()}/diff`)).status).toBe(404)
}),
)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/session-ui",
"version": "2.0.2",
"version": "2.0.3",
"private": true,
"type": "module",
"license": "MIT",
@@ -16,7 +16,7 @@ export { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap }
export type ReasoningMode = "hidden" | "compact" | "full"
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" | "idle" }>
type Entry = { type: "assistant"; message: SessionMessageAssistant } | { type: "notice"; message: Notice }
type Content = SessionMessageAssistant["content"][number]
type GroupRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
@@ -763,7 +763,8 @@ function record(value: unknown): value is Record<string, unknown> {
}
function isNotice(message: SessionMessageInfo): message is Notice {
if (message.type === "user" || message.type === "assistant" || message.type === "shell") return false
if (message.type === "user" || message.type === "assistant" || message.type === "shell" || message.type === "idle")
return false
if (message.type !== "synthetic") return true
return !!message.description?.trim() || timelineNoticeRequired(message)
}
@@ -4,6 +4,7 @@ import type { SessionDocument } from "../document"
import type { SessionUserActions } from "../actions"
import { createReactiveTimelineProjection, TimelineRow, type ReasoningMode } from "./projection"
import { createSessionTimelineRowRenderer, type SessionUserPresentation } from "./session-timeline-row"
import type { TimelineDetail } from "./detail"
export type { SessionUserPresentation } from "./session-timeline-row"
@@ -12,18 +13,21 @@ export type SessionTimelineProps = {
presentation?: Record<string, SessionUserPresentation | undefined>
actions?: SessionUserActions
reasoningMode?: ReasoningMode
timelineDetail?: TimelineDetail
shellToolDefaultOpen?: boolean
editToolDefaultOpen?: boolean
class?: string
}
export function SessionTimeline(props: SessionTimelineProps) {
const timelineDetail = props.timelineDetail
const projection = createReactiveTimelineProjection({
sessionMessages: () => props.document.messages,
status: () => props.document.status,
reasoningMode: () => props.reasoningMode ?? "compact",
shellToolDefaultOpen: () => props.shellToolDefaultOpen ?? false,
editToolDefaultOpen: () => props.editToolDefaultOpen ?? false,
timelineDetail: timelineDetail ? () => timelineDetail : undefined,
})
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>({})
const renderer = createSessionTimelineRowRenderer({
@@ -35,6 +39,7 @@ export function SessionTimeline(props: SessionTimelineProps) {
reasoningMode: () => props.reasoningMode ?? "compact",
shellToolDefaultOpen: () => props.shellToolDefaultOpen ?? false,
editToolDefaultOpen: () => props.editToolDefaultOpen ?? false,
timelineDetail: timelineDetail ? () => timelineDetail : undefined,
disclosure: {
value: (key) => toolOpen[key],
set: (key, open) => setToolOpen(key, open),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/simulation",
"version": "2.0.2",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-app",
"version": "2.0.2",
"version": "2.0.3",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-core",
"version": "2.0.2",
"version": "2.0.3",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-server",
"version": "2.0.2",
"version": "2.0.3",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/theme",
"version": "2.0.2",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/tui",
"version": "2.0.2",
"version": "2.0.3",
"private": true,
"type": "module",
"license": "MIT",
+11 -3
View File
@@ -285,8 +285,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
)
renderer.once("destroy", () => shutdown.openUnsafe())
yield* Effect.tryPromise(async () => {
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
void renderer.getPalette({ size: 16 }).catch(() => undefined)
const mode = handoff?.mode ?? (await renderer.waitForThemeMode(1000)) ?? "dark"
if (renderer.isDestroyed) return
@@ -484,7 +482,7 @@ function App(props: { pair?: DialogPairCredentials }) {
const toast = useToast()
const updater = useUpdateNotification()
const theme = useTheme()
const { mode, supports, setMode, locked, lock, unlock } = useThemes()
const { mode, supports, setMode, locked, lock, unlock, afterPaint } = useThemes()
const data = useData()
const location = useLocation()
const exit = useExit()
@@ -492,6 +490,16 @@ function App(props: { pair?: DialogPairCredentials }) {
const plugins = usePlugin()
const clipboard = useClipboard()
const terminalEnvironment = useTuiTerminalEnvironment()
let paletteTimer: ReturnType<typeof setTimeout> | undefined
const afterFrame = () => {
// The native writer can still be flushing the frame when FRAME fires. Keep OSC probes behind visible app output.
paletteTimer = setTimeout(afterPaint, 50)
}
onMount(() => renderer.once(CliRenderEvents.FRAME, afterFrame))
onCleanup(() => {
renderer.off(CliRenderEvents.FRAME, afterFrame)
if (paletteTimer) clearTimeout(paletteTimer)
})
createEffect(() => {
if (client.connection.status() !== "connected") return
if (route.data.type !== "session") return
+4
View File
@@ -139,3 +139,7 @@ export function useStorage() {
if (!storage) throw new Error("StorageProvider is missing")
return storage
}
export function useStorageOptional() {
return useContext(Context)
}
+90 -82
View File
@@ -24,10 +24,11 @@ import {
import { generateSystem, terminalMode } from "../theme/system"
import { discoverThemes } from "../theme/discovery"
import { createComponentTheme, createComponentThemeView, type ComponentTheme } from "../theme/component"
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useConfig } from "../config"
import { useStorageOptional } from "./storage"
import { DevTools } from "../devtools"
import { configDirectories } from "../util/config-directories"
@@ -94,7 +95,6 @@ export {
const THEME_REFRESH_DELAYS = [250, 1000] as const
type State = {
themes: Record<string, ThemeDocumentSource>
mode: "dark" | "light"
lock: "dark" | "light" | undefined
active: string
@@ -116,6 +116,7 @@ type Themes = {
unlock(): void
setMode(mode?: "dark" | "light", persist?: boolean): boolean
set(theme: string): boolean
afterPaint(): void
onError(handler: ThemeErrorHandler): () => void
readonly ready: boolean
}
@@ -127,14 +128,14 @@ type ThemeContextValue = {
}
const [store, setStore] = createStore<State>({
themes: allThemes(),
mode: "dark",
lock: undefined,
active: "opencode",
ready: false,
})
const [themeSources, setThemeSources] = createSignal(allThemes())
subscribeThemes((themes) => setStore("themes", themes))
subscribeThemes(setThemeSources)
const themeContext = createSimpleContext({
name: "Theme",
@@ -143,26 +144,24 @@ const themeContext = createSimpleContext({
const configState = useConfig()
const config = configState.data
const themes = props.source
const pick = (value: unknown) => {
if (value === "dark" || value === "light") return value
return
}
const cache = useStorageOptional()?.store<{ colors?: TerminalColors }>("system-theme", { initial: {} })
setStore(
produce((draft) => {
const lock = pick(config.theme?.mode)
const mode = lock ?? pick(renderer.themeMode) ?? props.mode
const lock = config.theme?.mode === "dark" || config.theme?.mode === "light" ? config.theme.mode : undefined
const mode = lock ?? renderer.themeMode ?? props.mode
draft.mode = mode
draft.lock = lock
const active = config.theme?.name ?? "opencode"
draft.active = typeof active === "string" ? active : "opencode"
draft.active = config.theme?.name ?? "opencode"
draft.ready = false
}),
)
createEffect(() => {
const theme = config.theme?.name
if (theme) setStore("active", theme)
if (!theme) return
setStore("active", theme)
if (theme === "system") refreshPalette()
})
createEffect(() => {
@@ -184,70 +183,69 @@ const themeContext = createSimpleContext({
}
onMount(() => {
const systemTheme = resolveSystemTheme(store.mode)
void Promise.allSettled([
store.active === "system" ? systemTheme : Promise.resolve(),
syncCustomThemes(),
]).finally(() => {
valuesV2()
// Terminal palette queries serialize with frame output. First paint uses the cached palette or built-in fallback.
void syncCustomThemes().finally(() => {
tokens()
setStore("ready", true)
})
})
let systemThemeSignature: string | undefined
let systemThemeMode: "dark" | "light" | undefined
let hasResolvedSystemTheme = false
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
return renderer
const cachedPalette = cache?.[0].colors
let palette = usablePalette(cachedPalette) ? cachedPalette : undefined
const applyPalette = () => {
if (palette) setSystemTheme(generateSystem(palette, store.mode))
}
if (palette) {
const mode = store.lock ?? terminalMode(palette) ?? store.mode
if (store.mode !== mode) setStore("mode", mode)
applyPalette()
} else setSystemTheme(undefined)
let canProbe = false
let probing = false
let queued = false
let disposed = false
function refreshPalette() {
if (store.active !== "system") return
queued = true
if (!canProbe || probing || disposed) return
queued = false
probing = true
// Clearing does not cancel a query already owned by the renderer. Share it, then run one fresh query.
const retry = renderer.paletteDetectionStatus === "detecting"
renderer.clearPaletteCache()
void renderer
.getPalette({ size: 16 })
.then((colors: TerminalColors) => {
if (!colors.palette[0]) {
if (hasResolvedSystemTheme) return
setSystemTheme(undefined)
if (store.active === "system") setStore("active", "opencode")
return
}
const next = store.lock ?? terminalMode(colors) ?? mode
if (store.mode !== next) setStore("mode", next)
const signature = JSON.stringify(colors)
hasResolvedSystemTheme = true
if (store.themes.system && systemThemeSignature === signature && systemThemeMode === next) return
systemThemeSignature = signature
systemThemeMode = next
setSystemTheme(generateSystem(colors, next))
.then((colors) => {
if (disposed || !usablePalette(colors)) return
palette = colors
const mode = store.lock ?? terminalMode(colors) ?? store.mode
if (store.mode !== mode) setStore("mode", mode)
applyPalette()
void cache?.[1]((draft) => {
draft.colors = colors
}).catch(() => {})
})
.catch(() => {
if (hasResolvedSystemTheme) return
setSystemTheme(undefined)
if (store.active === "system") setStore("active", "opencode")
.catch(() => {})
.finally(() => {
probing = false
if (disposed || (!retry && !queued)) return
refreshPalette()
})
}
let systemRefreshRunning = false
let systemRefreshQueued = false
let systemRefreshMode = store.mode
function refreshSystemTheme(mode: "dark" | "light" = store.mode) {
systemRefreshMode = mode
if (systemRefreshRunning) {
systemRefreshQueued = true
return
}
systemRefreshRunning = true
const retry = renderer.paletteDetectionStatus === "detecting"
renderer.clearPaletteCache()
void resolveSystemTheme(mode).finally(() => {
systemRefreshRunning = false
if (!retry && !systemRefreshQueued) return
systemRefreshQueued = false
refreshSystemTheme(systemRefreshMode)
})
function afterPaint() {
canProbe = true
refreshPalette()
}
function apply(mode: "dark" | "light") {
if (store.mode === mode) return
setStore("mode", mode)
refreshSystemTheme(mode)
if (store.mode !== mode) {
setStore("mode", mode)
applyPalette()
}
refreshPalette()
}
function pin(mode: "dark" | "light" = store.mode, persist = true) {
@@ -263,7 +261,7 @@ const themeContext = createSimpleContext({
function free(persist = true) {
setStore("lock", undefined)
refreshSystemTheme(renderer.themeMode ?? store.mode)
apply(renderer.themeMode ?? store.mode)
if (!persist) return
void configState
.update((draft) => {
@@ -272,33 +270,34 @@ const themeContext = createSimpleContext({
.catch(() => {})
}
const handle = (mode: "dark" | "light") => {
const handleMode = (mode: "dark" | "light") => {
if (store.lock) return
apply(mode)
}
renderer.on(CliRenderEvents.THEME_MODE, handle)
renderer.on(CliRenderEvents.THEME_MODE, handleMode)
const handleThemeNotification = (sequence: string) => {
if (sequence !== "\x1b[?997;1n" && sequence !== "\x1b[?997;2n") return false
queueMicrotask(() => refreshSystemTheme())
queueMicrotask(refreshPalette)
return false
}
renderer.prependInputHandler(handleThemeNotification)
let themeRefreshTimeouts: ReturnType<typeof setTimeout>[] = []
const refresh = () => {
const refreshThemes = () => {
for (const timeout of themeRefreshTimeouts) clearTimeout(timeout)
themeRefreshTimeouts = THEME_REFRESH_DELAYS.map((delay) =>
setTimeout(() => {
refreshSystemTheme()
refreshPalette()
if (delay === THEME_REFRESH_DELAYS[THEME_REFRESH_DELAYS.length - 1]) void syncCustomThemes()
}, delay),
)
}
const unsubscribeRefresh = themes.subscribeRefresh?.(refresh)
const unsubscribeRefresh = themes.subscribeRefresh?.(refreshThemes)
onCleanup(() => {
renderer.off(CliRenderEvents.THEME_MODE, handle)
disposed = true
renderer.off(CliRenderEvents.THEME_MODE, handleMode)
renderer.removeInputHandler(handleThemeNotification)
unsubscribeRefresh?.()
for (const timeout of themeRefreshTimeouts) clearTimeout(timeout)
@@ -307,29 +306,30 @@ const themeContext = createSimpleContext({
const initStarted = performance.now()
const selected = createMemo(() => {
const name = store.themes[store.active] ? store.active : "opencode"
const sources = themeSources()
const name = sources[store.active] ? store.active : "opencode"
try {
return loadTheme(store.themes[name], name, store.mode)
return loadTheme(sources[name], name, store.mode)
} catch (error) {
if (name === "opencode") throw error
themeErrors.emit(name, error)
setStore("active", "opencode")
return loadTheme(store.themes.opencode, "opencode", store.mode)
return loadTheme(sources.opencode, "opencode", store.mode)
}
})
const modes = () => selected().modes
const mode = () => selected().mode
const valuesV2 = () => selected().theme
valuesV2()
const tokens = () => selected().theme
tokens()
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
const current = createComponentTheme(valuesV2, mode)
const current = createComponentTheme(tokens, mode)
createEffect(() => renderer.setBackgroundColor(valuesV2().background.default))
createEffect(() => renderer.setBackgroundColor(tokens().background.default))
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(tokens(), mode()))
const service: Themes = {
current,
currentTokens: valuesV2,
currentTokens: tokens,
currentSyntax,
get selected() {
return store.active
@@ -350,6 +350,7 @@ const themeContext = createSimpleContext({
set(theme: string) {
if (!hasTheme(theme)) return false
setStore("active", theme)
if (theme === "system") refreshPalette()
void configState
.update((draft) => {
draft.theme = { ...draft.theme, name: theme }
@@ -357,6 +358,7 @@ const themeContext = createSimpleContext({
.catch(() => {})
return true
},
afterPaint,
onError: themeErrors.onError,
get ready() {
return store.ready
@@ -383,6 +385,12 @@ export function useTheme(context?: ContextName) {
}
export const ThemeProvider = themeContext.provider
function usablePalette(colors: TerminalColors | undefined): colors is TerminalColors {
return Boolean(
colors && (colors.defaultBackground ?? colors.palette[0]) && (colors.defaultForeground ?? colors.palette[7]),
)
}
/** Switches context without remounting children; undefined inherits the enclosing view. */
export function ThemeContextProvider(props: ParentProps<{ context: ContextName | undefined }>) {
const value = themeContext.use()
+1
View File
@@ -289,6 +289,7 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
...messages.filter(isInput),
].reduce<ProjectionEntry[]>((rows, message) => {
if (message.type !== "assistant") {
if (message.type === "idle") return rows
if (message.type === "synthetic" && !message.description?.trim()) return rows
if (message.type === "compaction" && message.status === "completed" && usage) usage.previousTurnCache = undefined
rows.push({ entry: { type: "message", messageID: message.id }, closesPrevious: !pending.has(message.id) })
+2 -3
View File
@@ -15,17 +15,16 @@ const listeners = new Set<(themes: Record<string, ThemeDocumentSource>) => void>
const parsed = new WeakMap<object, ThemeDocument>()
const decodeThemeDocument = Schema.decodeUnknownSync(ThemeDocument, { reportInput: true })
function listThemes() {
function listThemes(): Record<string, ThemeDocumentSource> {
// Priority: defaults < plugin installs < custom files < generated system.
const themes: Record<string, ThemeDocumentSource> = {
...DEFAULT_THEMES,
...pluginThemes,
...customThemes,
}
if (!systemTheme) return themes
return {
...themes,
system: systemTheme,
system: systemTheme ?? themes.system ?? themes.opencode,
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/ui",
"version": "2.0.2",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"repository": {
+2 -1
View File
@@ -44,7 +44,7 @@ function knownThemes() {
}
const names: Record<string, string> = {
"oc-2": "OpenCode",
"oc-2": "OC-2",
amoled: "AMOLED",
aura: "Aura",
ayu: "Ayu",
@@ -69,6 +69,7 @@ const names: Record<string, string> = {
nord: "Nord",
"one-dark": "One Dark",
onedarkpro: "One Dark Pro",
opencode: "OpenCode",
orng: "Orng",
"osaka-jade": "Osaka Jade",
palenight: "Palenight",
+3
View File
@@ -24,6 +24,7 @@ import nightowlThemeJson from "./themes/nightowl.json"
import nordThemeJson from "./themes/nord.json"
import oneDarkThemeJson from "./themes/one-dark.json"
import oneDarkProThemeJson from "./themes/onedarkpro.json"
import opencodeThemeJson from "./themes/opencode.json"
import orngThemeJson from "./themes/orng.json"
import osakaJadeThemeJson from "./themes/osaka-jade.json"
import palenightThemeJson from "./themes/palenight.json"
@@ -61,6 +62,7 @@ export const nightowlTheme = nightowlThemeJson as DesktopTheme
export const nordTheme = nordThemeJson as DesktopTheme
export const oneDarkTheme = oneDarkThemeJson as DesktopTheme
export const oneDarkProTheme = oneDarkProThemeJson as DesktopTheme
export const opencodeTheme = opencodeThemeJson as DesktopTheme
export const orngTheme = orngThemeJson as DesktopTheme
export const osakaJadeTheme = osakaJadeThemeJson as DesktopTheme
export const palenightTheme = palenightThemeJson as DesktopTheme
@@ -99,6 +101,7 @@ export const DEFAULT_THEMES: Record<string, DesktopTheme> = {
nord: nordTheme,
"one-dark": oneDarkTheme,
onedarkpro: oneDarkProTheme,
opencode: opencodeTheme,
orng: orngTheme,
"osaka-jade": osakaJadeTheme,
palenight: palenightTheme,
+1
View File
@@ -63,6 +63,7 @@ export {
nordTheme,
oneDarkTheme,
oneDarkProTheme,
opencodeTheme,
orngTheme,
osakaJadeTheme,
palenightTheme,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://opencode.ai/desktop-theme.json",
"name": "OpenCode",
"name": "OC-2",
"id": "oc-2",
"light": {
"palette": {
@@ -0,0 +1,89 @@
{
"$schema": "https://opencode.ai/desktop-theme.json",
"name": "OpenCode",
"id": "opencode",
"light": {
"palette": {
"neutral": "#ffffff",
"ink": "#1a1a1a",
"primary": "#3b7dd8",
"accent": "#d68c27",
"success": "#3d9a57",
"warning": "#d68c27",
"error": "#d1383d",
"info": "#318795",
"diffAdd": "#4db380",
"diffDelete": "#f52a65"
},
"overrides": {
"text-weak": "#8a8a8a",
"syntax-comment": "#8a8a8a",
"syntax-keyword": "#d68c27",
"syntax-string": "#3d9a57",
"syntax-primitive": "#3b7dd8",
"syntax-variable": "#d1383d",
"syntax-property": "#318795",
"syntax-type": "#b0851f",
"syntax-constant": "#d68c27",
"syntax-operator": "#318795",
"syntax-punctuation": "#1a1a1a",
"syntax-object": "#d1383d",
"markdown-heading": "#d68c27",
"markdown-text": "#1a1a1a",
"markdown-link": "#3b7dd8",
"markdown-link-text": "#318795",
"markdown-code": "#3d9a57",
"markdown-block-quote": "#b0851f",
"markdown-emph": "#b0851f",
"markdown-strong": "#d68c27",
"markdown-horizontal-rule": "#8a8a8a",
"markdown-list-item": "#3b7dd8",
"markdown-list-enumeration": "#318795",
"markdown-image": "#3b7dd8",
"markdown-image-text": "#318795",
"markdown-code-block": "#1a1a1a"
}
},
"dark": {
"palette": {
"neutral": "#0a0a0a",
"ink": "#eeeeee",
"primary": "#fab283",
"accent": "#9d7cd8",
"success": "#7fd88f",
"warning": "#f5a742",
"error": "#e06c75",
"info": "#56b6c2",
"diffAdd": "#b8db87",
"diffDelete": "#e26a75"
},
"overrides": {
"text-weak": "#808080",
"syntax-comment": "#808080",
"syntax-keyword": "#9d7cd8",
"syntax-string": "#7fd88f",
"syntax-primitive": "#fab283",
"syntax-variable": "#e06c75",
"syntax-property": "#56b6c2",
"syntax-type": "#e5c07b",
"syntax-constant": "#f5a742",
"syntax-operator": "#56b6c2",
"syntax-punctuation": "#eeeeee",
"syntax-object": "#e06c75",
"markdown-heading": "#9d7cd8",
"markdown-text": "#eeeeee",
"markdown-link": "#fab283",
"markdown-link-text": "#56b6c2",
"markdown-code": "#7fd88f",
"markdown-block-quote": "#e5c07b",
"markdown-emph": "#e5c07b",
"markdown-strong": "#f5a742",
"markdown-horizontal-rule": "#808080",
"markdown-list-item": "#fab283",
"markdown-list-enumeration": "#56b6c2",
"markdown-image": "#fab283",
"markdown-image-text": "#56b6c2",
"markdown-code-block": "#eeeeee"
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/util",
"version": "2.0.2",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode/web",
"type": "module",
"license": "MIT",
"version": "2.0.2",
"version": "2.0.3",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "2.0.2",
"version": "2.0.3",
"publisher": "sst-dev",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/update",
"version": "2.0.2",
"version": "2.0.3",
"private": true,
"type": "module",
"scripts": {
+181
View File
@@ -3242,6 +3242,152 @@
"summary": "Get session context"
}
},
"/api/session/{sessionID}/diff": {
"get": {
"tags": ["session"],
"operationId": "v2.session.diff",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "from",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
},
"required": false
},
{
"name": "to",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Later user message whose turn ends the range. Defaults to the turn of `from` alone."
},
"required": false
},
{
"name": "context",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Unchanged lines around each hunk. Omit for full-file patches."
},
"required": false
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff.Info"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "MessageNotFoundError | SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
}
}
}
},
"500": {
"description": "UnknownError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnknownErrorEncoded"
}
}
}
}
},
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
"summary": "Diff session turns"
}
},
"/api/session/{sessionID}/inbox": {
"get": {
"tags": ["session"],
@@ -18421,6 +18567,38 @@
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
"additionalProperties": false
},
"Session.Message.Idle": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^msg_"
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["idle"]
},
"outcome": {
"type": "string",
"enum": ["succeeded", "failed", "interrupted"]
}
},
"required": ["id", "time", "type", "outcome"],
"additionalProperties": false
},
"Session.Message.Info": {
"anyOf": [
{
@@ -18452,6 +18630,9 @@
},
{
"$ref": "#/components/schemas/Session.Message.Compaction"
},
{
"$ref": "#/components/schemas/Session.Message.Idle"
}
]
},
+181
View File
@@ -3242,6 +3242,152 @@
"summary": "Get session context"
}
},
"/api/session/{sessionID}/diff": {
"get": {
"tags": ["session"],
"operationId": "v2.session.diff",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "from",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
},
"required": false
},
{
"name": "to",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Later user message whose turn ends the range. Defaults to the turn of `from` alone."
},
"required": false
},
{
"name": "context",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Unchanged lines around each hunk. Omit for full-file patches."
},
"required": false
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff.Info"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "MessageNotFoundError | SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
}
}
}
},
"500": {
"description": "UnknownError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnknownErrorEncoded"
}
}
}
}
},
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
"summary": "Diff session turns"
}
},
"/api/session/{sessionID}/inbox": {
"get": {
"tags": ["session"],
@@ -18421,6 +18567,38 @@
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
"additionalProperties": false
},
"Session.Message.Idle": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^msg_"
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["idle"]
},
"outcome": {
"type": "string",
"enum": ["succeeded", "failed", "interrupted"]
}
},
"required": ["id", "time", "type", "outcome"],
"additionalProperties": false
},
"Session.Message.Info": {
"anyOf": [
{
@@ -18452,6 +18630,9 @@
},
{
"$ref": "#/components/schemas/Session.Message.Compaction"
},
{
"$ref": "#/components/schemas/Session.Message.Idle"
}
]
},