mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-25 19:16:15 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dc2b6336c | ||
|
|
6a23adcd6c |
@@ -478,22 +478,12 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
]
|
||||
: [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
const messages = [...system]
|
||||
const requireAssistantAfterTool =
|
||||
request.model.compatibility?.requireAssistantAfterTool ??
|
||||
["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) =>
|
||||
request.model.id.toLowerCase().includes(family),
|
||||
)
|
||||
const bridgeTools = () => {
|
||||
if (requireAssistantAfterTool && messages.at(-1)?.role === "tool") messages.push({ role: "assistant", content: "Done." })
|
||||
}
|
||||
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||
const flushImages = () => {
|
||||
if (pendingImages.length === 0) return
|
||||
bridgeTools()
|
||||
messages.push({ role: "user", content: pendingImages.splice(0) })
|
||||
}
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "user") bridgeTools()
|
||||
if (message.role === "system") {
|
||||
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
|
||||
if (pendingImages.length > 0) {
|
||||
@@ -536,8 +526,6 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (message.role === "assistant" && message.content.every((part) => part.type === "text" && part.text.trim() === ""))
|
||||
continue
|
||||
if (message.role === "tool") {
|
||||
const lowered = yield* lowerToolMessages(message, options)
|
||||
messages.push(...lowered.messages)
|
||||
|
||||
@@ -155,7 +155,6 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
|
||||
reasoningField: Schema.optional(Schema.String),
|
||||
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
|
||||
requireFinishReason: Schema.optional(Schema.Boolean),
|
||||
requireAssistantAfterTool: Schema.optional(Schema.Boolean),
|
||||
supportsStore: Schema.optional(Schema.Boolean),
|
||||
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
|
||||
supportsStrictMode: Schema.optional(Schema.Boolean),
|
||||
|
||||
@@ -85,28 +85,6 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits empty and whitespace-only assistant messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Before."),
|
||||
Message.assistant([]),
|
||||
Message.assistant(""),
|
||||
Message.assistant(" \n\t "),
|
||||
Message.assistant("After."),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: "Before." },
|
||||
{ role: "assistant", content: "After." },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays canonical reasoning as OpenAI-compatible reasoning_content", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -453,30 +431,6 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("bridges image tool results before their synthetic user message when required", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: LanguageModel.update(model, { compatibility: { requireAssistantAfterTool: true } }),
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_image", name: "read", input: {} })]),
|
||||
Message.tool({
|
||||
id: "call_image",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages.map((message) => message.role)).toEqual(["assistant", "tool", "assistant", "user"])
|
||||
expect(prepared.body.messages[2]).toEqual({ role: "assistant", content: "Done." })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("orders parallel tool responses before one aggregated vision message", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -238,47 +238,6 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("bridges tool results for Mistral-family models and honors compatibility overrides", () =>
|
||||
Effect.gen(function* () {
|
||||
const cases = [
|
||||
{ id: "mistral-small", bridge: true },
|
||||
{ id: "devstral-small", bridge: true },
|
||||
{ id: "codestral-latest", bridge: true },
|
||||
{ id: "pixtral-large", bridge: true },
|
||||
{ id: "open-mixtral-8x22b", bridge: true },
|
||||
{ id: "ordinary-model", bridge: false },
|
||||
{ id: "ordinary-model", override: true, bridge: true },
|
||||
{ id: "mistral-small", override: false, bridge: false },
|
||||
] as const
|
||||
|
||||
yield* Effect.forEach(cases, (item) =>
|
||||
Effect.gen(function* () {
|
||||
const selected = OpenAICompatibleChat.route
|
||||
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
|
||||
.model({
|
||||
id: item.id,
|
||||
compatibility: "override" in item ? { requireAssistantAfterTool: item.override } : undefined,
|
||||
})
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: selected,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "Sunny" }),
|
||||
Message.user("What next?"),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages.map((message) => message.role)).toEqual(
|
||||
item.bridge ? ["assistant", "tool", "assistant", "user"] : ["assistant", "tool", "user"],
|
||||
)
|
||||
if (item.bridge) expect(prepared.body.messages[2]).toEqual({ role: "assistant", content: "Done." })
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("posts to the configured compatible endpoint and parses text usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { BunPlugin } from "bun"
|
||||
import pkg from "../package.json"
|
||||
import { buildAppArchive } from "./app-assets"
|
||||
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
|
||||
import { resolveOpencodePty } from "./opencode-pty"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
const binary = "opencode2"
|
||||
@@ -79,23 +78,6 @@ const appAssetsPlugin: BunPlugin = {
|
||||
}
|
||||
|
||||
for (const item of targets) {
|
||||
const opencodePty = await resolveOpencodePty({
|
||||
platform: item.os,
|
||||
arch: item.arch,
|
||||
...(item.os === "linux" ? { libc: item.abi ?? "glibc" } : {}),
|
||||
})
|
||||
const opencodePtyPlugin: BunPlugin = {
|
||||
name: "opencode-pty-binary",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /persistent-pty[/\\]pty-binding\.ts$/ }, () => ({
|
||||
loader: "js",
|
||||
contents: opencodePty
|
||||
? `import file from ${JSON.stringify(opencodePty.source)} with { type: "file" }
|
||||
export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sha256: ${JSON.stringify(opencodePty.sha256)} }`
|
||||
: "export default undefined",
|
||||
}))
|
||||
},
|
||||
}
|
||||
const simulationInputs = new Set<string>()
|
||||
const simulationGraphPlugin: BunPlugin = {
|
||||
name: "opencode-simulation-graph",
|
||||
@@ -123,7 +105,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin],
|
||||
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, simulationGraphPlugin],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url"
|
||||
import { getNodeAssets } from "@opentui/core/node-assets"
|
||||
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
|
||||
import { collectFiles } from "./files"
|
||||
import { resolveOpencodePty } from "./opencode-pty"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
|
||||
@@ -19,11 +18,6 @@ export type NodeAsset = {
|
||||
}
|
||||
|
||||
export async function collectNodeAssets(target: NodeTarget) {
|
||||
const opencodePty = await resolveOpencodePty({
|
||||
platform: target.platform,
|
||||
arch: target.arch,
|
||||
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
|
||||
})
|
||||
const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage))
|
||||
const ptyRoot = path.resolve(path.dirname(ptyEntry), "..")
|
||||
const assets: NodeAsset[] = [
|
||||
@@ -47,7 +41,6 @@ export async function collectNodeAssets(target: NodeTarget) {
|
||||
key,
|
||||
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
|
||||
})),
|
||||
...(opencodePty && target.opencodePtyAsset ? [{ key: target.opencodePtyAsset, source: opencodePty.source }] : []),
|
||||
...(await collectFiles(ptyRoot))
|
||||
.filter((relative) => !relative.endsWith(".map") && !relative.endsWith(".pdb"))
|
||||
.map((relative) => ({
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { createHash } from "node:crypto"
|
||||
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
const VERSION = "0.1.5"
|
||||
const RELEASE = `https://github.com/anomalyco/opencode-pty/releases/download/v${VERSION}`
|
||||
const SHA256 = {
|
||||
"aarch64-apple-darwin": "d5156e44a6783381aadbd968dbd27c1d83e7e0f1b6042c7c934e6d33541d334f",
|
||||
"aarch64-unknown-linux-gnu": "075d99ffb269cbd0846d3d404fdee93965a53cd6eaf046dbd1064785a7ce9351",
|
||||
"aarch64-unknown-linux-musl": "22fb55c944ff05fbe03e84de67333e9fd037ad4e04ffc93d8a3f0b2193c29421",
|
||||
"x86_64-apple-darwin": "773e363b5385c1bd56021e69ada95132efd615ed5b9c3734f878ad644ae22b01",
|
||||
"x86_64-unknown-linux-gnu": "d9cac2a7c09d013188f696c45ded5eb5764d308e52dd31cb2de68bf4fc675624",
|
||||
"x86_64-unknown-linux-musl": "2a176302de3d24f8ae3fbacf0b4afce7b4af3e00abd619906187a487b5e50bd6",
|
||||
} as const
|
||||
|
||||
export type OpencodePtyAsset = {
|
||||
readonly source: string
|
||||
readonly version: string
|
||||
readonly sha256: string
|
||||
}
|
||||
|
||||
type Target = {
|
||||
readonly platform: string
|
||||
readonly arch: string
|
||||
readonly libc?: "glibc" | "musl"
|
||||
}
|
||||
|
||||
const pending = new Map<string, Promise<OpencodePtyAsset | undefined>>()
|
||||
|
||||
export function resolveOpencodePty(target: Target) {
|
||||
const rustTarget = targetName(target)
|
||||
if (!rustTarget) return Promise.resolve(undefined)
|
||||
const existing = pending.get(rustTarget)
|
||||
if (existing) return existing
|
||||
const result = acquire(rustTarget).catch((error) => {
|
||||
pending.delete(rustTarget)
|
||||
throw error
|
||||
})
|
||||
pending.set(rustTarget, result)
|
||||
return result
|
||||
}
|
||||
|
||||
async function acquire(target: keyof typeof SHA256): Promise<OpencodePtyAsset> {
|
||||
const root = path.resolve(import.meta.dirname, "../.cache/opencode-pty", VERSION, target)
|
||||
const executable = path.join(root, "opencode-pty")
|
||||
const cached = await readFile(executable).catch(() => undefined)
|
||||
if (cached)
|
||||
return {
|
||||
source: executable,
|
||||
version: VERSION,
|
||||
sha256: createHash("sha256").update(cached).digest("hex"),
|
||||
}
|
||||
|
||||
await mkdir(root, { recursive: true })
|
||||
const archiveName = `opencode-pty-${VERSION}-${target}.tar.gz`
|
||||
const response = await fetch(`${RELEASE}/${archiveName}`)
|
||||
if (!response.ok) throw new Error(`Failed to download ${archiveName}: ${response.status}`)
|
||||
const archive = new Uint8Array(await response.arrayBuffer())
|
||||
const actual = createHash("sha256").update(archive).digest("hex")
|
||||
if (actual !== SHA256[target]) throw new Error(`Checksum mismatch for ${archiveName}`)
|
||||
|
||||
const temporary = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-build-"))
|
||||
try {
|
||||
const archivePath = path.join(temporary, archiveName)
|
||||
await writeFile(archivePath, archive)
|
||||
run("tar", ["-xzf", archivePath, "-C", temporary])
|
||||
const source = path.join(temporary, `opencode-pty-${VERSION}-${target}`, "opencode-pty")
|
||||
const bytes = await readFile(source)
|
||||
const staged = path.join(root, `opencode-pty.${process.pid}.${crypto.randomUUID()}.tmp`)
|
||||
await writeFile(staged, bytes, { flag: "wx", mode: 0o755 })
|
||||
await rename(staged, executable).catch(async (error) => {
|
||||
await rm(staged, { force: true })
|
||||
if (!(await readFile(executable).catch(() => undefined))) throw error
|
||||
})
|
||||
const installed = await readFile(executable)
|
||||
return {
|
||||
source: executable,
|
||||
version: VERSION,
|
||||
sha256: createHash("sha256").update(installed).digest("hex"),
|
||||
}
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function targetName(target: Target): keyof typeof SHA256 | undefined {
|
||||
const arch = target.arch === "arm64" ? "aarch64" : target.arch === "x64" ? "x86_64" : undefined
|
||||
if (!arch) return undefined
|
||||
if (target.platform === "darwin") return arch === "aarch64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"
|
||||
if (target.platform === "linux" && target.libc === "musl")
|
||||
return arch === "aarch64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl"
|
||||
if (target.platform === "linux") return arch === "aarch64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"
|
||||
return undefined
|
||||
}
|
||||
|
||||
function run(command: string, args: readonly string[]) {
|
||||
const result = spawnSync(command, args, { stdio: "inherit" })
|
||||
if (result.error) throw result.error
|
||||
if (result.status !== 0) throw new Error(`${command} exited with status ${result.status ?? "unknown"}`)
|
||||
}
|
||||
@@ -13,7 +13,6 @@ export function nodeTarget(platform: string, arch: string) {
|
||||
const parcelWatcherPackage = `@parcel/watcher-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-glibc" : ""}`
|
||||
const fffPackage = `@ff-labs/fff-bin-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : ""}`
|
||||
const fffFfiPackage = `@yuuang/ffi-rs-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}`
|
||||
const opencodePtyAsset = targetPlatform === "win32" ? undefined : "opencode-pty/opencode-pty"
|
||||
|
||||
return {
|
||||
platform: targetPlatform,
|
||||
@@ -26,7 +25,6 @@ export function nodeTarget(platform: string, arch: string) {
|
||||
fffAsset: `${fffPackage}/${targetPlatform === "darwin" ? "libfff_c.dylib" : targetPlatform === "win32" ? "fff_c.dll" : "libfff_c.so"}`,
|
||||
fffFfiPackage,
|
||||
fffFfiAsset: `${fffFfiPackage}/ffi-rs.${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}.node`,
|
||||
opencodePtyAsset,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ test("collects each SEA asset key once", async () => {
|
||||
const keys = assets.map((asset) => asset.key)
|
||||
|
||||
expect(new Set(keys).size).toBe(keys.length)
|
||||
if (process.platform !== "win32") expect(keys.filter((key) => key === "opencode-pty/opencode-pty")).toHaveLength(1)
|
||||
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
|
||||
{
|
||||
key: shellParserWasmAssets.runtime,
|
||||
|
||||
@@ -120,7 +120,6 @@ function nodePrelude(input: NodeBuildInput) {
|
||||
input.target.platform === "darwin"
|
||||
? `${input.target.nodePtyPackage}/prebuilds/darwin-${input.target.arch}/spawn-helper`
|
||||
: undefined
|
||||
const opencodePtyAsset = input.target.opencodePtyAsset
|
||||
const promiseModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
|
||||
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
|
||||
export const Agent = sdk.Agent
|
||||
@@ -201,17 +200,13 @@ if (__ocIsSea()) {
|
||||
const __ocAssetRoot = __ocIsSea()
|
||||
? __ocPath.join(__ocCacheRoot, ${JSON.stringify(`${input.assetHash}-${input.target.platform}-${input.target.arch}`)})
|
||||
: __ocFileURLToPath(new URL("./assets/", import.meta.url))
|
||||
const __ocPersistentPty = ${JSON.stringify(opencodePtyAsset)}
|
||||
if (__ocIsSea()) {
|
||||
const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)}
|
||||
for (const __ocKey of __ocAssetKeys()) {
|
||||
const __ocTarget = __ocPath.join(__ocAssetRoot, __ocKey)
|
||||
if (__ocExists(__ocTarget)) continue
|
||||
__ocMkdir(__ocPath.dirname(__ocTarget), { recursive: true })
|
||||
const __ocTemporary = \`${"${__ocTarget}"}.${"${process.pid}"}.${"${crypto.randomUUID()}"}.tmp\`
|
||||
__ocWrite(__ocTemporary, new Uint8Array(__ocRawAsset(__ocKey)))
|
||||
if ((__ocKey === __ocPtySpawnHelper || __ocKey === __ocPersistentPty) && process.platform !== "win32")
|
||||
__ocChmod(__ocTemporary, 0o755)
|
||||
try {
|
||||
__ocRename(__ocTemporary, __ocTarget)
|
||||
} catch (__ocError) {
|
||||
@@ -219,6 +214,8 @@ if (__ocIsSea()) {
|
||||
if (!__ocExists(__ocTarget)) throw __ocError
|
||||
}
|
||||
}
|
||||
const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)}
|
||||
if (__ocPtySpawnHelper) __ocChmod(__ocPath.join(__ocAssetRoot, __ocPtySpawnHelper), 0o755)
|
||||
}
|
||||
process.env.OPENCODE_NODE_ASSETS_DIR = __ocAssetRoot
|
||||
process.env.OTUI_ASSET_ROOT = __ocAssetRoot
|
||||
@@ -230,7 +227,6 @@ process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, $
|
||||
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
|
||||
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
|
||||
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
|
||||
if (__ocPersistentPty && !process.env.OPENCODE_PTY_BIN) process.env.OPENCODE_PTY_BIN = __ocPath.join(__ocAssetRoot, __ocPersistentPty)
|
||||
try {
|
||||
globalThis.__OPENCODE_FFF_FFI = require(process.env.OPENCODE_FFF_FFI_PATH)
|
||||
} catch {}
|
||||
|
||||
@@ -1312,7 +1312,6 @@ export type ModelCompatibility = {
|
||||
reasoningField?: ModelReasoningField
|
||||
maxTokensField?: ModelMaxTokensField
|
||||
requireFinishReason?: boolean
|
||||
requireAssistantAfterTool?: boolean
|
||||
}
|
||||
|
||||
export type ModelCost = {
|
||||
|
||||
@@ -41,12 +41,6 @@
|
||||
"node": "./src/pty/pty.node.ts",
|
||||
"default": "./src/pty/pty.bun.ts"
|
||||
},
|
||||
"#persistent-pty-binary": {
|
||||
"workerd": "./src/persistent-pty/binary.workerd.ts",
|
||||
"bun": "./src/persistent-pty/binary.bun.ts",
|
||||
"node": "./src/persistent-pty/binary.node.ts",
|
||||
"default": "./src/persistent-pty/binary.bun.ts"
|
||||
},
|
||||
"#fff": {
|
||||
"workerd": "./src/filesystem/fff.workerd.ts",
|
||||
"bun": "./src/filesystem/fff.bun.ts",
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import { chmod, lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import asset from "./pty-binding.js"
|
||||
|
||||
export async function resolveBinary(bin: string) {
|
||||
if (process.env.OPENCODE_PTY_BIN) return process.env.OPENCODE_PTY_BIN
|
||||
if (!asset) return "opencode-pty"
|
||||
return install(bin, asset)
|
||||
}
|
||||
|
||||
async function install(
|
||||
bin: string,
|
||||
input: { readonly path: string; readonly version: string; readonly sha256: string },
|
||||
) {
|
||||
const root = path.join(bin, "opencode-pty")
|
||||
await privateDirectory(root)
|
||||
const directory = path.join(root, `${input.version}-${input.sha256.slice(0, 16)}`)
|
||||
await privateDirectory(directory)
|
||||
const destination = path.join(directory, "opencode-pty")
|
||||
if (await exists(destination, input.sha256)) return destination
|
||||
|
||||
const bytes = new Uint8Array(await Bun.file(input.path).arrayBuffer())
|
||||
if (sha256(bytes) !== input.sha256) throw new Error("Embedded opencode-pty checksum mismatch")
|
||||
const temporary = path.join(directory, `opencode-pty.${process.pid}.${crypto.randomUUID()}.tmp`)
|
||||
try {
|
||||
const file = await open(temporary, "wx", 0o700)
|
||||
try {
|
||||
await file.writeFile(bytes)
|
||||
await file.sync()
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
await chmod(temporary, 0o755)
|
||||
await rename(temporary, destination).catch(async (error) => {
|
||||
if (!(await exists(destination, input.sha256))) throw error
|
||||
})
|
||||
} finally {
|
||||
await rm(temporary, { force: true })
|
||||
}
|
||||
return validate(destination, input.sha256)
|
||||
}
|
||||
|
||||
async function privateDirectory(directory: string) {
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 })
|
||||
const info = await lstat(directory)
|
||||
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Unsafe opencode-pty directory: ${directory}`)
|
||||
const uid = typeof process.getuid === "function" ? process.getuid() : undefined
|
||||
if (uid !== undefined && info.uid !== uid)
|
||||
throw new Error(`opencode-pty directory is owned by another user: ${directory}`)
|
||||
await chmod(directory, 0o700)
|
||||
}
|
||||
|
||||
async function exists(file: string, expected: string) {
|
||||
try {
|
||||
await validate(file, expected)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function validate(file: string, expected?: string) {
|
||||
const info = await lstat(file)
|
||||
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`Unsafe opencode-pty executable: ${file}`)
|
||||
const uid = typeof process.getuid === "function" ? process.getuid() : undefined
|
||||
if (uid !== undefined && info.uid !== uid)
|
||||
throw new Error(`opencode-pty executable is owned by another user: ${file}`)
|
||||
if (expected && sha256(await readFile(file)) !== expected)
|
||||
throw new Error(`Cached opencode-pty checksum mismatch: ${file}`)
|
||||
await chmod(file, 0o755)
|
||||
return file
|
||||
}
|
||||
|
||||
function sha256(bytes: Uint8Array) {
|
||||
return createHash("sha256").update(bytes).digest("hex")
|
||||
}
|
||||
|
||||
function isMissing(error: unknown): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error && error.code === "ENOENT"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export async function resolveBinary() {
|
||||
return process.env.OPENCODE_PTY_BIN || "opencode-pty"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export async function resolveBinary(): Promise<string> {
|
||||
throw new Error("Persistent PTYs are unavailable in this runtime")
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import { Session } from "@opencode-ai/schema/session"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import {
|
||||
makeDaemonTransport,
|
||||
type DaemonTransport,
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
type WireResponse,
|
||||
type WireTerminal,
|
||||
} from "./daemon.js"
|
||||
import { resolveBinary } from "#persistent-pty-binary"
|
||||
|
||||
export type { Role, StreamEvent } from "./daemon.js"
|
||||
|
||||
@@ -122,18 +120,9 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const database = yield* Database.Service
|
||||
const global = yield* Global.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
let binary: Promise<string> | undefined
|
||||
const daemon = yield* makeDaemonTransport(
|
||||
runtimeDirectory(databasePath(database.db)),
|
||||
() =>
|
||||
(binary ??= resolveBinary(global.bin).catch((error) => {
|
||||
binary = undefined
|
||||
throw error
|
||||
})),
|
||||
)
|
||||
const daemon = yield* makeDaemonTransport(runtimeDirectory(databasePath(database.db)))
|
||||
const removing = new Set<Pty.ID>()
|
||||
|
||||
const list = Effect.fn("PersistentPty.list")(function* (sessionID?: Session.ID) {
|
||||
@@ -328,7 +317,7 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, Database.node, Global.node] })
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, Database.node] })
|
||||
|
||||
const request = (daemon: DaemonTransport, value: object, start = false) =>
|
||||
daemon.request(value, start).pipe(Effect.mapError(unavailable))
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
const asset: { readonly path: string; readonly version: string; readonly sha256: string } | undefined = undefined
|
||||
|
||||
export default asset
|
||||
@@ -392,7 +392,6 @@ describe("ModelResolver", () => {
|
||||
reasoningField: "vendor_reasoning",
|
||||
maxTokensField: "max_completion_tokens",
|
||||
requireFinishReason: false,
|
||||
requireAssistantAfterTool: true,
|
||||
},
|
||||
settings: {
|
||||
apiKey: "settings-secret",
|
||||
@@ -418,7 +417,6 @@ describe("ModelResolver", () => {
|
||||
expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning")
|
||||
expect(resolved.compatibility?.maxTokensField).toBe("max_completion_tokens")
|
||||
expect(resolved.compatibility?.requireFinishReason).toBe(false)
|
||||
expect(resolved.compatibility?.requireAssistantAfterTool).toBe(true)
|
||||
expect(prepared.body).toMatchObject({ max_completion_tokens: 10 })
|
||||
expect(prepared.body).not.toHaveProperty("max_tokens")
|
||||
expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1")
|
||||
|
||||
@@ -57,7 +57,6 @@ export const Compatibility = Schema.Struct({
|
||||
reasoningField: ReasoningField.pipe(optional),
|
||||
maxTokensField: MaxTokensField.pipe(optional),
|
||||
requireFinishReason: Schema.Boolean.pipe(optional),
|
||||
requireAssistantAfterTool: Schema.Boolean.pipe(optional),
|
||||
}).annotate({ identifier: "Model.Compatibility" })
|
||||
|
||||
export interface Capabilities extends Schema.Schema.Type<typeof Capabilities> {}
|
||||
|
||||
@@ -42,13 +42,11 @@ describe("Model.Compatibility", () => {
|
||||
reasoningField: "vendor_reasoning",
|
||||
maxTokensField: "max_completion_tokens",
|
||||
requireFinishReason: false,
|
||||
requireAssistantAfterTool: true,
|
||||
}),
|
||||
).toEqual({
|
||||
reasoningField: "vendor_reasoning",
|
||||
maxTokensField: "max_completion_tokens",
|
||||
requireFinishReason: false,
|
||||
requireAssistantAfterTool: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createMemo, createResource, createSignal } from "solid-js"
|
||||
import path from "path"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import { dialogWidth, useDialog } from "../ui/dialog"
|
||||
import { DialogSelect, dialogSelectContentWidth } from "../ui/dialog-select"
|
||||
import { DialogSelect, dialogSelectContentWidth, type DialogSelectRef } from "../ui/dialog-select"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useData } from "../context/data"
|
||||
import { useClient } from "../context/client"
|
||||
@@ -15,6 +16,8 @@ import { Locale } from "../util/locale"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
import { truncateFilePath } from "../ui/file-path"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { Spinner } from "./spinner"
|
||||
@@ -23,7 +26,11 @@ import { projectName } from "../util/project"
|
||||
const RECENT_LIMIT = 8
|
||||
export const DialogOpenKey = Symbol("DialogOpen")
|
||||
|
||||
type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string }
|
||||
type OpenTarget =
|
||||
| { type: "session"; sessionID: string }
|
||||
| { type: "project"; directory: string; projectID?: string }
|
||||
| { type: "browse"; directory: string }
|
||||
| { type: "new"; projectID: string }
|
||||
|
||||
export async function loadDialogOpen(data: ReturnType<typeof useData>, client: ReturnType<typeof useClient>) {
|
||||
const [, sessions] = await Promise.all([
|
||||
@@ -43,6 +50,7 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
const client = useClient()
|
||||
const location = useLocation()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const toast = useToast()
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
const mode = themes.mode
|
||||
@@ -51,6 +59,29 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const [selectionMoved, setSelectionMoved] = createSignal(false)
|
||||
const [selected, setSelected] = createSignal<OpenTarget>()
|
||||
const [directory, setDirectory] = createSignal<string>()
|
||||
const [projectID, setProjectID] = createSignal<string>()
|
||||
let select: DialogSelectRef<OpenTarget> | undefined
|
||||
function browse(next?: string) {
|
||||
select?.clearFilter()
|
||||
setSelectionMoved(false)
|
||||
setSelected(undefined)
|
||||
setProjectID(undefined)
|
||||
setDirectory(next)
|
||||
}
|
||||
const [worktrees] = createResource(projectID, (projectID) =>
|
||||
client.api.worktree.list({ projectID }).catch((error: unknown) => {
|
||||
toast.show({ title: "Loading worktrees failed", message: errorMessage(error), variant: "error" })
|
||||
return []
|
||||
}),
|
||||
)
|
||||
const [entries] = createResource(directory, (directory) =>
|
||||
client.api.file
|
||||
.list({ location: { directory, workspace: location.ref?.workspaceID ?? data.location.default().workspaceID } })
|
||||
.then((result) => result.data.filter((entry) => entry.type === "directory"))
|
||||
.catch(() => undefined),
|
||||
)
|
||||
|
||||
const [matched] = createResource(
|
||||
() => {
|
||||
@@ -95,15 +126,17 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
const sessionOptions = recent.map((session) => {
|
||||
const project = data.project.get(session.projectID)
|
||||
const name = projectName(project)
|
||||
const basename = path.basename(session.location.directory)
|
||||
const label = name && name.toLowerCase() !== basename.toLowerCase() ? `${name} · ${basename}` : name || basename
|
||||
const running =
|
||||
data.session.status(session.id) === "running" ||
|
||||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
return {
|
||||
title: withTimestampedFallback(session),
|
||||
searchText: session.id,
|
||||
searchText: `${session.id} ${session.location.directory}`,
|
||||
value: { type: "session", sessionID: session.id } as OpenTarget,
|
||||
category: "Sessions",
|
||||
footer: `${name ? `${Locale.truncate(name, 20)} · ` : ""}${timeAgo(session.time.updated)}`,
|
||||
footer: `${label ? `${Locale.truncate(label, 30)} · ` : ""}${timeAgo(session.time.updated)}`,
|
||||
onSelect: () => location.set(session.location),
|
||||
gutter: running
|
||||
? (color: RGBA) => <Spinner color={color} />
|
||||
@@ -113,28 +146,46 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
}
|
||||
})
|
||||
|
||||
const current = location.current?.project
|
||||
const current = location.ref?.directory ?? location.current?.directory
|
||||
const seen = new Set<string>()
|
||||
const projectOptions = data.project
|
||||
.list()
|
||||
.filter((project) => {
|
||||
if (project.canonical === "/" || seen.has(project.canonical)) return false
|
||||
seen.add(project.canonical)
|
||||
const projectOptions = [
|
||||
...data.project
|
||||
.list()
|
||||
.flatMap((project) => [project.canonical, ...project.sandboxes].map((directory) => ({ directory, project }))),
|
||||
...sessions().map((session) => ({
|
||||
directory: session.location.directory,
|
||||
project: data.project.get(session.projectID),
|
||||
})),
|
||||
]
|
||||
.filter((item) => {
|
||||
if (item.directory === "/" || seen.has(item.directory)) return false
|
||||
seen.add(item.directory)
|
||||
return true
|
||||
})
|
||||
.map((project) => {
|
||||
const title = projectName(project) ?? project.canonical
|
||||
const footer = abbreviateHome(project.canonical, paths.home)
|
||||
.map((item) => {
|
||||
const title =
|
||||
item.directory === item.project?.canonical
|
||||
? (projectName(item.project) ?? path.basename(item.directory))
|
||||
: path.basename(item.directory)
|
||||
const footer = abbreviateHome(item.directory, paths.home)
|
||||
const git = item.project?.vcs === "git"
|
||||
const width =
|
||||
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
|
||||
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) -
|
||||
stringWidth(title) -
|
||||
(git ? 2 : 0)
|
||||
return {
|
||||
title,
|
||||
footer: truncateFilePath(footer, width),
|
||||
searchText: footer,
|
||||
value: { type: "project", directory: project.canonical } as OpenTarget,
|
||||
footer: `${truncateFilePath(footer, width)}${git ? " →" : ""}`,
|
||||
searchText: `${footer} ${projectName(item.project) ?? ""}`,
|
||||
value: {
|
||||
type: "project",
|
||||
directory: item.directory,
|
||||
...(git ? { projectID: item.project!.id } : {}),
|
||||
} as OpenTarget,
|
||||
category: "Projects",
|
||||
gutter:
|
||||
project.canonical === current?.canonical
|
||||
item.directory === current ||
|
||||
(item.directory === location.current?.project.canonical && (!current || !seen.has(current)))
|
||||
? () => <text fg={theme.text.formfield.selected}>●</text>
|
||||
: undefined,
|
||||
}
|
||||
@@ -143,33 +194,207 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
return [...sessionOptions, ...projectOptions]
|
||||
})
|
||||
|
||||
const worktreeOptions = createMemo(() => {
|
||||
const id = projectID()
|
||||
if (!id) return []
|
||||
const project = data.project.get(id)
|
||||
if (!project) return []
|
||||
const current = location.ref?.directory ?? location.current?.directory
|
||||
const directories = [project.canonical, ...(worktrees() ?? []).map((worktree) => worktree.directory)]
|
||||
return [
|
||||
...directories
|
||||
.filter((directory, index) => directories.indexOf(directory) === index)
|
||||
.toSorted((a, b) => {
|
||||
if (a === project.canonical) return -1
|
||||
if (b === project.canonical) return 1
|
||||
if (a === current) return -1
|
||||
if (b === current) return 1
|
||||
return 0
|
||||
})
|
||||
.map((directory) => {
|
||||
const title =
|
||||
directory === project.canonical
|
||||
? (projectName(project) ?? path.basename(directory))
|
||||
: path.basename(directory)
|
||||
return {
|
||||
title,
|
||||
footer: truncateFilePath(
|
||||
abbreviateHome(directory, paths.home),
|
||||
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title),
|
||||
),
|
||||
value: { type: "project", directory } as OpenTarget,
|
||||
category: "Worktrees",
|
||||
gutter: directory === current ? () => <text fg={theme.text.formfield.selected}>●</text> : undefined,
|
||||
}
|
||||
}),
|
||||
{
|
||||
title: "+ New worktree",
|
||||
value: { type: "new", projectID: id } as OpenTarget,
|
||||
category: "Worktrees",
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const directoryOptions = createMemo(() => {
|
||||
const current = directory()
|
||||
if (!current) return []
|
||||
return [
|
||||
{
|
||||
title: "Open this directory",
|
||||
footer: truncateFilePath(
|
||||
abbreviateHome(current, paths.home),
|
||||
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) -
|
||||
stringWidth("Open this directory"),
|
||||
),
|
||||
value: { type: "project", directory: current } as OpenTarget,
|
||||
category: "Current",
|
||||
},
|
||||
...(path.dirname(current) !== current
|
||||
? [
|
||||
{
|
||||
title: "..",
|
||||
value: { type: "browse", directory: path.dirname(current) } as OpenTarget,
|
||||
category: "Current",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(entries() ?? [])
|
||||
.toSorted((a, b) => a.path.localeCompare(b.path))
|
||||
.map((entry) => ({
|
||||
title: path.basename(entry.path),
|
||||
value: { type: "browse", directory: path.resolve(current, entry.path) } as OpenTarget,
|
||||
category: "Directories",
|
||||
})),
|
||||
]
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Open"
|
||||
placeholder="Search sessions and projects…"
|
||||
options={options()}
|
||||
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
|
||||
focusCurrent={false}
|
||||
ref={(value) => (select = value)}
|
||||
title={projectID() ? "Worktrees" : "Open"}
|
||||
placeholder={
|
||||
directory()
|
||||
? abbreviateHome(directory()!, paths.home)
|
||||
: projectID()
|
||||
? "Search worktrees…"
|
||||
: "Search sessions and projects…"
|
||||
}
|
||||
options={directory() ? directoryOptions() : projectID() ? worktreeOptions() : options()}
|
||||
current={
|
||||
directory()
|
||||
? ({ type: "project", directory: directory()! } as OpenTarget)
|
||||
: projectID() && (location.ref?.directory ?? location.current?.directory)
|
||||
? ({ type: "project", directory: (location.ref?.directory ?? location.current?.directory)! } as OpenTarget)
|
||||
: currentSessionID()
|
||||
? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget)
|
||||
: undefined
|
||||
}
|
||||
focusCurrent={Boolean(directory() || projectID())}
|
||||
sectionNavigation={true}
|
||||
preserveSelection={selectionMoved()}
|
||||
onMove={() => setSelectionMoved(true)}
|
||||
onMove={(option) => {
|
||||
setSelectionMoved(true)
|
||||
setSelected(option.value)
|
||||
}}
|
||||
onFilter={setFilter}
|
||||
bindings={[
|
||||
{
|
||||
bind: "ctrl+o",
|
||||
title: directory() ? "Return to projects" : "Browse directories",
|
||||
group: "Dialog",
|
||||
run: () =>
|
||||
browse(directory() ? undefined : (location.ref?.directory ?? location.current?.directory ?? paths.cwd)),
|
||||
},
|
||||
...(!directory() && !projectID()
|
||||
? [
|
||||
{
|
||||
bind: "right",
|
||||
title: "Show project worktrees",
|
||||
group: "Dialog",
|
||||
run: () => {
|
||||
const target = selected() ?? select?.filtered[0]?.value
|
||||
if (target?.type !== "project" || !target.projectID) return
|
||||
select?.clearFilter()
|
||||
setSelectionMoved(false)
|
||||
setSelected(undefined)
|
||||
setProjectID(target.projectID)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(projectID()
|
||||
? [
|
||||
{
|
||||
bind: "left",
|
||||
title: "Return to projects",
|
||||
group: "Dialog",
|
||||
run: () => browse(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(directory() && path.dirname(directory()!) !== directory()
|
||||
? [
|
||||
{
|
||||
bind: "ctrl+u",
|
||||
title: "Browse parent directory",
|
||||
group: "Dialog",
|
||||
run: () => browse(path.dirname(directory()!)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
footerHints={[
|
||||
...(projectID() ? [{ title: "back", label: "←" }] : []),
|
||||
{ title: directory() ? "back" : "browse directories", label: "ctrl+o" },
|
||||
...(directory() && path.dirname(directory()!) !== directory() ? [{ title: "parent", label: "ctrl+u" }] : []),
|
||||
]}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>
|
||||
{shortcuts.get("session.list")
|
||||
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
|
||||
: "No matches"}
|
||||
{directory()
|
||||
? entries.loading
|
||||
? "Loading directories…"
|
||||
: "No matching directories"
|
||||
: shortcuts.get("session.list")
|
||||
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
|
||||
: "No matches"}
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
onSelect={(option) => {
|
||||
if (option.value.type === "browse") {
|
||||
browse(option.value.directory)
|
||||
return
|
||||
}
|
||||
if (option.value.type === "new") {
|
||||
const id = option.value.projectID
|
||||
void client.api.worktree
|
||||
.create({ projectID: id, strategy: "git", directory: path.join(paths.worktree, id.slice(0, 6)) })
|
||||
.then((created) => {
|
||||
const target = {
|
||||
directory: created.directory,
|
||||
...(location.ref?.workspaceID ? { workspaceID: location.ref.workspaceID } : {}),
|
||||
}
|
||||
dialog.clear()
|
||||
route.navigate({ type: "home", location: target })
|
||||
location.set(target)
|
||||
})
|
||||
.catch((error: unknown) =>
|
||||
toast.show({ title: "Creating worktree failed", message: errorMessage(error), variant: "error" }),
|
||||
)
|
||||
return
|
||||
}
|
||||
dialog.clear()
|
||||
if (option.value.type === "session") {
|
||||
route.navigate({ type: "session", sessionID: option.value.sessionID })
|
||||
return
|
||||
}
|
||||
const target = { directory: option.value.directory }
|
||||
const target = {
|
||||
directory: option.value.directory,
|
||||
...((directory() || projectID()) && location.ref?.workspaceID
|
||||
? { workspaceID: location.ref.workspaceID }
|
||||
: {}),
|
||||
}
|
||||
route.navigate({ type: "home", location: target })
|
||||
location.set(target)
|
||||
}}
|
||||
|
||||
@@ -93,6 +93,7 @@ export function dialogSelectContentWidth(dialogWidth: number) {
|
||||
export type DialogSelectRef<T> = {
|
||||
filter: string
|
||||
filtered: DialogSelectOption<T>[]
|
||||
clearFilter(): void
|
||||
moveTo(value: T): void
|
||||
}
|
||||
|
||||
@@ -526,6 +527,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
get filtered() {
|
||||
return filtered()
|
||||
},
|
||||
clearFilter() {
|
||||
input.value = ""
|
||||
batch(() => {
|
||||
setStore("filter", "")
|
||||
props.onFilter?.("")
|
||||
})
|
||||
},
|
||||
moveTo(value) {
|
||||
const index = flat().findIndex((option) => isDeepEqual(option.value, value))
|
||||
if (index >= 0) moveTo(index, true)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { onMount } from "solid-js"
|
||||
import { DialogOpen, DialogOpenKey, loadDialogOpen } from "../../../src/component/dialog-open"
|
||||
@@ -131,6 +132,412 @@ test("shows the current project and opens its root", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("includes unique sandbox and recent session directories, including global projects", async () => {
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/project")
|
||||
return json([
|
||||
{
|
||||
id: "proj_current",
|
||||
canonical: "/tmp/opencode/project",
|
||||
name: "OpenCode",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: ["/tmp/opencode/feature-branch"],
|
||||
},
|
||||
{
|
||||
id: "global",
|
||||
canonical: "/",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
if (url.pathname !== "/api/session") return undefined
|
||||
return json({
|
||||
data: [
|
||||
{
|
||||
id: "ses_global",
|
||||
projectID: "global",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 3 },
|
||||
title: "Standalone session",
|
||||
location: { directory: "/tmp/standalone-notes" },
|
||||
},
|
||||
{
|
||||
id: "ses_worktree",
|
||||
projectID: "proj_current",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 2 },
|
||||
title: "Worktree session",
|
||||
location: { directory: "/tmp/opencode/feature-branch" },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
const frame = await fixture.app.waitForFrame(
|
||||
(value) => value.includes("Standalone session") && value.includes("feature-branch") && value.includes("Projects"),
|
||||
)
|
||||
expect(frame).toContain("standalone-notes")
|
||||
expect(frame).toContain("OpenCode · feature-branch")
|
||||
expect(frame.match(/\/tmp\/opencode\/feature-branch/g)).toHaveLength(1)
|
||||
|
||||
await fixture.app.mockInput.typeText("standalone-notes")
|
||||
await fixture.app.waitForFrame((value) => value.includes("standalone-notes"))
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "home")
|
||||
expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/standalone-notes" } })
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("shows nested Git session directories as projects and in their session footer", async () => {
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/project")
|
||||
return json([
|
||||
{
|
||||
id: "proj_current",
|
||||
canonical: "/tmp/opencode/project",
|
||||
name: "OpenCode",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
if (url.pathname !== "/api/session") return undefined
|
||||
return json({
|
||||
data: [
|
||||
{
|
||||
id: "ses_dashboard",
|
||||
projectID: "proj_current",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 2 },
|
||||
title: "Improve dashboard",
|
||||
location: { directory: "/tmp/opencode/project/packages/dashboard" },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
const frame = await fixture.app.waitForFrame(
|
||||
(value) => value.includes("Improve dashboard") && value.includes("browse directories"),
|
||||
)
|
||||
expect(frame).toContain("OpenCode · dashboard")
|
||||
expect(frame).toContain("/tmp/opencode/project/packages/dashboard")
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("loads Git worktrees only when drilling into a project or its associated directory", async () => {
|
||||
const root = path.resolve("/tmp/opencode/project")
|
||||
const current = path.resolve("/tmp/opencode/current-branch")
|
||||
const other = path.resolve("/tmp/opencode/other-branch")
|
||||
const workspaceID = "ws_worktree"
|
||||
let requests = 0
|
||||
const fixture = await renderOpen(
|
||||
(url) => {
|
||||
if (url.pathname === "/api/project")
|
||||
return json([
|
||||
{
|
||||
id: "proj_git",
|
||||
canonical: root,
|
||||
name: "OpenCode",
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [current],
|
||||
},
|
||||
])
|
||||
if (url.pathname === "/api/location")
|
||||
return json({
|
||||
directory: current,
|
||||
workspaceID,
|
||||
project: { id: "proj_git", directory: current, canonical: root },
|
||||
})
|
||||
if (url.pathname !== "/api/worktree/proj_git") return undefined
|
||||
requests++
|
||||
return json([{ directory: other, strategy: "git" }, { directory: root }, { directory: current, strategy: "git" }])
|
||||
},
|
||||
async ({ data, location }) => {
|
||||
await data.location.sync({ directory: current, workspaceID })
|
||||
location.set({ directory: current, workspaceID })
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
const projects = await fixture.app.waitForFrame(
|
||||
(frame) => frame.includes("OpenCode") && frame.includes("current-branch") && frame.includes("→"),
|
||||
)
|
||||
expect(projects).not.toContain("Browse directories")
|
||||
expect(requests).toBe(0)
|
||||
|
||||
fixture.app.mockInput.pressArrow("right")
|
||||
const worktrees = await fixture.app.waitForFrame(
|
||||
(frame) => frame.includes("other-branch") && frame.includes("+ New worktree"),
|
||||
)
|
||||
expect(requests).toBe(1)
|
||||
expect(worktrees).toContain("Worktrees")
|
||||
expect(worktrees).toContain("●")
|
||||
expect(worktrees.indexOf("OpenCode")).toBeLessThan(worktrees.indexOf("current-branch"))
|
||||
expect(worktrees.indexOf("current-branch")).toBeLessThan(worktrees.indexOf("other-branch"))
|
||||
|
||||
fixture.app.mockInput.pressArrow("left")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and projects"))
|
||||
await fixture.app.mockInput.typeText("current-branch")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("current-branch") && !frame.includes("OpenCode"))
|
||||
fixture.app.mockInput.pressArrow("right")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("other-branch") && frame.includes("+ New worktree"))
|
||||
expect(requests).toBe(2)
|
||||
|
||||
await fixture.app.mockInput.typeText("other-branch")
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "home")
|
||||
expect(fixture.route.data).toEqual({ type: "home", location: { directory: other, workspaceID } })
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not show or trigger worktree navigation for non-Git and global directories", async () => {
|
||||
const root = path.resolve("/tmp/plain-project")
|
||||
const standalone = path.resolve("/tmp/standalone-notes")
|
||||
let requests = 0
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/project")
|
||||
return json([
|
||||
{ id: "proj_plain", canonical: root, name: "Plain project", time: { created: 1, updated: 2 }, sandboxes: [] },
|
||||
{ id: "global", canonical: "/", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
])
|
||||
if (url.pathname === "/api/session")
|
||||
return json({
|
||||
data: [
|
||||
{
|
||||
id: "ses_global",
|
||||
projectID: "global",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 2 },
|
||||
title: "Standalone session",
|
||||
location: { directory: standalone },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
if (!url.pathname.startsWith("/api/worktree/")) return undefined
|
||||
requests++
|
||||
return json([])
|
||||
})
|
||||
|
||||
try {
|
||||
const frame = await fixture.app.waitForFrame(
|
||||
(value) => value.includes("Plain project") && value.includes("standalone-notes"),
|
||||
)
|
||||
expect(frame).not.toContain("→")
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
fixture.app.mockInput.pressArrow("right")
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
fixture.app.mockInput.pressArrow("right")
|
||||
await fixture.app.renderOnce()
|
||||
expect(fixture.app.captureCharFrame()).toContain("Search sessions and projects")
|
||||
expect(requests).toBe(0)
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("creates an unnamed Git worktree and opens it in the current workspace", async () => {
|
||||
const projectID = "proj_git_create"
|
||||
const root = path.resolve("/tmp/opencode/project")
|
||||
const created = path.resolve("/tmp/opencode/created-branch")
|
||||
const workspaceID = "ws_create"
|
||||
let payload: unknown
|
||||
const fixture = await renderOpen(
|
||||
async (url, request) => {
|
||||
if (url.pathname === "/api/project")
|
||||
return json([
|
||||
{
|
||||
id: projectID,
|
||||
canonical: root,
|
||||
name: "OpenCode",
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
if (url.pathname === "/api/location")
|
||||
return json({ directory: root, workspaceID, project: { id: projectID, directory: root, canonical: root } })
|
||||
if (url.pathname !== `/api/worktree/${projectID}`) return undefined
|
||||
if (request.method === "GET") return json([{ directory: root }])
|
||||
payload = await request.json()
|
||||
return json({ directory: created })
|
||||
},
|
||||
async ({ data, location }) => {
|
||||
await data.location.sync({ directory: root, workspaceID })
|
||||
location.set({ directory: root, workspaceID })
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("OpenCode") && frame.includes("→"))
|
||||
fixture.app.mockInput.pressArrow("right")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("+ New worktree"))
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "home")
|
||||
|
||||
expect(payload).toEqual({
|
||||
strategy: "git",
|
||||
directory: path.join("/tmp/opencode", projectID.slice(0, 6)),
|
||||
})
|
||||
expect(fixture.route.data).toEqual({ type: "home", location: { directory: created, workspaceID } })
|
||||
expect(fixture.location.ref).toEqual({ directory: created, workspaceID })
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps directory browsing in the footer and clears its search when toggling the browser", async () => {
|
||||
const root = path.resolve(
|
||||
"/private/var/folders/very-long-temporary-directory/opencode-drive/run-6462634d-8106-4652-ab87-e7e3cf5177ad/files",
|
||||
)
|
||||
const fixture = await renderOpen(
|
||||
(url) => {
|
||||
if (url.pathname === "/api/location")
|
||||
return json({ directory: root, project: { id: "proj_current", directory: root, canonical: root } })
|
||||
if (url.pathname !== "/api/fs/list") return undefined
|
||||
return json({
|
||||
location: { directory: root, project: { id: "proj_current", directory: root, canonical: root } },
|
||||
data: [{ path: "packages", type: "directory" }],
|
||||
})
|
||||
},
|
||||
async ({ data, location }) => {
|
||||
await data.location.sync({ directory: root })
|
||||
location.set({ directory: root })
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
const initial = await fixture.app.waitForFrame((frame) => frame.includes("browse directories"))
|
||||
expect(initial).not.toContain("Browse directories")
|
||||
await fixture.app.mockInput.typeText("missing")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("No matches"))
|
||||
fixture.app.mockInput.pressKey("o", { ctrl: true })
|
||||
const browser = await fixture.app.waitForFrame(
|
||||
(frame) => frame.includes("Open this directory") && frame.includes("packages"),
|
||||
)
|
||||
expect(browser).not.toContain("No matching directories")
|
||||
|
||||
await fixture.app.mockInput.typeText("packages")
|
||||
fixture.app.mockInput.pressKey("o", { ctrl: true })
|
||||
const projects = await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and projects"))
|
||||
expect(projects).toContain("browse directories")
|
||||
expect(projects).not.toContain("Browse directories")
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("browses from the current directory and opens an arbitrary child directory", async () => {
|
||||
const root = path.resolve("/tmp/opencode/project")
|
||||
const packages = path.resolve(root, "packages")
|
||||
const untracked = path.resolve(packages, "untracked")
|
||||
const workspaceID = "ws_browser"
|
||||
const fixture = await renderOpen(
|
||||
(url) => {
|
||||
if (url.pathname === "/api/location")
|
||||
return json({ directory: root, workspaceID, project: { id: "proj_current", directory: root, canonical: root } })
|
||||
if (url.pathname !== "/api/fs/list") return undefined
|
||||
expect(url.searchParams.get("location[workspace]")).toBe(workspaceID)
|
||||
const current = url.searchParams.get("location[directory]")
|
||||
return json({
|
||||
location: {
|
||||
directory: current,
|
||||
workspaceID,
|
||||
project: { id: "proj_current", directory: root, canonical: root },
|
||||
},
|
||||
data:
|
||||
current === root
|
||||
? [
|
||||
{ path: "packages", type: "directory" },
|
||||
{ path: "README.md", type: "file" },
|
||||
]
|
||||
: current && path.normalize(current) === path.normalize(packages)
|
||||
? [{ path: "untracked", type: "directory" }]
|
||||
: [],
|
||||
})
|
||||
},
|
||||
async ({ data, location }) => {
|
||||
await data.location.sync({ directory: root, workspaceID })
|
||||
location.set({ directory: root, workspaceID })
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("browse"))
|
||||
fixture.app.mockInput.pressKey("o", { ctrl: true })
|
||||
const rootFrame = await fixture.app.waitForFrame(
|
||||
(frame) => frame.includes("Open this directory") && frame.includes("packages"),
|
||||
)
|
||||
expect(rootFrame).not.toContain("README.md")
|
||||
|
||||
fixture.app.mockInput.pressArrow("down", { meta: true })
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("untracked"))
|
||||
|
||||
fixture.app.mockInput.pressArrow("down", { meta: true })
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitForFrame((frame) => frame.includes(untracked))
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "home")
|
||||
expect(fixture.route.data).toEqual({
|
||||
type: "home",
|
||||
location: { directory: untracked, workspaceID },
|
||||
})
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("navigates to the parent directory and returns to the project picker", async () => {
|
||||
const root = path.resolve("/tmp/opencode/project")
|
||||
const parent = path.dirname(root)
|
||||
const fixture = await renderOpen(
|
||||
(url) => {
|
||||
if (url.pathname === "/api/location")
|
||||
return json({ directory: root, project: { id: "proj_current", directory: root, canonical: root } })
|
||||
if (url.pathname !== "/api/fs/list") return undefined
|
||||
const current = url.searchParams.get("location[directory]")
|
||||
return json({
|
||||
location: { directory: current, project: { id: "proj_current", directory: root, canonical: root } },
|
||||
data:
|
||||
current && path.normalize(current) === path.normalize(parent) ? [{ path: "sibling", type: "directory" }] : [],
|
||||
})
|
||||
},
|
||||
async ({ data, location }) => {
|
||||
await data.location.sync({ directory: root })
|
||||
location.set({ directory: root })
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("browse"))
|
||||
fixture.app.mockInput.pressKey("o", { ctrl: true })
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Open this directory"))
|
||||
fixture.app.mockInput.pressKey("u", { ctrl: true })
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("sibling") && frame.includes(parent))
|
||||
fixture.app.mockInput.pressKey("o", { ctrl: true })
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and projects"))
|
||||
expect(fixture.location.ref).toEqual({ directory: root })
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("waits for sessions before showing the populated picker", async () => {
|
||||
let resolveSessions!: (response: Response) => void
|
||||
const sessions = new Promise<Response>((resolve) => (resolveSessions = resolve))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user