Compare commits

..
42 changed files with 1892 additions and 2041 deletions
+25 -25
View File
@@ -22,7 +22,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.1.8", "version": "1.1.7",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -70,7 +70,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.1.8", "version": "1.1.7",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -98,7 +98,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.1.8", "version": "1.1.7",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -125,7 +125,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.1.8", "version": "1.1.7",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "2.0.0", "@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2", "@ai-sdk/openai": "2.0.2",
@@ -149,7 +149,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.1.8", "version": "1.1.7",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -173,7 +173,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.1.8", "version": "1.1.7",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -202,7 +202,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.1.8", "version": "1.1.7",
"dependencies": { "dependencies": {
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -231,7 +231,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.1.8", "version": "1.1.7",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -247,7 +247,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.1.8", "version": "1.1.7",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -286,8 +286,8 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2", "@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.70", "@opentui/core": "0.1.69",
"@opentui/solid": "0.1.70", "@opentui/solid": "0.1.69",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
@@ -350,7 +350,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.1.8", "version": "1.1.7",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
@@ -370,7 +370,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.1.8", "version": "1.1.7",
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "0.88.1", "@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
@@ -381,7 +381,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.1.8", "version": "1.1.7",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -394,7 +394,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.1.8", "version": "1.1.7",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -433,7 +433,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.1.8", "version": "1.1.7",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -444,7 +444,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.1.8", "version": "1.1.7",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -1201,21 +1201,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.70", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.70", "@opentui/core-darwin-x64": "0.1.70", "@opentui/core-linux-arm64": "0.1.70", "@opentui/core-linux-x64": "0.1.70", "@opentui/core-win32-arm64": "0.1.70", "@opentui/core-win32-x64": "0.1.70", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-6cPAlbCnaiUUtQtvZNpkr0Xv8AdVAgJuy2VAwIsDN1pIv0zMpa0ZG+mr7afCGygw1eeDRveefrjfgFAB1r0SVw=="], "@opentui/core": ["@opentui/core@0.1.69", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.69", "@opentui/core-darwin-x64": "0.1.69", "@opentui/core-linux-arm64": "0.1.69", "@opentui/core-linux-x64": "0.1.69", "@opentui/core-win32-arm64": "0.1.69", "@opentui/core-win32-x64": "0.1.69", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-BcEFnAuMq4vgfb+zxOP/l+NO1AS3fVHkYjn+E8Wpmaxr0AzWNTi2NPAMtQf+Wqufxo0NYh0gY4c9B6n8OxTjGw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.70", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rM8EnvW1tOAXWnp2Iy2M82I+ViSmRwUagx3v1/ni6N8GCcw/3mE0C6eB3sVlYNXVMwBEgiKpWFn85RCe4+qXQw=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.69", "", { "os": "darwin", "cpu": "arm64" }, "sha512-d9RPAh84O2XIyMw+7+X0fEyi+4KH5sPk9AxLze8GHRBGOzkRunqagFCLBrN5VFs2e2nbhIYtjMszo7gcpWyh7g=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.70", "", { "os": "darwin", "cpu": "x64" }, "sha512-XdBgW+em8J+YGSUpaKF8/NxPjikJygK3dIkeMAw5xQ2lt7jXKxeM5MMmN/V4MfK3pLMtO56rLJlXaLH/h50uQA=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.69", "", { "os": "darwin", "cpu": "x64" }, "sha512-41K9zkL2IG0ahL+8Gd+e9ulMrnJF6lArPzG7grjWzo+FWEZwvw0WLCO1/Gn5K85G8Yx7gQXkZOUaw1BmHjxoRw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.70", "", { "os": "linux", "cpu": "arm64" }, "sha512-oSVWNMSOx0Na0M0LCqtWCxeh4SuLSK5lg8ZwVzsEoimIAxh0snp9nRUo/Qi8yD9BP0DSDmXuM/B3ONtzFaf0dw=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.69", "", { "os": "linux", "cpu": "arm64" }, "sha512-IcUjwjuIpX3BBG1a9kjMqWrHYCFHAVfjh5nIRozWZZoqaczLzJb3nJeF2eg8aDeIoGhXvERWB1r1gmqPW8u3vQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.70", "", { "os": "linux", "cpu": "x64" }, "sha512-WUrhukefMghcZ7sAjkxEy50vA6ii0X21xh7m8c4omXyYYfQXyDs25pNExB8cwoCrZEaC8RTlF4lRSNPIXsZKhA=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.69", "", { "os": "linux", "cpu": "x64" }, "sha512-5S9vqEIq7q+MEdp4cT0HLegBWu0pWLcletHZL80bsLbJt9OT8en3sQmL5bvas9sIuyeBFru9bfCmrQ/gnVTTiA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.70", "", { "os": "win32", "cpu": "arm64" }, "sha512-p1K2VJXGmZqSV7mR61v7KJpT1Zth7DS99wEtaqqfK68OWt33K2XxLmGO0KD142R2JLfXu32NnRmBHxmVx8IjBA=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.69", "", { "os": "win32", "cpu": "arm64" }, "sha512-eSKcGwbcnJJPtrTFJI7STZ7inSYeedHS0swwjZhh9SADAruEz08intamunOslffv5+mnlvRp7UBGK35cMjbv/w=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.70", "", { "os": "win32", "cpu": "x64" }, "sha512-G6b8te1twMeDhjg1oZa0IcUjhOJZFCSdlQt+q5gu5vVtjCrIwAn9o7m5EwNMPakc31pDWUZ7v0ktgv0Xw1AQVA=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.69", "", { "os": "win32", "cpu": "x64" }, "sha512-OjG/0jqYXURqbbUwNgSPrBA6yuKF3OOFh8JSG7VvzoYHJFJRmwVWY0fztWv/hgGHe354ti37c7JDJBQ44HOCdA=="],
"@opentui/solid": ["@opentui/solid@0.1.70", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.70", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-8Cw/w4Of2OJhsFhcp/Wdj8cJRVaGvVsIiUoYiFtyToM01J4en0bg/vnbeZteyuZWeEtA4iz1/rSEQf7Dp+2FIQ=="], "@opentui/solid": ["@opentui/solid@0.1.69", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.69", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-ls589N8P9gvcNW8uF+Il4xisF5Uouk0RRmSaLdzmItNJSW5J9Y0nPtMELta6hBp0yIRAurWUO1wtkKXVF+eaxg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"nodeModules": "sha256-KjBAaI9Kv6huOmPvUbtyYsMhbScI91w1lOZyXpIWqI0=" "nodeModules": "sha256-rNGq0yjL5ZHYVg+zyV4nFPug4gqhKhyOnfebaufyd34="
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.1.8", "version": "1.1.7",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
+12 -10
View File
@@ -108,16 +108,18 @@ export function AppInterface() {
<Route path="/" component={() => <Navigate href="session" />} /> <Route path="/" component={() => <Navigate href="session" />} />
<Route <Route
path="/session/:id?" path="/session/:id?"
component={() => ( component={(p) => (
<TerminalProvider> <Show when={p.params.id ?? "new"} keyed>
<FileProvider> <TerminalProvider>
<PromptProvider> <FileProvider>
<Suspense fallback={<Loading />}> <PromptProvider>
<Session /> <Suspense fallback={<Loading />}>
</Suspense> <Session />
</PromptProvider> </Suspense>
</FileProvider> </PromptProvider>
</TerminalProvider> </FileProvider>
</TerminalProvider>
</Show>
)} )}
/> />
</Route> </Route>
@@ -7,11 +7,15 @@ import { createMemo, createSignal, For, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { type LocalProject, getAvatarColors } from "@/context/layout" import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/util/path"
import { Avatar } from "@opencode-ai/ui/avatar" import { Avatar } from "@opencode-ai/ui/avatar"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
function getFilename(input: string) {
const parts = input.split("/")
return parts[parts.length - 1] || input
}
export function DialogEditProject(props: { project: LocalProject }) { export function DialogEditProject(props: { project: LocalProject }) {
const dialog = useDialog() const dialog = useDialog()
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
+64 -146
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createRoot, onCleanup } from "solid-js" import { createEffect, createMemo, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { FileContent } from "@opencode-ai/sdk/v2" import type { FileContent } from "@opencode-ai/sdk/v2"
@@ -82,106 +82,8 @@ function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
} }
} }
const WORKSPACE_KEY = "__workspace__"
const MAX_FILE_VIEW_SESSIONS = 20
const MAX_VIEW_FILES = 500
type ViewSession = ReturnType<typeof createViewSession>
type ViewCacheEntry = {
value: ViewSession
dispose: VoidFunction
}
function createViewSession(dir: string, id: string | undefined) {
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
const [view, setView, _, ready] = persisted(
Persist.scoped(dir, id, "file-view", [legacyViewKey]),
createStore<{
file: Record<string, FileViewState>
}>({
file: {},
}),
)
const meta = { pruned: false }
const pruneView = (keep?: string) => {
const keys = Object.keys(view.file)
if (keys.length <= MAX_VIEW_FILES) return
const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
if (drop.length === 0) return
setView(
produce((draft) => {
for (const key of drop) {
delete draft.file[key]
}
}),
)
}
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
meta.pruned = true
pruneView()
})
const scrollTop = (path: string) => view.file[path]?.scrollTop
const scrollLeft = (path: string) => view.file[path]?.scrollLeft
const selectedLines = (path: string) => view.file[path]?.selectedLines
const setScrollTop = (path: string, top: number) => {
setView("file", path, (current) => {
if (current?.scrollTop === top) return current
return {
...(current ?? {}),
scrollTop: top,
}
})
pruneView(path)
}
const setScrollLeft = (path: string, left: number) => {
setView("file", path, (current) => {
if (current?.scrollLeft === left) return current
return {
...(current ?? {}),
scrollLeft: left,
}
})
pruneView(path)
}
const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
const next = range ? normalizeSelectedLines(range) : null
setView("file", path, (current) => {
if (current?.selectedLines === next) return current
return {
...(current ?? {}),
selectedLines: next,
}
})
pruneView(path)
}
return {
ready,
scrollTop,
scrollLeft,
selectedLines,
setScrollTop,
setScrollLeft,
setSelectedLines,
}
}
export const { use: useFile, provider: FileProvider } = createSimpleContext({ export const { use: useFile, provider: FileProvider } = createSimpleContext({
name: "File", name: "File",
gate: false,
init: () => { init: () => {
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
@@ -232,45 +134,42 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
file: {}, file: {},
}) })
const viewCache = new Map<string, ViewCacheEntry>() const legacyViewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
const disposeViews = () => { const [view, setView, _, ready] = persisted(
for (const entry of viewCache.values()) { Persist.scoped(params.dir!, params.id, "file-view", [legacyViewKey()]),
entry.dispose() createStore<{
} file: Record<string, FileViewState>
viewCache.clear() }>({
file: {},
}),
)
const MAX_VIEW_FILES = 500
const viewMeta = { pruned: false }
const pruneView = (keep?: string) => {
const keys = Object.keys(view.file)
if (keys.length <= MAX_VIEW_FILES) return
const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
if (drop.length === 0) return
setView(
produce((draft) => {
for (const key of drop) {
delete draft.file[key]
}
}),
)
} }
const pruneViews = () => { createEffect(() => {
while (viewCache.size > MAX_FILE_VIEW_SESSIONS) { if (!ready()) return
const first = viewCache.keys().next().value if (viewMeta.pruned) return
if (!first) return viewMeta.pruned = true
const entry = viewCache.get(first) pruneView()
entry?.dispose() })
viewCache.delete(first)
}
}
const loadView = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}`
const existing = viewCache.get(key)
if (existing) {
viewCache.delete(key)
viewCache.set(key, existing)
return existing.value
}
const entry = createRoot((dispose) => ({
value: createViewSession(dir, id),
dispose,
}))
viewCache.set(key, entry)
pruneViews()
return entry.value
}
const view = createMemo(() => loadView(params.dir!, params.id))
function ensure(path: string) { function ensure(path: string) {
if (!path) return if (!path) return
@@ -347,32 +246,51 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const get = (input: string) => store.file[normalize(input)] const get = (input: string) => store.file[normalize(input)]
const scrollTop = (input: string) => view().scrollTop(normalize(input)) const scrollTop = (input: string) => view.file[normalize(input)]?.scrollTop
const scrollLeft = (input: string) => view().scrollLeft(normalize(input)) const scrollLeft = (input: string) => view.file[normalize(input)]?.scrollLeft
const selectedLines = (input: string) => view().selectedLines(normalize(input)) const selectedLines = (input: string) => view.file[normalize(input)]?.selectedLines
const setScrollTop = (input: string, top: number) => { const setScrollTop = (input: string, top: number) => {
const path = normalize(input) const path = normalize(input)
view().setScrollTop(path, top) setView("file", path, (current) => {
if (current?.scrollTop === top) return current
return {
...(current ?? {}),
scrollTop: top,
}
})
pruneView(path)
} }
const setScrollLeft = (input: string, left: number) => { const setScrollLeft = (input: string, left: number) => {
const path = normalize(input) const path = normalize(input)
view().setScrollLeft(path, left) setView("file", path, (current) => {
if (current?.scrollLeft === left) return current
return {
...(current ?? {}),
scrollLeft: left,
}
})
pruneView(path)
} }
const setSelectedLines = (input: string, range: SelectedLineRange | null) => { const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
const path = normalize(input) const path = normalize(input)
view().setSelectedLines(path, range) const next = range ? normalizeSelectedLines(range) : null
setView("file", path, (current) => {
if (current?.selectedLines === next) return current
return {
...(current ?? {}),
selectedLines: next,
}
})
pruneView(path)
} }
onCleanup(() => { onCleanup(() => stop())
stop()
disposeViews()
})
return { return {
ready: () => view().ready(), ready,
normalize, normalize,
tab, tab,
pathFromTab, pathFromTab,
+59 -131
View File
@@ -1,6 +1,6 @@
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo, createRoot, onCleanup } from "solid-js" import { batch, createMemo } from "solid-js"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import type { FileSelection } from "@/context/file" import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
@@ -99,146 +99,74 @@ function clonePrompt(prompt: Prompt): Prompt {
return prompt.map(clonePart) return prompt.map(clonePart)
} }
const WORKSPACE_KEY = "__workspace__"
const MAX_PROMPT_SESSIONS = 20
type PromptSession = ReturnType<typeof createPromptSession>
type PromptCacheEntry = {
value: PromptSession
dispose: VoidFunction
}
function createPromptSession(dir: string, id: string | undefined) {
const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
const [store, setStore, _, ready] = persisted(
Persist.scoped(dir, id, "prompt", [legacy]),
createStore<{
prompt: Prompt
cursor?: number
context: {
activeTab: boolean
items: (ContextItem & { key: string })[]
}
}>({
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
context: {
activeTab: true,
items: [],
},
}),
)
function keyForItem(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
return `${item.type}:${item.path}:${start}:${end}`
}
return {
ready,
current: createMemo(() => store.prompt),
cursor: createMemo(() => store.cursor),
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
context: {
activeTab: createMemo(() => store.context.activeTab),
items: createMemo(() => store.context.items),
addActive() {
setStore("context", "activeTab", true)
},
removeActive() {
setStore("context", "activeTab", false)
},
add(item: ContextItem) {
const key = keyForItem(item)
if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }])
},
remove(key: string) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
},
},
set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt)
batch(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
},
reset() {
batch(() => {
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
setStore("cursor", 0)
})
},
}
}
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
name: "Prompt", name: "Prompt",
gate: false,
init: () => { init: () => {
const params = useParams() const params = useParams()
const cache = new Map<string, PromptCacheEntry>() const legacy = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`)
const disposeAll = () => { const [store, setStore, _, ready] = persisted(
for (const entry of cache.values()) { Persist.scoped(params.dir!, params.id, "prompt", [legacy()]),
entry.dispose() createStore<{
} prompt: Prompt
cache.clear() cursor?: number
context: {
activeTab: boolean
items: (ContextItem & { key: string })[]
}
}>({
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
context: {
activeTab: true,
items: [],
},
}),
)
function keyForItem(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
return `${item.type}:${item.path}:${start}:${end}`
} }
onCleanup(disposeAll)
const prune = () => {
while (cache.size > MAX_PROMPT_SESSIONS) {
const first = cache.keys().next().value
if (!first) return
const entry = cache.get(first)
entry?.dispose()
cache.delete(first)
}
}
const load = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}`
const existing = cache.get(key)
if (existing) {
cache.delete(key)
cache.set(key, existing)
return existing.value
}
const entry = createRoot((dispose) => ({
value: createPromptSession(dir, id),
dispose,
}))
cache.set(key, entry)
prune()
return entry.value
}
const session = createMemo(() => load(params.dir!, params.id))
return { return {
ready: () => session().ready(), ready,
current: () => session().current(), current: createMemo(() => store.prompt),
cursor: () => session().cursor(), cursor: createMemo(() => store.cursor),
dirty: () => session().dirty(), dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
context: { context: {
activeTab: () => session().context.activeTab(), activeTab: createMemo(() => store.context.activeTab),
items: () => session().context.items(), items: createMemo(() => store.context.items),
addActive: () => session().context.addActive(), addActive() {
removeActive: () => session().context.removeActive(), setStore("context", "activeTab", true)
add: (item: ContextItem) => session().context.add(item), },
remove: (key: string) => session().context.remove(key), removeActive() {
setStore("context", "activeTab", false)
},
add(item: ContextItem) {
const key = keyForItem(item)
if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }])
},
remove(key: string) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
},
},
set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt)
batch(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
},
reset() {
batch(() => {
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
setStore("cursor", 0)
})
}, },
set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
reset: () => session().reset(),
} }
}, },
}) })
+54 -160
View File
@@ -1,5 +1,5 @@
import { batch, createMemo } from "solid-js" import { batch, createMemo } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store" import { produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry" import { retry } from "@opencode-ai/util/retry"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
@@ -14,76 +14,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const sdk = useSDK() const sdk = useSDK()
const [store, setStore] = globalSync.child(sdk.directory) const [store, setStore] = globalSync.child(sdk.directory)
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/") const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
const chunk = 200
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number>,
complete: {} as Record<string, boolean>,
loading: {} as Record<string, boolean>,
})
const getSession = (sessionID: string) => {
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
}
const limitFor = (count: number) => {
if (count <= chunk) return chunk
return Math.ceil(count / chunk) * chunk
}
const hydrateMessages = (sessionID: string) => {
if (meta.limit[sessionID] !== undefined) return
const messages = store.message[sessionID]
if (!messages) return
const limit = limitFor(messages.length)
setMeta("limit", sessionID, limit)
setMeta("complete", sessionID, messages.length < limit)
}
const loadMessages = async (sessionID: string, limit: number) => {
if (meta.loading[sessionID]) return
setMeta("loading", sessionID, true)
await retry(() => sdk.client.session.messages({ sessionID, limit }))
.then((messages) => {
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const next = items
.map((x) => x.info)
.filter((m) => !!m?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
batch(() => {
setStore("message", sessionID, reconcile(next, { key: "id" }))
for (const message of items) {
setStore(
"part",
message.info.id,
reconcile(
message.parts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
setMeta("limit", sessionID, limit)
setMeta("complete", sessionID, next.length < limit)
})
})
.finally(() => {
setMeta("loading", sessionID, false)
})
}
return { return {
data: store, data: store,
@@ -100,7 +30,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return undefined return undefined
}, },
session: { session: {
get: getSession, get(sessionID: string) {
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
},
addOptimisticMessage(input: { addOptimisticMessage(input: {
sessionID: string sessionID: string
messageID: string messageID: string
@@ -132,98 +66,58 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}), }),
) )
}, },
async sync(sessionID: string) { async sync(sessionID: string, _isRetry = false) {
const hasSession = getSession(sessionID) !== undefined const [session, messages, todo, diff] = await Promise.all([
hydrateMessages(sessionID) retry(() => sdk.client.session.get({ sessionID })),
retry(() => sdk.client.session.messages({ sessionID, limit: 1000 })),
retry(() => sdk.client.session.todo({ sessionID })),
retry(() => sdk.client.session.diff({ sessionID })),
])
const hasMessages = store.message[sessionID] !== undefined batch(() => {
if (hasSession && hasMessages) return setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = session.data!
return
}
draft.splice(match.index, 0, session.data!)
}),
)
const pending = inflight.get(sessionID) setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
if (pending) return pending setStore(
"message",
sessionID,
reconcile(
(messages.data ?? [])
.map((x) => x.info)
.filter((m) => !!m?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
const limit = meta.limit[sessionID] ?? chunk for (const message of messages.data ?? []) {
if (!message?.info?.id) continue
setStore(
"part",
message.info.id,
reconcile(
message.parts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
const sessionReq = hasSession setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
? Promise.resolve() })
: retry(() => sdk.client.session.get({ sessionID })).then((session) => {
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
const messagesReq = hasMessages ? Promise.resolve() : loadMessages(sessionID, limit)
const promise = Promise.all([sessionReq, messagesReq])
.then(() => {})
.finally(() => {
inflight.delete(sessionID)
})
inflight.set(sessionID, promise)
return promise
},
async diff(sessionID: string) {
if (store.session_diff[sessionID] !== undefined) return
const pending = inflightDiff.get(sessionID)
if (pending) return pending
const promise = retry(() => sdk.client.session.diff({ sessionID }))
.then((diff) => {
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
})
.finally(() => {
inflightDiff.delete(sessionID)
})
inflightDiff.set(sessionID, promise)
return promise
},
async todo(sessionID: string) {
if (store.todo[sessionID] !== undefined) return
const pending = inflightTodo.get(sessionID)
if (pending) return pending
const promise = retry(() => sdk.client.session.todo({ sessionID }))
.then((todo) => {
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
})
.finally(() => {
inflightTodo.delete(sessionID)
})
inflightTodo.set(sessionID, promise)
return promise
},
history: {
more(sessionID: string) {
if (store.message[sessionID] === undefined) return false
if (meta.limit[sessionID] === undefined) return false
if (meta.complete[sessionID]) return false
return true
},
loading(sessionID: string) {
return meta.loading[sessionID] ?? false
},
async loadMore(sessionID: string, count = chunk) {
if (meta.loading[sessionID]) return
if (meta.complete[sessionID]) return
const current = meta.limit[sessionID] ?? chunk
await loadMessages(sessionID, current + count)
},
}, },
fetch: async (count = 10) => { fetch: async (count = 10) => {
setStore("limit", (x) => x + count) setStore("limit", (x) => x + count)
+95 -162
View File
@@ -1,6 +1,6 @@
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo, createRoot, onCleanup } from "solid-js" import { batch, createMemo } from "solid-js"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
@@ -14,175 +14,108 @@ export type LocalPTY = {
scrollY?: number scrollY?: number
} }
const WORKSPACE_KEY = "__workspace__"
const MAX_TERMINAL_SESSIONS = 20
type TerminalSession = ReturnType<typeof createTerminalSession>
type TerminalCacheEntry = {
value: TerminalSession
dispose: VoidFunction
}
function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id: string | undefined) {
const legacy = `${dir}/terminal${id ? "/" + id : ""}.v1`
const [store, setStore, _, ready] = persisted(
Persist.scoped(dir, id, "terminal", [legacy]),
createStore<{
active?: string
all: LocalPTY[]
}>({
all: [],
}),
)
return {
ready,
all: createMemo(() => Object.values(store.all)),
active: createMemo(() => store.active),
new() {
sdk.client.pty
.create({ title: `Terminal ${store.all.length + 1}` })
.then((pty) => {
const id = pty.data?.id
if (!id) return
setStore("all", [
...store.all,
{
id,
title: pty.data?.title ?? "Terminal",
},
])
setStore("active", id)
})
.catch((e) => {
console.error("Failed to create terminal", e)
})
},
update(pty: Partial<LocalPTY> & { id: string }) {
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
sdk.client.pty
.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
.catch((e) => {
console.error("Failed to update terminal", e)
})
},
async clone(id: string) {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
const clone = await sdk.client.pty
.create({
title: pty.title,
})
.catch((e) => {
console.error("Failed to clone terminal", e)
return undefined
})
if (!clone?.data) return
setStore("all", index, {
...pty,
...clone.data,
})
if (store.active === pty.id) {
setStore("active", clone.data.id)
}
},
open(id: string) {
setStore("active", id)
},
async close(id: string) {
batch(() => {
setStore(
"all",
store.all.filter((x) => x.id !== id),
)
if (store.active === id) {
const index = store.all.findIndex((f) => f.id === id)
const previous = store.all[Math.max(0, index - 1)]
setStore("active", previous?.id)
}
})
await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
console.error("Failed to close terminal", e)
})
},
move(id: string, to: number) {
const index = store.all.findIndex((f) => f.id === id)
if (index === -1) return
setStore(
"all",
produce((all) => {
all.splice(to, 0, all.splice(index, 1)[0])
}),
)
},
}
}
export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({ export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
name: "Terminal", name: "Terminal",
gate: false,
init: () => { init: () => {
const sdk = useSDK() const sdk = useSDK()
const params = useParams() const params = useParams()
const cache = new Map<string, TerminalCacheEntry>() const legacy = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
const disposeAll = () => { const [store, setStore, _, ready] = persisted(
for (const entry of cache.values()) { Persist.scoped(params.dir!, params.id, "terminal", [legacy()]),
entry.dispose() createStore<{
} active?: string
cache.clear() all: LocalPTY[]
} }>({
all: [],
onCleanup(disposeAll) }),
)
const prune = () => {
while (cache.size > MAX_TERMINAL_SESSIONS) {
const first = cache.keys().next().value
if (!first) return
const entry = cache.get(first)
entry?.dispose()
cache.delete(first)
}
}
const load = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}`
const existing = cache.get(key)
if (existing) {
cache.delete(key)
cache.set(key, existing)
return existing.value
}
const entry = createRoot((dispose) => ({
value: createTerminalSession(sdk, dir, id),
dispose,
}))
cache.set(key, entry)
prune()
return entry.value
}
const session = createMemo(() => load(params.dir!, params.id))
return { return {
ready: () => session().ready(), ready,
all: () => session().all(), all: createMemo(() => Object.values(store.all)),
active: () => session().active(), active: createMemo(() => store.active),
new: () => session().new(), new() {
update: (pty: Partial<LocalPTY> & { id: string }) => session().update(pty), sdk.client.pty
clone: (id: string) => session().clone(id), .create({ title: `Terminal ${store.all.length + 1}` })
open: (id: string) => session().open(id), .then((pty) => {
close: (id: string) => session().close(id), const id = pty.data?.id
move: (id: string, to: number) => session().move(id, to), if (!id) return
setStore("all", [
...store.all,
{
id,
title: pty.data?.title ?? "Terminal",
},
])
setStore("active", id)
})
.catch((e) => {
console.error("Failed to create terminal", e)
})
},
update(pty: Partial<LocalPTY> & { id: string }) {
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
sdk.client.pty
.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
.catch((e) => {
console.error("Failed to update terminal", e)
})
},
async clone(id: string) {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
const clone = await sdk.client.pty
.create({
title: pty.title,
})
.catch((e) => {
console.error("Failed to clone terminal", e)
return undefined
})
if (!clone?.data) return
setStore("all", index, {
...pty,
...clone.data,
})
if (store.active === pty.id) {
setStore("active", clone.data.id)
}
},
open(id: string) {
setStore("active", id)
},
async close(id: string) {
batch(() => {
setStore(
"all",
store.all.filter((x) => x.id !== id),
)
if (store.active === id) {
const index = store.all.findIndex((f) => f.id === id)
const previous = store.all[Math.max(0, index - 1)]
setStore("active", previous?.id)
}
})
await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
console.error("Failed to close terminal", e)
})
},
move(id: string, to: number) {
const index = store.all.findIndex((f) => f.id === id)
if (index === -1) return
setStore(
"all",
produce((all) => {
all.splice(to, 0, all.splice(index, 1)[0])
}),
)
},
} }
}, },
}) })
+3 -189
View File
@@ -1,5 +1,4 @@
import { import {
batch,
createEffect, createEffect,
createMemo, createMemo,
createSignal, createSignal,
@@ -32,7 +31,7 @@ import { getFilename } from "@opencode-ai/util/path"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Session } from "@opencode-ai/sdk/v2/client" import { Session } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { import {
DragDropProvider, DragDropProvider,
DragDropSensors, DragDropSensors,
@@ -48,7 +47,6 @@ import { useGlobalSDK } from "@/context/global-sdk"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
@@ -57,7 +55,6 @@ import { DialogEditProject } from "@/components/dialog-edit-project"
import { DialogSelectServer } from "@/components/dialog-select-server" import { DialogSelectServer } from "@/components/dialog-select-server"
import { useCommand, type CommandOption } from "@/context/command" import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd" import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { navStart } from "@/utils/perf"
import { DialogSelectDirectory } from "@/components/dialog-select-directory" import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
@@ -287,146 +284,6 @@ export default function Layout(props: ParentProps) {
const currentSessions = createMemo(() => projectSessions(currentProject())) const currentSessions = createMemo(() => projectSessions(currentProject()))
type PrefetchQueue = {
inflight: Set<string>
pending: string[]
pendingSet: Set<string>
running: number
}
const prefetchChunk = 200
const prefetchConcurrency = 1
const prefetchPendingLimit = 6
const prefetchToken = { value: 0 }
const prefetchQueues = new Map<string, PrefetchQueue>()
createEffect(() => {
params.dir
globalSDK.url
prefetchToken.value += 1
for (const q of prefetchQueues.values()) {
q.pending.length = 0
q.pendingSet.clear()
}
})
const queueFor = (directory: string) => {
const existing = prefetchQueues.get(directory)
if (existing) return existing
const created: PrefetchQueue = {
inflight: new Set(),
pending: [],
pendingSet: new Set(),
running: 0,
}
prefetchQueues.set(directory, created)
return created
}
const prefetchMessages = (directory: string, sessionID: string, token: number) => {
const [, setStore] = globalSync.child(directory)
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
.then((messages) => {
if (prefetchToken.value !== token) return
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const next = items
.map((x) => x.info)
.filter((m) => !!m?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
batch(() => {
setStore("message", sessionID, reconcile(next, { key: "id" }))
for (const message of items) {
setStore(
"part",
message.info.id,
reconcile(
message.parts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
})
})
.catch(() => undefined)
}
const pumpPrefetch = (directory: string) => {
const q = queueFor(directory)
if (q.running >= prefetchConcurrency) return
const sessionID = q.pending.shift()
if (!sessionID) return
q.pendingSet.delete(sessionID)
q.inflight.add(sessionID)
q.running += 1
const token = prefetchToken.value
void prefetchMessages(directory, sessionID, token).finally(() => {
q.running -= 1
q.inflight.delete(sessionID)
pumpPrefetch(directory)
})
}
const prefetchSession = (session: Session, priority: "high" | "low" = "low") => {
const directory = session.directory
if (!directory) return
const [store] = globalSync.child(directory)
if (store.message[session.id] !== undefined) return
const q = queueFor(directory)
if (q.inflight.has(session.id)) return
if (q.pendingSet.has(session.id)) return
if (priority === "high") q.pending.unshift(session.id)
if (priority !== "high") q.pending.push(session.id)
q.pendingSet.add(session.id)
while (q.pending.length > prefetchPendingLimit) {
const dropped = q.pending.pop()
if (!dropped) continue
q.pendingSet.delete(dropped)
}
pumpPrefetch(directory)
}
createEffect(() => {
const sessions = currentSessions()
const id = params.id
if (!id) {
const first = sessions[0]
if (first) prefetchSession(first)
const second = sessions[1]
if (second) prefetchSession(second)
return
}
const index = sessions.findIndex((s) => s.id === id)
if (index === -1) return
const next = sessions[index + 1]
if (next) prefetchSession(next)
const prev = sessions[index - 1]
if (prev) prefetchSession(prev)
})
function navigateSessionByOffset(offset: number) { function navigateSessionByOffset(offset: number) {
const projects = layout.projects.list() const projects = layout.projects.list()
if (projects.length === 0) return if (projects.length === 0) return
@@ -452,27 +309,6 @@ export default function Layout(props: ParentProps) {
if (targetIndex >= 0 && targetIndex < sessions.length) { if (targetIndex >= 0 && targetIndex < sessions.length) {
const session = sessions[targetIndex] const session = sessions[targetIndex]
const next = sessions[targetIndex + 1]
const prev = sessions[targetIndex - 1]
if (offset > 0) {
if (next) prefetchSession(next, "high")
if (prev) prefetchSession(prev)
}
if (offset < 0) {
if (prev) prefetchSession(prev, "high")
if (next) prefetchSession(next)
}
if (import.meta.env.DEV) {
navStart({
dir: base64Encode(session.directory),
from: params.id,
to: session.id,
trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup",
})
}
navigateToSession(session) navigateToSession(session)
queueMicrotask(() => scrollToSession(session.id)) queueMicrotask(() => scrollToSession(session.id))
return return
@@ -488,27 +324,7 @@ export default function Layout(props: ParentProps) {
return return
} }
const index = offset > 0 ? 0 : nextProjectSessions.length - 1 const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1]
const targetSession = nextProjectSessions[index]
const nextSession = nextProjectSessions[index + 1]
const prevSession = nextProjectSessions[index - 1]
if (offset > 0) {
if (nextSession) prefetchSession(nextSession, "high")
}
if (offset < 0) {
if (prevSession) prefetchSession(prevSession, "high")
}
if (import.meta.env.DEV) {
navStart({
dir: base64Encode(targetSession.directory),
from: params.id,
to: targetSession.id,
trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup",
})
}
navigateToSession(targetSession) navigateToSession(targetSession)
queueMicrotask(() => scrollToSession(targetSession.id)) queueMicrotask(() => scrollToSession(targetSession.id))
} }
@@ -863,8 +679,6 @@ export default function Layout(props: ParentProps) {
<A <A
href={`${props.slug}/session/${props.session.id}`} href={`${props.slug}/session/${props.session.id}`}
class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1" class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1"
onMouseEnter={() => prefetchSession(props.session, "high")}
onFocus={() => prefetchSession(props.session, "high")}
> >
<div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7"> <div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
<span <span
@@ -1061,7 +875,7 @@ export default function Layout(props: ParentProps) {
</Collapsible> </Collapsible>
</Match> </Match>
<Match when={true}> <Match when={true}>
<Tooltip placement="right" value={getFilename(props.project.worktree)}> <Tooltip placement="right" value={props.project.worktree}>
<ProjectVisual project={props.project} /> <ProjectVisual project={props.project} />
</Tooltip> </Tooltip>
</Match> </Match>
+201 -519
View File
@@ -1,4 +1,4 @@
import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on } from "solid-js" import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
@@ -8,7 +8,6 @@ import { createStore } from "solid-js/store"
import { PromptInput } from "@/components/prompt-input" import { PromptInput } from "@/components/prompt-input"
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { DiffChanges } from "@opencode-ai/ui/diff-changes"
@@ -50,17 +49,10 @@ import {
NewSessionView, NewSessionView,
} from "@/components/session" } from "@/components/session"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { navMark, navParams } from "@/utils/perf"
import { same } from "@/utils/same" import { same } from "@/utils/same"
type DiffStyle = "unified" | "split" type DiffStyle = "unified" | "split"
const handoff = {
prompt: "",
terminals: [] as string[],
files: {} as Record<string, SelectedLineRange | null>,
}
interface SessionReviewTabProps { interface SessionReviewTabProps {
diffs: () => FileDiff[] diffs: () => FileDiff[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]> view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
@@ -170,46 +162,6 @@ export default function Page() {
const tabs = createMemo(() => layout.tabs(sessionKey())) const tabs = createMemo(() => layout.tabs(sessionKey()))
const view = createMemo(() => layout.view(sessionKey())) const view = createMemo(() => layout.view(sessionKey()))
if (import.meta.env.DEV) {
createEffect(
on(
() => [params.dir, params.id] as const,
([dir, id], prev) => {
if (!id) return
navParams({ dir, from: prev?.[1], to: id })
},
),
)
createEffect(() => {
const id = params.id
if (!id) return
if (!prompt.ready()) return
navMark({ dir: params.dir, to: id, name: "storage:prompt-ready" })
})
createEffect(() => {
const id = params.id
if (!id) return
if (!terminal.ready()) return
navMark({ dir: params.dir, to: id, name: "storage:terminal-ready" })
})
createEffect(() => {
const id = params.id
if (!id) return
if (!file.ready()) return
navMark({ dir: params.dir, to: id, name: "storage:file-view-ready" })
})
createEffect(() => {
const id = params.id
if (!id) return
if (sync.data.message[id] === undefined) return
navMark({ dir: params.dir, to: id, name: "session:data-ready" })
})
}
const isDesktop = createMediaQuery("(min-width: 768px)") const isDesktop = createMediaQuery("(min-width: 768px)")
function normalizeTab(tab: string) { function normalizeTab(tab: string) {
@@ -264,8 +216,6 @@ export default function Page() {
}) })
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const reviewCount = createMemo(() => info()?.summary?.files ?? 0)
const hasReview = createMemo(() => reviewCount() > 0)
const revertMessageID = createMemo(() => info()?.revert?.messageID) const revertMessageID = createMemo(() => info()?.revert?.messageID)
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const messagesReady = createMemo(() => { const messagesReady = createMemo(() => {
@@ -273,16 +223,6 @@ export default function Page() {
if (!id) return true if (!id) return true
return sync.data.message[id] !== undefined return sync.data.message[id] !== undefined
}) })
const historyMore = createMemo(() => {
const id = params.id
if (!id) return false
return sync.session.history.more(id)
})
const historyLoading = createMemo(() => {
const id = params.id
if (!id) return false
return sync.session.history.loading(id)
})
const emptyUserMessages: UserMessage[] = [] const emptyUserMessages: UserMessage[] = []
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages) const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages)
const visibleUserMessages = createMemo(() => { const visibleUserMessages = createMemo(() => {
@@ -309,20 +249,11 @@ export default function Page() {
activeTerminalDraggable: undefined as string | undefined, activeTerminalDraggable: undefined as string | undefined,
expanded: {} as Record<string, boolean>, expanded: {} as Record<string, boolean>,
messageId: undefined as string | undefined, messageId: undefined as string | undefined,
turnStart: 0,
mobileTab: "session" as "session" | "review", mobileTab: "session" as "session" | "review",
newSessionWorktree: "main", newSessionWorktree: "main",
promptHeight: 0, promptHeight: 0,
}) })
const renderedUserMessages = createMemo(() => {
const msgs = visibleUserMessages()
const start = store.turnStart
if (start <= 0) return msgs
if (start >= msgs.length) return emptyUserMessages
return msgs.slice(start)
}, emptyUserMessages)
const newSessionWorktree = createMemo(() => { const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create" if (store.newSessionWorktree === "create") return "create"
const project = sync.project const project = sync.project
@@ -359,12 +290,6 @@ export default function Page() {
} }
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
return sync.data.session_diff[id] !== undefined
})
const idle = { type: "idle" as const } const idle = { type: "idle" as const }
let inputRef!: HTMLDivElement let inputRef!: HTMLDivElement
@@ -377,10 +302,11 @@ export default function Page() {
}) })
createEffect(() => { createEffect(() => {
if (!layout.terminal.opened()) return if (layout.terminal.opened()) {
if (!terminal.ready()) return if (terminal.all().length === 0) {
if (terminal.all().length !== 0) return terminal.new()
terminal.new() }
}
}) })
createEffect( createEffect(
@@ -717,10 +643,12 @@ export default function Page() {
.filter((tab) => tab !== "context"), .filter((tab) => tab !== "context"),
) )
const reviewTab = createMemo(() => hasReview() || tabs().active() === "review") const reviewTab = createMemo(() => diffs().length > 0 || tabs().active() === "review")
const mobileReview = createMemo(() => !isDesktop() && hasReview() && store.mobileTab === "review") const mobileReview = createMemo(() => !isDesktop() && diffs().length > 0 && store.mobileTab === "review")
const showTabs = createMemo(() => layout.review.opened() && (hasReview() || tabs().all().length > 0 || contextOpen())) const showTabs = createMemo(
() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0 || contextOpen()),
)
const activeTab = createMemo(() => { const activeTab = createMemo(() => {
const active = tabs().active() const active = tabs().active()
@@ -736,22 +664,10 @@ export default function Page() {
createEffect(() => { createEffect(() => {
if (!layout.ready()) return if (!layout.ready()) return
if (tabs().active()) return if (tabs().active()) return
if (!hasReview() && openedTabs().length === 0 && !contextOpen()) return if (diffs().length === 0 && openedTabs().length === 0 && !contextOpen()) return
tabs().setActive(activeTab()) tabs().setActive(activeTab())
}) })
createEffect(() => {
const id = params.id
if (!id) return
if (!hasReview()) return
const wants = isDesktop() ? layout.review.opened() && activeTab() === "review" : store.mobileTab === "review"
if (!wants) return
if (diffsReady()) return
sync.session.diff(id)
})
const isWorking = createMemo(() => status().type !== "idle") const isWorking = createMemo(() => status().type !== "idle")
const autoScroll = createAutoScroll({ const autoScroll = createAutoScroll({
working: isWorking, working: isWorking,
@@ -767,88 +683,6 @@ export default function Page() {
autoScroll.scrollRef(el) autoScroll.scrollRef(el)
} }
const turnInit = 20
const turnBatch = 20
let turnHandle: number | undefined
let turnIdle = false
function cancelTurnBackfill() {
const handle = turnHandle
if (handle === undefined) return
turnHandle = undefined
if (turnIdle && window.cancelIdleCallback) {
window.cancelIdleCallback(handle)
return
}
clearTimeout(handle)
}
function scheduleTurnBackfill() {
if (turnHandle !== undefined) return
if (store.turnStart <= 0) return
if (window.requestIdleCallback) {
turnIdle = true
turnHandle = window.requestIdleCallback(() => {
turnHandle = undefined
backfillTurns()
})
return
}
turnIdle = false
turnHandle = window.setTimeout(() => {
turnHandle = undefined
backfillTurns()
}, 0)
}
function backfillTurns() {
const start = store.turnStart
if (start <= 0) return
const next = start - turnBatch
const nextStart = next > 0 ? next : 0
const el = scroller
if (!el) {
setStore("turnStart", nextStart)
scheduleTurnBackfill()
return
}
const beforeTop = el.scrollTop
const beforeHeight = el.scrollHeight
setStore("turnStart", nextStart)
requestAnimationFrame(() => {
const delta = el.scrollHeight - beforeHeight
if (delta) el.scrollTop = beforeTop + delta
})
scheduleTurnBackfill()
}
createEffect(
on(
() => [params.id, messagesReady()] as const,
([id, ready]) => {
cancelTurnBackfill()
setStore("turnStart", 0)
if (!id || !ready) return
const len = visibleUserMessages().length
const start = len > turnInit ? len - turnInit : 0
setStore("turnStart", start)
scheduleTurnBackfill()
},
{ defer: true },
),
)
createResizeObserver( createResizeObserver(
() => promptDock, () => promptDock,
({ height }) => { ({ height }) => {
@@ -876,21 +710,6 @@ export default function Page() {
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
setActiveMessage(message) setActiveMessage(message)
const msgs = visibleUserMessages()
const index = msgs.findIndex((m) => m.id === message.id)
if (index !== -1 && index < store.turnStart) {
setStore("turnStart", index)
scheduleTurnBackfill()
requestAnimationFrame(() => {
const el = document.getElementById(anchor(message.id))
if (el) el.scrollIntoView({ behavior, block: "start" })
})
updateHash(message.id)
return
}
const el = document.getElementById(anchor(message.id)) const el = document.getElementById(anchor(message.id))
if (el) el.scrollIntoView({ behavior, block: "start" }) if (el) el.scrollIntoView({ behavior, block: "start" })
updateHash(message.id) updateHash(message.id)
@@ -936,27 +755,12 @@ export default function Page() {
if (!sessionID || !ready) return if (!sessionID || !ready) return
requestAnimationFrame(() => { requestAnimationFrame(() => {
const hash = window.location.hash.slice(1) const id = window.location.hash.slice(1)
if (!hash) { const hashTarget = id ? document.getElementById(id) : undefined
autoScroll.forceScrollToBottom()
return
}
const hashTarget = document.getElementById(hash)
if (hashTarget) { if (hashTarget) {
hashTarget.scrollIntoView({ behavior: "auto", block: "start" }) hashTarget.scrollIntoView({ behavior: "auto", block: "start" })
return return
} }
const match = hash.match(/^message-(.+)$/)
if (match) {
const msg = visibleUserMessages().find((m) => m.id === match[1])
if (msg) {
scrollToMessage(msg, "auto")
return
}
}
autoScroll.forceScrollToBottom() autoScroll.forceScrollToBottom()
}) })
}) })
@@ -965,43 +769,7 @@ export default function Page() {
document.addEventListener("keydown", handleKeyDown) document.addEventListener("keydown", handleKeyDown)
}) })
const previewPrompt = () =>
prompt
.current()
.map((part) => {
if (part.type === "file") return `[file:${part.path}]`
if (part.type === "agent") return `@${part.name}`
if (part.type === "image") return `[image:${part.filename}]`
return part.content
})
.join("")
.trim()
createEffect(() => {
if (!prompt.ready()) return
handoff.prompt = previewPrompt()
})
createEffect(() => {
if (!terminal.ready()) return
handoff.terminals = terminal.all().map((t) => t.title)
})
createEffect(() => {
if (!file.ready()) return
handoff.files = Object.fromEntries(
tabs()
.all()
.flatMap((tab) => {
const path = file.pathFromTab(tab)
if (!path) return []
return [[path, file.selectedLines(path) ?? null] as const]
}),
)
})
onCleanup(() => { onCleanup(() => {
cancelTurnBackfill()
document.removeEventListener("keydown", handleKeyDown) document.removeEventListener("keydown", handleKeyDown)
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame) if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
}) })
@@ -1011,7 +779,7 @@ export default function Page() {
<SessionHeader /> <SessionHeader />
<div class="flex-1 min-h-0 flex flex-col md:flex-row"> <div class="flex-1 min-h-0 flex flex-col md:flex-row">
{/* Mobile tab bar - only shown on mobile when there are diffs */} {/* Mobile tab bar - only shown on mobile when there are diffs */}
<Show when={!isDesktop() && hasReview()}> <Show when={!isDesktop() && diffs().length > 0}>
<Tabs class="h-auto"> <Tabs class="h-auto">
<Tabs.List> <Tabs.List>
<Tabs.Trigger <Tabs.Trigger
@@ -1028,7 +796,7 @@ export default function Page() {
classes={{ button: "w-full" }} classes={{ button: "w-full" }}
onClick={() => setStore("mobileTab", "review")} onClick={() => setStore("mobileTab", "review")}
> >
{reviewCount()} Files Changed {diffs().length} Files Changed
</Tabs.Trigger> </Tabs.Trigger>
</Tabs.List> </Tabs.List>
</Tabs> </Tabs>
@@ -1053,26 +821,21 @@ export default function Page() {
when={!mobileReview()} when={!mobileReview()}
fallback={ fallback={
<div class="relative h-full overflow-hidden"> <div class="relative h-full overflow-hidden">
<Show <SessionReviewTab
when={diffsReady()} diffs={diffs}
fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>} view={view}
> diffStyle="unified"
<SessionReviewTab onViewFile={(path) => {
diffs={diffs} const value = file.tab(path)
view={view} tabs().open(value)
diffStyle="unified" file.load(path)
onViewFile={(path) => { }}
const value = file.tab(path) classes={{
tabs().open(value) root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
file.load(path) header: "px-4",
}} container: "px-4",
classes={{ }}
root: "pb-[calc(var(--prompt-height,8rem)+32px)]", />
header: "px-4",
container: "px-4",
}}
/>
</Show>
</div> </div>
} }
> >
@@ -1105,82 +868,42 @@ export default function Page() {
"mt-0": showTabs(), "mt-0": showTabs(),
}} }}
> >
<Show when={store.turnStart > 0}> <For each={visibleUserMessages()}>
<div class="w-full flex justify-center"> {(message) => (
<Button <div
variant="ghost" id={anchor(message.id)}
size="large" data-message-id={message.id}
class="text-12-medium opacity-50" classList={{
onClick={() => setStore("turnStart", 0)} "min-w-0 w-full max-w-full": true,
> "last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]":
Render earlier messages platform.platform !== "desktop",
</Button> "last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]":
</div> platform.platform === "desktop",
</Show>
<Show when={historyMore()}>
<div class="w-full flex justify-center">
<Button
variant="ghost"
size="large"
class="text-12-medium opacity-50"
disabled={historyLoading()}
onClick={() => {
const id = params.id
if (!id) return
setStore("turnStart", 0)
sync.session.history.loadMore(id)
}} }}
> >
{historyLoading() ? "Loading earlier messages..." : "Load earlier messages"} <SessionTurn
</Button> sessionID={params.id!}
</div> messageID={message.id}
</Show> lastUserMessageID={lastUserMessage()?.id}
<For each={renderedUserMessages()}> stepsExpanded={store.expanded[message.id] ?? false}
{(message) => { onStepsExpandedToggle={() =>
if (import.meta.env.DEV) { setStore("expanded", message.id, (open: boolean | undefined) => !open)
onMount(() => { }
const id = params.id classes={{
if (!id) return root: "min-w-0 w-full relative",
navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" }) content:
}) "flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
} container:
"px-4 md:px-6 " +
return ( (!showTabs()
<div ? "md:max-w-200 md:mx-auto"
id={anchor(message.id)} : visibleUserMessages().length > 1
data-message-id={message.id} ? "md:pr-6 md:pl-18"
classList={{ : ""),
"min-w-0 w-full max-w-full": true,
"last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]":
platform.platform !== "desktop",
"last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]":
platform.platform === "desktop",
}} }}
> />
<SessionTurn </div>
sessionID={params.id!} )}
messageID={message.id}
lastUserMessageID={lastUserMessage()?.id}
stepsExpanded={store.expanded[message.id] ?? false}
onStepsExpandedToggle={() =>
setStore("expanded", message.id, (open: boolean | undefined) => !open)
}
classes={{
root: "min-w-0 w-full relative",
content:
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
container:
"px-4 md:px-6 " +
(!showTabs()
? "md:max-w-200 md:mx-auto"
: visibleUserMessages().length > 1
? "md:pr-6 md:pl-18"
: ""),
}}
/>
</div>
)
}}
</For> </For>
</div> </div>
</div> </div>
@@ -1221,22 +944,13 @@ export default function Page() {
"md:max-w-200": !showTabs(), "md:max-w-200": !showTabs(),
}} }}
> >
<Show <PromptInput
when={prompt.ready()} ref={(el) => {
fallback={ inputRef = el
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"> }}
{handoff.prompt || "Loading prompt..."} newSessionWorktree={newSessionWorktree()}
</div> onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
} />
>
<PromptInput
ref={(el) => {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
/>
</Show>
</div> </div>
</div> </div>
@@ -1320,40 +1034,31 @@ export default function Page() {
</div> </div>
<Show when={reviewTab()}> <Show when={reviewTab()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict"> <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}> <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> <SessionReviewTab
<Show diffs={diffs}
when={diffsReady()} view={view}
fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>} diffStyle={layout.review.diffStyle()}
> onDiffStyleChange={layout.review.setDiffStyle}
<SessionReviewTab onViewFile={(path) => {
diffs={diffs} const value = file.tab(path)
view={view} tabs().open(value)
diffStyle={layout.review.diffStyle()} file.load(path)
onDiffStyleChange={layout.review.setDiffStyle} }}
onViewFile={(path) => { />
const value = file.tab(path) </div>
tabs().open(value)
file.load(path)
}}
/>
</Show>
</div>
</Show>
</Tabs.Content> </Tabs.Content>
</Show> </Show>
<Show when={contextOpen()}> <Show when={contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict"> <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "context"}> <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> <SessionContextTab
<SessionContextTab messages={messages}
messages={messages} visibleUserMessages={visibleUserMessages}
visibleUserMessages={visibleUserMessages} view={view}
view={view} info={info}
info={info} />
/> </div>
</div>
</Show>
</Tabs.Content> </Tabs.Content>
</Show> </Show>
<For each={openedTabs()}> <For each={openedTabs()}>
@@ -1402,8 +1107,7 @@ export default function Page() {
const selectedLines = createMemo(() => { const selectedLines = createMemo(() => {
const p = path() const p = path()
if (!p) return null if (!p) return null
if (file.ready()) return file.selectedLines(p) ?? null return file.selectedLines(p) ?? null
return handoff.files[p] ?? null
}) })
const selection = createMemo(() => { const selection = createMemo(() => {
const range = selectedLines() const range = selectedLines()
@@ -1500,63 +1204,37 @@ export default function Page() {
}} }}
onScroll={handleScroll} onScroll={handleScroll}
> >
<Show when={activeTab() === tab}> <Show when={selection()}>
<Show when={selection()}> {(sel) => (
{(sel) => ( <div class="hidden sticky top-0 z-10 px-6 py-2 _flex justify-end bg-background-base border-b border-border-weak-base">
<div class="hidden sticky top-0 z-10 px-6 py-2 _flex justify-end bg-background-base border-b border-border-weak-base"> <button
<button type="button"
type="button" class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-strong hover:bg-surface-raised-base-hover"
class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-strong hover:bg-surface-raised-base-hover" onClick={() => {
onClick={() => { const p = path()
const p = path() if (!p) return
if (!p) return prompt.context.add({ type: "file", path: p, selection: sel() })
prompt.context.add({ type: "file", path: p, selection: sel() }) }}
}} >
> <Icon name="plus-small" size="small" />
<Icon name="plus-small" size="small" /> <span>Add {selectionLabel()} to context</span>
<span>Add {selectionLabel()} to context</span> </button>
</button> </div>
</div> )}
)} </Show>
</Show> <Switch>
<Switch> <Match when={state()?.loaded && isImage()}>
<Match when={state()?.loaded && isImage()}> <div class="px-6 py-4 pb-40">
<div class="px-6 py-4 pb-40"> <img src={imageDataUrl()} alt={path()} class="max-w-full" />
<img src={imageDataUrl()} alt={path()} class="max-w-full" /> </div>
</div> </Match>
</Match> <Match when={state()?.loaded && isSvg()}>
<Match when={state()?.loaded && isSvg()}> <div class="flex flex-col gap-4 px-6 py-4">
<div class="flex flex-col gap-4 px-6 py-4">
<Dynamic
component={codeComponent}
file={{
name: path() ?? "",
contents: svgContent() ?? "",
cacheKey: cacheKey(),
}}
enableLineSelection
selectedLines={selectedLines()}
onLineSelected={(range: SelectedLineRange | null) => {
const p = path()
if (!p) return
file.setSelectedLines(p, range)
}}
overflow="scroll"
class="select-text"
/>
<Show when={svgPreviewUrl()}>
<div class="flex justify-center pb-40">
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
</div>
</Show>
</div>
</Match>
<Match when={state()?.loaded}>
<Dynamic <Dynamic
component={codeComponent} component={codeComponent}
file={{ file={{
name: path() ?? "", name: path() ?? "",
contents: contents(), contents: svgContent() ?? "",
cacheKey: cacheKey(), cacheKey: cacheKey(),
}} }}
enableLineSelection enableLineSelection
@@ -1567,17 +1245,41 @@ export default function Page() {
file.setSelectedLines(p, range) file.setSelectedLines(p, range)
}} }}
overflow="scroll" overflow="scroll"
class="select-text pb-40" class="select-text"
/> />
</Match> <Show when={svgPreviewUrl()}>
<Match when={state()?.loading}> <div class="flex justify-center pb-40">
<div class="px-6 py-4 text-text-weak">Loading...</div> <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
</Match> </div>
<Match when={state()?.error}> </Show>
{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>} </div>
</Match> </Match>
</Switch> <Match when={state()?.loaded}>
</Show> <Dynamic
component={codeComponent}
file={{
name: path() ?? "",
contents: contents(),
cacheKey: cacheKey(),
}}
enableLineSelection
selectedLines={selectedLines()}
onLineSelected={(range: SelectedLineRange | null) => {
const p = path()
if (!p) return
file.setSelectedLines(p, range)
}}
overflow="scroll"
class="select-text pb-40"
/>
</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">Loading...</div>
</Match>
<Match when={state()?.error}>
{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
</Match>
</Switch>
</Tabs.Content> </Tabs.Content>
) )
}} }}
@@ -1614,74 +1316,54 @@ export default function Page() {
onResize={layout.terminal.resize} onResize={layout.terminal.resize}
onCollapse={layout.terminal.close} onCollapse={layout.terminal.close}
/> />
<Show <DragDropProvider
when={terminal.ready()} onDragStart={handleTerminalDragStart}
fallback={ onDragEnd={handleTerminalDragEnd}
<div class="flex flex-col h-full pointer-events-none"> onDragOver={handleTerminalDragOver}
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden"> collisionDetector={closestCenter}
<For each={handoff.terminals}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
{title}
</div>
)}
</For>
<div class="flex-1" />
<div class="text-text-weak pr-2">Loading...</div>
</div>
<div class="flex-1 flex items-center justify-center text-text-weak">Loading terminal...</div>
</div>
}
> >
<DragDropProvider <DragDropSensors />
onDragStart={handleTerminalDragStart} <ConstrainDragYAxis />
onDragEnd={handleTerminalDragEnd} <Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
onDragOver={handleTerminalDragOver} <Tabs.List class="h-10">
collisionDetector={closestCenter} <SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
> <For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
<DragDropSensors /> </SortableProvider>
<ConstrainDragYAxis /> <div class="h-full flex items-center justify-center">
<Tabs variant="alt" value={terminal.active()} onChange={terminal.open}> <TooltipKeybind
<Tabs.List class="h-10"> title="New terminal"
<SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}> keybind={command.keybind("terminal.new")}
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For> class="flex items-center"
</SortableProvider> >
<div class="h-full flex items-center justify-center"> <IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
<TooltipKeybind </TooltipKeybind>
title="New terminal" </div>
keybind={command.keybind("terminal.new")} </Tabs.List>
class="flex items-center" <For each={terminal.all()}>
> {(pty) => (
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} /> <Tabs.Content value={pty.id}>
</TooltipKeybind> <Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
</div> </Tabs.Content>
</Tabs.List> )}
<For each={terminal.all()}> </For>
{(pty) => ( </Tabs>
<Tabs.Content value={pty.id}> <DragOverlay>
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} /> <Show when={store.activeTerminalDraggable}>
</Tabs.Content> {(draggedId) => {
)} const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
</For> return (
</Tabs> <Show when={pty()}>
<DragOverlay> {(t) => (
<Show when={store.activeTerminalDraggable}> <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{(draggedId) => { {t().title}
const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId())) </div>
return ( )}
<Show when={pty()}> </Show>
{(t) => ( )
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular"> }}
{t().title} </Show>
</div> </DragOverlay>
)} </DragDropProvider>
</Show>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
</Show>
</div> </div>
</Show> </Show>
</div> </div>
-135
View File
@@ -1,135 +0,0 @@
type Nav = {
id: string
dir?: string
from?: string
to: string
trigger?: string
start: number
marks: Record<string, number>
logged: boolean
timer?: ReturnType<typeof setTimeout>
}
const dev = import.meta.env.DEV
const key = (dir: string | undefined, to: string) => `${dir ?? ""}:${to}`
const now = () => performance.now()
const uid = () => crypto.randomUUID?.() ?? Math.random().toString(16).slice(2)
const navs = new Map<string, Nav>()
const pending = new Map<string, string>()
const active = new Map<string, string>()
const required = [
"session:params",
"session:data-ready",
"session:first-turn-mounted",
"storage:prompt-ready",
"storage:terminal-ready",
"storage:file-view-ready",
]
function flush(id: string, reason: "complete" | "timeout") {
if (!dev) return
const nav = navs.get(id)
if (!nav) return
if (nav.logged) return
nav.logged = true
if (nav.timer) clearTimeout(nav.timer)
const baseName = nav.marks["navigate:start"] !== undefined ? "navigate:start" : "session:params"
const base = nav.marks[baseName] ?? nav.start
const ms = Object.fromEntries(
Object.entries(nav.marks)
.slice()
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, t]) => [name, Math.round((t - base) * 100) / 100]),
)
console.log(
"perf.session-nav " +
JSON.stringify({
type: "perf.session-nav.v0",
id: nav.id,
dir: nav.dir,
from: nav.from,
to: nav.to,
trigger: nav.trigger,
base: baseName,
reason,
ms,
}),
)
navs.delete(id)
}
function maybeFlush(id: string) {
if (!dev) return
const nav = navs.get(id)
if (!nav) return
if (nav.logged) return
if (!required.every((name) => nav.marks[name] !== undefined)) return
flush(id, "complete")
}
function ensure(id: string, data: Omit<Nav, "marks" | "logged" | "timer">) {
const existing = navs.get(id)
if (existing) return existing
const nav: Nav = {
...data,
marks: {},
logged: false,
}
nav.timer = setTimeout(() => flush(id, "timeout"), 5000)
navs.set(id, nav)
return nav
}
export function navStart(input: { dir?: string; from?: string; to: string; trigger?: string }) {
if (!dev) return
const id = uid()
const start = now()
const nav = ensure(id, { ...input, id, start })
nav.marks["navigate:start"] = start
pending.set(key(input.dir, input.to), id)
return id
}
export function navParams(input: { dir?: string; from?: string; to: string }) {
if (!dev) return
const k = key(input.dir, input.to)
const pendingId = pending.get(k)
if (pendingId) pending.delete(k)
const id = pendingId ?? uid()
const start = now()
const nav = ensure(id, { ...input, id, start, trigger: pendingId ? "key" : "route" })
nav.marks["session:params"] = start
active.set(k, id)
maybeFlush(id)
return id
}
export function navMark(input: { dir?: string; to: string; name: string }) {
if (!dev) return
const id = active.get(key(input.dir, input.to))
if (!id) return
const nav = navs.get(id)
if (!nav) return
if (nav.marks[input.name] !== undefined) return
nav.marks[input.name] = now()
maybeFlush(id)
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.1.8", "version": "1.1.7",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.1.8", "version": "1.1.7",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.1.8", "version": "1.1.7",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.1.8", "version": "1.1.7",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.1.8", "version": "1.1.7",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+77
View File
@@ -15,6 +15,7 @@ use tauri::{
}; };
use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt; use tauri_plugin_shell::ShellExt;
use tauri_plugin_store::StoreExt;
use tokio::net::TcpSocket; use tokio::net::TcpSocket;
use crate::window_customizer::PinchZoomDisablePlugin; use crate::window_customizer::PinchZoomDisablePlugin;
@@ -45,6 +46,65 @@ impl ServerState {
struct LogState(Arc<Mutex<VecDeque<String>>>); struct LogState(Arc<Mutex<VecDeque<String>>>);
const MAX_LOG_ENTRIES: usize = 200; const MAX_LOG_ENTRIES: usize = 200;
const GLOBAL_STORAGE: &str = "opencode.global.dat";
/// Check if a URL's origin matches any configured server in the store.
/// Returns true if the URL should be allowed for internal navigation.
fn is_allowed_server(app: &AppHandle, url: &tauri::Url) -> bool {
// Always allow localhost and 127.0.0.1
if let Some(host) = url.host_str() {
if host == "localhost" || host == "127.0.0.1" {
return true;
}
}
// Try to read the server list from the store
let Ok(store) = app.store(GLOBAL_STORAGE) else {
return false;
};
let Some(server_data) = store.get("server") else {
return false;
};
// Parse the server list from the stored JSON
let Some(list) = server_data.get("list").and_then(|v| v.as_array()) else {
return false;
};
// Get the origin of the navigation URL (scheme + host + port)
let url_origin = format!(
"{}://{}{}",
url.scheme(),
url.host_str().unwrap_or(""),
url.port().map(|p| format!(":{}", p)).unwrap_or_default()
);
// Check if any configured server matches the URL's origin
for server in list {
let Some(server_url) = server.as_str() else {
continue;
};
// Parse the server URL to extract its origin
let Ok(parsed) = tauri::Url::parse(server_url) else {
continue;
};
let server_origin = format!(
"{}://{}{}",
parsed.scheme(),
parsed.host_str().unwrap_or(""),
parsed.port().map(|p| format!(":{}", p)).unwrap_or_default()
);
if url_origin == server_origin {
return true;
}
}
false
}
#[tauri::command] #[tauri::command]
fn kill_sidecar(app: AppHandle) { fn kill_sidecar(app: AppHandle) {
@@ -236,6 +296,7 @@ pub fn run() {
.unwrap_or(LogicalSize::new(1920, 1080)); .unwrap_or(LogicalSize::new(1920, 1080));
// Create window immediately with serverReady = false // Create window immediately with serverReady = false
let app_for_nav = app.clone();
let mut window_builder = let mut window_builder =
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into())) WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
.title("OpenCode") .title("OpenCode")
@@ -243,6 +304,22 @@ pub fn run() {
.decorations(true) .decorations(true)
.zoom_hotkeys_enabled(true) .zoom_hotkeys_enabled(true)
.disable_drag_drop_handler() .disable_drag_drop_handler()
.on_navigation(move |url| {
// Allow internal navigation (tauri:// scheme)
if url.scheme() == "tauri" {
return true;
}
// Allow navigation to configured servers (localhost, 127.0.0.1, or remote)
if is_allowed_server(&app_for_nav, url) {
return true;
}
// Open external http/https URLs in default browser
if url.scheme() == "http" || url.scheme() == "https" {
let _ = app_for_nav.shell().open(url.as_str(), None);
return false; // Cancel internal navigation
}
true
})
.initialization_script(format!( .initialization_script(format!(
r#" r#"
window.__OPENCODE__ ??= {{}}; window.__OPENCODE__ ??= {{}};
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.1.8", "version": "1.1.7",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.1.8" version = "1.1.7"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.8/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.8/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.8/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.8/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.8/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.1.8", "version": "1.1.7",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.1.8", "version": "1.1.7",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -81,8 +81,8 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2", "@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.70", "@opentui/core": "0.1.69",
"@opentui/solid": "0.1.70", "@opentui/solid": "0.1.69",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
@@ -53,7 +53,6 @@ export type AutocompleteRef = {
export type AutocompleteOption = { export type AutocompleteOption = {
display: string display: string
value?: string
aliases?: string[] aliases?: string[]
disabled?: boolean disabled?: boolean
description?: string description?: string
@@ -222,7 +221,6 @@ export function Autocomplete(props: {
const isDir = item.endsWith("/") const isDir = item.endsWith("/")
return { return {
display: Locale.truncateMiddle(filename, width), display: Locale.truncateMiddle(filename, width),
value: filename,
isDirectory: isDir, isDirectory: isDir,
path: item, path: item,
onSelect: () => { onSelect: () => {
@@ -261,10 +259,8 @@ export function Autocomplete(props: {
const width = props.anchor().width - 4 const width = props.anchor().width - 4
for (const res of Object.values(sync.data.mcp_resource)) { for (const res of Object.values(sync.data.mcp_resource)) {
const text = `${res.name} (${res.uri})`
options.push({ options.push({
display: Locale.truncateMiddle(text, width), display: Locale.truncateMiddle(`${res.name} (${res.uri})`, width),
value: text,
description: res.description, description: res.description,
onSelect: () => { onSelect: () => {
insertPart(res.name, { insertPart(res.name, {
@@ -489,11 +485,7 @@ export function Autocomplete(props: {
} }
const result = fuzzysort.go(removeLineRange(currentFilter), mixed, { const result = fuzzysort.go(removeLineRange(currentFilter), mixed, {
keys: [ keys: [(obj) => removeLineRange(obj.display.trimEnd()), "description", (obj) => obj.aliases?.join(" ") ?? ""],
(obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),
"description",
(obj) => obj.aliases?.join(" ") ?? "",
],
limit: 10, limit: 10,
scoreFn: (objResults) => { scoreFn: (objResults) => {
const displayResult = objResults[0] const displayResult = objResults[0]
@@ -62,7 +62,6 @@ function init() {
current.onClose?.() current.onClose?.()
setStore("stack", store.stack.slice(0, -1)) setStore("stack", store.stack.slice(0, -1))
evt.preventDefault() evt.preventDefault()
evt.stopPropagation()
refocus() refocus()
} }
}) })
+1 -1
View File
@@ -348,7 +348,7 @@ export const rustfmt: Info = {
} }
export const cargofmt: Info = { export const cargofmt: Info = {
name: "cargofmt", name: "cargo fmt",
command: ["cargo", "fmt", "--", "$FILE"], command: ["cargo", "fmt", "--", "$FILE"],
extensions: [".rs"], extensions: [".rs"],
async enabled() { async enabled() {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.1.8", "version": "1.1.7",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.1.8", "version": "1.1.7",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1249 -281
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.1.8", "version": "1.1.7",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.1.8", "version": "1.1.7",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"exports": { "exports": {
+2 -36
View File
@@ -1,53 +1,19 @@
import { useMarked } from "../context/marked" import { useMarked } from "../context/marked"
import { checksum } from "@opencode-ai/util/encode"
import { ComponentProps, createResource, splitProps } from "solid-js" import { ComponentProps, createResource, splitProps } from "solid-js"
type Entry = {
hash: string
html: string
}
const max = 200
const cache = new Map<string, Entry>()
function touch(key: string, value: Entry) {
cache.delete(key)
cache.set(key, value)
if (cache.size <= max) return
const first = cache.keys().next().value
if (!first) return
cache.delete(first)
}
export function Markdown( export function Markdown(
props: ComponentProps<"div"> & { props: ComponentProps<"div"> & {
text: string text: string
cacheKey?: string
class?: string class?: string
classList?: Record<string, boolean> classList?: Record<string, boolean>
}, },
) { ) {
const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"]) const [local, others] = splitProps(props, ["text", "class", "classList"])
const marked = useMarked() const marked = useMarked()
const [html] = createResource( const [html] = createResource(
() => local.text, () => local.text,
async (markdown) => { async (markdown) => {
const hash = checksum(markdown) return marked.parse(markdown)
const key = local.cacheKey ?? hash
if (key && hash) {
const cached = cache.get(key)
if (cached && cached.hash === hash) {
touch(key, cached)
return cached.html
}
}
const next = await marked.parse(markdown)
if (key && hash) touch(key, { hash, html: next })
return next
}, },
{ initialValue: "" }, { initialValue: "" },
) )
+2 -2
View File
@@ -566,7 +566,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
return ( return (
<Show when={throttledText()}> <Show when={throttledText()}>
<div data-component="text-part"> <div data-component="text-part">
<Markdown text={throttledText()} cacheKey={part.id} /> <Markdown text={throttledText()} />
</div> </div>
</Show> </Show>
) )
@@ -580,7 +580,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
return ( return (
<Show when={throttledText()}> <Show when={throttledText()}>
<div data-component="reasoning-part"> <div data-component="reasoning-part">
<Markdown text={throttledText()} cacheKey={part.id} /> <Markdown text={throttledText()} />
</div> </div>
</Show> </Show>
) )
+14 -62
View File
@@ -350,31 +350,15 @@ export function SessionTurn(
onUserInteracted: props.onUserInteracted, onUserInteracted: props.onUserInteracted,
}) })
const diffInit = 20
const diffBatch = 20
const [store, setStore] = createStore({ const [store, setStore] = createStore({
stickyTitleRef: undefined as HTMLDivElement | undefined, stickyTitleRef: undefined as HTMLDivElement | undefined,
stickyTriggerRef: undefined as HTMLDivElement | undefined, stickyTriggerRef: undefined as HTMLDivElement | undefined,
stickyHeaderHeight: 0, stickyHeaderHeight: 0,
retrySeconds: 0, retrySeconds: 0,
diffsOpen: [] as string[],
diffLimit: diffInit,
status: rawStatus(), status: rawStatus(),
duration: duration(), duration: duration(),
}) })
createEffect(
on(
() => message()?.id,
() => {
setStore("diffsOpen", [])
setStore("diffLimit", diffInit)
},
{ defer: true },
),
)
createEffect(() => { createEffect(() => {
const r = retry() const r = retry()
if (!r) { if (!r) {
@@ -558,23 +542,10 @@ export function SessionTurn(
<div data-slot="session-turn-summary-section"> <div data-slot="session-turn-summary-section">
<div data-slot="session-turn-summary-header"> <div data-slot="session-turn-summary-header">
<h2 data-slot="session-turn-summary-title">Response</h2> <h2 data-slot="session-turn-summary-title">Response</h2>
<Markdown <Markdown data-slot="session-turn-markdown" data-diffs={hasDiffs()} text={response() ?? ""} />
data-slot="session-turn-markdown"
data-diffs={hasDiffs()}
text={response() ?? ""}
cacheKey={responsePartId()}
/>
</div> </div>
<Accordion <Accordion data-slot="session-turn-accordion" multiple>
data-slot="session-turn-accordion" <For each={msg().summary?.diffs ?? []}>
multiple
value={store.diffsOpen}
onChange={(value) => {
if (!Array.isArray(value)) return
setStore("diffsOpen", value)
}}
>
<For each={(msg().summary?.diffs ?? []).slice(0, store.diffLimit)}>
{(diff) => ( {(diff) => (
<Accordion.Item value={diff.file}> <Accordion.Item value={diff.file}>
<StickyAccordionHeader> <StickyAccordionHeader>
@@ -602,41 +573,22 @@ export function SessionTurn(
</Accordion.Trigger> </Accordion.Trigger>
</StickyAccordionHeader> </StickyAccordionHeader>
<Accordion.Content data-slot="session-turn-accordion-content"> <Accordion.Content data-slot="session-turn-accordion-content">
<Show when={store.diffsOpen.includes(diff.file!)}> <Dynamic
<Dynamic component={diffComponent}
component={diffComponent} before={{
before={{ name: diff.file!,
name: diff.file!, contents: diff.before!,
contents: diff.before!, }}
}} after={{
after={{ name: diff.file!,
name: diff.file!, contents: diff.after!,
contents: diff.after!, }}
}} />
/>
</Show>
</Accordion.Content> </Accordion.Content>
</Accordion.Item> </Accordion.Item>
)} )}
</For> </For>
</Accordion> </Accordion>
<Show when={(msg().summary?.diffs?.length ?? 0) > store.diffLimit}>
<Button
data-slot="session-turn-accordion-more"
variant="ghost"
size="small"
onClick={() => {
const total = msg().summary?.diffs?.length ?? 0
setStore("diffLimit", (limit) => {
const next = limit + diffBatch
if (next > total) return total
return next
})
}}
>
Show more changes ({(msg().summary?.diffs?.length ?? 0) - store.diffLimit})
</Button>
</Show>
</div> </div>
</Show> </Show>
<Show when={error() && !props.stepsExpanded}> <Show when={error() && !props.stepsExpanded}>
-7
View File
@@ -3,19 +3,12 @@ import { createContext, createMemo, Show, useContext, type ParentProps, type Acc
export function createSimpleContext<T, Props extends Record<string, any>>(input: { export function createSimpleContext<T, Props extends Record<string, any>>(input: {
name: string name: string
init: ((input: Props) => T) | (() => T) init: ((input: Props) => T) | (() => T)
gate?: boolean
}) { }) {
const ctx = createContext<T>() const ctx = createContext<T>()
return { return {
provider: (props: ParentProps<Props>) => { provider: (props: ParentProps<Props>) => {
const init = input.init(props) const init = input.init(props)
const gate = input.gate ?? true
if (!gate) {
return <ctx.Provider value={init}>{props.children}</ctx.Provider>
}
// Access init.ready inside the memo to make it reactive for getter properties // Access init.ready inside the memo to make it reactive for getter properties
const isReady = createMemo(() => { const isReady = createMemo(() => {
// @ts-expect-error // @ts-expect-error
-3
View File
@@ -10,7 +10,6 @@ import ayuThemeJson from "./themes/ayu.json"
import oneDarkProThemeJson from "./themes/onedarkpro.json" import oneDarkProThemeJson from "./themes/onedarkpro.json"
import shadesOfPurpleThemeJson from "./themes/shadesofpurple.json" import shadesOfPurpleThemeJson from "./themes/shadesofpurple.json"
import nightowlThemeJson from "./themes/nightowl.json" import nightowlThemeJson from "./themes/nightowl.json"
import vesperThemeJson from "./themes/vesper.json"
export const oc1Theme = oc1ThemeJson as DesktopTheme export const oc1Theme = oc1ThemeJson as DesktopTheme
export const tokyonightTheme = tokyoThemeJson as DesktopTheme export const tokyonightTheme = tokyoThemeJson as DesktopTheme
@@ -23,7 +22,6 @@ export const ayuTheme = ayuThemeJson as DesktopTheme
export const oneDarkProTheme = oneDarkProThemeJson as DesktopTheme export const oneDarkProTheme = oneDarkProThemeJson as DesktopTheme
export const shadesOfPurpleTheme = shadesOfPurpleThemeJson as DesktopTheme export const shadesOfPurpleTheme = shadesOfPurpleThemeJson as DesktopTheme
export const nightowlTheme = nightowlThemeJson as DesktopTheme export const nightowlTheme = nightowlThemeJson as DesktopTheme
export const vesperTheme = vesperThemeJson as DesktopTheme
export const DEFAULT_THEMES: Record<string, DesktopTheme> = { export const DEFAULT_THEMES: Record<string, DesktopTheme> = {
"oc-1": oc1Theme, "oc-1": oc1Theme,
@@ -37,5 +35,4 @@ export const DEFAULT_THEMES: Record<string, DesktopTheme> = {
onedarkpro: oneDarkProTheme, onedarkpro: oneDarkProTheme,
shadesofpurple: shadesOfPurpleTheme, shadesofpurple: shadesOfPurpleTheme,
nightowl: nightowlTheme, nightowl: nightowlTheme,
vesper: vesperTheme,
} }
-1
View File
@@ -42,5 +42,4 @@ export {
oneDarkProTheme, oneDarkProTheme,
shadesOfPurpleTheme, shadesOfPurpleTheme,
nightowlTheme, nightowlTheme,
vesperTheme,
} from "./default-themes" } from "./default-themes"
-131
View File
@@ -1,131 +0,0 @@
{
"$schema": "https://opencode.ai/desktop-theme.json",
"name": "Vesper",
"id": "vesper",
"light": {
"seeds": {
"neutral": "#F0F0F0",
"primary": "#FFC799",
"success": "#99FFE4",
"warning": "#FFC799",
"error": "#FF8080",
"info": "#FFC799",
"interactive": "#FFC799",
"diffAdd": "#99FFE4",
"diffDelete": "#FF8080"
},
"overrides": {
"background-base": "#FFF",
"background-weak": "#F8F8F8",
"background-strong": "#F0F0F0",
"background-stronger": "#E8E8E8",
"border-weak-base": "#E8E8E8",
"border-weak-hover": "#E0E0E0",
"border-weak-active": "#D8D8D8",
"border-weak-selected": "#D0D0D0",
"border-weak-disabled": "#F0F0F0",
"border-weak-focus": "#D8D8D8",
"border-base": "#D0D0D0",
"border-hover": "#C8C8C8",
"border-active": "#C0C0C0",
"border-selected": "#B8B8B8",
"border-disabled": "#E8E8E8",
"border-focus": "#C0C0C0",
"border-strong-base": "#A0A0A0",
"border-strong-hover": "#989898",
"border-strong-active": "#909090",
"border-strong-selected": "#888888",
"border-strong-disabled": "#D0D0D0",
"border-strong-focus": "#909090",
"surface-diff-add-base": "#e8f5e8",
"surface-diff-delete-base": "#f5e8e8",
"surface-diff-hidden-base": "#F0F0F0",
"text-base": "#101010",
"text-weak": "#A0A0A0",
"text-strong": "#000000",
"syntax-string": "#99FFE4",
"syntax-primitive": "#FF8080",
"syntax-property": "#FFC799",
"syntax-type": "#FFC799",
"syntax-constant": "#A0A0A0",
"syntax-info": "#A0A0A0",
"markdown-heading": "#FFC799",
"markdown-text": "#101010",
"markdown-link": "#FFC799",
"markdown-link-text": "#A0A0A0",
"markdown-code": "#A0A0A0",
"markdown-block-quote": "#101010",
"markdown-emph": "#101010",
"markdown-strong": "#101010",
"markdown-horizontal-rule": "#65737E",
"markdown-list-item": "#101010",
"markdown-list-enumeration": "#101010",
"markdown-image": "#FFC799",
"markdown-image-text": "#A0A0A0",
"markdown-code-block": "#FFC799"
}
},
"dark": {
"seeds": {
"neutral": "#101010",
"primary": "#FFC799",
"success": "#99FFE4",
"warning": "#FFC799",
"error": "#FF8080",
"info": "#FFC799",
"interactive": "#FFC799",
"diffAdd": "#99FFE4",
"diffDelete": "#FF8080"
},
"overrides": {
"background-base": "#101010",
"background-weak": "#141414",
"background-strong": "#0C0C0C",
"background-stronger": "#080808",
"border-weak-base": "#1C1C1C",
"border-weak-hover": "#202020",
"border-weak-active": "#242424",
"border-weak-selected": "#282828",
"border-weak-disabled": "#141414",
"border-weak-focus": "#242424",
"border-base": "#282828",
"border-hover": "#303030",
"border-active": "#383838",
"border-selected": "#404040",
"border-disabled": "#181818",
"border-focus": "#383838",
"border-strong-base": "#505050",
"border-strong-hover": "#585858",
"border-strong-active": "#606060",
"border-strong-selected": "#686868",
"border-strong-disabled": "#202020",
"border-strong-focus": "#606060",
"surface-diff-add-base": "#0d2818",
"surface-diff-delete-base": "#281a1a",
"surface-diff-hidden-base": "#141414",
"text-base": "#FFF",
"text-weak": "#A0A0A0",
"text-strong": "#FFFFFF",
"syntax-string": "#99FFE4",
"syntax-primitive": "#FF8080",
"syntax-property": "#FFC799",
"syntax-type": "#FFC799",
"syntax-constant": "#A0A0A0",
"syntax-info": "#8b8b8b",
"markdown-heading": "#FFC799",
"markdown-text": "#FFF",
"markdown-link": "#FFC799",
"markdown-link-text": "#A0A0A0",
"markdown-code": "#A0A0A0",
"markdown-block-quote": "#FFF",
"markdown-emph": "#FFF",
"markdown-strong": "#FFF",
"markdown-horizontal-rule": "#65737E",
"markdown-list-item": "#FFF",
"markdown-list-enumeration": "#FFF",
"markdown-image": "#FFC799",
"markdown-image-text": "#A0A0A0",
"markdown-code-block": "#FFF"
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.1.8", "version": "1.1.7",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+2 -2
View File
@@ -1,7 +1,7 @@
export function getFilename(path: string | undefined) { export function getFilename(path: string | undefined) {
if (!path) return "" if (!path) return ""
const trimmed = path.replace(/[\/\\]+$/, "") const trimmed = path.replace(/[\/]+$/, "")
const parts = trimmed.split(/[\/\\]/) const parts = trimmed.split("/")
return parts[parts.length - 1] ?? "" return parts[parts.length - 1] ?? ""
} }
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"version": "1.1.8", "version": "1.1.7",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
@@ -22,7 +22,6 @@ OpenCode comes with several built-in formatters for popular languages and framew
| ktlint | .kt, .kts | `ktlint` command available | | ktlint | .kt, .kts | `ktlint` command available |
| ruff | .py, .pyi | `ruff` command available with config | | ruff | .py, .pyi | `ruff` command available with config |
| rustfmt | .rs | `rustfmt` command available | | rustfmt | .rs | `rustfmt` command available |
| cargofmt | .rs | `cargo fmt` command available |
| uv | .py, .pyi | `uv` command available | | uv | .py, .pyi | `uv` command available |
| rubocop | .rb, .rake, .gemspec, .ru | `rubocop` command available | | rubocop | .rb, .rake, .gemspec, .ru | `rubocop` command available |
| standardrb | .rb, .rake, .gemspec, .ru | `standardrb` command available | | standardrb | .rb, .rake, .gemspec, .ru | `standardrb` command available |
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode", "name": "opencode",
"displayName": "opencode", "displayName": "opencode",
"description": "opencode for VS Code", "description": "opencode for VS Code",
"version": "1.1.8", "version": "1.1.7",
"publisher": "sst-dev", "publisher": "sst-dev",
"repository": { "repository": {
"type": "git", "type": "git",