Compare commits

..

1 Commits

Author SHA1 Message Date
Victor Navarro 5e6500b52f feat(cli): add console logout command 2026-07-29 18:00:33 +00:00
118 changed files with 1455 additions and 3509 deletions
+2 -16
View File
@@ -143,10 +143,7 @@
"open": "10.1.2",
"semver": "catalog:",
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"uqr": "0.1.3",
"web-tree-sitter": "0.25.10",
"ws": "8.21.0",
},
"devDependencies": {
@@ -405,11 +402,8 @@
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"semver": "^7.6.3",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"venice-ai-sdk-provider": "2.1.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"zod": "catalog:",
},
@@ -1079,11 +1073,9 @@
},
"trustedDependencies": [
"esbuild",
"tree-sitter-powershell",
"protobufjs",
"electron",
"web-tree-sitter",
"tree-sitter-bash",
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
@@ -4080,10 +4072,10 @@
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"ffi-rs": ["ffi-rs@1.3.2", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-x64": "1.3.2", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-x64-musl": "1.3.2", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2" } }, "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"ffi-rs": ["ffi-rs@1.3.2", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-x64": "1.3.2", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-x64-musl": "1.3.2", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2" } }, "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ=="],
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -5642,10 +5634,6 @@
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
"tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
"tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
"treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
@@ -6868,8 +6856,6 @@
"topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"tree-sitter-bash/node-addon-api": ["node-addon-api@8.9.0", "", {}, "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q=="],
"tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
+1 -19
View File
@@ -5,7 +5,6 @@ import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import type { ProviderPackage } from "../provider-package"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAIChat from "../protocols/openai-chat"
import { isRecord } from "../protocols/shared"
@@ -31,12 +30,6 @@ export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenRouterProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenRouterProviderOptionsInput
}
const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [
Schema.Record(Schema.String, Schema.Any),
])
@@ -120,15 +113,4 @@ export const configure = (input: ModelOptions = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (
modelID,
settings,
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
export const model = provider.model
+1 -16
View File
@@ -6,7 +6,6 @@ import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
import { XAIImages } from "../protocols/xai-images"
import type { OpenAIProviderOptionsInput } from "./openai-options"
import type { ProviderPackage } from "../provider-package"
export const id = ProviderID.make("xai")
@@ -16,12 +15,6 @@ export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type { XAIImageOptions } from "../protocols/xai-images"
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
@@ -72,15 +65,7 @@ export const configure = (input: ModelOptions = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
export const model = provider.model
export const responses = provider.responses
export const chat = provider.chat
export const image = provider.image
+2
View File
@@ -53,7 +53,9 @@ describe("public exports", () => {
expect(CloudflareWorkersAI.configure).toBeFunction()
expect(CloudflareWorkersAI.configure({ accountId: "fixture", apiKey: "fixture" }).model).toBeFunction()
expect(OpenRouter.model).toBeFunction()
expect(OpenRouter.provider.model).toBe(OpenRouter.model)
expect(XAI.model).toBeFunction()
expect(XAI.provider.model).toBe(XAI.model)
expect(XAI.provider.responses).toBe(XAI.responses)
expect(XAI.provider.chat).toBe(XAI.chat)
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses")
-31
View File
@@ -21,8 +21,6 @@ describe("provider package entrypoints", () => {
import("@opencode-ai/ai/providers/google-vertex/chat"),
import("@opencode-ai/ai/providers/google-vertex/responses"),
import("@opencode-ai/ai/providers/google-vertex/messages"),
import("@opencode-ai/ai/providers/openrouter"),
import("@opencode-ai/ai/providers/xai"),
])
for (const module of modules) expect(module.model).toBeFunction()
@@ -31,35 +29,6 @@ describe("provider package entrypoints", () => {
expect(modules[12].model).toBe(modules[13].model)
})
test("maps OpenRouter and xAI package settings onto executable models", async () => {
const OpenRouter = await import("@opencode-ai/ai/providers/openrouter")
const XAI = await import("@opencode-ai/ai/providers/xai")
const settings = {
apiKey: "fixture",
baseURL: "https://provider.example.test/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
}
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
...settings,
providerOptions: { openrouter: { usage: true } },
})
const xai = XAI.model("grok-4", {
...settings,
providerOptions: { openai: { reasoningEffort: "high" } },
})
for (const selected of [openrouter, xai]) {
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
expect(selected.route.defaults.headers).toEqual(settings.headers)
expect(selected.route.defaults.http?.body).toEqual(settings.body)
expect(selected.route.defaults.limits).toEqual(settings.limits)
}
expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { usage: true } })
expect(xai.route.defaults.providerOptions).toEqual({ openai: { reasoningEffort: "high", store: false } })
})
test("maps package settings onto the executable model", () => {
const selected = model("gpt-5", {
apiKey: "fixture",
@@ -129,8 +129,8 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
api.list().then((projects) => {
return projects
.filter((p) => !!p?.id)
.map(normalizeProjectInfo)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.map(normalizeProjectInfo)
.slice()
.sort((a, b) => cmp(a.id, b.id))
}),
@@ -168,7 +168,6 @@ export function sanitizeProject(project: Project) {
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
return {
...project,
worktree: "canonical" in project ? project.canonical : project.worktree,
vcs: project.vcs === "git" ? "git" : undefined,
}
}
+3 -10
View File
@@ -128,7 +128,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
const located = <T>(data: T, value?: { directory?: string }) => ({
location: {
directory: directory(value) ?? "",
project: { id: "", directory: directory(value) ?? "", canonical: directory(value) ?? "" },
project: { id: "", directory: directory(value) ?? "" },
},
data,
})
@@ -298,19 +298,12 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
project: {
...input.current.project,
async list() {
return ((await legacy().project.list()).data ?? []).map((project) => ({
...project,
canonical: project.worktree,
}))
return ((await legacy().project.list()).data ?? []) as Project[]
},
async current(value?: Parameters<ServerApi["project"]["current"]>[0]) {
const result = await legacy(value?.location).project.current()
if (!result.data) throw new Error("Project not found")
return {
id: result.data.id,
directory: result.data.worktree,
canonical: result.data.worktree,
} satisfies ProjectCurrent
return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
},
// async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
// const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
-3
View File
@@ -40,9 +40,6 @@
"open": "10.1.2",
"semver": "catalog:",
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"web-tree-sitter": "0.25.10",
"uqr": "0.1.3",
"ws": "8.21.0"
},
+1 -1
View File
@@ -62,8 +62,8 @@ for (const target of targets) {
await rm("dist-node", { recursive: true, force: true })
const assetHash = await hashNodeAssets(assets)
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
await copyNodeAssets(assets)
await build(mainConfig(input))
await copyNodeAssets(assets)
const host = target.platform === process.platform && target.arch === process.arch
if (host) {
+1 -5
View File
@@ -3,7 +3,7 @@ import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { getNodeAssets } from "@opentui/core/node-assets"
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
import { attentionSoundAssets, type NodeTarget, photonWasmAsset } from "../src/node/target"
const dir = path.resolve(import.meta.dirname, "..")
@@ -43,10 +43,6 @@ export async function collectNodeAssets(target: NodeTarget) {
key: photonWasmAsset,
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
},
...Object.values(shellParserWasmAssets).map((key) => ({
key,
source: fileURLToPath(import.meta.resolve(key)),
})),
...attentionSoundAssets.map((key) => ({
key,
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
+3
View File
@@ -79,6 +79,9 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
url: Argument.string("url").pipe(Argument.withDescription("Console server URL"), Argument.optional),
},
}),
Spec.make("logout", {
description: "Log out of OpenCode Console",
}),
],
}),
Spec.make("auth", {
@@ -0,0 +1,32 @@
import { EOL } from "node:os"
import { Effect } from "effect"
import { OpenCode } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
const integrationID = "opencode"
const location = { directory: process.cwd() }
export default Runtime.handler(
Commands.commands.console.commands.logout,
Effect.fn("cli.console.logout")(function* () {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const found = yield* Effect.promise(() => client.integration.get({ integrationID, location }))
const credentials = found.data?.connections.filter((connection) => connection.type === "credential") ?? []
if (credentials.length === 0) {
process.stdout.write("Not logged in" + EOL)
return
}
yield* Effect.forEach(
credentials,
(connection) => Effect.promise(() => client.credential.remove({ credentialID: connection.id, location })),
{ discard: true },
)
process.stdout.write("Logged out from OpenCode Console" + EOL)
}),
)
+1
View File
@@ -25,6 +25,7 @@ const Handlers = Runtime.handlers(Commands, {
},
console: {
login: () => import("./commands/handlers/console/login"),
logout: () => import("./commands/handlers/console/logout"),
},
mcp: {
list: () => import("./commands/handlers/mcp/list"),
-5
View File
@@ -29,11 +29,6 @@ export function nodeTarget(platform: string, arch: string) {
}
export const photonWasmAsset = "@silvia-odwyer/photon-node/photon_rs_bg.wasm"
export const shellParserWasmAssets = {
runtime: "web-tree-sitter/tree-sitter.wasm",
bash: "tree-sitter-bash/tree-sitter-bash.wasm",
powershell: "tree-sitter-powershell/tree-sitter-powershell.wasm",
} as const
export const nodeExecArgv = ["--experimental-ffi", "--use-system-ca", "--disable-warning=ExperimentalWarning"] as const
export const attentionSoundAssets = [
@@ -136,7 +136,7 @@ describe("acp service lifecycle", () => {
method: "POST",
path: "/api/session/ses_loaded/fork",
query: {},
body: { boundary: { type: "through" } },
body: {},
})
})
+86
View File
@@ -0,0 +1,86 @@
import { afterEach, expect, test } from "bun:test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { OPENCODE_VERSION } from "../src/version"
const cleanup: Array<() => Promise<void>> = []
afterEach(async () => {
await Promise.all(cleanup.splice(0).map((fn) => fn()))
})
async function cli(args: string[], env?: Record<string, string>) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
cwd: path.join(import.meta.dir, ".."),
env: { ...process.env, ...env },
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
return { stdout, stderr, exitCode }
}
test("registers the console logout command", async () => {
const result = await cli(["console", "--help"])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("logout Log out of OpenCode Console")
})
test("removes stored OpenCode Console credentials", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-console-logout-"))
const removed: string[] = []
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/health") {
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
}
if (request.method === "GET" && url.pathname === "/api/integration/opencode") {
return Response.json({
location: { directory: process.cwd(), project: { id: "global", directory: "/" } },
data: {
id: "opencode",
name: "OpenCode",
methods: [],
connections: removed.length === 0 ? [{ type: "credential", id: "cred_test", label: "default" }] : [],
},
})
}
if (request.method === "DELETE" && url.pathname === "/api/credential/cred_test") {
removed.push(url.pathname)
return new Response(null, { status: 204 })
}
return new Response(null, { status: 404 })
},
})
cleanup.push(async () => {
server.stop(true)
await fs.rm(root, { recursive: true, force: true })
})
await fs.mkdir(path.join(root, "state", "opencode"), { recursive: true })
await fs.writeFile(
path.join(root, "state", "opencode", "service-local.json"),
JSON.stringify({ version: OPENCODE_VERSION, url: server.url.toString(), pid: process.pid }),
)
const env = {
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_STATE_HOME: path.join(root, "state"),
}
const result = await cli(["console", "logout"], env)
expect(result).toMatchObject({ exitCode: 0, stdout: "Logged out from OpenCode Console\n" })
expect(removed).toEqual(["/api/credential/cred_test"])
const repeated = await cli(["console", "logout"], env)
expect(repeated).toMatchObject({ exitCode: 0, stdout: "Not logged in\n" })
})
+1 -1
View File
@@ -3,7 +3,7 @@ import { OpenCode, type LocationGetOutput, type ModelRef, type SessionInfo } fro
import { resolveSessionTarget, SessionTargetMutationError } from "../src/session-target"
function location(directory: string, workspaceID?: string): LocationGetOutput {
return { directory, workspaceID, project: { id: "project", directory, canonical: directory } }
return { directory, workspaceID, project: { id: "project", directory } }
}
function session(id: string, directory: string, workspaceID?: string, model?: ModelRef): SessionInfo {
+1 -10
View File
@@ -3,13 +3,7 @@ import { readFile } from "node:fs/promises"
import { createRequire } from "node:module"
import { defineConfig, type Plugin, type UserConfig } from "vite"
import solid from "vite-plugin-solid"
import {
nodeExecArgv,
nodeTarget,
type NodeTarget,
photonWasmAsset,
shellParserWasmAssets,
} from "./src/node/target"
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset } from "./src/node/target"
const dir = import.meta.dirname
@@ -200,9 +194,6 @@ process.env.OTUI_ASSET_ROOT = __ocAssetRoot
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
process.env.OPENCODE_TREE_SITTER_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.runtime)})
process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.bash)})
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)})
try {
+39 -43
View File
@@ -279,7 +279,7 @@ export type ProjectCommands = { start?: string }
export type ProjectTime = { created: number; updated: number; initialized?: number }
export type ProjectCurrent = { id: string; directory: string; canonical: string }
export type ProjectCurrent = { id: string; directory: string }
export type ProjectDirectory = { directory: string; strategy?: string }
@@ -1430,7 +1430,7 @@ export type McpResourceCatalog = { resources: Array<McpResource>; templates: Arr
export type Project = {
id: string
canonical: string
worktree: string
vcs?: ProjectVcs
name?: string
icon?: ProjectIcon
@@ -2515,11 +2515,7 @@ export type LocationGetInput = {
}["location"]
}
export type LocationGetOutput = {
directory: string
workspaceID?: string
project: { id: string; directory: string; canonical: string }
}
export type LocationGetOutput = { directory: string; workspaceID?: string; project: { id: string; directory: string } }
export type AgentListInput = {
readonly location?: {
@@ -2528,7 +2524,7 @@ export type AgentListInput = {
}
export type AgentListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<AgentInfo>
}
@@ -2540,7 +2536,7 @@ export type AgentGetInput = {
}
export type AgentGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: AgentInfo
}
@@ -2551,7 +2547,7 @@ export type PluginListInput = {
}
export type PluginListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<PluginInfo>
}
@@ -3235,7 +3231,7 @@ export type ModelListInput = {
}
export type ModelListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<ModelInfo>
}
@@ -3246,7 +3242,7 @@ export type ModelDefaultInput = {
}
export type ModelDefaultOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: ModelInfo | null
}
@@ -3273,7 +3269,7 @@ export type ProviderListInput = {
}
export type ProviderListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<ProviderInfo>
}
@@ -3285,7 +3281,7 @@ export type ProviderGetInput = {
}
export type ProviderGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: ProviderInfo
}
@@ -3296,7 +3292,7 @@ export type IntegrationListInput = {
}
export type IntegrationListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<IntegrationInfo>
}
@@ -3308,7 +3304,7 @@ export type IntegrationGetInput = {
}
export type IntegrationGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: IntegrationInfo | null
}
@@ -3355,7 +3351,7 @@ export type IntegrationOauthConnectInput = {
}
export type IntegrationOauthConnectOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: {
attemptID: string
url: string
@@ -3374,7 +3370,7 @@ export type IntegrationOauthStatusInput = {
}
export type IntegrationOauthStatusOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: IntegrationAttemptStatus
}
@@ -3409,7 +3405,7 @@ export type IntegrationCommandConnectInput = {
}
export type IntegrationCommandConnectOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: IntegrationCommandAttempt
}
@@ -3422,7 +3418,7 @@ export type IntegrationCommandStatusInput = {
}
export type IntegrationCommandStatusOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: IntegrationCommandAttemptStatus
}
@@ -3443,7 +3439,7 @@ export type McpListInput = {
}
export type McpListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<McpServer>
}
@@ -3532,7 +3528,7 @@ export type McpResourceCatalogInput = {
}
export type McpResourceCatalogOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: McpResourceCatalog
}
@@ -3581,7 +3577,7 @@ export type FormRequestListInput = {
}
export type FormRequestListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<FormInfo>
}
@@ -4437,7 +4433,7 @@ export type PermissionRequestListInput = {
}
export type PermissionRequestListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<PermissionRequest>
}
@@ -4559,7 +4555,7 @@ export type FileListInput = {
}
export type FileListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<FileSystemEntry>
}
@@ -4591,7 +4587,7 @@ export type FileFindInput = {
}
export type FileFindOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<FileSystemEntry>
}
@@ -4602,7 +4598,7 @@ export type CommandListInput = {
}
export type CommandListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<CommandInfo>
}
@@ -4613,7 +4609,7 @@ export type SkillListInput = {
}
export type SkillListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<SkillInfo>
}
@@ -4626,7 +4622,7 @@ export type PtyListInput = {
}
export type PtyListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<Pty>
}
@@ -4672,7 +4668,7 @@ export type PtyCreateInput = {
}
export type PtyCreateOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Pty
}
@@ -4684,7 +4680,7 @@ export type PtyGetInput = {
}
export type PtyGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Pty
}
@@ -4701,7 +4697,7 @@ export type PtyUpdateInput = {
}
export type PtyUpdateOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Pty
}
@@ -4721,7 +4717,7 @@ export type ShellListInput = {
}
export type ShellListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<ShellInfo1>
}
@@ -4756,7 +4752,7 @@ export type ShellCreateInput = {
}
export type ShellCreateOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: ShellInfo1
}
@@ -4768,7 +4764,7 @@ export type ShellGetInput = {
}
export type ShellGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: ShellInfo1
}
@@ -4781,7 +4777,7 @@ export type ShellTimeoutInput = {
}
export type ShellTimeoutOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: ShellInfo1
}
@@ -4805,7 +4801,7 @@ export type ShellOutputInput = {
}
export type ShellOutputOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: { output: string; cursor: number; size: number; truncated: boolean }
}
@@ -4825,7 +4821,7 @@ export type QuestionRequestListInput = {
}
export type QuestionRequestListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<QuestionRequest>
}
@@ -4855,7 +4851,7 @@ export type ReferenceListInput = {
}
export type ReferenceListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<ReferenceInfo>
}
@@ -4898,7 +4894,7 @@ export type VcsStatusInput = {
}
export type VcsStatusOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<VcsFileStatus>
}
@@ -4921,7 +4917,7 @@ export type VcsDiffInput = {
}
export type VcsDiffOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<FileDiffInfo>
}
@@ -4942,7 +4938,7 @@ export type WebsearchProvidersInput = {
}
export type WebsearchProvidersOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<WebSearchProvider>
}
@@ -4955,6 +4951,6 @@ export type WebsearchQueryInput = {
}
export type WebsearchQueryOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: { providerID: string; results: Array<WebSearchResult> }
}
-8
View File
@@ -41,11 +41,6 @@
"node": "./src/image/photon-wasm.node.ts",
"default": "./src/image/photon-wasm.bun.ts"
},
"#shell-parser-wasm": {
"bun": "./src/shell/parser-wasm.bun.ts",
"node": "./src/shell/parser-wasm.node.ts",
"default": "./src/shell/parser-wasm.bun.ts"
},
"#process-lock-ffi": {
"bun": "./src/util/process-lock-ffi.bun.ts",
"node": "./src/util/process-lock-ffi.node.ts",
@@ -124,10 +119,7 @@
"jsonc-parser": "3.3.1",
"semver": "^7.6.3",
"turndown": "7.2.0",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"venice-ai-sdk-provider": "2.1.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"zod": "catalog:"
},
+5 -19
View File
@@ -6,7 +6,6 @@ export const Entry = Schema.Struct({
path: Schema.String,
description: Schema.String,
signature: Schema.String,
pinned: Schema.optionalKey(Schema.Boolean),
})
export type Entry = typeof Entry.Type
@@ -57,39 +56,26 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
if (left.path > right.path) return 1
return 0
})
const ranked = rankListings(listings)
const pinned = new Set(
namespaceEntries
.filter((entry) => entry.pinned)
.map((entry) => listings.find((listing) => listing.path === entry.path))
.filter((listing) => listing !== undefined),
)
return {
name,
listings,
selectionOrder: ranked.filter((candidate) => !pinned.has(candidate.listing)),
selectedListings: pinned,
selectionIndex: 0,
selectionOrder: rankListings(listings),
selectedListings: new Set<typeof Listing.Type>(),
}
})
const active = new Set(namespaces)
let remaining =
budget -
namespaces
.flatMap((namespace) => namespace.listings.filter((listing) => namespace.selectedListings.has(listing)))
.reduce((total, listing) => total + Math.round(listing.line.length / CHARACTERS_PER_TOKEN), 0)
let remaining = budget
while (active.size > 0) {
for (const namespace of active) {
const candidate = namespace.selectionOrder[namespace.selectionIndex]
const candidate = namespace.selectionOrder[namespace.selectedListings.size]
if (!candidate || candidate.cost > remaining) {
active.delete(namespace)
continue
}
namespace.selectedListings.add(candidate.listing)
namespace.selectionIndex += 1
remaining -= candidate.cost
if (namespace.selectionIndex === namespace.selectionOrder.length) active.delete(namespace)
if (namespace.selectedListings.size === namespace.selectionOrder.length) active.delete(namespace)
}
}
+6 -15
View File
@@ -138,14 +138,7 @@ export const create = (
}
export const catalog = (registrations: ReadonlyMap<string, Info>) => {
const pinned = new Set(
Array.from(registrations.values())
.filter((registration) => registration.options?.pinned === true)
.map(qualifiedName),
)
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable")))
.catalog()
.map((entry) => ({ ...entry, pinned: pinned.has(entry.path) }))
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).catalog()
}
function runtime(
@@ -156,7 +149,11 @@ function runtime(
const tools: Record<string, Tool.Tool<never>> = {}
for (const [name, registration] of registrations) {
const child = definition(registration)
const path = qualifiedName(registration)
const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_")
const path =
registration.options?.namespace === undefined
? normalized
: `${registration.options.namespace}.${normalized}`
tools[path] = Tool.make({
description: child.description,
input: child.inputSchema,
@@ -167,12 +164,6 @@ function runtime(
return CodeMode.make<typeof tools>({ tools, ...hooks })
}
function qualifiedName(registration: Info) {
const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_")
if (registration.options?.namespace === undefined) return normalized
return `${registration.options.namespace}.${normalized}`
}
// Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact.
function displayInput(input: unknown): Record<string, typeof Schema.Json.Type> | undefined {
if (input === null || input === undefined) return
+16 -3
View File
@@ -5,7 +5,6 @@ import { Context, Effect, Layer, Schema } from "effect"
import { dirname } from "path"
import { KeyedMutex } from "./effect/keyed-mutex"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bom } from "@opencode-ai/util/bom"
export interface Target {
readonly canonical: string
@@ -109,13 +108,13 @@ const layer = Layer.effect(
const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
withTargetLock(input.target)(
Effect.gen(function* () {
const next = Bom.split(input.content)
const next = splitBom(input.content)
const current = yield* fs
.readFile(input.target.canonical)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
yield* fs.writeWithDirs(
input.target.canonical,
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom),
)
return writeResult(input.target, current !== undefined)
}),
@@ -173,6 +172,20 @@ const layer = Layer.effect(
}),
)
function splitBom(text: string) {
const stripped = text.replace(/^\uFEFF+/, "")
return { bom: stripped.length !== text.length, text: stripped }
}
function joinBom(text: string, bom: boolean) {
const stripped = splitBom(text).text
return bom ? `\uFEFF${stripped}` : stripped
}
function hasUtf8Bom(content: Uint8Array) {
return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
}
function sameBytes(left: Uint8Array, right: Uint8Array) {
if (left.length !== right.length) return false
return left.every((byte, index) => byte === right[index])
-154
View File
@@ -1,154 +0,0 @@
export * as Formatter from "./formatter"
import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import path from "path"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Npm } from "@opencode-ai/util/npm"
import { AppProcess } from "@opencode-ai/util/process"
import { Config } from "./config"
import { Location } from "./location"
import { make, type Info } from "./formatter/builtins"
export const Status = Schema.Struct({
name: Schema.String,
extensions: Schema.Array(Schema.String),
enabled: Schema.Boolean,
}).annotate({ identifier: "FormatterStatus" })
export type Status = typeof Status.Type
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<Status[]>
readonly file: (filepath: string) => Effect.Effect<boolean>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Formatter") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const processes = yield* AppProcess.Service
const commands = new Map<string, string[] | false>()
let formatters: Info[] = []
const load = yield* Effect.cached(
Effect.gen(function* () {
const configured = Config.latest(yield* config.entries(), "formatter")
if (!configured) {
yield* Effect.logInfo("all formatters are disabled")
return
}
const builtIns = make({
directory: location.directory,
worktree: location.project.directory,
fs,
npm,
processes,
})
formatters = builtIns
if (configured === true) return
for (const [name, entry] of Object.entries(configured)) {
const index = formatters.findIndex((formatter) => formatter.name === name)
if (entry.disabled) {
if (index !== -1) formatters.splice(index, 1)
continue
}
const builtIn = builtIns.find((formatter) => formatter.name === name)
const formatter: Info = {
name,
extensions: entry.extensions ?? builtIn?.extensions ?? [],
environment: { ...builtIn?.environment, ...entry.environment },
enabled:
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
}
if (index === -1) formatters.push(formatter)
else formatters[index] = formatter
}
}).pipe(Effect.withSpan("Formatter.load")),
)
const command = Effect.fnUntraced(function* (formatter: Info) {
const cached = commands.get(formatter.name)
if (cached !== undefined) return cached
const result = yield* formatter.enabled
if (result !== false) commands.set(formatter.name, result)
return result
})
const init = Effect.fn("Formatter.init")(function* () {
yield* load
})
const status = Effect.fn("Formatter.status")(function* () {
yield* load
return yield* Effect.forEach(formatters, (formatter) =>
command(formatter).pipe(
Effect.map((enabled) => ({
name: formatter.name,
extensions: [...formatter.extensions],
enabled: enabled !== false,
})),
),
)
})
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
yield* load
const matching = formatters.filter((formatter) =>
formatter.extensions.includes(path.extname(filepath)),
)
for (const formatter of matching) {
const enabled = yield* command(formatter)
if (enabled === false) continue
const cmd = enabled.map((argument) => argument.replace("$FILE", filepath))
yield* Effect.logInfo("formatting file", { file: filepath, command: cmd })
const result = yield* processes
.run(
ChildProcess.make(cmd[0], cmd.slice(1), {
cwd: location.directory,
env: formatter.environment,
extendEnv: true,
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
}),
)
.pipe(
Effect.catch((error) =>
Effect.logError("failed to format file", {
file: filepath,
command: cmd,
error: error.message,
}).pipe(Effect.as(undefined)),
),
)
if (!result) continue
if (result.exitCode === 0) return true
yield* Effect.logError("formatter exited unsuccessfully", {
file: filepath,
command: cmd,
exitCode: result.exitCode,
})
}
return false
})
return Service.of({ init, status, file })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node],
})
-314
View File
@@ -1,314 +0,0 @@
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Npm } from "@opencode-ai/util/npm"
import { AppProcess } from "@opencode-ai/util/process"
import { which } from "../util/which"
export interface Info {
readonly name: string
readonly environment?: Record<string, string>
readonly extensions: readonly string[]
readonly enabled: Effect.Effect<string[] | false>
}
export function make(input: {
readonly directory: string
readonly worktree: string
readonly fs: FSUtil.Interface
readonly npm: Npm.Interface
readonly processes: AppProcess.Interface
}) {
const disabled = false as const
const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree)
const readText = (file: string) => input.fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))
const commandOutput = (command: string[]) =>
input.processes
.run(
ChildProcess.make(command[0], command.slice(1), {
cwd: input.directory,
extendEnv: true,
stdin: "ignore",
}),
)
.pipe(Effect.option)
const gofmt: Info = {
name: "gofmt",
extensions: [".go"],
enabled: Effect.sync(() => {
const match = which("gofmt")
return match ? [match, "-w", "$FILE"] : disabled
}),
}
const mix: Info = {
name: "mix",
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
enabled: Effect.sync(() => {
const match = which("mix")
return match ? [match, "format", "$FILE"] : disabled
}),
}
const prettier: Info = {
name: "prettier",
environment: { BUN_BE_BUN: "1" },
extensions: [
".js",
".jsx",
".mjs",
".cjs",
".ts",
".tsx",
".mts",
".cts",
".html",
".htm",
".css",
".scss",
".sass",
".less",
".vue",
".svelte",
".json",
".jsonc",
".yaml",
".yml",
".toml",
".xml",
".md",
".mdx",
".graphql",
".gql",
],
enabled: Effect.gen(function* () {
for (const file of yield* findUp("package.json")) {
if (!hasDependency(yield* input.fs.readJson(file), "prettier")) continue
const bin = yield* input.npm.which("prettier")
if (bin) return [bin, "--write", "$FILE"]
}
return disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const oxfmt: Info = {
name: "oxfmt",
environment: { BUN_BE_BUN: "1" },
extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"],
enabled: Effect.gen(function* () {
for (const file of yield* findUp("package.json")) {
if (!hasDependency(yield* input.fs.readJson(file), "oxfmt")) continue
const bin = yield* input.npm.which("oxfmt")
if (bin) return [bin, "$FILE"]
}
return disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const biome: Info = {
name: "biome",
environment: { BUN_BE_BUN: "1" },
extensions: [
".js",
".jsx",
".mjs",
".cjs",
".ts",
".tsx",
".mts",
".cts",
".html",
".htm",
".css",
".scss",
".sass",
".less",
".vue",
".svelte",
".json",
".jsonc",
".yaml",
".yml",
".toml",
".xml",
".md",
".mdx",
".graphql",
".gql",
],
enabled: Effect.gen(function* () {
const found = yield* Effect.forEach(["biome.json", "biome.jsonc"], findUp, { concurrency: "unbounded" })
if (!found.some((items) => items.length > 0)) return disabled
const bin = yield* input.npm.which("@biomejs/biome")
return bin ? [bin, "format", "--write", "$FILE"] : disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const zig: Info = {
name: "zig",
extensions: [".zig", ".zon"],
enabled: Effect.sync(() => {
const match = which("zig")
return match ? [match, "fmt", "$FILE"] : disabled
}),
}
const clang: Info = {
name: "clang-format",
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
enabled: Effect.gen(function* () {
if (!(yield* findUp(".clang-format")).length) return disabled
const match = which("clang-format")
return match ? [match, "-i", "$FILE"] : disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const ktlint: Info = {
name: "ktlint",
extensions: [".kt", ".kts"],
enabled: Effect.sync(() => {
const match = which("ktlint")
return match ? [match, "-F", "$FILE"] : disabled
}),
}
const ruff: Info = {
name: "ruff",
extensions: [".py", ".pyi"],
enabled: Effect.gen(function* () {
if (!which("ruff")) return disabled
for (const config of ["pyproject.toml", "ruff.toml", ".ruff.toml"]) {
const found = yield* findUp(config)
if (!found.length) continue
if (config !== "pyproject.toml" || (yield* readText(found[0])).includes("[tool.ruff]")) {
return ["ruff", "format", "$FILE"]
}
}
for (const dependency of ["requirements.txt", "pyproject.toml", "Pipfile"]) {
const found = yield* findUp(dependency)
if (found.length && (yield* readText(found[0])).includes("ruff")) return ["ruff", "format", "$FILE"]
}
return disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const air: Info = {
name: "air",
extensions: [".R"],
enabled: Effect.gen(function* () {
const bin = which("air")
if (!bin) return disabled
const output = yield* commandOutput([bin, "--help"])
if (output._tag === "None" || output.value.exitCode !== 0) return disabled
const first = output.value.stdout.toString("utf8").split("\n")[0]
return first.includes("R language") && first.includes("formatter") ? [bin, "format", "$FILE"] : disabled
}),
}
const uv: Info = {
name: "uv",
extensions: [".py", ".pyi"],
enabled: Effect.gen(function* () {
const bin = which("uv")
if (!bin) return disabled
const output = yield* commandOutput([bin, "format", "--help"])
return output._tag === "Some" && output.value.exitCode === 0
? [bin, "format", "--", "$FILE"]
: disabled
}),
}
const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"])
const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"])
const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"])
const dart = executable("dart", [".dart"], ["format", "$FILE"])
const ocamlformat: Info = {
name: "ocamlformat",
extensions: [".ml", ".mli"],
enabled: Effect.gen(function* () {
if (!(yield* findUp(".ocamlformat")).length) return disabled
const match = which("ocamlformat")
return match ? [match, "-i", "$FILE"] : disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"])
const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"])
const gleam = executable("gleam", [".gleam"], ["format", "$FILE"])
const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"])
const nixfmt = executable("nixfmt", [".nix"], ["$FILE"])
const rustfmt = executable("rustfmt", [".rs"], ["$FILE"])
const pint: Info = {
name: "pint",
extensions: [".php"],
enabled: Effect.gen(function* () {
for (const file of yield* findUp("composer.json")) {
const json = yield* input.fs.readJson(file)
if (hasRecordKey(json, "require", "laravel/pint") || hasRecordKey(json, "require-dev", "laravel/pint")) {
return ["./vendor/bin/pint", "$FILE"]
}
}
return disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"])
const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"])
const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"])
return [
gofmt,
mix,
oxfmt,
prettier,
biome,
zig,
clang,
ktlint,
ruff,
air,
uv,
rubocop,
standardrb,
htmlbeautifier,
dart,
ocamlformat,
terraform,
latexindent,
gleam,
shfmt,
nixfmt,
rustfmt,
pint,
ormolu,
cljfmt,
dfmt,
] satisfies Info[]
}
function executable(name: string, extensions: readonly string[], args: string[]): Info {
return {
name,
extensions,
enabled: Effect.sync(() => {
const match = which(name)
return match ? [match, ...args] : false
}),
}
}
function hasDependency(input: unknown, dependency: string) {
return hasRecordKey(input, "dependencies", dependency) || hasRecordKey(input, "devDependencies", dependency)
}
function hasRecordKey(input: unknown, field: string, key: string) {
if (!isRecord(input)) return false
return isRecord(input[field]) && key in input[field]
}
function isRecord(input: unknown): input is Record<string, unknown> {
return Boolean(input && typeof input === "object" && !Array.isArray(input))
}
-2
View File
@@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Node } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus"
import { FileMutation } from "./file-mutation"
import { Formatter } from "./formatter"
import { FileSystem } from "./filesystem"
import { FileSystemSearch } from "./filesystem/search"
import { Generate } from "./generate"
@@ -74,7 +73,6 @@ const locationServiceNodes = [
InstructionDiscovery.node,
LocationMutation.node,
FileMutation.node,
Formatter.node,
MCP.node,
Permission.node,
Tool.node,
+1 -1
View File
@@ -25,7 +25,7 @@ const layer = (ref: Ref) =>
return Service.of({
directory: ref.directory,
workspaceID: ref.workspaceID,
project: { id: resolved.id, directory: resolved.directory, canonical: resolved.canonical },
project: { id: resolved.id, directory: resolved.directory },
vcs: resolved.vcs,
})
}),
-2
View File
@@ -2,7 +2,6 @@ export * as PluginHooks from "./hooks"
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
import { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -11,7 +10,6 @@ import { State } from "../state"
export interface Domains {
readonly aisdk: AISDKHooks
readonly session: SessionHooks
readonly shell: ShellHooks
readonly tool: ToolHooks
}
-3
View File
@@ -296,9 +296,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
})
}),
},
shell: {
hook: (name, callback) => hooks.register("shell", name, callback),
},
tool: {
transform: (callback) =>
tools
-3
View File
@@ -16,7 +16,6 @@ import { ConfigSkillPlugin } from "../config/plugin/skill"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
import { Bus } from "../bus"
import { FileMutation } from "../file-mutation"
import { Formatter } from "../formatter"
import { Form } from "../form"
import { FileSystem } from "../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -69,7 +68,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const config = yield* Config.Service
const bus = yield* Bus.Service
const mutation = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const filesystem = yield* FileSystem.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
@@ -100,7 +98,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Config.Service, config),
Context.make(Bus.Service, bus),
Context.make(FileMutation.Service, mutation),
Context.make(Formatter.Service, formatter),
Context.make(FileSystem.Service, filesystem),
Context.make(FSUtil.Service, fs),
Context.make(Global.Service, global),
-4
View File
@@ -326,10 +326,6 @@ export function fromPromise(plugin: Plugin) {
),
interrupt: (input) => run(host.session.interrupt({ sessionID: Session.ID.make(input.sessionID) })),
},
shell: {
hook: (name, callback) =>
register(host.shell.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
}
const cleanup = yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
-2
View File
@@ -14,7 +14,6 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Bus } from "../bus"
import { FileMutation } from "../file-mutation"
import { Formatter } from "../formatter"
import { FileSystem } from "../filesystem"
import { Watcher } from "../filesystem/watcher"
import { Form } from "../form"
@@ -319,7 +318,6 @@ export const node = makeLocationNode({
Config.node,
Bus.node,
FileMutation.node,
Formatter.node,
FileSystem.node,
FSUtil.node,
Global.node,
+6 -49
View File
@@ -2,7 +2,7 @@ export * as Project from "./project"
import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { asc, desc, isNotNull, isNull, ne, or } from "drizzle-orm"
import { asc, desc } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema"
import { Database } from "./database/database"
@@ -40,7 +40,6 @@ export interface Resolved {
readonly previous?: ID
readonly id: ID
readonly directory: AbsolutePath
readonly canonical: AbsolutePath
readonly vcs?: Vcs
}
@@ -84,7 +83,7 @@ function fromRow(row: typeof ProjectTable.$inferSelect): Info {
: undefined
return {
id: row.id,
canonical: row.worktree,
worktree: row.worktree,
vcs: row.vcs ?? undefined,
name: row.name ?? undefined,
icon,
@@ -107,40 +106,6 @@ const layer = Layer.effect(
const db = (yield* Database.Service).db
const projectDirectories = yield* ProjectDirectories.Service
const persist = Effect.fnUntraced(function* (project: Resolved) {
yield* db
.transaction((tx) =>
Effect.gen(function* () {
const vcs = project.vcs?.type
yield* tx
.insert(ProjectTable)
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
.onConflictDoUpdate({
target: ProjectTable.id,
set: { worktree: project.canonical, vcs: vcs ?? null },
setWhere: or(
ne(ProjectTable.worktree, project.canonical),
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
),
})
.run()
if (!project.vcs) return
yield* projectDirectories.create({ projectID: project.id, directory: project.canonical }, tx)
if (project.directory === project.canonical) return
yield* projectDirectories.create(
{
projectID: project.id,
directory: project.directory,
strategy: project.vcs.type === "git" ? "git_worktree" : undefined,
},
tx,
)
}),
)
.pipe(Effect.orDie)
return project
})
const list = Effect.fn("Project.list")(function* () {
const rows = yield* db
.select()
@@ -246,25 +211,17 @@ const layer = Layer.effect(
if (repo) {
const previous = yield* cached(repo.commonDirectory)
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
const canonical = yield* git.worktree
.list(repo)
.pipe(
Effect.map((items) => items.find((item) => item.kind === "main")?.directory ?? repo.worktree),
Effect.catch(() => Effect.succeed(repo.worktree)),
)
return yield* persist({
return {
previous,
id: id ?? ID.global,
directory: repo.worktree,
canonical,
vcs: { type: "git" as const, store: repo.commonDirectory },
})
}
}
const hg = yield* hgDiscover(input)
if (hg) return yield* persist({ ...hg, canonical: hg.directory })
const directory = AbsolutePath.make(path.parse(input).root)
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
if (hg) return hg
return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
})
const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
+13 -20
View File
@@ -3,7 +3,7 @@ export * from "./session/schema"
import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, ne, or, type SQL } from "drizzle-orm"
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
import { Project } from "./project"
import { Workspace } from "./workspace"
import { Model } from "./model"
@@ -325,22 +325,6 @@ const layer = Layer.effect(
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const persistProject = (project: Project.Resolved) => {
const vcs = project.vcs?.type
return db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
.onConflictDoUpdate({
target: ProjectTable.id,
set: { worktree: project.canonical, vcs: vcs ?? null },
setWhere: or(
ne(ProjectTable.worktree, project.canonical),
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
),
})
.run()
.pipe(Effect.orDie)
}
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError(
@@ -363,7 +347,12 @@ const layer = Layer.effect(
if (location === undefined)
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
const project = yield* projects.resolve(location.directory)
yield* persistProject(project)
yield* db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
const now = Date.now()
const info = SessionV1.SessionInfo.make({
id: sessionID,
@@ -462,7 +451,6 @@ const layer = Layer.effect(
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
if (input.parentID !== undefined)
conditions.push(
@@ -744,7 +732,12 @@ const layer = Layer.effect(
)
return
const project = yield* projects.resolve(directory)
yield* persistProject(project)
yield* db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
if ((yield* execution.active).has(input.sessionID)) {
yield* execution.interrupt(input.sessionID)
yield* execution.awaitIdle(input.sessionID)
+18 -34
View File
@@ -12,8 +12,6 @@ import { Bus } from "./bus"
import { Location } from "./location"
import { Global } from "@opencode-ai/util/global"
import { ShellSelect } from "./shell/select"
import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"
import { PluginHooks } from "./plugin/hooks"
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Shell.NotFoundError", {
id: Shell.ID,
@@ -47,10 +45,7 @@ type Active = {
*/
export interface Interface {
readonly name: () => Effect.Effect<string>
readonly create: <E = never, R = never>(
input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) => Effect.Effect<Shell.Info, E, R>
readonly create: (input: Shell.CreateInput) => Effect.Effect<Shell.Info>
// Currently running commands only; exited shells are retained for get/output but excluded here.
readonly list: () => Effect.Effect<Shell.Info[]>
readonly get: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
@@ -73,7 +68,6 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
const config = yield* Config.Service
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const hooks = yield* PluginHooks.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<string, Active>()
@@ -178,34 +172,24 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
}
})
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) {
const invocation: ShellCreateBefore = {
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* resolve(),
env: {
...process.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
},
}
yield* hooks.trigger("shell", "create.before", invocation)
if (before) yield* before(invocation)
const create = Effect.fn("Shell.create")(function* (input: Shell.CreateInput) {
const id = Shell.ID.ascending()
const args = ShellSelect.args(invocation.shell, invocation.command)
const cwd = input.cwd ?? location.directory
const shell = yield* resolve()
const args = ShellSelect.args(shell, input.command)
const file = path.join(outputDir, `${id}.out`)
const env = {
...process.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
} as Record<string, string>
const info: Info = {
id,
status: "running",
command: invocation.command,
cwd: invocation.cwd,
shell: invocation.shell,
command: input.command,
cwd,
shell,
file,
metadata: input.metadata ?? {},
time: { started: Date.now() },
@@ -219,9 +203,9 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
Effect.scoped(
Effect.gen(function* () {
const handle = yield* appProcess.spawn(
ChildProcess.make(invocation.shell, args, {
cwd: invocation.cwd,
env: invocation.env,
ChildProcess.make(shell, args, {
cwd,
env,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
@@ -313,7 +297,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
)
})
yield* session.timeout(invocation.timeout)
yield* session.timeout(input.timeout)
runFork(
handle.exitCode.pipe(
@@ -343,7 +327,7 @@ export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, PluginHooks.node],
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node],
})
}
-296
View File
@@ -1,296 +0,0 @@
export * as ShellParse from "./parse"
import { Effect } from "effect"
import { fileURLToPath } from "url"
import os from "os"
import path from "path"
import type { Node } from "web-tree-sitter"
import { shellParserWasm } from "#shell-parser-wasm"
import { ShellSelect } from "./select"
type Part = { type: string; text: string }
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
const POWERSHELL_PATH_FLAGS = new Set(["-literalpath", "-path"])
const ARITY: Record<string, number> = {
cat: 1,
cd: 1,
chmod: 1,
chown: 1,
cp: 1,
echo: 1,
env: 1,
export: 1,
grep: 1,
kill: 1,
killall: 1,
ln: 1,
ls: 1,
mkdir: 1,
mv: 1,
ps: 1,
pwd: 1,
rm: 1,
rmdir: 1,
sleep: 1,
source: 1,
tail: 1,
touch: 1,
unset: 1,
which: 1,
aws: 3,
az: 3,
bazel: 2,
brew: 2,
bun: 2,
"bun run": 3,
"bun x": 3,
cargo: 2,
"cargo add": 3,
"cargo run": 3,
cdk: 2,
cf: 2,
cmake: 2,
composer: 2,
consul: 2,
"consul kv": 3,
crictl: 2,
deno: 2,
"deno task": 3,
doctl: 3,
docker: 2,
"docker builder": 3,
"docker compose": 3,
"docker container": 3,
"docker image": 3,
"docker network": 3,
"docker volume": 3,
eksctl: 2,
"eksctl create": 3,
firebase: 2,
flyctl: 2,
gcloud: 3,
gh: 3,
git: 2,
"git config": 3,
"git remote": 3,
"git stash": 3,
go: 2,
gradle: 2,
helm: 2,
heroku: 2,
hugo: 2,
ip: 2,
"ip addr": 3,
"ip link": 3,
"ip netns": 3,
"ip route": 3,
kind: 2,
"kind create": 3,
kubectl: 2,
"kubectl kustomize": 3,
"kubectl rollout": 3,
kustomize: 2,
make: 2,
mc: 2,
"mc admin": 3,
minikube: 2,
mongosh: 2,
mysql: 2,
mvn: 2,
ng: 2,
npm: 2,
"npm exec": 3,
"npm init": 3,
"npm run": 3,
"npm view": 3,
npx: 2,
nvm: 2,
nx: 2,
openssl: 2,
"openssl req": 3,
"openssl x509": 3,
pip: 2,
pipenv: 2,
pnpm: 2,
"pnpm dlx": 3,
"pnpm exec": 3,
"pnpm run": 3,
poetry: 2,
podman: 2,
"podman container": 3,
"podman image": 3,
psql: 2,
pulumi: 2,
"pulumi stack": 3,
python: 2,
pyenv: 2,
rake: 2,
rbenv: 2,
"redis-cli": 2,
rustup: 2,
serverless: 2,
sfdx: 3,
skaffold: 2,
sls: 2,
sst: 2,
swift: 2,
systemctl: 2,
terraform: 2,
"terraform workspace": 3,
tmux: 2,
turbo: 2,
ufw: 2,
vault: 2,
"vault auth": 3,
"vault kv": 3,
vercel: 2,
volta: 2,
wp: 2,
yarn: 2,
"yarn dlx": 3,
"yarn run": 3,
}
export const scan = Effect.fn("ShellParse.scan")(function* (command: string, shell: string, cwd: string) {
const parsers = yield* Effect.promise(load)
const powershell = ShellSelect.ps(shell)
const tree = (powershell ? parsers.ps : parsers.bash).parse(command)
if (!tree) return yield* Effect.fail(new Error("Failed to parse shell command"))
return yield* Effect.acquireUseRelease(
Effect.succeed(tree),
(tree) =>
Effect.sync(() =>
tree.rootNode.descendantsOfType("command").reduce(
(result, node) => {
if (!node) return result
const command = parts(node)
const tokens = command.map((part) => part.text)
if (tokens.length === 0) return result
const name = powershell ? tokens[0].toLowerCase() : tokens[0]
if (CWD.has(name)) {
result.directories.push(...directoryArgs(command, powershell, cwd, shell))
return result
}
result.commands.push({
resource: (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim(),
save: `${prefix(tokens).join(" ")} *`,
})
return result
},
{ commands: [] as Array<{ resource: string; save: string }>, directories: [] as string[] },
),
),
(tree) => Effect.sync(() => tree.delete()),
)
})
function parts(node: Node) {
return Array.from({ length: node.childCount }).flatMap((_, index): Part[] => {
const child = node.child(index)
if (!child) return []
if (child.type === "command_elements")
return Array.from({ length: child.childCount }).flatMap((_, itemIndex): Part[] => {
const item = child.child(itemIndex)
if (!item || item.type === "command_argument_sep" || item.type === "redirection") return []
return [{ type: item.type, text: item.text }]
})
if (!["command_name", "command_name_expr", "word", "string", "raw_string", "concatenation"].includes(child.type))
return []
return [{ type: child.type, text: child.text }]
})
}
function directoryArgs(command: Part[], powershell: boolean, cwd: string, shell: string) {
if (!powershell)
return command
.slice(1)
.filter((part) => !part.text.startsWith("-"))
.map((part) => directoryArgument(part.text, powershell, cwd, shell))
.filter((part) => part !== undefined)
const directories: string[] = []
let path = false
for (const part of command.slice(1)) {
if (path) {
const value = directoryArgument(part.text, powershell, cwd, shell)
if (value) directories.push(value)
path = false
continue
}
if (part.type === "command_parameter") {
path = POWERSHELL_PATH_FLAGS.has(part.text.toLowerCase())
continue
}
const value = directoryArgument(part.text, powershell, cwd, shell)
if (value) directories.push(value)
}
return directories
}
function directoryArgument(value: string, powershell: boolean, cwd: string, shell: string) {
const quote = value[0]
const text = (quote === '"' || quote === "'") && value.at(-1) === quote ? value.slice(1, -1) : value
if (!powershell) return expandKnownDirectory(text)
// PowerShell exposes environment variables through $env:NAME and provides these
// automatic directory variables. Expand only values we can determine without executing code.
return expandKnownDirectory(
text
.replace(/\$\{env:([^}]+)\}/gi, (_, key: string) => environment(key) ?? "")
.replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, key: string) => environment(key) ?? "")
.replace(/\$(HOME|PWD|PSHOME)(?=$|[\\/])/gi, (_, key: string) => {
if (key.toUpperCase() === "HOME") return os.homedir()
if (key.toUpperCase() === "PWD") return cwd
return path.dirname(shell)
}),
)
}
function expandKnownDirectory(value: string) {
// Unknown shell expressions cannot be resolved safely during permission analysis.
if (value.includes("$") || value.includes("`") || value.startsWith("(")) return
if (value === "~") return os.homedir()
if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2))
return value
}
function environment(key: string) {
if (process.platform !== "win32") return process.env[key]
const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase())
return name ? process.env[name] : undefined
}
function prefix(tokens: string[]) {
for (let length = tokens.length; length > 0; length--) {
const arity = ARITY[tokens.slice(0, length).join(" ")]
if (arity !== undefined) return tokens.slice(0, arity)
}
return tokens.slice(0, 1)
}
function resolve(asset: string) {
if (asset.startsWith("file://")) return fileURLToPath(asset)
if (path.isAbsolute(asset)) return asset
return fileURLToPath(new URL(asset, import.meta.url))
}
const load = (() => {
let loading: ReturnType<typeof initialize> | undefined
return () => (loading ??= initialize())
})()
async function initialize() {
const { Parser, Language } = await import("web-tree-sitter")
await Parser.init({ locateFile: () => resolve(shellParserWasm.runtime) })
const [bashLanguage, psLanguage] = await Promise.all([
Language.load(resolve(shellParserWasm.bash)),
Language.load(resolve(shellParserWasm.powershell)),
])
const bash = new Parser()
bash.setLanguage(bashLanguage)
const ps = new Parser()
ps.setLanguage(psLanguage)
return { bash, ps }
}
@@ -1,8 +0,0 @@
// @ts-ignore Bun embeds static file imports when compiling the CLI.
import runtime from "web-tree-sitter/tree-sitter.wasm" with { type: "file" }
// @ts-ignore Bun embeds static file imports when compiling the CLI.
import bash from "tree-sitter-bash/tree-sitter-bash.wasm" with { type: "file" }
// @ts-ignore Bun embeds static file imports when compiling the CLI.
import powershell from "tree-sitter-powershell/tree-sitter-powershell.wasm" with { type: "file" }
export const shellParserWasm = { runtime, bash, powershell }
@@ -1,12 +0,0 @@
import { createRequire } from "node:module"
const require = createRequire(import.meta.url)
export const shellParserWasm = {
runtime: process.env.OPENCODE_TREE_SITTER_WASM_PATH ?? require.resolve("web-tree-sitter/tree-sitter.wasm"),
bash:
process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH ?? require.resolve("tree-sitter-bash/tree-sitter-bash.wasm"),
powershell:
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH ??
require.resolve("tree-sitter-powershell/tree-sitter-powershell.wasm"),
}
+37 -32
View File
@@ -9,14 +9,12 @@ export * as EditTool from "./edit"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Bom } from "@opencode-ai/util/bom"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Schema } from "effect"
import { FileMutation } from "../../file-mutation"
import { Formatter } from "../../formatter"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { fileDiff } from "./file-diff"
export const name = "edit"
@@ -101,6 +99,7 @@ const findLineOccurrences = (content: string, search: string) => {
}
/** Deferred edit behavior and UX integrations remain visible at the model-facing seam. */
// TODO: Add formatter integration after formatter runtime exists.
// TODO: Publish watcher/file-edit events after watcher integration exists.
// TODO: Add snapshots / undo after design exists.
// TODO: Add LSP notification and diagnostics after LSP runtime exists.
@@ -110,7 +109,6 @@ export const Plugin = {
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const fs = yield* FSUtil.Service
const permission = yield* Permission.Service
@@ -153,6 +151,14 @@ export const Plugin = {
})
}
yield* permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
})
const info = yield* fs.stat(target.canonical).pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
@@ -161,8 +167,9 @@ export const Plugin = {
if (info.type === "Directory") {
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
}
const original = yield* Bom.readFile(fs, target.canonical)
const source = original.text
const bytes = yield* fs.readFile(target.canonical)
const bom = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
const source = new TextDecoder().decode(bom ? bytes.slice(3) : bytes)
const ending = source.includes(crlf) ? crlf : "\n"
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
@@ -176,26 +183,6 @@ export const Plugin = {
: findLineOccurrences(source, oldString)
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
const replacements = matches.length
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
.toReversed()
.reduce(
(content, match) =>
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
source,
)
const preview =
replacements > 0 && (replacements === 1 || input.replaceAll === true)
? fileDiff(target.resource, source, replaced)
: undefined
yield* permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
metadata: preview ? { files: [preview] } : undefined,
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
})
if (replacements === 0) {
return yield* new ToolFailure({
message: `Could not find oldString in ${input.path}. It must match exactly, including whitespace and indentation.`,
@@ -206,17 +193,35 @@ export const Plugin = {
message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`,
})
}
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
.toReversed()
.reduce(
(content, match) =>
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
source,
)
const counts = diffLines(source, replaced).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
)
const replacementBom = replaced.startsWith("\uFEFF")
const result = yield* files.write({
target,
content: Bom.join(replaced, original.bom || replacementBom),
content: `${bom || replacementBom ? "\uFEFF" : ""}${replacementBom ? replaced.slice(1) : replaced}`,
})
const bom = original.bom || replacementBom
const formatted = (yield* formatter.file(target.canonical))
? yield* Bom.syncFile(fs, target.canonical, bom)
: (yield* Bom.readFile(fs, target.canonical)).text
return {
files: [fileDiff(result.resource, source, formatted)],
files: [
{
file: result.resource,
patch: createTwoFilesPatch(result.resource, result.resource, source, replaced),
status: "modified" as const,
...counts,
},
],
replacements,
} satisfies Output
}).pipe(
@@ -1,23 +0,0 @@
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
export function fileDiff(
file: string,
before: string,
after: string,
status: typeof FileDiff.Info.Type.status = "modified",
): typeof FileDiff.Info.Type {
const counts = diffLines(before, after).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
)
return {
file,
patch: createTwoFilesPatch(file, file, before, after),
status,
...counts,
}
}
+21 -47
View File
@@ -7,9 +7,7 @@ import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Schema } from "effect"
import { PlatformError } from "effect/PlatformError"
import path from "path"
import { Bom } from "@opencode-ai/util/bom"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Formatter } from "../../formatter"
import { Location } from "../../location"
import { Patch } from "@opencode-ai/util/patch"
import { Permission } from "../../permission"
@@ -70,7 +68,6 @@ export const Plugin = {
id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
const fs = yield* FSUtil.Service
const formatter = yield* Formatter.Service
const location = yield* Location.Service
const permission = yield* Permission.Service
@@ -132,16 +129,15 @@ export const Plugin = {
...hunk,
target,
before: "",
after: Bom.split(
hunk.contents.endsWith("\n") || hunk.contents === ""
? hunk.contents
: `${hunk.contents}\n`,
).text,
after: (hunk.contents.endsWith("\n") || hunk.contents === ""
? hunk.contents
: `${hunk.contents}\n`
).replace(/^\uFEFF/, ""),
})
return
}
if (hunk.type === "delete") {
const content = yield* Bom.readFile(fs, target.canonical).pipe(
const content = yield* fs.readFile(target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
@@ -149,7 +145,8 @@ export const Plugin = {
}),
),
)
prepared.push({ ...hunk, target, before: content.text, after: "" })
const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(content)
prepared.push({ ...hunk, target, before: original.replace(/^\uFEFF/, ""), after: "" })
return
}
const previous = updates.get(target.canonical)
@@ -169,17 +166,18 @@ export const Plugin = {
message: `patch verification failed: Failed to read file to update ${target.canonical}: path is a directory`,
})
}
const content = yield* Bom.readFile(fs, target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
}),
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(
yield* fs.readFile(target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
}),
),
),
)
return Bom.join(content.text, content.bom)
}))
const before = Bom.split(original).text
const before = original.replace(/^\uFEFF/, "")
const update = yield* Effect.try({
try: () => Patch.derive(hunk.path, hunk.chunks, original),
catch: (error) =>
@@ -219,7 +217,7 @@ export const Plugin = {
)
}
const patchFiles = prepared.map((change) => patchFile(change))
const patchFiles = prepared.map(patchFile)
yield* permission.assert({
action: "edit",
resources: [...new Set(targets.map((target) => target.resource))],
@@ -297,31 +295,7 @@ export const Plugin = {
}),
{ discard: true },
)
const formatted = new Map<string, string>()
yield* Effect.forEach(
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
(target) =>
Effect.gen(function* () {
const current = yield* Bom.readFile(fs, target).pipe(
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
)
formatted.set(
target,
(yield* formatter.file(target))
? yield* Bom.syncFile(fs, target, current.bom).pipe(
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
)
: current.text,
)
}),
{ discard: true },
)
const files = yield* Effect.forEach(prepared, (change) => {
if (change.type === "delete") return Effect.succeed(patchFile(change))
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
return Effect.succeed(patchFile(change, formatted.get(target.canonical)))
})
return { applied, files }
return { applied, files: patchFiles }
}).pipe(
Effect.map((output) => ({
output,
@@ -363,15 +337,15 @@ function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error)
}
function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
const patch = trimDiff(
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, after),
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, change.after),
)
const counts =
change.type === "delete"
? { additions: 0, deletions: change.before.split("\n").length }
: diffLines(change.before, after).reduce(
: diffLines(change.before, change.after).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
+45 -51
View File
@@ -1,6 +1,5 @@
export * as ShellTool from "./shell"
import path from "path"
import { ToolFailure } from "@opencode-ai/ai"
import type { Content } from "@opencode-ai/schema/tool"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
@@ -12,7 +11,6 @@ import { PluginRuntime } from "../../plugin/runtime"
import { NonNegativeInt } from "../../schema"
import { SessionSchema } from "../../session/schema"
import { Shell } from "../../shell"
import { ShellParse } from "../../shell/parse"
export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
@@ -77,6 +75,18 @@ const modelOutput = (output: Output): string | undefined => {
return `Command exited with code ${output.exit}.`
}
/**
* Minimal core shell boundary. Keep parity debt visible without pulling the
* legacy shell runtime into core.
*/
// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction.
// TODO: Port BashArity reusable command-prefix approvals.
// TODO: Add plugin shell.env environment augmentation once plugin hooks exist.
// TODO: Persist job status and define restart recovery before exposing remote observation.
// TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined.
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
// TODO: Revisit binary output handling if stdout/stderr decoding is text-only.
export const Plugin = {
id: "opencode.tool.shell",
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
@@ -136,50 +146,34 @@ export const Plugin = {
messageID: context.messageID,
callID: context.callID,
}
const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
action: name,
resources: [input.command],
save: [input.command],
sessionID: context.sessionID,
agent: context.agent,
source,
})
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
let finalTimeout = timeout
const info = yield* shell.create(
{
command: input.command,
cwd: input.workdir,
timeout,
metadata: { sessionID: context.sessionID },
},
(invocation) =>
Effect.gen(function* () {
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.canonical)
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
mutation.resolve({ path: path.resolve(target.canonical, directory), kind: "directory" }),
)
invocation.cwd = target.canonical
finalTimeout = invocation.timeout
const external = [target, ...directories]
.map((item) => item.externalDirectory)
.filter((item) => item !== undefined)
.filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index)
if (external.length > 0)
yield* permission.assert({
action: "external_directory",
resources: external.map((item) => item.resource),
save: external.map((item) => item.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
if (parsed.commands.length > 0)
yield* permission.assert({
action: name,
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.map((command) => command.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
}),
)
const info = yield* shell.create({
command: input.command,
cwd: target.canonical,
timeout,
metadata: { sessionID: context.sessionID },
})
yield* context.progress({ shellID: info.id })
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
@@ -198,20 +192,20 @@ export const Plugin = {
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
const final = yield* shell.wait(info.id)
const capture = yield* captureShell()
// `exit` is optionalKey in the Output schema; a present-but-undefined key
// fails output encoding, so omit it when the process has no exit code.
if (final.status === "timeout") {
return {
...(final.exit !== undefined ? { exit: final.exit } : {}),
output: `${capture.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: capture.truncated,
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: false,
timeout: true,
status: "completed" as const,
}
}
const capture = yield* captureShell()
return {
...(final.exit !== undefined ? { exit: final.exit } : {}),
output: capture.output,
@@ -229,14 +223,14 @@ export const Plugin = {
const job = yield* runtime.job.start({
id: context.callID,
type: name,
title: info.command,
title: input.command,
metadata: { sessionID: context.sessionID, shellID: info.id },
run,
})
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.callID, info.command)
yield* notifyWhenDone(context.sessionID, context.callID, input.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
@@ -250,7 +244,7 @@ export const Plugin = {
)
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.callID, info.command)
yield* notifyWhenDone(context.sessionID, context.callID, input.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
+5 -10
View File
@@ -13,19 +13,15 @@ export const name = "subagent"
const NO_TEXT = "Subagent completed without a text response."
const backgroundStarted = (sessionID: SessionSchema.ID) =>
[
`The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes.`,
"DO NOT sleep, poll for progress, ask the subagent for status, or duplicate this subagent's work; avoid working with the same files or topics it is using.",
"Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.",
].join("\n")
`The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress.`
export const Input = Schema.Struct({
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }),
agent: Schema.String.annotate({ description: "The configured agent to run as the subagent" }),
description: Schema.String.annotate({ description: "A short description of the subagent's task" }),
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
background: Schema.optionalKey(Schema.Boolean).annotate({
description:
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress.",
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.",
}),
})
@@ -35,8 +31,7 @@ export const Output = Schema.Struct({
output: Schema.String,
})
export const description = [
"Spawns an agent in a child session to work on the specified task.",
"Include all relevant context and instructions in the prompt because the child starts with fresh context.",
"Spawn a subagent: a child session running a configured agent with fresh context.",
"Foreground (default) runs the subagent to completion and returns its final response.",
"Background mode (background=true) launches it asynchronously and returns immediately; you are notified when it finishes.",
"Use background only for independent work that can run while you continue elsewhere.",
+5 -23
View File
@@ -9,20 +9,17 @@ export * as WriteTool from "./write"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { Bom } from "@opencode-ai/util/bom"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { FileMutation } from "../../file-mutation"
import { Formatter } from "../../formatter"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { fileDiff } from "./file-diff"
export const name = "write"
// TODO: Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.
export const Input = Schema.Struct({
path: Schema.String.annotate({
description: "Path to the file to write to",
description:
"File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval.",
}),
content: Schema.String.annotate({ description: "Content to write to the file" }),
})
@@ -39,6 +36,7 @@ export const toModelOutput = (output: Output) =>
`${output.existed ? "Wrote" : "Created"} file successfully: ${output.resource}`
/** Deferred write UX integrations remain visible at the model-facing seam. */
// TODO: Add formatter integration after formatter runtime exists.
// TODO: Publish watcher/file-edit events after watcher integration exists.
// TODO: Add snapshots / undo after design exists.
// TODO: Add LSP notification and diagnostics after LSP runtime exists.
@@ -48,8 +46,6 @@ export const Plugin = {
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const fs = yield* FSUtil.Service
const permission = yield* Permission.Service
yield* ctx.tool
@@ -59,7 +55,7 @@ export const Plugin = {
name,
options: { codemode: false, permission: "edit" },
description:
"Writes a file to the local filesystem, overwriting if one exists.\n\nMissing parent directories are created automatically.\n\nUse this tool to create new files or overwrite existing files. For partial changes, use the edit tool instead.",
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
input: Input,
output: Output,
execute: (input, context) =>
@@ -78,29 +74,15 @@ export const Plugin = {
agent: context.agent,
source,
})
const current = yield* Bom.readFile(fs, target.canonical).pipe(
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
)
const next = Bom.split(input.content)
const preview = fileDiff(
target.resource,
current?.text ?? "",
next.text,
current ? "modified" : "added",
)
yield* permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
metadata: { files: [preview] },
sessionID: context.sessionID,
agent: context.agent,
source,
})
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
const bom = (yield* Bom.readFile(fs, target.canonical)).bom
if (yield* formatter.file(target.canonical)) yield* Bom.syncFile(fs, target.canonical, bom)
return result
return yield* files.writeTextPreservingBom({ target, content: input.content })
}).pipe(
Effect.map((output) => ({ output, content: toModelOutput(output) })),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
-2
View File
@@ -16,7 +16,6 @@ describe("CodeMode", () => {
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.String,
options: { pinned: true },
execute: ({ text }) => Effect.succeed({ output: text }),
}),
)
@@ -28,7 +27,6 @@ describe("CodeMode", () => {
path: "echo",
description: "Echo text",
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
pinned: true,
},
])
}).pipe(
+1 -26
View File
@@ -2,11 +2,10 @@ import { describe, expect, test } from "bun:test"
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
const entry = (path: string, description: string, signature?: string, pinned = false): CodeModeCatalog.Entry => ({
const entry = (path: string, description: string, signature?: string): CodeModeCatalog.Entry => ({
path,
description,
signature: signature ?? `tools.${path}(input: {\n q: string,\n}): Promise<string>`,
pinned,
})
const lookup = entry(
@@ -47,30 +46,6 @@ describe("CodeModeCatalog.summarize", () => {
expect(catalog.namespaces.every((namespace) => namespace.entries.length === 0)).toBe(true)
})
test("always retains pinned tools beyond the inline budget", () => {
const pinned = [
entry("alpha.first", "First", undefined, true),
entry("beta.second", "Second", undefined, true),
]
const catalog = CodeModeCatalog.summarize([...pinned, entry("alpha.unpinned", "Unpinned")], 0)
expect(catalog.shown).toBe(2)
expect(catalog.namespaces.flatMap((namespace) => namespace.entries.map((item) => item.path))).toEqual([
"alpha.first",
"beta.second",
])
})
test("spends the budget remaining after pinned tools on unpinned tools", () => {
const pinned = entry("alpha.pinned", "Pinned", undefined, true)
const unpinned = entry("beta.unpinned", "Unpinned")
const pinCost = Math.round(` - ${pinned.signature} // Pinned`.length / 4)
const unpinnedCost = Math.round(` - ${unpinned.signature} // Unpinned`.length / 4)
expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost).shown).toBe(2)
expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost - 1).shown).toBe(1)
})
test("retains only the rendered portion of inline descriptions", () => {
const catalog = CodeModeCatalog.summarize([entry("alpha.one", `Summary\n${"detail".repeat(10_000)}`)])
expect(catalog.namespaces[0]?.entries[0]?.line).toEndWith("// Summary")
@@ -56,7 +56,7 @@ describe("node build", () => {
Location.Service.of({
directory: ref.directory,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: service.directory, canonical: service.directory },
project: { id: Project.ID.global, directory: service.directory },
}),
),
{ idleTimeToLive: "1 minute" },
@@ -79,7 +79,7 @@ describe("node build", () => {
return Project.Service.of({
list: () => Effect.succeed([]),
directories: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
commit: () => Effect.void,
})
}),
+1 -2
View File
@@ -5,11 +5,10 @@ import { Effect, Layer } from "effect"
import { tmpdir } from "./tmpdir"
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
const directory = input.projectDirectory ?? ref.directory
return {
directory: ref.directory,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory, canonical: directory },
project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory },
vcs: input.vcs,
} satisfies Location.Interface
}
-199
View File
@@ -1,199 +0,0 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer, Schema, Stream } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Npm } from "@opencode-ai/util/npm"
import { Config } from "../src/config"
import { Formatter } from "../src/formatter"
import { Location } from "../src/location"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
type ConfigInput = typeof Config.Info.Encoded
function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) {
const entries =
configured === undefined
? []
: [
new Config.Document({
type: "document",
info: Schema.decodeUnknownSync(Config.Info)({ formatter: configured }),
}),
]
return AppNodeBuilder.build(Formatter.node, [
[
Config.node,
Layer.succeed(
Config.Service,
Config.Service.of({
entries: () => Effect.succeed(entries),
changes: () => Stream.empty,
}),
),
],
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
),
],
[Npm.node, Layer.mock(Npm.Service, { which: () => Effect.succeed(undefined) })],
])
}
function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => body(tmp.path),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
}
describe("Formatter", () => {
it.live("status() returns empty list when no formatters are configured", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) => formatter.status()).pipe(Effect.provide(formatterLayer(directory))),
),
)
it.live("status() returns built-in formatters when formatter is true", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) =>
Effect.gen(function* () {
const statuses = yield* formatter.status()
const gofmt = statuses.find((item) => item.name === "gofmt")
expect(gofmt).toBeDefined()
expect(gofmt?.extensions).toContain(".go")
}),
).pipe(Effect.provide(formatterLayer(directory, true))),
),
)
it.live("status() keeps built-in formatters when config object is provided", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) =>
Effect.gen(function* () {
const statuses = yield* formatter.status()
expect(statuses.find((item) => item.name === "gofmt")?.extensions).toContain(".go")
expect(statuses.find((item) => item.name === "mix")).toBeDefined()
}),
).pipe(Effect.provide(formatterLayer(directory, { gofmt: {} }))),
),
)
it.live("status() excludes formatters marked as disabled in config", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) =>
Effect.gen(function* () {
const statuses = yield* formatter.status()
expect(statuses.find((item) => item.name === "gofmt")).toBeUndefined()
expect(statuses.find((item) => item.name === "mix")).toBeDefined()
}),
).pipe(Effect.provide(formatterLayer(directory, { gofmt: { disabled: true } }))),
),
)
it.live("service initializes without error", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) => formatter.init()).pipe(Effect.provide(formatterLayer(directory))),
),
)
it.live("file() returns false when no formatter runs", () =>
withTemp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.txt")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
}).pipe(Effect.provide(formatterLayer(directory, false))),
),
)
it.live("status() initializes formatter state per directory", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([off, on]) =>
Effect.gen(function* () {
const disabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
Effect.provide(formatterLayer(off.path, false)),
)
const enabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
Effect.provide(formatterLayer(on.path, true)),
)
expect(disabled).toEqual([])
expect(enabled.find((item) => item.name === "gofmt")).toBeDefined()
}),
(directories) =>
Effect.promise(() => Promise.all(directories.map((tmp) => tmp[Symbol.asyncDispose]())).then(() => undefined)),
),
)
it.live("stops after the first matching formatter succeeds", () =>
withTemp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.seq")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA")
}).pipe(
Effect.provide(
formatterLayer(directory, {
first: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')",
"$FILE",
],
extensions: [".seq"],
},
second: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
"$FILE",
],
extensions: [".seq"],
},
}),
),
),
),
)
it.live("tries the next matching formatter when the first fails", () =>
withTemp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.fallback")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB")
}).pipe(
Effect.provide(
formatterLayer(directory, {
first: {
command: [process.execPath, "-e", "process.exit(1)", "$FILE"],
extensions: [".fallback"],
},
second: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
"$FILE",
],
extensions: [".fallback"],
},
}),
),
),
),
)
})
-2
View File
@@ -18,7 +18,6 @@ const projectLayer = Layer.succeed(
Effect.succeed({
id: Project.ID.make("project"),
directory: AbsolutePath.make("/repo"),
canonical: AbsolutePath.make("/main/repo"),
vcs: { type: "git", store: AbsolutePath.make("/repo/.git") },
}),
commit: () => Effect.void,
@@ -35,7 +34,6 @@ describe("Location", () => {
expect(location.workspaceID).toBe(workspaceID)
expect(location.project.id).toBe(Project.ID.make("project"))
expect(location.project.directory).toBe(AbsolutePath.make("/repo"))
expect(location.project.canonical).toBe(AbsolutePath.make("/main/repo"))
expect(location.vcs).toEqual({
type: "git",
store: AbsolutePath.make("/repo/.git"),
+21
View File
@@ -11,6 +11,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Job } from "@opencode-ai/core/job"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
@@ -87,6 +88,11 @@ describe("MoveSession", () => {
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const sessionID = Session.ID.make("ses_move")
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
@@ -138,6 +144,11 @@ describe("MoveSession", () => {
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const sessionID = Session.ID.make("ses_move_nested")
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
@@ -193,6 +204,11 @@ describe("MoveSession", () => {
const destinationProjectID = (yield* Project.Service.use((service) => service.resolve(destination))).id
const sessionID = Session.ID.make("ses_move_project")
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
@@ -252,6 +268,11 @@ describe("MoveSession", () => {
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const sessionID = Session.ID.make("ses_move_nested_checkout")
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
-21
View File
@@ -42,25 +42,4 @@ describe("PluginHooks", () => {
expect(event.messages).toEqual([Message.user("changed")])
}),
)
it.effect("mutates shell creation input", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
yield* hooks.register("shell", "create.before", (event) =>
Effect.sync(() => {
event.command = "echo changed"
}),
)
const event = {
command: "echo original",
cwd: "/tmp",
timeout: 0,
shell: "/bin/sh",
env: {},
}
expect(yield* hooks.trigger("shell", "create.before", event)).toBe(event)
expect(event.command).toBe("echo changed")
}),
)
})
+3 -18
View File
@@ -86,9 +86,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
transform: () => Effect.die("unused skill.transform"),
reload: () => Effect.die("unused skill.reload"),
},
shell: overrides.shell ?? {
hook: () => Effect.die("unused shell.hook"),
},
tool: overrides.tool ?? {
transform: () => Effect.die("unused tool.transform"),
hook: () => Effect.die("unused tool.hook"),
@@ -121,11 +118,7 @@ export function agentHost(agent: Agent.Interface): Plugin.Context["agent"] {
? Effect.succeed({
location: new Location.Info({
directory: AbsolutePath.make("/"),
project: {
id: Project.ID.make("test"),
directory: AbsolutePath.make("/"),
canonical: AbsolutePath.make("/"),
},
project: { id: Project.ID.make("test"), directory: AbsolutePath.make("/") },
}),
data: agentInfo(value),
})
@@ -167,11 +160,7 @@ export function catalogHost(catalog: Catalog.Interface): Plugin.Context["catalog
Effect.map((data) => ({
location: new Location.Info({
directory: AbsolutePath.make("/"),
project: {
id: Project.ID.make("test"),
directory: AbsolutePath.make("/"),
canonical: AbsolutePath.make("/"),
},
project: { id: Project.ID.make("test"), directory: AbsolutePath.make("/") },
}),
data: data.map(modelInfo),
})),
@@ -365,11 +354,7 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
export function webSearchHost(websearch: WebSearch.Interface): Plugin.Context["websearch"] {
const location = Location.Info.make({
directory: AbsolutePath.make("/tmp/websearch-test"),
project: {
id: Project.ID.make("websearch-test"),
directory: AbsolutePath.make("/tmp/websearch-test"),
canonical: AbsolutePath.make("/tmp/websearch-test"),
},
project: { id: Project.ID.make("websearch-test"), directory: AbsolutePath.make("/tmp/websearch-test") },
})
return {
providers: () => websearch.providers().pipe(Effect.map((data) => ({ location, data }))),
+3 -38
View File
@@ -47,13 +47,13 @@ describe("Project.list", () => {
expect(yield* project.list()).toEqual([
{
id: Project.ID.make("newer"),
canonical: abs("/newer"),
worktree: abs("/newer"),
time: { created: 2, updated: 2, initialized: 3 },
sandboxes: [],
},
{
id: Project.ID.make("older"),
canonical: abs("/older"),
worktree: abs("/older"),
vcs: "git",
name: "Older",
icon: { color: "#000000" },
@@ -105,7 +105,6 @@ describe("Project.resolve", () => {
expect(result.id).toBe(Project.ID.make("global"))
expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root)
expect(result.canonical).toBe(result.directory)
expect(result.previous).toBeUndefined()
expect(result.vcs).toBeUndefined()
}),
@@ -124,7 +123,6 @@ describe("Project.resolve", () => {
expect(result.id).toBe(Project.ID.make("global"))
expect(result.directory).toBe(yield* real(tmp.path))
expect(result.canonical).toBe(result.directory)
expect(result.previous).toBeUndefined()
expect(result.vcs?.type).toBe("git")
}),
@@ -329,46 +327,13 @@ describe("Project.resolve", () => {
yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
yield* Effect.promise(() => $`git worktree add ${worktree} -b test-${Date.now()}`.cwd(tmp.path).quiet())
const project = yield* Project.Service
const db = (yield* Database.Service).db
const id = remoteID("github.com/owner/repo")
yield* db
.insert(ProjectTable)
.values({
id,
worktree: abs("/stale-worktree"),
vcs: "hg",
name: "Preserved name",
icon_color: "#123456",
commands: { start: "bun dev" },
sandboxes: [abs("/preserved-sandbox")],
time_created: 1,
time_updated: 1,
time_initialized: 2,
})
.run()
const result = yield* project.resolve(abs(worktree))
expect(result.directory).toBe(yield* real(worktree))
expect(result.canonical).toBe(yield* real(tmp.path))
expect(result.previous).toBe(Project.ID.make("old-id"))
expect(result.id).toBe(id)
expect(result.id).toBe(remoteID("github.com/owner/repo"))
expect(result.vcs?.type).toBe("git")
expect((yield* project.list()).find((item) => item.id === id)).toMatchObject({
canonical: yield* real(tmp.path),
vcs: "git",
name: "Preserved name",
icon: { color: "#123456" },
commands: { start: "bun dev" },
sandboxes: [abs("/preserved-sandbox")],
time: { created: 1, initialized: 2 },
})
expect(
(yield* project.directories({ projectID: id })).toSorted((a, b) => a.directory.localeCompare(b.directory)),
).toEqual([
{ directory: yield* real(tmp.path) },
{ directory: yield* real(worktree), strategy: "git_worktree" },
])
}),
)
})
+1 -1
View File
@@ -34,7 +34,7 @@ const projects = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
+2 -22
View File
@@ -14,7 +14,7 @@ import { Model } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
@@ -32,7 +32,7 @@ const projects = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
@@ -184,26 +184,6 @@ describe("Session.create", () => {
}),
)
it.effect("filters project sessions by subpath", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const { db } = yield* Database.Service
const root = yield* session.create({ location, title: "root" })
const nested = yield* session.create({ location, title: "nested" })
yield* db.update(SessionTable).set({ path: "packages/tui" }).where(eq(SessionTable.id, nested.id)).run()
const page = yield* session.list({
project: Project.ID.global,
subpath: RelativePath.make("packages/tui"),
parentID: null,
})
expect(page.data.map((item) => item.id)).toEqual([nested.id])
expect(page.data.map((item) => item.id)).not.toContain(root.id)
}),
)
it.effect("forks a session by replaying a durable fork event into copied projected rows", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -15,6 +15,7 @@ import { Location } from "@opencode-ai/core/location"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
import { ID } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Provider } from "@opencode-ai/core/provider"
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -190,6 +191,11 @@ const setup = Effect.gen(function* () {
agent.mode = "primary"
}),
)
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
@@ -54,7 +54,7 @@ const projects = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
+1 -1
View File
@@ -21,7 +21,7 @@ const projects = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
+1 -1
View File
@@ -17,7 +17,7 @@ const projects = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
@@ -67,7 +67,7 @@ const transform = (
) =>
service.transform((draft) =>
Object.entries(tools).forEach(([name, tool]) =>
draft.add({ ...tool, name, options: options ?? tool.options }),
draft.add({ ...tool, name, options: { ...tool.options, ...options } }),
),
)
+1 -1
View File
@@ -231,7 +231,7 @@ const permission = Layer.succeed(
const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string, Info>>, options?: Tool.Options) =>
registry.transform((draft) =>
Object.entries(tools).forEach(([name, tool]) =>
draft.add({ ...tool, name, options: options ?? tool.options }),
draft.add({ ...tool, name, options: { ...tool.options, ...options } }),
),
)
const echo = Layer.effectDiscard(
+1 -1
View File
@@ -20,7 +20,7 @@ import { testEffect } from "./lib/effect"
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const projects = Layer.mock(Project.Service, {
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
})
const skills = Layer.mock(Skill.Service, {
list: () =>
+1 -1
View File
@@ -17,7 +17,7 @@ import { testEffect } from "./lib/effect"
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const awaited: Session.ID[] = []
const projects = Layer.mock(Project.Service, {
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
})
const execution = Layer.mock(SessionExecution.Service, {
awaitIdle: (sessionID) => Effect.sync(() => awaited.push(sessionID)),
-55
View File
@@ -1,55 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import os from "os"
import path from "path"
import { ShellParse } from "@opencode-ai/core/shell/parse"
describe("ShellParse", () => {
test("splits bash commands and derives reusable prefixes", async () => {
const result = await Effect.runPromise(
ShellParse.scan("git status && npm run test -- --watch", "/bin/bash", "/workspace"),
)
expect(result).toEqual({
commands: [
{ resource: "git status", save: "git status *" },
{ resource: "npm run test -- --watch", save: "npm run test *" },
],
directories: [],
})
})
test("splits PowerShell commands case-insensitively", async () => {
const result = await Effect.runPromise(
ShellParse.scan("Get-ChildItem; Write-Output 'done'", "C:\\Program Files\\PowerShell\\7\\pwsh.exe", "C:\\workspace"),
)
expect(result.commands).toEqual([
{ resource: "Get-ChildItem", save: "Get-ChildItem *" },
{ resource: "Write-Output 'done'", save: "Write-Output *" },
])
})
test("does not permission directory changes separately", async () => {
const result = await Effect.runPromise(ShellParse.scan("cd 'src dir' && git status", "/bin/bash", "/workspace"))
expect(result).toEqual({
commands: [{ resource: "git status", save: "git status *" }],
directories: ["src dir"],
})
})
test("extracts PowerShell directory parameters", async () => {
const result = await Effect.runPromise(
ShellParse.scan("Set-Location -LiteralPath '..\\outside'; Get-ChildItem", "pwsh", "C:\\workspace"),
)
expect(result.directories).toEqual(["..\\outside"])
})
test("expands deterministic directory variables", async () => {
const bash = await Effect.runPromise(ShellParse.scan("cd ~/src", "/bin/bash", "/workspace"))
expect(bash.directories).toEqual([path.join(os.homedir(), "src")])
const powershell = await Effect.runPromise(
ShellParse.scan('Set-Location "$PWD/src"; Set-Location $PSHOME', "/usr/local/bin/pwsh", "/workspace"),
)
expect(powershell.directories).toEqual(["/workspace/src", "/usr/local/bin"])
})
})
+4 -61
View File
@@ -5,7 +5,6 @@ import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
@@ -23,7 +22,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const editToolNode = makeLocationNode({
name: "test/edit-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
deps: [Tool.node, LocationMutation.node, FileMutation.node, FSUtil.node, Permission.node],
})
const sessionID = Session.ID.make("ses_edit_tool_test")
@@ -32,7 +31,6 @@ const writes: string[] = []
let reads = 0
let denyAction: string | undefined
let afterRead = (_target: string, _content: Uint8Array): Effect.Effect<void> => Effect.void
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
const permission = Layer.succeed(
Permission.Service,
@@ -59,17 +57,12 @@ const permission = Layer.succeed(
}),
)
const formatter = Layer.mock(Formatter.Service, {
file: (target) => formatFile(target),
})
const reset = () => {
assertions.length = 0
writes.length = 0
reads = 0
denyAction = undefined
afterRead = () => Effect.void
formatFile = () => Effect.succeed(false)
}
const filesystem = Layer.effect(
@@ -116,7 +109,6 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
[
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[Formatter.node, formatter],
[Permission.node, permission],
],
),
@@ -179,17 +171,6 @@ describe("EditTool", () => {
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
expect(assertions[0]?.metadata).toMatchObject({
files: [
{
file: "hello.txt",
status: "modified",
additions: 1,
deletions: 1,
patch: expect.stringContaining("-before\n+after"),
},
],
})
expect(writes).toEqual([yield* Effect.promise(() => fs.realpath(target))])
}),
),
@@ -200,39 +181,6 @@ describe("EditTool", () => {
),
)
it.live("returns the diff for final formatted content", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "formatted.txt")
formatFile = (file) =>
Effect.promise(async () => {
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace("after", "AFTER"))
return true
})
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const settled = yield* executeTool(
registry,
call({ path: "formatted.txt", oldString: "before", newString: "after" }),
)
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.output.files[0]?.patch).toContain("-before\n+AFTER")
expect(settled.metadata?.files?.[0]?.patch).toContain("-before\n+AFTER")
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("AFTER\n")
}),
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("accepts an absolute file path inside the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -354,7 +302,7 @@ describe("EditTool", () => {
error: { type: "permission.rejected", message: "Permission denied: edit" },
})
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(reads).toBe(1)
expect(reads).toBe(0)
expect(writes).toEqual([])
expect(yield* Effect.promise(() => fs.readFile(external, "utf8"))).toBe("before")
}),
@@ -365,7 +313,7 @@ describe("EditTool", () => {
),
)
it.live("denied edit does not disclose whether oldString matches", () =>
it.live("denied edit reads no target content and does not disclose whether oldString matches", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
@@ -391,7 +339,7 @@ describe("EditTool", () => {
})
expect(missing).toEqual(matching)
expect(assertions.map((input) => input.action)).toEqual(["edit", "edit"])
expect(reads).toBe(2)
expect(reads).toBe(0)
expect(writes).toEqual([])
}),
),
@@ -626,11 +574,6 @@ describe("EditTool", () => {
(tmp) => {
reset()
const target = path.join(tmp.path, "windows.txt")
formatFile = (file) =>
Effect.promise(async () => {
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace(/^\uFEFF/, ""))
return true
})
return Effect.promise(() => fs.writeFile(target, "\uFEFFbefore\r\nrest\r\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
+1 -36
View File
@@ -6,7 +6,6 @@ import { systemError } from "effect/PlatformError"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Formatter } from "@opencode-ai/core/formatter"
import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -22,7 +21,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const patchToolNode = makeLocationNode({
name: "test/patch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
deps: [Tool.node, FSUtil.node, Location.node, Permission.node],
})
const sessionID = Session.ID.make("ses_patch_tool_test")
@@ -34,7 +33,6 @@ let failWriteTarget: string | undefined
let readsBeforeEditApproval = 0
let editApproved = false
let afterEditApproval = (): Effect.Effect<void> => Effect.void
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
const permission = Layer.succeed(
Permission.Service,
@@ -65,10 +63,6 @@ const permission = Layer.succeed(
}),
)
const formatter = Layer.mock(Formatter.Service, {
file: (target) => formatFile(target),
})
const reset = () => {
assertions.length = 0
denyAction = undefined
@@ -78,7 +72,6 @@ const reset = () => {
readsBeforeEditApproval = 0
editApproved = false
afterEditApproval = () => Effect.void
formatFile = () => Effect.succeed(false)
}
const filesystem = Layer.effect(
@@ -142,7 +135,6 @@ const withTool = <A, E, R>(
AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[Formatter.node, formatter],
[Permission.node, permission],
]),
),
@@ -262,28 +254,6 @@ describe("PatchTool", () => {
),
)
it.live("returns file diffs for final formatted content", () =>
withTempTool((directory, registry) => {
const target = path.join(directory, "formatted.txt")
formatFile = (file) =>
Effect.promise(async () => {
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace("created", "FORMATTED"))
return true
})
return Effect.gen(function* () {
const settled = yield* executeTool(
registry,
call("*** Begin Patch\n*** Add File: formatted.txt\n+created\n*** End Patch"),
)
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.output.files[0]?.patch).toContain("+FORMATTED")
expect(settled.metadata?.files?.[0]?.patch).toContain("+FORMATTED")
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMATTED\n")
})
}),
)
it.live("moves and updates a file", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -582,11 +552,6 @@ describe("PatchTool", () => {
const bom = "\uFEFF"
const target = path.join(directory, "example.cs")
yield* Effect.promise(() => fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`))
formatFile = (file) =>
Effect.promise(async () => {
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace(/^\uFEFF/, ""))
return true
})
const settled = yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"),
+9 -88
View File
@@ -1,6 +1,5 @@
import fs from "fs/promises"
import { realpathSync } from "node:fs"
import os from "os"
import path from "path"
import { describe, expect } from "bun:test"
import { DateTime, Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
@@ -158,9 +157,6 @@ const mixedOutputCommand = isWindows
? "[Console]::Out.Write('stdout'); Start-Sleep -Milliseconds 50; [Console]::Error.Write('stderr'); Start-Sleep -Milliseconds 100"
: "printf stdout; sleep 0.05; printf stderr >&2"
const idleCommand = isWindows ? "Start-Sleep -Seconds 60" : "sleep 60"
const timeoutOutputCommand = isWindows
? "[Console]::Out.Write('before timeout'); Start-Sleep -Seconds 60"
: "printf 'before timeout'; sleep 60"
const steadyProgressCommand = isWindows
? "[Console]::Out.Write('steady'); Start-Sleep -Milliseconds 3400"
: "printf steady; sleep 3.4"
@@ -223,10 +219,7 @@ describe("ShellTool", () => {
type: "text",
text: expect.stringContaining("Command exited with code 0."),
})
expect(assertions).toMatchObject([
{ sessionID, action: "shell", resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand] },
])
expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"])
expect(assertions).toMatchObject([{ sessionID, action: "shell", resources: [helloCommand] }])
}),
)
},
@@ -257,29 +250,6 @@ describe("ShellTool", () => {
),
)
it.live("permissions compound commands separately", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions).toHaveLength(1)
expect(assertions[0]).toMatchObject({
resources: ["printf one", "printf two"],
save: ["printf *", "printf *"],
})
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("captures stderr-only and mixed stdout/stderr output", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -352,51 +322,6 @@ describe("ShellTool", () => {
),
)
it.live("approves an external directory used by a directory-change command", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
const command = isWindows
? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path`
: `cd '${outside.path}' && pwd`
return withSession(active.path, (registry) => executeTool(registry, call({ command }, "call-external-cd"))).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
expect(assertions[0]).toMatchObject({
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
})
}),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("approves an expanded external home directory", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const command = isWindows ? "Set-Location $HOME; (Get-Location).Path" : "cd ~ && pwd"
return withSession(tmp.path, (registry) => executeTool(registry, call({ command }, "call-external-home"))).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
expect(assertions[0]?.resources[0]).toStartWith(os.homedir().replaceAll("\\", "/"))
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("does not execute after external-directory or shell denial", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
@@ -536,18 +461,14 @@ describe("ShellTool", () => {
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 500 : 50 })),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.metadata).toMatchObject({ timeout: true, truncated: false })
expect(settled.content?.[0]).toMatchObject({
type: "text",
text: expect.stringContaining("before timeout"),
})
expect(settled.content?.[1]).toMatchObject({
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: idleCommand, timeout: 50 })),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.metadata).toMatchObject({ timeout: true, truncated: false })
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command timed out"),
})
+1 -60
View File
@@ -3,7 +3,6 @@ import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -23,13 +22,12 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const writeToolNode = makeLocationNode({
name: "test/write-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
deps: [Tool.node, LocationMutation.node, FileMutation.node, Permission.node],
})
const sessionID = Session.ID.make("ses_write_tool_test")
const assertions: Permission.AssertInput[] = []
const writes: string[] = []
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
let denyAction: string | undefined
const permission = Layer.succeed(
@@ -57,14 +55,9 @@ const permission = Layer.succeed(
}),
)
const formatter = Layer.mock(Formatter.Service, {
file: (target) => formatFile(target),
})
const reset = () => {
assertions.length = 0
writes.length = 0
formatFile = () => Effect.succeed(false)
denyAction = undefined
}
@@ -100,7 +93,6 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
[
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[Formatter.node, formatter],
[Permission.node, permission],
],
),
@@ -140,17 +132,6 @@ describe("WriteTool", () => {
"created",
)
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }])
expect(assertions[0]?.metadata).toMatchObject({
files: [
{
file: "src/new.txt",
status: "added",
additions: 1,
deletions: 0,
patch: expect.stringContaining("+created"),
},
],
})
expect(writes).toEqual([path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt")])
}),
)
@@ -159,30 +140,6 @@ describe("WriteTool", () => {
),
)
it.live("formats the committed file", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "formatted.txt")
formatFile = (file) =>
Effect.promise(async () => {
await fs.writeFile(file, (await fs.readFile(file, "utf8")).toUpperCase())
return true
})
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(yield* executeTool(registry, call({ path: "formatted.txt", content: "format me" }))).toMatchObject({
status: "completed",
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME")
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("overwrites a relative existing file and reports that it wrote the file", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -198,17 +155,6 @@ describe("WriteTool", () => {
if (settled.status !== "completed") return
expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }])
expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true })
expect(assertions[0]?.metadata).toMatchObject({
files: [
{
file: "existing.txt",
status: "modified",
additions: 1,
deletions: 1,
patch: expect.stringMatching(/-before[\s\S]*\+after/),
},
],
})
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe(
"after",
)
@@ -228,11 +174,6 @@ describe("WriteTool", () => {
reset()
const preserved = path.join(tmp.path, "preserved.txt")
const deduplicated = path.join(tmp.path, "deduplicated.txt")
formatFile = (target) =>
Effect.promise(async () => {
await fs.writeFile(target, `\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`)
return true
})
return Effect.promise(() =>
Promise.all([fs.writeFile(preserved, "\uFEFFbefore"), fs.writeFile(deduplicated, "\uFEFFbefore")]),
).pipe(
-2
View File
@@ -10,7 +10,6 @@ import type { EventDomain } from "./event.js"
import type { IntegrationDomain } from "./integration.js"
import type { ReferenceDomain } from "./reference.js"
import type { SessionDomain } from "./session.js"
import type { ShellDomain } from "./shell.js"
import type { SkillDomain } from "./skill.js"
import type { ToolDomain } from "./tool.js"
import type { WebSearchDomain } from "./websearch.js"
@@ -27,7 +26,6 @@ export interface Context {
readonly plugin: PluginApi<unknown>
readonly reference: ReferenceDomain
readonly session: SessionDomain
readonly shell: ShellDomain
readonly skill: SkillDomain
readonly tool: ToolDomain
readonly websearch: WebSearchDomain
-17
View File
@@ -1,17 +0,0 @@
import type { Hooks } from "./registration.js"
export interface ShellCreateBefore {
command: string
cwd: string
timeout: number
shell: string
env: Record<string, string | undefined>
}
export interface ShellHooks {
readonly "create.before": ShellCreateBefore
}
export interface ShellDomain {
readonly hook: Hooks<ShellHooks>
}
-2
View File
@@ -9,7 +9,6 @@ import type { EventDomain } from "./event.js"
import type { IntegrationDomain } from "./integration.js"
import type { ReferenceDomain } from "./reference.js"
import type { SessionDomain } from "./session.js"
import type { ShellDomain } from "./shell.js"
import type { SkillDomain } from "./skill.js"
import type { ToolDomain } from "./tool.js"
import type { WebSearchDomain } from "./websearch.js"
@@ -26,7 +25,6 @@ export interface Context {
readonly plugin: PluginApi
readonly reference: ReferenceDomain
readonly session: SessionDomain
readonly shell: ShellDomain
readonly skill: SkillDomain
readonly tool: ToolDomain
readonly websearch: WebSearchDomain
-17
View File
@@ -1,17 +0,0 @@
import type { Hooks } from "./registration.js"
export interface ShellCreateBefore {
command: string
cwd: string
timeout: number
shell: string
env: Record<string, string | undefined>
}
export interface ShellHooks {
readonly "create.before": ShellCreateBefore
}
export interface ShellDomain {
readonly hook: Hooks<ShellHooks>
}
-24
View File
@@ -11,7 +11,6 @@ import type {
OpenCodeEvent,
PermissionSavedInfo,
PermissionRequest,
Project,
ProviderInfo,
ReferenceInfo,
SessionInfo,
@@ -78,10 +77,6 @@ export interface Data {
}
}
readonly project: {
list(): Project[]
get(projectID: string): Project | undefined
sync(): Promise<void>
invalidate(): void
readonly permission: {
list(projectID: string): PermissionSavedInfo[] | undefined
sync(projectID: string): Promise<void>
@@ -351,25 +346,6 @@ export interface UI {
navigate(destination: Destination): void
current(): Route
}
readonly tabs: {
/** Returns whether session tabs are enabled for this TUI. */
enabled(): boolean
/** Returns the currently open root-session tabs. Reactive when read in a Solid computation. */
list(): readonly {
readonly sessionID: string
readonly title?: string
readonly active: boolean
readonly busy: boolean
readonly attention: boolean
readonly unread?: "activity" | "error"
}[]
/** Opens (or focuses) a tab for a session, adding it when not already open. Returns false when tabs are disabled. */
open(sessionID: string): boolean
/** Focuses an already-open tab and returns false when it is not open. */
focus(sessionID: string): boolean
/** Closes an open tab, or the active tab when omitted, and returns false when no tab matched. */
close(sessionID?: string): boolean
}
readonly slot: <Name extends SlotName>(name: Name, render: Slot<Name>) => () => void
}
-1
View File
@@ -17,7 +17,6 @@ export class Info extends Schema.Class<Info>("Location.Info")({
project: Schema.Struct({
id: ProjectID,
directory: AbsolutePath,
canonical: AbsolutePath,
}),
}) {}
+1 -2
View File
@@ -12,7 +12,6 @@ export const Vcs = Schema.Literals(["git", "hg"]).annotate({ identifier: "Projec
export const Current = Schema.Struct({
id: ID,
directory: AbsolutePath,
canonical: AbsolutePath,
}).annotate({ identifier: "Project.Current" })
export interface Current extends Schema.Schema.Type<typeof Current> {}
export const Directory = Schema.Struct({
@@ -47,7 +46,7 @@ export interface Time extends Schema.Schema.Type<typeof Time> {}
export const Info = Schema.Struct({
id: ID,
canonical: AbsolutePath,
worktree: Schema.String,
vcs: optional(Vcs),
name: optional(Schema.String),
icon: optional(Icon),
+2 -13
View File
@@ -19,23 +19,12 @@ export interface Context {
readonly progress: (update: Metadata) => Effect.Effect<void>
}
interface BaseOptions {
export interface Options {
readonly namespace?: string
readonly codemode?: boolean
readonly permission?: string
}
export type Options = BaseOptions &
(
| {
readonly codemode?: true
readonly pinned?: boolean
}
| {
readonly codemode: boolean
readonly pinned?: never
}
)
export type ValueSchema<A = unknown> =
| Schema.Codec<A, any>
| (StandardSchemaV1<any, A> & StandardJSONSchemaV1<any, A>)
+1 -5
View File
@@ -9,11 +9,7 @@ export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handl
.handle("project.list", () => Project.Service.use((project) => project.list()))
.handle("project.current", () =>
Location.Service.use((location) =>
Effect.succeed({
id: location.project.id,
directory: location.project.directory,
canonical: location.project.canonical,
}),
Effect.succeed({ id: location.project.id, directory: location.project.directory }),
),
)
.handle("project.directories", (ctx) =>
+2 -21
View File
@@ -66,7 +66,6 @@ import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list"
import { DialogProject } from "./component/dialog-project"
import { SessionTabs } from "./component/session-tabs"
import { ThemeErrorToast } from "./component/theme-error-toast"
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
@@ -327,17 +326,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
>
<TuiStartupProvider
value={{
initialRoute: process.env.OPENCODE_STORY
? {
type: "plugin",
id: "opencode.storybook",
name: "storybook",
// OPENCODE_STORY=1 opens the index; any other value opens that story.
data:
process.env.OPENCODE_STORY === "1"
? undefined
: { story: process.env.OPENCODE_STORY },
}
initialRoute: process.env.OPENCODE_SCRAP
? { type: "plugin", id: "scrap", name: "scrap" }
: process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE)
: undefined,
@@ -649,15 +639,6 @@ function App(props: { pair?: DialogPairCredentials }) {
dialog.clear()
},
},
{
name: "project.switch",
title: "Switch project",
category: "Session",
slash: { name: "projects", aliases: ["project"] },
run: () => {
dialog.replace(() => <DialogProject />)
},
},
...Array.from({ length: 9 }, (_, i) => ({
name: `session.quick_switch.${i + 1}`,
title: `Switch to session in quick slot ${i + 1}`,
@@ -102,12 +102,12 @@ export function DialogIntegration(
title="Connect a service"
options={options()}
emptyView={
<box paddingLeft={4} paddingRight={4}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.subdued}>No integrations available</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.subdued}>No integrations found</text>
</box>
}
@@ -328,7 +328,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
options={options()}
emptyView={
showError() ? (
<box paddingLeft={4} paddingRight={4}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Could not load project directories
</text>
@@ -336,17 +336,17 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
<text fg={theme.text.subdued}>Close and reopen Move session to try again.</text>
</box>
) : directories.loading || loadedProject.loading ? (
<box paddingLeft={4} paddingRight={4}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.subdued}>Loading project directories</text>
</box>
) : (
<box paddingLeft={4} paddingRight={4}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.subdued}>No project directories available</text>
</box>
)
}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.subdued}>No project directories found</text>
</box>
}
@@ -1,67 +0,0 @@
import path from "path"
import { createMemo } from "solid-js"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useData } from "../context/data"
import { useRoute } from "../context/route"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "../context/runtime"
import { useLocation } from "../context/location"
import { useToast } from "../ui/toast"
export function DialogProject() {
const dialog = useDialog()
const data = useData()
const route = useRoute()
const paths = useTuiPaths()
const location = useLocation()
const toast = useToast()
data.project.invalidate()
void data.project.sync().catch(toast.error)
const current = () => location.current?.project
const options = createMemo(() => {
const seen = new Set<string>()
return data.project
.list()
.filter((project) => {
if (project.canonical === "/" || seen.has(project.canonical)) return false
seen.add(project.canonical)
return true
})
.toSorted((a, b) => {
if (a.id === current()?.id) return -1
if (b.id === current()?.id) return 1
return 0
})
.map((project) => ({
title: project.name ?? path.basename(project.canonical),
description: abbreviateHome(project.canonical, paths.home),
value: project.canonical,
category: project.id === current()?.id ? "Current" : "Projects",
}))
})
return (
<DialogSelect
title="Switch project"
placeholder="Search projects…"
options={options()}
current={current()?.canonical}
emptyView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text>No projects found</text>
</box>
}
onSelect={(option) => {
dialog.clear()
if (option.value === current()?.canonical) return
const target = { directory: option.value }
route.navigate({ type: "home", location: target })
location.set(target)
}}
/>
)
}
@@ -1,7 +1,6 @@
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import path from "path"
import type { SessionInfo } from "@opencode-ai/client"
import { TextAttributes } from "@opentui/core"
import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route"
@@ -33,69 +32,52 @@ export function DialogSessionList() {
const shortcuts = Keymap.useShortcuts()
const [search, setSearch] = createDebouncedSignal("", 150)
const [toDelete, setToDelete] = createSignal<string>()
const [allProjects, setAllProjects] = createSignal(false)
const [searchResults, { mutate: setSearchResults }] = createResource(
() => ({ query: search().trim(), allProjects: allProjects() }),
async ({ query, allProjects }) => {
try {
if (!data.location.info()) await data.location.sync()
const current = data.location.info()
if (!current) throw new Error("Location unavailable")
const response = await client.api.session.list({
...(allProjects
? {}
: {
project: current.project.id,
subpath: path.relative(current.project.directory, current.directory).replaceAll("\\", "/"),
}),
...(query ? { search: query } : {}),
limit: 50,
order: "desc",
parentID: null,
})
return { query, allProjects, sessions: response.data, error: undefined }
} catch (error) {
// A transient transport failure must degrade search, not crash the TUI
// through the root ErrorBoundary when the errored resource is read.
return { query, allProjects, sessions: [] as SessionInfo[], error }
}
},
)
const [searchResults] = createResource(search, async (query) => {
if (!query) return
try {
if (!data.location.info()) await data.location.sync()
const current = data.location.info()
if (!current) throw new Error("Location unavailable")
const response = await client.api.session.list({
project: current.project.id,
search: query,
limit: 50,
order: "desc",
parentID: null,
})
return { query, sessions: response.data, error: undefined }
} catch (error) {
// A transient transport failure must degrade search, not crash the TUI
// through the root ErrorBoundary when the errored resource is read.
return { query, sessions: [] as SessionInfo[], error }
}
})
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const localSessions = createMemo(() => {
const query = filter().trim().toLowerCase()
const current = data.location.info()
const sessions = data.session
.list()
.filter(
(session) =>
allProjects() ||
(session.projectID === current?.project.id && session.location.directory === current.directory),
)
const sessions = data.session.list()
if (!query) return sessions
return sessions.filter((session) => !session.parentID && session.title.toLowerCase().includes(query))
})
const sessions = createMemo(() => {
const query = filter().trim()
const query = filter()
const local = localSessions()
if (query !== search().trim() || searchResults.loading) return searchResults.latest?.sessions ?? local
if (!query) return local
if (query !== search() || searchResults.loading) return local
const result = searchResults()
if (result?.query !== query || result.allProjects !== allProjects() || result.error) return local
if (result?.query !== query || result.error) return local
return result.sessions
})
const searchState = createMemo(() => {
const query = filter().trim()
if (query !== search().trim() || searchResults.loading)
return { message: query ? "Searching sessions…" : "Loading sessions…", error: false }
const query = filter()
if (!query) return { message: "No sessions available", error: false }
if (query !== search() || searchResults.loading) return { message: "Searching sessions…", error: false }
const result = searchResults()
if (result?.query === query && result.error)
return {
message: query ? "Could not search sessions. Change the search to try again." : "Could not load sessions.",
error: true,
}
return { message: query ? "No sessions found" : "No sessions available", error: false }
return { message: "Could not search sessions. Change the search to try again.", error: true }
return { message: "No sessions found", error: false }
})
const quickSwitchHint = createMemo(() => {
@@ -109,13 +91,6 @@ export function DialogSessionList() {
const hint = quickSwitchHint()
return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : []
})
const currentProjectName = createMemo(() => {
const current = data.location.info()
if (!current) return ""
const project = data.project.get(current.project.id)
if (!project) return ""
return project.name || path.basename(project.canonical)
})
const options = createMemo(() => {
const today = new Date().toDateString()
@@ -130,10 +105,8 @@ export function DialogSessionList() {
const option = (session: SessionInfo, category: string) => {
const directory = session.location.directory
const project = data.project.get(session.projectID)
const footer = allProjects()
? Locale.truncate(project?.name || path.basename(project?.canonical ?? directory), 20)
: undefined
const footer =
directory !== data.location.info()?.project.directory ? Locale.truncate(path.basename(directory), 20) : ""
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
const deleting = toDelete() === session.id
return {
@@ -166,16 +139,6 @@ export function DialogSessionList() {
return (
<DialogSelect
title="Sessions"
titleView={
<box flexDirection="row">
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
Sessions
</text>
<Show when={!allProjects() && currentProjectName()}>
<text fg={theme.text.subdued}> for {currentProjectName()}</text>
</Show>
</box>
}
options={options()}
skipFilter={true}
current={currentSessionID()}
@@ -183,25 +146,13 @@ export function DialogSessionList() {
setFilter(query)
setSearch(query)
}}
bindings={[
{
bind: "ctrl+a",
title: allProjects() ? "Show current directory sessions" : "Show all project sessions",
group: "Dialog",
run: () => {
setAllProjects((value) => !value)
},
},
]}
emptyView={
<box paddingLeft={4} paddingRight={4}>
<text fg={searchState().error ? theme.text.feedback.error.default : theme.text.subdued}>
{searchState().message}
</text>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.subdued}>No sessions available</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={searchState().error ? theme.text.feedback.error.default : theme.text.subdued}>
{searchState().message}
</text>
@@ -227,34 +178,24 @@ export function DialogSessionList() {
setToDelete(option.value)
return
}
void client.api.session
.remove({ sessionID: option.value })
.then(() => {
setSearchResults((result) =>
result ? { ...result, sessions: result.sessions.filter((session) => session.id !== option.value) } : result,
)
})
.catch((error) => {
setToDelete(undefined)
toast.show({
message: `Failed to delete session: ${errorMessage(error)}`,
variant: "error",
duration: 5000,
})
void client.api.session.remove({ sessionID: option.value }).catch((error) => {
setToDelete(undefined)
toast.show({
message: `Failed to delete session: ${errorMessage(error)}`,
variant: "error",
duration: 5000,
})
})
},
},
{
command: "session.rename",
title: "rename",
onTrigger: (option: { value: string; title: string }) =>
DialogSessionRename.show(dialog, option.value, option.title),
onTrigger: (option: { value: string }) =>
DialogSessionRename.show(dialog, option.value, data.session.get(option.value)?.title),
},
]}
footerHints={[
...quickSwitchFooterHints(),
{ title: allProjects() ? "current directory" : "all projects", label: "ctrl+a", side: "right" },
]}
footerHints={quickSwitchFooterHints()}
/>
)
}
+4 -4
View File
@@ -62,13 +62,13 @@ export function DialogSkill(props: DialogSkillProps) {
emptyView={
<Switch
fallback={
<box paddingLeft={4} paddingRight={4}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.subdued}>No skills available</text>
</box>
}
>
<Match when={showError()}>
<box paddingLeft={4} paddingRight={4}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Could not load skills
</text>
@@ -77,14 +77,14 @@ export function DialogSkill(props: DialogSkillProps) {
</box>
</Match>
<Match when={skills.loading}>
<box paddingLeft={4} paddingRight={4}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.subdued}>Loading skills</text>
</box>
</Match>
</Switch>
}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.subdued}>No skills found</text>
</box>
}
-73
View File
@@ -1,73 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { DiffRenderable, LineNumberRenderable, type ColorInput } from "@opentui/core"
import type { JSX } from "@opentui/solid"
import { createMemo, For, Show, splitProps } from "solid-js"
import { splitPatchHunks } from "../util/diff"
import { stringWidth } from "../util/string-width"
type Props = Omit<JSX.IntrinsicElements["diff"], "diff" | "lineNumberBg"> & {
diff: string
hunkFg: ColorInput
lineNumberBg: ColorInput
}
export function PatchDiff(props: Props) {
const [local, diffProps] = splitProps(props, ["diff", "hunkFg", "lineNumberBg"])
const hunks = createMemo(() => splitPatchHunks(local.diff))
const nodes = new Set<DiffRenderable>()
const syncGutters = (attempt = 0) => {
requestAnimationFrame(() => {
const sides = [...nodes]
.filter((item) => !item.isDestroyed)
.flatMap((item) => item.getChildren().filter((side) => side instanceof LineNumberRenderable))
const lineNumbers = sides.map((side) => new Map([...side.getLineNumbers()].filter(([line]) => line >= 0)))
const digits = lineNumbers.map((numbers) => Math.max(0, ...numbers.values()).toString().length)
const after = sides.map((side) =>
Math.max(
0,
...[...side.getLineSigns()]
.filter(([line]) => line >= 0)
.map(([, sign]) => stringWidth(sign.after ?? "")),
),
)
const maxDigits = Math.max(...digits)
const maxAfter = Math.max(...after)
if (!maxDigits && attempt < 2) return syncGutters(attempt + 1)
if (!maxDigits) return
sides.forEach((side) => {
const index = sides.indexOf(side)
const signs = new Map([...side.getLineSigns()].filter(([line]) => line >= 0))
signs.set(-1, { after: " ".repeat(maxAfter + maxDigits - digits[index]) })
side.setLineNumbers(lineNumbers[index])
side.setLineSigns(signs)
})
})
}
const register = (node: DiffRenderable) => {
nodes.add(node)
syncGutters()
}
return (
<For each={hunks()}>
{(hunk, index) => (
<>
<Show when={index() > 0}>
<box width="100%" height={1} backgroundColor={local.lineNumberBg}>
<text fg={local.hunkFg} bg={local.lineNumberBg}>
{` ${hunk.header ?? ""}`}
</text>
</box>
</Show>
<diff
{...diffProps}
ref={register}
diff={hunk.patch}
minHeight={hunk.rows}
lineNumberBg={local.lineNumberBg}
/>
</>
)}
</For>
)
}
+3 -18
View File
@@ -216,34 +216,19 @@ export function Prompt(props: PromptProps) {
})
Keymap.createLayer(() => ({
mode: "global",
enabled: props.sessionID !== undefined,
commands: [
{
id: "session.cd",
title: "Change working directory",
slash: { name: "cd", arguments: true },
run: async (input) => {
const sessionID = props.sessionID
if (!sessionID) return
if (!input?.trim()) {
toast.show({ message: "Directory is required", variant: "error" })
return
}
const sessionID = props.sessionID
if (!sessionID) {
const value = input.trim()
const expanded =
value === "~" ? paths.home : value.startsWith("~/") ? path.join(paths.home, value.slice(2)) : value
const directory = path.resolve(
currentLocation.current?.directory ?? data.location.default().directory,
expanded,
)
const location = await client.api.location.get({ location: { directory } }).catch((error) => {
toast.show({ title: "Failed to change directory", message: errorMessage(error), variant: "error" })
return undefined
})
if (!location) return
move.setDirectory(location.directory, location.directory !== location.project.directory)
currentLocation.set(location)
return
}
await client.api.session
.move({ sessionID, directory: input })
.catch((error) =>
@@ -158,10 +158,6 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
setCreating(false)
}
function setDirectory(directory: string, subdirectory: boolean) {
setDestination({ type: "directory", directory, subdirectory })
}
createEffect(() => {
if (!creating()) {
setCreatingDots(3)
@@ -180,7 +176,6 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
pending,
pendingNew,
progress,
setDirectory,
startSubmit,
}
}
+19 -25
View File
@@ -14,7 +14,7 @@ import {
import { createAnimatable, spring, tween } from "../ui/animation"
import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width"
import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
import { TabPulse } from "./tab-pulse"
import { tint } from "../theme/color"
// A long title fades out over its last cells instead of cutting hard.
@@ -182,9 +182,6 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
return tint(base, theme.raise(theme.background.surface.offset), dragged() ? 1 : selection())
})
const pulseColor = () => tint(background(), theme.text.default, 0.45)
// The edge flash washes toward a brighter stop on the same background-to-text ramp,
// so it reads as a lift of the pulse color rather than a different hue.
const flashColor = () => tint(background(), theme.text.default, 0.65)
const feedbackColor = () => {
if (status().attention) return theme.text.feedback.warning.default
if (status().unread === "error") return theme.text.feedback.error.default
@@ -225,6 +222,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
const cut = Math.round(front * Math.max(parts.length, previous.length))
return [...parts.slice(0, cut), ...previous.slice(cut)]
})
const fadedTitleParts = createMemo(() => displayedParts().slice(-FADE_WIDTH))
const titleFades = createMemo(
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH,
)
@@ -232,19 +230,6 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
if (hovered() === tab.sessionID) return theme.text.default
return tint(theme.text.subdued, theme.text.default, selection())
}
// Title characters sitting over the glow tinge toward its color, following the same
// spatial falloff as the glow itself; characters beyond the tail stay neutral.
const characterColor = (index: number) => {
const base = foreground()
const color = glows()
? tint(base, glowColor(), 0.12 * unreadGlowIntensity(1 + numberWidth() + index, width()))
: base
if (!titleFades() || index < displayedParts().length - FADE_WIDTH) return color
const position = index - (displayedParts().length - FADE_WIDTH)
return tint(color, background(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
}
// The running sweep's level under the number cell, reported by the pulse renderable.
const [sweepLevel, setSweepLevel] = createSignal(0)
const numberColor = () => {
const feedback = feedbackColor()
if (feedback) return feedback
@@ -252,9 +237,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
hovered() === tab.sessionID && !selected()
? foreground()
: tint(idleNumber(), activeNumber(), selection())
const color = tint(base, accent(), activity())
// The number brightens faintly as the running sweep passes beneath it.
return sweepLevel() === 0 ? color : tint(color, theme.text.default, 0.15 * sweepLevel())
return tint(base, accent(), activity())
}
const bold = () => (selected() || dragged() ? TextAttributes.BOLD : undefined)
const closeColor = () => tint(theme.text.subdued, theme.text.default, 0.6)
@@ -288,10 +271,8 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
breathe={status().attention}
color={pulseColor()}
glowColor={glowColor()}
flashColor={flashColor()}
completionColor={accent()}
backgroundColor={background()}
onLevel={setSweepLevel}
/>
<box zIndex={1} width="100%" flexDirection="row">
<text width={1} selectable={false}>
@@ -307,9 +288,22 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
selectable={false}
attributes={bold()}
>
<Show when={glows() || titleFades()} fallback={displayedParts().join("")}>
<For each={displayedParts()}>
{(character, index) => <span style={{ fg: characterColor(index()) }}>{character}</span>}
<Show when={titleFades()} fallback={displayedParts().join("")}>
{displayedParts().slice(0, -FADE_WIDTH).join("")}
<For each={fadedTitleParts()}>
{(character, index) => (
<span
style={{
fg: tint(
foreground(),
background(),
0.2 + 0.72 * (index() / Math.max(1, fadedTitleParts().length - 1)),
),
}}
>
{character}
</span>
)}
</For>
</Show>
</text>
+6 -52
View File
@@ -9,11 +9,8 @@ type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
breathe?: boolean
color?: RGBA
glowColor?: RGBA
flashColor?: RGBA
completionColor?: RGBA
backgroundColor?: RGBA
/** Reports the running sweep's intensity at the tab number's cell, quantized; 0 when idle. */
onLevel?: (level: number) => void
}
const clamp = (value: number) => Math.max(0, Math.min(1, value))
@@ -22,11 +19,10 @@ const RUN_DURATION = 2_800
const RUN_HEAD = 4
const RUN_TAIL = 18
const RUN_FADE_OUT = 500
const COMPLETION_DURATION = 1_200
const COMPLETION_ATTACK = 0.12
const COMPLETION_DURATION = 900
const COMPLETION_ATTACK = 0.16
const COMPLETION_OPACITY = 0.18
const EDGE_FLASH_DURATION = 800
const EDGE_FLASH_ATTACK = 0.1
const EDGE_FLASH_DURATION = 500
const EDGE_FLASH_OPACITY = 0.1
const GLOW_IGNITION_DURATION = 600
const GLOW_IGNITION_PEAK = 1.5
@@ -65,11 +61,9 @@ export function blendTabPulseColor(
background: RGBA,
glowColor: RGBA,
runningColor: RGBA,
flashColor: RGBA,
completionColor: RGBA,
glow: number,
running: number,
flash: number,
completion: number,
) {
output.r = background.r + (glowColor.r - background.r) * glow
@@ -78,9 +72,6 @@ export function blendTabPulseColor(
output.r += (runningColor.r - output.r) * running
output.g += (runningColor.g - output.g) * running
output.b += (runningColor.b - output.b) * running
output.r += (flashColor.r - output.r) * flash
output.g += (flashColor.g - output.g) * flash
output.b += (flashColor.b - output.b) * flash
output.r += (completionColor.r - output.r) * completion
output.g += (completionColor.g - output.g) * completion
output.b += (completionColor.b - output.b) * completion
@@ -132,7 +123,6 @@ class TabPulseRenderable extends Renderable {
private _breathe: boolean
private _color: RGBA
private _glowColor: RGBA
private _flashColor: RGBA
private _completionColor: RGBA
private _backgroundColor: RGBA
private clock = 0
@@ -140,13 +130,11 @@ class TabPulseRenderable extends Renderable {
private completionPending = false
private runFade = new Envelope(RUN_FADE_OUT, fadeOut)
private completionPulse = new Envelope(COMPLETION_DURATION, completionPulseOpacity)
private edgeFlash = new Envelope(EDGE_FLASH_DURATION, (progress) => attackDecay(progress, EDGE_FLASH_ATTACK, 1, 0))
private edgeFlash = new Envelope(EDGE_FLASH_DURATION, completionPulseOpacity)
private ignition = new Envelope(GLOW_IGNITION_DURATION, glowIgnitionLevel)
private glowOff = new Envelope(GLOW_FADE_OUT, fadeOut)
private envelopes = [this.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff]
private renderColor = RGBA.fromInts(0, 0, 0)
private _onLevel: ((level: number) => void) | undefined
private lastLevel = 0
constructor(ctx: RenderContext, options: TabPulseOptions = {}) {
const enabled = options.enabled ?? true
@@ -159,21 +147,8 @@ class TabPulseRenderable extends Renderable {
this._breathe = options.breathe ?? false
this._color = options.color ?? RGBA.defaultForeground()
this._glowColor = options.glowColor ?? this._color
this._flashColor = options.flashColor ?? this._color
this._completionColor = options.completionColor ?? this._color
this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
this._onLevel = options.onLevel
}
set onLevel(value: ((level: number) => void) | undefined) {
this._onLevel = value
}
private emitLevel(value: number) {
const quantized = Math.round(value * 32) / 32
if (quantized === this.lastLevel) return
this.lastLevel = quantized
this._onLevel?.(quantized)
}
private get breathing() {
@@ -273,12 +248,6 @@ class TabPulseRenderable extends Renderable {
this.requestRender()
}
set flashColor(value: RGBA) {
if (value.equals(this._flashColor)) return
this._flashColor = value
this.requestRender()
}
set completionColor(value: RGBA) {
if (value.equals(this._completionColor)) return
this._completionColor = value
@@ -314,21 +283,12 @@ class TabPulseRenderable extends Renderable {
// The edge flash is a neutral wash on the running stage; the accent completion stage stays reserved for results.
const flash = this.edgeFlash.level() * EDGE_FLASH_OPACITY
const glowLevel = this.glowLevel()
if (glowLevel === 0 && running === 0 && completion === 0 && flash === 0) {
this.emitLevel(0)
return
}
if (glowLevel === 0 && running === 0 && completion === 0 && flash === 0) return
const progress = (this.clock % RUN_DURATION) / RUN_DURATION
const start = -RUN_HEAD
const end = this.width - 1 + RUN_TAIL
const front = start + coast(progress) * (end - start)
const secondFront = start + coast((progress + 0.5) % 1) * (end - start)
this.emitLevel(
running === 0
? 0
: Math.max(intensityAt(1, front, RUN_HEAD, RUN_TAIL), intensityAt(1, secondFront, RUN_HEAD, RUN_TAIL)) *
running,
)
for (let index = 0; index < this.width; index++) {
// Skip per-cell sweep and glow math when that stage is idle, e.g. a steady breathing glow.
const sweep =
@@ -345,11 +305,9 @@ class TabPulseRenderable extends Renderable {
this._backgroundColor,
this._glowColor,
this._color,
this._flashColor,
this._completionColor,
glowLevel === 0 ? 0 : unreadGlowIntensity(index, this.width) * GLOW_OPACITY * glowLevel,
sweep,
flash,
Math.max(sweep, flash),
completion,
)
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor)
@@ -373,10 +331,8 @@ export function TabPulse(props: {
breathe?: boolean
color: RGBA
glowColor?: RGBA
flashColor?: RGBA
completionColor?: RGBA
backgroundColor: RGBA
onLevel?: (level: number) => void
}) {
return (
<tab_pulse
@@ -390,10 +346,8 @@ export function TabPulse(props: {
breathe={props.breathe ?? false}
color={props.color}
glowColor={props.glowColor ?? props.color}
flashColor={props.flashColor ?? props.color}
completionColor={props.completionColor ?? props.color}
backgroundColor={props.backgroundColor}
onLevel={props.onLevel}
/>
)
}
+3 -21
View File
@@ -1,6 +1,6 @@
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup, onMount } from "solid-js"
import { onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { errorMessage } from "../util/error"
import { createSimpleContext } from "./helper"
@@ -25,7 +25,6 @@ type ManagedService = {
type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
const connectTimeout = 2_000
const connectionHistoryLimit = 50
const eventFlushInterval = 10
export const { use: useClient, provider: ClientProvider } = createSimpleContext({
name: "Client",
@@ -35,8 +34,6 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
const history: ClientConnectionEvent[] = []
let api = props.api
const events = createGlobalEmitter<ClientEventMap>()
let pending: OpenCodeEvent[] = []
let flushTimer: ReturnType<typeof setTimeout> | undefined
const [connection, setConnection] = createStore<{
status: ClientConnectionStatus
attempt: number
@@ -52,19 +49,6 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
if (history.length > connectionHistoryLimit) history.shift()
}
function flushEvents() {
flushTimer = undefined
const queued = pending
pending = []
batch(() => queued.forEach((event) => events.emit(event.type, event)))
}
function emit(event: OpenCodeEvent) {
pending.push(event)
if (flushTimer) return
flushTimer = setTimeout(flushEvents, eventFlushInterval)
}
async function connect(signal: AbortSignal, attempt: number) {
let connectedAt: number | undefined
@@ -96,7 +80,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
record("connected", attempt)
connectedAt = Date.now()
log.info("event stream connected")
emit(first.value)
events.emit(first.value.type, first.value)
setConnection({ status: "connected", attempt: 0, error: undefined })
// Forward events until the stream closes or this connection is cancelled.
@@ -113,7 +97,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
seq: event.value.durable.seq,
})
emit(event.value)
events.emit(event.value.type, event.value)
}
return { error: undefined, connectedAt }
@@ -170,8 +154,6 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
onCleanup(() => {
abort.abort()
stream?.abort()
if (flushTimer) clearTimeout(flushTimer)
pending = []
events.clear()
})
+24 -40
View File
@@ -15,7 +15,6 @@ import type {
ModelInfo,
PermissionSavedInfo,
PermissionRequest,
Project,
ProviderInfo,
ReferenceInfo,
SessionMessageInfo,
@@ -81,7 +80,6 @@ type Store = {
form: Record<string, FormWithLocation[]>
}
project: {
info: Record<string, Project>
permission: Record<string, PermissionSavedInfo[]>
}
location: Record<string, LocationData>
@@ -141,7 +139,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
form: {},
},
project: {
info: {},
permission: {},
},
location: {},
@@ -957,26 +954,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
sync.invalidate(`session.pending:${sessionID}`)
},
},
sync(sessionID: string, options?: { children?: boolean }) {
return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => {
const [info, children] = await Promise.all([
client.api.session.get({ sessionID }),
options?.children
? client.api.session.list({ parentID: sessionID, order: "desc" }).then((response) => response.data)
: [],
])
const sessions = [info, ...children]
setStore(
"session",
"info",
produce((draft) => {
for (const session of sessions) draft[session.id] = session
}),
)
for (const session of sessions) {
sync.complete(`session:${session.id}`)
registerSession(session.id)
}
sync(sessionID: string) {
return sync.run(`session:${sessionID}`, async () => {
setStore("session", "info", sessionID, await client.api.session.get({ sessionID }))
registerSession(sessionID)
})
},
invalidate(sessionID: string) {
@@ -1056,21 +1037,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
},
project: {
list() {
return Object.values(store.project.info).toSorted((a, b) => b.time.updated - a.time.updated)
},
get(projectID: string) {
return store.project.info[projectID]
},
sync() {
return sync.run("project", async () => {
const projects = await client.api.project.list()
setStore("project", "info", reconcile(Object.fromEntries(projects.map((project) => [project.id, project]))))
})
},
invalidate() {
sync.invalidate("project")
},
permission: {
list(projectID: string) {
return store.project.permission[projectID]
@@ -1352,9 +1318,27 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
.then((location) => {
const key = locationKey(location)
setStore("location", key, { ...store.location[key], info: location })
return client.api.session.list({
project: location.project.id,
limit: 50,
order: "desc",
parentID: null,
})
})
.catch((error) => console.error("Failed to preload location", error))
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
.then((response) => {
setStore(
"session",
"info",
produce((draft) => {
for (const session of response.data) draft[session.id] = session
}),
)
for (const session of response.data) {
sync.complete(`session:${session.id}`)
registerSession(session.id)
}
})
.catch((error) => console.error("Failed to preload sessions", error))
return
}
handleEvent(details)
-7
View File
@@ -1,5 +1,4 @@
import { createStore, reconcile } from "solid-js/store"
import type { LocationRef } from "@opencode-ai/client"
import { createSimpleContext } from "./helper"
import type { PromptInfo } from "../prompt/history"
import { useTuiStartup } from "./runtime"
@@ -7,7 +6,6 @@ import { useTuiStartup } from "./runtime"
export type HomeRoute = {
type: "home"
prompt?: PromptInfo
location?: LocationRef
}
export type SessionRoute = {
@@ -57,11 +55,6 @@ function initialRoute(value: unknown): Route | undefined {
"name" in value &&
typeof value.name === "string"
) {
const data =
"data" in value && typeof value.data === "object" && value.data !== null && !Array.isArray(value.data)
? (value.data as Record<string, unknown>)
: undefined
if (data) return { type: "plugin", id: value.id, name: value.name, data }
return { type: "plugin", id: value.id, name: value.name }
}
}
+1 -45
View File
@@ -1,7 +1,6 @@
import { createEffect, createMemo, onCleanup } from "solid-js"
import { createEffect, onCleanup } from "solid-js"
import { isDeepEqual } from "remeda"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
import { useData } from "./data"
import { useEvent } from "./event"
import { useRoute } from "./route"
@@ -32,14 +31,10 @@ type PersistedState = {
const empty = (): TabsState => ({ tabs: [], unread: {} })
// Deliberately after connect settles: the visible session's mount syncs win the first slots.
const TAB_PREFETCH_DELAY = 300
export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimpleContext({
name: "SessionTabs",
init: () => {
const route = useRoute()
const client = useClient()
const data = useData()
const event = useEvent()
const config = useConfig().data
@@ -134,45 +129,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
})
// Warm open tabs' session data so first switches render from cache instead of fetching inside
// the switch gesture. Uses only existing sync methods (each dedupes internally), so reruns on
// tab-set or connection changes are no-ops for already-warm sessions, and reconnects double as
// a cache refresh after an SSE gap. The delay lets the current session's own mount syncs get
// the first connection slots. The effect tracks only the id set: reorders, tab switches, and
// title updates neither restart the timer nor an in-flight warm pass; the timer callback
// itself runs untracked, where the current session is skipped.
const openTabSessions = createMemo(() =>
state()
.tabs.map((tab) => tab.sessionID)
.sort()
.join("\n"),
)
createEffect(() => {
if (!enabled()) return
if (client.connection.status() !== "connected") return
if (openTabSessions() === "") return
let stale = false
const timer = setTimeout(async () => {
const sessions = state()
.tabs.map((tab) => tab.sessionID)
.filter((sessionID) => sessionID !== current())
for (const sessionID of sessions) {
if (stale) return
await Promise.allSettled([
data.session.sync(sessionID),
data.session.message.sync(sessionID),
data.session.pending.sync(sessionID),
data.session.permission.sync(sessionID),
data.session.form.sync(sessionID),
])
}
}, TAB_PREFETCH_DELAY)
onCleanup(() => {
stale = true
clearTimeout(timer)
})
})
onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity")))
onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity")))
onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error")))
@@ -1,8 +1,8 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, Match, Show, Switch } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { FilePath } from "../../ui/file-path"
import { stringWidth } from "../../util/string-width"
import { FadeFilePath } from "../../ui/fade-file-path"
function Directory(props: { context: Plugin.Context; maxWidth: number }) {
const directory = createMemo(() =>
@@ -10,12 +10,9 @@ function Directory(props: { context: Plugin.Context; maxWidth: number }) {
)
return (
<FadeFilePath
value={directory()}
maxWidth={props.maxWidth}
fg={props.context.theme.text.subdued}
bg={props.context.theme.background.default}
/>
<Show when={directory()}>
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.text.subdued} />}
</Show>
)
}
@@ -0,0 +1,186 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { batch, createSignal } from "solid-js"
import { SessionTabs, type SessionTabsController } from "../../component/session-tabs"
import { moveSessionTab, type SessionTab } from "../../context/session-tabs-model"
type FixtureStatus = ReturnType<SessionTabsController["status"]>
const FIXTURE_TABS = [
{ sessionID: "fixture-1", title: "Implement session tabs" },
{ sessionID: "fixture-2", title: "Investigate rendering" },
{ sessionID: "fixture-3", title: "A deliberately long session title for truncation" },
{ sessionID: "fixture-4", title: "Fix provider state" },
{ sessionID: "fixture-5", title: "Review animation" },
{ sessionID: "fixture-6", title: "Untitled behavior" },
{ sessionID: "fixture-7", title: "Queue follow-up work" },
{ sessionID: "fixture-8", title: "Check narrow layout" },
{ sessionID: "fixture-9", title: "Profile terminal output" },
{ sessionID: "fixture-10", title: "Handle permission" },
{ sessionID: "fixture-11", title: "Run focused tests" },
{ sessionID: "fixture-12", title: "Prepare review" },
]
const EMPTY_STATUS: FixtureStatus = { unread: undefined, attention: false, busy: false }
function Commands(props: { context: Plugin.Context }) {
props.context.keymap.layer(() => ({
mode: "global",
commands: [
{
id: "app.scrap",
title: "Open scrap screen",
group: "Debug",
palette: true,
run() {
props.context.ui.router.navigate({ type: "plugin", name: "scrap" })
props.context.ui.dialog.clear()
},
},
],
}))
return null
}
function Scrap(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme
const elevatedTheme = theme.contextual.elevated
const [tabs, setTabs] = createSignal<SessionTab[]>(FIXTURE_TABS.slice(0, 6))
const [active, setActive] = createSignal<string | undefined>("fixture-2")
const [animations, setAnimations] = createSignal(true)
const [statuses, setStatuses] = createSignal<Record<string, FixtureStatus>>({
"fixture-2": { ...EMPTY_STATUS, busy: true },
"fixture-3": { ...EMPTY_STATUS, unread: "activity" },
"fixture-4": { ...EMPTY_STATUS, unread: "error" },
"fixture-5": { ...EMPTY_STATUS, attention: true },
"fixture-6": { ...EMPTY_STATUS, busy: true, attention: true },
})
const controller = {
tabs,
current: active,
status(sessionID) {
return statuses()[sessionID] ?? EMPTY_STATUS
},
move(sessionID, index) {
setTabs((current) => moveSessionTab(current, sessionID, index))
},
select(sessionID) {
setActive(sessionID)
},
close(sessionID?: string) {
const target = sessionID ?? active()
if (!target) return
const items = tabs()
const index = items.findIndex((tab) => tab.sessionID === target)
if (index === -1) return
const next = items.filter((tab) => tab.sessionID !== target)
batch(() => {
setTabs(next)
if (active() === target) setActive(next[index]?.sessionID ?? next[index - 1]?.sessionID)
})
},
} satisfies SessionTabsController
const cycle = (direction: 1 | -1) => {
const items = tabs()
if (items.length === 0) return
const index = items.findIndex((tab) => tab.sessionID === active())
controller.select(items[(index + direction + items.length) % items.length].sessionID)
}
const updateStatus = (update: (status: FixtureStatus) => FixtureStatus) => {
const sessionID = active()
if (!sessionID) return
setStatuses((current) => ({ ...current, [sessionID]: update(current[sessionID] ?? EMPTY_STATUS) }))
}
props.context.keymap.layer(() => ({
commands: [
{
bind: "escape",
title: "Back home",
group: "Scrap",
run() {
props.context.ui.router.navigate({ type: "home" })
},
},
{ bind: "h", title: "Previous tab", group: "Scrap", run: () => cycle(-1) },
{ bind: "l", title: "Next tab", group: "Scrap", run: () => cycle(1) },
{
bind: "t",
title: "Add tab",
group: "Scrap",
run() {
const next = FIXTURE_TABS.find((fixture) => !tabs().some((tab) => tab.sessionID === fixture.sessionID))
if (next) setTabs((current) => [...current, next])
},
},
{ bind: "d", title: "Close tab", group: "Scrap", run: () => controller.close() },
{
bind: "b",
title: "Toggle busy",
group: "Scrap",
run: () =>
updateStatus((status) =>
status.busy ? { ...status, busy: false, unread: "activity" } : { ...status, busy: true, unread: undefined },
),
},
{
bind: "u",
title: "Cycle unread",
group: "Scrap",
run: () =>
updateStatus((status) => ({
...status,
unread: status.unread === undefined ? "activity" : status.unread === "activity" ? "error" : undefined,
})),
},
{
bind: "a",
title: "Toggle attention",
group: "Scrap",
run: () => updateStatus((status) => ({ ...status, attention: !status.attention })),
},
{
bind: "m",
title: "Toggle motion",
group: "Scrap",
run: () => setAnimations((enabled) => !enabled),
},
],
}))
return (
<box
width={dimensions().width}
height={dimensions().height}
flexDirection="column"
backgroundColor={theme.background.default}
>
<SessionTabs controller={controller} animations={animations()} />
<box
height={1}
flexShrink={0}
backgroundColor={elevatedTheme.background.default}
paddingLeft={1}
paddingRight={1}
flexDirection="row"
>
<text fg={elevatedTheme.text.subdued}>tab playground</text>
<box flexGrow={1} />
<text fg={elevatedTheme.text.subdued}>
h/l select | t add | d close | b busy | u unread | a attention | m motion | esc home
</text>
</box>
<box flexGrow={1} />
</box>
)
}
export default Plugin.define({
id: "opencode.scrap",
setup(context) {
context.ui.router.register({ name: "scrap", render: () => <Scrap context={context} /> })
context.ui.slot("app", () => <Commands context={context} />)
},
})
@@ -1,142 +0,0 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { createSignal, For, type JSX } from "solid-js"
import { sessionTabsStory } from "./session-tabs"
/**
* A story is a full-screen, fixture-driven simulation of a real production component. Stories own
* their entire screen (including any footer) and should bind escape back to the storybook index.
*/
export type Story = {
id: string
title: string
render: (context: Plugin.Context) => JSX.Element
}
const stories: Story[] = [sessionTabsStory]
function Commands(props: { context: Plugin.Context }) {
props.context.keymap.layer(() => ({
mode: "global",
commands: [
{
id: "app.storybook",
title: "Open storybook",
group: "Debug",
palette: true,
run() {
props.context.ui.router.navigate({ type: "plugin", name: "storybook" })
props.context.ui.dialog.clear()
},
},
...stories.map((story) => ({
id: `app.storybook.${story.id}`,
title: `Storybook: ${story.title}`,
group: "Debug",
palette: true as const,
run() {
props.context.ui.router.navigate({ type: "plugin", name: "storybook", data: { story: story.id } })
props.context.ui.dialog.clear()
},
})),
],
}))
return null
}
function StorybookIndex(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme
const elevatedTheme = theme.contextual.elevated
const [selected, setSelected] = createSignal(0)
const open = (story: Story) =>
props.context.ui.router.navigate({ type: "plugin", name: "storybook", data: { story: story.id } })
props.context.keymap.layer(() => ({
commands: [
{
bind: "escape",
title: "Back home",
group: "Storybook",
run() {
props.context.ui.router.navigate({ type: "home" })
},
},
{
bind: "up,k",
title: "Previous story",
group: "Storybook",
run: () => setSelected((current) => (current + stories.length - 1) % stories.length),
},
{
bind: "down,j",
title: "Next story",
group: "Storybook",
run: () => setSelected((current) => (current + 1) % stories.length),
},
{
bind: "return",
title: "Open story",
group: "Storybook",
run: () => open(stories[selected()]),
},
...stories.map((story, index) => ({
bind: String(index + 1),
title: `Open ${story.title}`,
group: "Storybook",
run: () => open(story),
})),
],
}))
return (
<box
width={dimensions().width}
height={dimensions().height}
flexDirection="column"
backgroundColor={theme.background.default}
>
<box paddingTop={2} paddingLeft={2} flexDirection="column">
<text fg={theme.text.default}>storybook</text>
<text fg={theme.text.subdued}>fixture-driven simulations of production components</text>
<box height={1} />
<For each={stories}>
{(story, index) => (
<text fg={index() === selected() ? theme.text.default : theme.text.subdued}>
{index() === selected() ? " " : " "}
{index() + 1} {story.title}
</text>
)}
</For>
</box>
<box flexGrow={1} />
<box
height={1}
flexShrink={0}
backgroundColor={elevatedTheme.background.default}
paddingLeft={1}
paddingRight={1}
flexDirection="row"
>
<text fg={elevatedTheme.text.subdued}>storybook</text>
<box flexGrow={1} />
<text fg={elevatedTheme.text.subdued}>/ select | enter open | esc home</text>
</box>
</box>
)
}
export default Plugin.define({
id: "opencode.storybook",
setup(context) {
context.ui.router.register({
name: "storybook",
render: (input) => {
const story = stories.find((story) => story.id === input.data?.story)
if (story) return story.render(context)
return <StorybookIndex context={context} />
},
})
context.ui.slot("app", () => <Commands context={context} />)
},
})
@@ -1,368 +0,0 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { batch, createSignal, For, onCleanup } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { SessionTabs, type SessionTabsController } from "../../../component/session-tabs"
import { moveSessionTab } from "../../../context/session-tabs-model"
import type { Story } from "./index"
type FixtureStatus = ReturnType<SessionTabsController["status"]>
const FIXTURE_TABS = [
{ sessionID: "fixture-1", title: "Implement session tabs" },
{ sessionID: "fixture-2", title: "Investigate rendering" },
{ sessionID: "fixture-3", title: "A deliberately long session title for truncation" },
{ sessionID: "fixture-4", title: "Fix provider state" },
{ sessionID: "fixture-5", title: "Review animation" },
{ sessionID: "fixture-6", title: "Untitled behavior" },
{ sessionID: "fixture-7", title: "Queue follow-up work" },
{ sessionID: "fixture-8", title: "Check narrow layout" },
{ sessionID: "fixture-9", title: "Profile terminal output" },
{ sessionID: "fixture-10", title: "Handle permission" },
{ sessionID: "fixture-11", title: "Run focused tests" },
{ sessionID: "fixture-12", title: "Prepare review" },
]
const EMPTY_STATUS: FixtureStatus = { unread: undefined, attention: false, busy: false }
const RUN_DURATION = 1_800
const RESUME_DURATION = 900
// Plausible targets for the fake transcript's tool calls, picked per fixture index.
const TRANSCRIPT_FILES = [
"packages/tui/src/component/session-tabs.tsx",
"packages/tui/src/component/tab-pulse.tsx",
"packages/tui/src/context/session-tabs-model.ts",
"packages/core/src/session/runner.ts",
"packages/server/src/routes/session.ts",
"packages/tui/src/ui/animation.ts",
]
function SessionTabsStory(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme
const elevatedTheme = theme.contextual.elevated
// A keyed store mirrors production: retitles mutate rows in place instead of remounting them.
const [tabStore, setTabStore] = createStore<{ items: { sessionID: string; title?: string }[] }>({
items: FIXTURE_TABS.slice(0, 6).map((tab) => ({ ...tab })),
})
const tabs = () => tabStore.items
const setItems = (next: { sessionID: string; title?: string }[]) =>
setTabStore("items", reconcile(next, { key: "sessionID" }))
const [active, setActive] = createSignal<string | undefined>("fixture-1")
const [lastEvent, setLastEvent] = createSignal("press space to start a random tab")
const [statuses, setStatuses] = createSignal<Record<string, FixtureStatus>>({})
// Unread clears on select, so the transcript remembers how each session's last run ended.
const [outcomes, setOutcomes] = createSignal<Record<string, "completed" | "failed">>({})
const runs = new Map<string, ReturnType<typeof setTimeout>>()
onCleanup(() => runs.forEach(clearTimeout))
const number = (sessionID: string) => tabs().findIndex((tab) => tab.sessionID === sessionID) + 1
function finishRun(sessionID: string, resumed: boolean) {
runs.delete(sessionID)
if (!tabs().some((item) => item.sessionID === sessionID)) return
const roll = Math.random()
// A permission request pauses the still-busy run until the tab is selected.
if (!resumed && roll < 0.25) {
setStatuses((current) => ({
...current,
[sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), attention: true },
}))
setLastEvent(`tab ${number(sessionID)} needs input; select it to resolve`)
return
}
const failed = roll >= 0.75
const unread = active() === sessionID ? undefined : failed ? ("error" as const) : ("activity" as const)
batch(() => {
setOutcomes((current) => ({ ...current, [sessionID]: failed ? "failed" : "completed" }))
setStatuses((current) => ({
...current,
[sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), busy: false, unread },
}))
// An untitled session earns its title after its first completed run, like a real summarization.
const index = number(sessionID) - 1
const fixture = FIXTURE_TABS.find((tab) => tab.sessionID === sessionID)
if (!failed && fixture && tabs()[index]?.title === undefined) setTabStore("items", index, "title", fixture.title)
})
setLastEvent(
`tab ${number(sessionID)} ${failed ? "failed" : "completed"}${unread ? " (unread)" : " while selected"}`,
)
}
const select = (sessionID: string) => {
const status = statuses()[sessionID]
const resumes = status !== undefined && status.attention && status.busy && !runs.has(sessionID)
batch(() => {
setActive(sessionID)
if (status && (status.unread || status.attention))
setStatuses((current) => ({ ...current, [sessionID]: { ...status, unread: undefined, attention: false } }))
})
if (resumes) {
setLastEvent(`tab ${number(sessionID)} input resolved, resuming`)
runs.set(
sessionID,
setTimeout(() => finishRun(sessionID, true), RESUME_DURATION),
)
}
}
const controller = {
tabs,
current: active,
status(sessionID) {
return statuses()[sessionID] ?? EMPTY_STATUS
},
select,
move(sessionID: string, index: number) {
const next = moveSessionTab(tabs(), sessionID, index)
if (next === tabs()) return
setItems(next.map((tab) => ({ ...tab })))
},
close(sessionID?: string) {
const target = sessionID ?? active()
if (!target) return
const items = tabs()
const index = items.findIndex((tab) => tab.sessionID === target)
if (index === -1) return
const next = items.filter((tab) => tab.sessionID !== target).map((tab) => ({ ...tab }))
const selected = next[index]?.sessionID ?? next[index - 1]?.sessionID
clearTimeout(runs.get(target))
runs.delete(target)
batch(() => {
setItems(next)
setStatuses((current) => {
const updated = { ...current }
delete updated[target]
return updated
})
if (active() === target && selected) select(selected)
if (active() === target && !selected) setActive(undefined)
})
},
} satisfies SessionTabsController
const cycle = (direction: 1 | -1) => {
const items = tabs()
if (items.length === 0) return
const index = items.findIndex((tab) => tab.sessionID === active())
select(items[(index + direction + items.length) % items.length].sessionID)
}
const startRun = (sessionID: string) => {
setStatuses((current) => ({
...current,
[sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), busy: true, unread: undefined },
}))
setOutcomes((current) => {
const next = { ...current }
delete next[sessionID]
return next
})
setLastEvent(`tab ${number(sessionID)} running`)
runs.set(
sessionID,
setTimeout(() => finishRun(sessionID, false), RUN_DURATION),
)
}
const randomInactiveTab = () => {
const candidates = tabs().filter((tab) => {
const status = controller.status(tab.sessionID)
return !status.busy && !status.unread && !status.attention
})
// Untitled sessions run first so their title arrival is easy to trigger.
const untitled = candidates.filter((tab) => tab.title === undefined)
const pool = untitled.length > 0 ? untitled : candidates
return pool[Math.floor(Math.random() * pool.length)]
}
// A fake transcript for the selected session so tab switches feel like moving between real
// sessions; the tail line tracks the live status of the current run.
const transcript = () => {
const current = active()
if (!current) return [{ text: "no session selected", color: theme.text.subdued }]
const index = Math.max(
0,
FIXTURE_TABS.findIndex((fixture) => fixture.sessionID === current),
)
const fixture = FIXTURE_TABS[index]
const status = controller.status(current)
const outcome = outcomes()[current]
const file = TRANSCRIPT_FILES[index % TRANSCRIPT_FILES.length]
const lines = [
{ text: `> ${fixture.title}`, color: theme.text.default },
{ text: "", color: theme.text.default },
]
if (!status.busy && outcome === undefined) {
lines.push({ text: "no activity yet — press s to run this session", color: theme.text.subdued })
return lines
}
lines.push(
{ text: "● Taking a look — reading the relevant code first.", color: theme.text.default },
{ text: "", color: theme.text.default },
{ text: ` ✱ Read ${file}`, color: theme.text.subdued },
{ text: ` ✱ Edit ${file}`, color: theme.text.subdued },
{ text: ` ✱ Bash bun run test`, color: theme.text.subdued },
{ text: "", color: theme.text.default },
)
if (status.attention)
lines.push({
text: "⚠ Permission required: Bash `bun run test` — select this tab to approve",
color: theme.text.feedback.warning.default,
})
else if (status.busy) lines.push({ text: "● Working…", color: theme.text.subdued })
else if (outcome === "failed")
lines.push({
text: `✗ bun run test failed — 3 tests failing in ${file}`,
color: theme.text.feedback.error.default,
})
else
lines.push({
text: `✓ Done — updated ${file} and the tests pass.`,
color: theme.text.feedback.success.default,
})
return lines
}
const selectedState = () => {
const current = active()
const status = current ? controller.status(current) : EMPTY_STATUS
const activity = status.busy
? "running"
: status.unread === "activity"
? "completed (unread)"
: status.unread === "error"
? "failed (unread)"
: "read"
return status.attention ? `${activity} + needs input` : activity
}
props.context.keymap.layer(() => ({
commands: [
{
bind: "escape",
title: "Back to storybook",
group: "Storybook",
run() {
props.context.ui.router.navigate({ type: "plugin", name: "storybook" })
},
},
{ bind: "left,h", title: "Previous tab", group: "Storybook", run: () => cycle(-1) },
{ bind: "right,l", title: "Next tab", group: "Storybook", run: () => cycle(1) },
...Array.from({ length: 10 }, (_, index) => ({
bind: String((index + 1) % 10),
title: `Select tab ${index + 1}`,
group: "Storybook",
run() {
const tab = tabs()[index]
if (tab) select(tab.sessionID)
},
})),
{
bind: "space",
title: "Start a random tab",
group: "Storybook",
run() {
const tab = randomInactiveTab()
if (!tab) {
setLastEvent("every tab is busy or unread; select tabs to read them, or press r")
return
}
startRun(tab.sessionID)
},
},
{
// Random runs stay off the selected tab, so this is the way to watch the edge flash
// and running sweep under the cursor.
bind: "s",
title: "Run selected tab",
group: "Storybook",
run() {
const current = active()
if (!current) return
if (controller.status(current).busy) {
setLastEvent(`tab ${number(current)} is already running`)
return
}
startRun(current)
},
},
{
bind: "t",
title: "Add tab",
group: "Storybook",
run() {
const next = FIXTURE_TABS.find((fixture) => !tabs().some((tab) => tab.sessionID === fixture.sessionID))
if (!next) {
setLastEvent("all fixture tabs are open")
return
}
setItems([...tabs().map((tab) => ({ ...tab })), { sessionID: next.sessionID }])
select(next.sessionID)
setLastEvent(`tab ${number(next.sessionID)} opened untitled; run it to earn its title`)
},
},
{ bind: "d", title: "Close tab", group: "Storybook", run: () => controller.close() },
{
bind: "r",
title: "Reset",
group: "Storybook",
run() {
runs.forEach(clearTimeout)
runs.clear()
batch(() => {
setItems(FIXTURE_TABS.slice(0, 6).map((tab) => ({ ...tab })))
setStatuses({})
setOutcomes({})
setActive("fixture-1")
})
setLastEvent("reset; press space to start a random tab")
},
},
],
}))
return (
<box
width={dimensions().width}
height={dimensions().height}
flexDirection="column"
backgroundColor={theme.background.default}
>
<SessionTabs controller={controller} />
<box height={1} />
<box flexGrow={1} paddingLeft={2} paddingRight={2} flexDirection="column">
<For each={transcript()}>
{(line) => (
<text fg={line.color} wrapMode="none" selectable={false}>
{line.text || " "}
</text>
)}
</For>
</box>
<box paddingLeft={2} flexDirection="column">
<text fg={theme.text.subdued}>
selected: {number(active() ?? "")} | state: {selectedState()}
</text>
<text fg={theme.text.subdued}>background: {lastEvent()}</text>
</box>
<box
height={1}
flexShrink={0}
backgroundColor={elevatedTheme.background.default}
paddingLeft={1}
paddingRight={1}
flexDirection="row"
>
<text fg={elevatedTheme.text.subdued}>storybook / session tabs</text>
<box flexGrow={1} />
<text fg={elevatedTheme.text.subdued}>
space/s run | t add | d close | r reset | / 1-0 move | drag reorders | esc back
</text>
</box>
</box>
)
}
export const sessionTabsStory: Story = {
id: "session-tabs",
title: "Session tabs",
render: (context) => <SessionTabsStory context={context} />,
}
+1 -3
View File
@@ -32,7 +32,6 @@ import { footerWidthPolicy } from "./footer.width"
import { toolFiletype } from "./tool"
import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
import type { MiniPermissionRequest, PermissionReply } from "./types"
import { PatchDiff } from "../component/patch-diff"
function buttons(
list: PermissionOption[],
@@ -406,9 +405,8 @@ export function RunPermissionBody(props: {
</Show>
}
>
<PatchDiff
<diff
diff={info().diff!}
hunkFg={props.block.diffLineNumber}
view="unified"
filetype={ft()}
syntaxStyle={props.block.syntax}
+1 -3
View File
@@ -13,7 +13,6 @@ import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
import { toolFiletype, toolStructuredFinal } from "./tool"
import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme"
import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit, TurnSummary } from "./types"
import { PatchDiff } from "../component/patch-diff"
export function entryGroupKey(commit: StreamCommit): string | undefined {
if (!commit.partID) {
@@ -179,9 +178,8 @@ export function RunEntryContent(props: {
</text>
{item.diff.trim() ? (
<box width="100%" paddingLeft={1}>
<PatchDiff
<diff
diff={item.diff}
hunkFg={theme().block.diffLineNumber}
view="unified"
filetype={toolFiletype(item.file)}
syntaxStyle={syntax()}

Some files were not shown because too many files have changed in this diff Show More