Compare commits

..

3 Commits

Author SHA1 Message Date
Aiden Cline 4c893a77a0 test(tui): restore compaction event lifecycle 2026-07-29 18:06:32 -05:00
Aiden Cline ddfcdee425 refactor(core): simplify edit file diffs 2026-07-29 17:29:43 -05:00
Aiden Cline ff58d21b22 fix(core): add mutation permission previews 2026-07-29 17:27:57 -05:00
101 changed files with 1753 additions and 2001 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
+12 -50
View File
@@ -1,62 +1,31 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { Route, type RouteDefaultsInput } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { HttpOptions, ProviderID, type ModelID, type ProviderOptions } from "../schema"
import type { RouteDefaultsInput } from "../route/client"
import { HttpOptions, ProviderID, type ModelID } from "../schema"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
import { XAIImages } from "../protocols/xai-images"
import type { OpenAIOptionsInput } from "./openai-options"
import type { ProviderPackage } from "../provider-package"
import type { OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("xai")
export type XAIProviderOptionsInput = ProviderOptions & {
readonly xai?: OpenAIOptionsInput
}
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: XAIProviderOptionsInput
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: XAIProviderOptionsInput
}
export type { XAIImageOptions } from "../protocols/xai-images"
const responsesRoute = Route.make({
id: "openai-responses",
provider: id,
providerMetadataKey: "xai",
protocol: OpenAIResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenAIResponses.httpTransport,
defaults: { providerOptions: { xai: { store: false } } },
})
const chatRoute = Route.make({
id: "openai-compatible-chat",
provider: id,
providerMetadataKey: "xai",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenAICompatibleChat.route.transport,
})
export const routes = [responsesRoute, chatRoute]
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
const configuredResponsesRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return responsesRoute.with({
return OpenAIResponses.route.with({
...rest,
provider: id,
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input),
})
@@ -64,8 +33,9 @@ const configuredResponsesRoute = (input: ModelOptions) => {
const configuredChatRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return chatRoute.with({
return OpenAICompatibleChat.route.with({
...rest,
provider: id,
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input),
})
@@ -74,8 +44,8 @@ const configuredChatRoute = (input: ModelOptions) => {
export const configure = (input: ModelOptions = {}) => {
const responsesRoute = configuredResponsesRoute(input)
const chatRoute = configuredChatRoute(input)
const responses = (modelID: string | ModelID) => responsesRoute.model<XAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) => chatRoute.model<XAIProviderOptionsInput>({ id: modelID })
const responses = (modelID: string | ModelID) => responsesRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) => chatRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
const image = (modelID: string | ModelID) =>
XAIImages.model({
id: modelID,
@@ -95,15 +65,7 @@ export const configure = (input: ModelOptions = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["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")
@@ -3,11 +3,11 @@ import { XAI } from "../../src/providers"
const model = XAI.provider.model("grok-4")
LLM.request({ model, prompt: "Hello", providerOptions: { xai: { reasoningEffort: "high" } } })
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error xAI's OpenAI-compatible reasoning effort must be a string.
providerOptions: { xai: { reasoningEffort: true } },
providerOptions: { openai: { reasoningEffort: true } },
})
-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: { xai: { 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).toMatchObject({ xai: { 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)),
-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 = [
+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:"
},
+3
View File
@@ -54,6 +54,9 @@ const layer = Layer.effect(
})
formatters = builtIns
if (configured === true) return
if (configured.ruff?.disabled || configured.uv?.disabled) {
formatters = formatters.filter((formatter) => formatter.name !== "ruff" && formatter.name !== "uv")
}
for (const [name, entry] of Object.entries(configured)) {
const index = formatters.findIndex((formatter) => formatter.name === name)
+1
View File
@@ -18,6 +18,7 @@ export function make(input: {
readonly fs: FSUtil.Interface
readonly npm: Npm.Interface
readonly processes: AppProcess.Interface
readonly experimentalOxfmt?: boolean
}) {
const disabled = false as const
const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree)
+1 -1
View File
@@ -201,7 +201,7 @@ const layer = Layer.effect(
locks.withLock(repository.gitDirectory)(effect)
const discover = Effect.fn("Git.repo.discover")(function* (input: AbsolutePath) {
const dotgit = yield* fs.up({ targets: [".git"], start: input, mode: "first" }).pipe(
const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe(
Effect.map((matches) => matches[0]),
Effect.catch(() => Effect.succeed(undefined)),
)
+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,
})
}),
+28 -25
View File
@@ -17,6 +17,7 @@ import { Credential } from "./credential"
import { Integration } from "./integration"
import { Capabilities, ID, Info, Ref, VariantID } from "./model"
import { Npm } from "@opencode-ai/util/npm"
import { OpenAICodex } from "./plugin/provider/openai-codex"
import { Provider } from "./provider"
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
@@ -151,7 +152,12 @@ export const fromCatalogModel = (
const packageName = Provider.packageName(resolved.package)
const key = apiKey(resolved, credential)
if (OpenAICodex.isChatGPT(credential) && !Provider.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) {
return Effect.succeed(codexModel(resolved, credential, key))
}
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key))
return Effect.succeed(
withDefaults(resolved, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
@@ -176,8 +182,7 @@ export const fromCatalogModel = (
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
const native = Provider.isAISDK(resolved.package) ? nativePackage(packageName) : resolved.package
if (Provider.isAISDK(resolved.package) && !native) {
if (Provider.isAISDK(resolved.package)) {
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
const runtime = produce(resolved, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
@@ -188,22 +193,20 @@ export const fromCatalogModel = (
})
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
}
if (!native) return Effect.fail(unsupported(resolved))
if (!resolved.package) return Effect.fail(unsupported(resolved))
const specifier = native
const specifier = resolved.package
return Effect.gen(function* () {
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
)
const configured = { ...resolved.settings, ...credential?.metadata }
const providerOptions = nativeProviderOptions(packageName, configured)
const settings = {
...(credential ? withoutNativeAuthSettings(configured) : configured),
...nativeCredentialSettings(specifier, credential),
headers: resolved.headers,
body: resolved.body,
limits: { context: resolved.limit.context, output: resolved.limit.output },
...(providerOptions ? { providerOptions } : {}),
}
return yield* Effect.try({
try: () => {
@@ -220,25 +223,9 @@ export const fromCatalogModel = (
})
}
const nativePackage = (packageName: string | undefined) => {
if (packageName === "@ai-sdk/google") return "@opencode-ai/ai/providers/google"
if (packageName === "@openrouter/ai-sdk-provider") return "@opencode-ai/ai/providers/openrouter"
if (packageName === "@ai-sdk/xai") return "@opencode-ai/ai/providers/xai"
return undefined
}
const nativeProviderOptions = (packageName: string | undefined, settings: Readonly<Record<string, unknown>>) => {
const values = Object.fromEntries(
Object.entries(settings).filter(
([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key),
),
)
if (Object.keys(values).length === 0) return undefined
if (packageName === "@ai-sdk/google") return { gemini: values }
if (packageName === "@openrouter/ai-sdk-provider") return { openrouter: values }
if (packageName === "@ai-sdk/xai") return { xai: values }
return undefined
}
const isNativeOpenAI = (packageName: string | undefined) =>
packageName === "@opencode-ai/ai/providers/openai" ||
packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
if (!credential) return {}
@@ -261,6 +248,22 @@ const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
return rest
}
const codexModel = (
model: Info,
credential: Credential.Value | undefined,
key: ReturnType<typeof Auth.value> | undefined,
) => {
const account = OpenAICodex.accountID(credential)
return withDefaults(model, OpenAIResponses.route)
.with({
endpoint: { baseURL: OpenAICodex.baseURL },
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
),
})
.model({ id: model.modelID ?? model.id, compatibility: model.compatibility })
}
const unsupported = (model: Info) =>
new UnsupportedPackageError({
providerID: model.providerID,
+2
View File
@@ -11,6 +11,8 @@ import { Permission } from "../permission"
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
// Whitelisted so agents can read a command's full captured output without an external-directory prompt.
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
const BUILD_SYSTEM =
"You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions."
const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
@@ -0,0 +1,42 @@
export * as OpenAICodex from "./openai-codex"
// TEMPORARY SEAM (#34765): plugins have no hook into LLM route construction, so
// Codex routing lives in ModelResolver and catalog filtering.
// in OpenAIPlugin, sharing this module. Once the native provider packages land
// (#33689/#33925/#34462) this should collapse into the native OpenAI provider.
// The eligibility rules mirror V1's CodexAuthPlugin allowlist; models.dev has no
// plan-eligibility data for OpenAI today, but models other vendors' subscriptions
// as dedicated providers (e.g. zai-coding-plan) - a future openai-chatgpt-plan
// provider entry could replace the hardcoded rules with catalog data.
/** ChatGPT-plan requests must target the codex backend instead of the public API. */
export const baseURL = "https://chatgpt.com/backend-api/codex"
const methodIDs: readonly string[] = ["chatgpt-browser", "chatgpt-headless"]
/** Structural credential shape so both core and plugin-facing credential types fit. */
type CredentialLike = {
readonly type: string
readonly methodID?: string
readonly metadata?: Record<string, unknown> | undefined
}
export const isChatGPT = (credential: CredentialLike | undefined) =>
credential?.type === "oauth" && credential.methodID !== undefined && methodIDs.includes(credential.methodID)
export const accountID = (credential: CredentialLike | undefined) => {
if (!isChatGPT(credential)) return undefined
const value = credential?.metadata?.accountID
return typeof value === "string" ? value : undefined
}
const allowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
const disallowed = new Set(["gpt-5.5-pro", "gpt-5.6"])
/** Which API model ids a ChatGPT subscription may call through the codex backend. */
export const eligible = (apiID: string) => {
if (allowed.has(apiID)) return true
if (disallowed.has(apiID)) return false
const match = apiID.match(/^gpt-(\d+\.\d+)/)
return match ? Number.parseFloat(match[1]) > 5.4 : false
}
+19 -24
View File
@@ -10,16 +10,14 @@ import { Model } from "../../model"
import { OauthCallbackPage } from "../../oauth/page"
import { Provider } from "../../provider"
import type { PluginInternal } from "../internal"
import { OpenAICodex } from "./openai-codex"
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
const issuer = "https://auth.openai.com"
const callbackPort = 1455
const pollingSafetyMargin = 3000
const codexBaseURL = "https://chatgpt.com/backend-api/codex"
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
const codexAllowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
const codexDisallowed = new Set(["gpt-5.5-pro", "gpt-5.6"])
type Pkce = {
verifier: string
@@ -166,18 +164,14 @@ export const OpenAIPlugin = define({
effect: Effect.fn(function* (ctx) {
const bus = yield* Bus.Service
const loading = Semaphore.makeUnsafe(1)
let chatgpt: Credential.OAuth | undefined
let chatgpt = false
const load = Effect.fn("OpenAIPlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("openai")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
: undefined
chatgpt =
credential?.type === "oauth" &&
(credential.methodID === browserMethodID || credential.methodID === headlessMethodID)
? credential
: undefined
chatgpt = OpenAICodex.isChatGPT(credential)
})
yield* ctx.integration.transform((draft) => {
@@ -189,9 +183,6 @@ export const OpenAIPlugin = define({
for (const item of evt.provider.list()) {
if (!Provider.isAISDK(item.provider.package)) continue
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai") continue
evt.provider.update(item.provider.id, (provider) => {
provider.package = "@opencode-ai/ai/providers/openai"
})
if (!item.models.has(Model.ID.make("gpt-5-chat-latest"))) continue
evt.model.update(item.provider.id, Model.ID.make("gpt-5-chat-latest"), (model) => {
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
@@ -202,12 +193,6 @@ export const OpenAIPlugin = define({
if (!chatgpt) return
const item = evt.provider.get(Provider.ID.openai)
if (!item) return
item.provider.settings = Provider.mergeOverlay(item.provider.settings, { baseURL: codexBaseURL })
const account = chatgpt.metadata?.accountID
item.provider.headers = Provider.mergeHeaders(
item.provider.headers,
typeof account === "string" ? { "chatgpt-account-id": account } : undefined,
)
for (const model of item.models.values()) {
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
@@ -216,12 +201,7 @@ export const OpenAIPlugin = define({
draft.enabled = false
return
}
const apiID = draft.modelID ?? draft.id
const match = apiID.match(/^gpt-(\d+\.\d+)/)
if (
!codexAllowed.has(apiID) &&
(codexDisallowed.has(apiID) || !match || Number.parseFloat(match[1]) <= 5.4)
) {
if (!OpenAICodex.eligible(draft.modelID ?? draft.id)) {
draft.enabled = false
return
}
@@ -236,6 +216,21 @@ export const OpenAIPlugin = define({
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
)
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/openai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/openai"))
evt.sdk = mod.createOpenAI(evt.options)
}),
)
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
if (evt.model.providerID !== Provider.ID.openai) return
evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)
}),
)
}),
} satisfies PluginInternal.InternalPlugin)
+8 -52
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
}
@@ -49,7 +48,7 @@ export const root = Effect.fn("Project.root")(function* (
fs: FSUtil.Interface,
input: AbsolutePath,
) {
return yield* fs.up({ targets: [".git", ".hg"], start: input, mode: "first" }).pipe(
return yield* fs.up({ targets: [".git", ".hg"], start: input }).pipe(
Effect.map((matches) => matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined),
Effect.catch(() => Effect.succeed(undefined)),
)
@@ -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()
@@ -224,7 +189,7 @@ const layer = Layer.effect(
})
const hgDiscover = Effect.fnUntraced(function* (input: AbsolutePath) {
const dotHg = yield* fs.up({ targets: [".hg"], start: input, mode: "first" }).pipe(
const dotHg = yield* fs.up({ targets: [".hg"], start: input }).pipe(
Effect.map((matches) => matches[0]),
Effect.catch(() => Effect.succeed(undefined)),
)
@@ -246,26 +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 =
repo.gitDirectory === repo.commonDirectory
? repo.worktree
: 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)
-6
View File
@@ -657,12 +657,6 @@ const layer = Layer.effectDiscard(
input: event.data.input,
timeCreated: event.created,
})
yield* db
.update(SessionTable)
.set({ time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
}),
)
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
-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"),
}
+17 -1
View File
@@ -10,13 +10,13 @@ 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"
@@ -238,3 +238,19 @@ export const Plugin = {
.pipe(Effect.orDie)
}),
}
function fileDiff(file: string, before: string, after: string): 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: "modified",
...counts,
}
}
@@ -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,
}
}
+23 -23
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) {
@@ -148,34 +158,24 @@ export const Plugin = {
(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)
const external = target.externalDirectory
if (external)
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),
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
action: name,
resources: [invocation.command],
save: [invocation.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}`))
}),
+14 -6
View File
@@ -8,14 +8,15 @@ export * as WriteTool from "./write"
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 { Effect, Schema } from "effect"
import { createTwoFilesPatch, diffLines } from "diff"
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"
@@ -82,12 +83,19 @@ export const Plugin = {
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",
const counts = diffLines(current?.text ?? "", next.text).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 preview: typeof FileDiff.Info.Type = {
file: target.resource,
patch: createTwoFilesPatch(target.resource, target.resource, current?.text ?? "", next.text),
status: current ? "modified" : "added",
...counts,
}
yield* permission.assert({
action: "edit",
resources: [target.resource],
@@ -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,5 +1,5 @@
import { describe, test, expect } from "bun:test"
import { Effect, FileSystem, Layer } from "effect"
import { Effect, FileSystem } from "effect"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -267,33 +267,6 @@ describe("FSUtil", () => {
expect(result).toContain(path.join(tmp, "b.txt"))
}),
)
it(
"stops at the first match when requested",
Effect.gen(function* () {
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
yield* filesys.writeFileString(path.join(tmp, "marker"), "root")
const child = path.join(tmp, "sub")
yield* filesys.makeDirectory(child)
yield* filesys.writeFileString(path.join(child, "marker"), "child")
const checked: string[] = []
const instrumented = FileSystem.FileSystem.of({
...filesys,
exists: (target) => Effect.sync(() => checked.push(target)).pipe(Effect.andThen(filesys.exists(target))),
})
const search = yield* FSUtil.Service.pipe(
Effect.provide(
FSUtil.layer.pipe(Layer.fresh, Layer.provide(Layer.succeed(FileSystem.FileSystem, instrumented))),
),
)
expect(yield* search.up({ targets: ["marker", "other"], start: child, mode: "first" })).toEqual([
path.join(child, "marker"),
])
expect(checked).toEqual([path.join(child, "marker")])
}),
)
})
describe("glob", () => {
+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
}
+1 -1
View File
@@ -15,7 +15,7 @@ import { testEffect } from "./lib/effect"
const selected = Info.make({
...Info.default(Provider.ID.make("test-provider"), ID.make("gemini")),
package: Provider.aisdk("@ai-sdk/mistral"),
package: Provider.aisdk("@ai-sdk/google"),
})
const runtime = Model.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route })
-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"),
-37
View File
@@ -1,7 +1,6 @@
import path from "node:path"
import { describe, expect, test } from "bun:test"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
@@ -560,42 +559,6 @@ test("lists, reads, and reports MCP resource changes", async () => {
)
})
test("does not reconnect an SSE stream after a JSON-RPC error response", async () => {
let requests = 0
const transport = new StreamableHTTPClientTransport(new URL("http://mcp.invalid"), {
fetch: async () => {
requests += 1
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("id: prime\nretry: 1\ndata:\n\n"))
controller.enqueue(
new TextEncoder().encode(
'id: error\ndata: {"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}\n\n',
),
)
controller.close()
},
}),
{ status: 200, headers: { "content-type": "text/event-stream" } },
)
},
reconnectionOptions: {
initialReconnectionDelay: 1,
maxReconnectionDelay: 1,
reconnectionDelayGrowFactor: 1,
maxRetries: 2,
},
})
await transport.start()
await transport.send({ jsonrpc: "2.0", method: "resources/list", id: 1 })
await Bun.sleep(25)
await transport.close()
expect(requests).toBe(1)
})
test("skips MCP resource requests when the capability is absent", async () => {
await Effect.runPromise(
Effect.scoped(
+76 -86
View File
@@ -307,12 +307,12 @@ describe("ModelResolver", () => {
}),
)
it.effect("applies plugin-projected OpenAI endpoint and headers", () =>
it.effect("routes ChatGPT OAuth credentials to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model("@opencode-ai/ai/providers/openai", {
settings: { baseURL: "https://chatgpt.com/backend-api/codex" },
headers: { "chatgpt-account-id": "acct_123" },
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
body: {},
}),
Credential.OAuth.make({
@@ -337,8 +337,37 @@ describe("ModelResolver", () => {
id: "openai-responses",
endpoint: { baseURL: "https://chatgpt.com/backend-api/codex" },
})
expect(resolved.route.defaults.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
expect(headers.authorization).toBe("Bearer chatgpt-token")
expect(headers["chatgpt-account-id"]).toBe("acct_123")
}),
)
it.effect("routes native OpenAI provider packages with ChatGPT credentials to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model("@opencode-ai/ai/providers/openai", {
settings: { baseURL: "https://openai.example/v1" },
}),
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("chatgpt-browser"),
access: "chatgpt-token",
refresh: "refresh",
expires: Date.now() + 60_000,
metadata: { accountID: "acct_123" },
}),
)
const headers = yield* resolved.route.auth.apply({
request: LLM.request({ model: resolved, prompt: "Hello" }),
method: "POST",
url: "https://chatgpt.com/backend-api/codex/responses",
body: "{}",
headers: Headers.empty,
})
expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
expect(headers.authorization).toBe("Bearer chatgpt-token")
expect(headers["chatgpt-account-id"]).toBe("acct_123")
}),
)
@@ -378,6 +407,37 @@ describe("ModelResolver", () => {
}),
)
it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
body: {},
}),
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("chatgpt-headless"),
access: "chatgpt-token",
refresh: "refresh",
expires: Date.now() + 60_000,
}),
)
const request = LLM.request({ model: resolved, prompt: "Hello" })
const headers = yield* resolved.route.auth.apply({
request,
method: "POST",
url: "https://chatgpt.com/backend-api/codex/responses",
body: "{}",
headers: Headers.empty,
})
expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
expect(headers.authorization).toBe("Bearer chatgpt-token")
expect(headers["chatgpt-account-id"]).toBeUndefined()
}),
)
it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
@@ -486,76 +546,6 @@ describe("ModelResolver", () => {
}),
)
it.effect("routes supported AISDK catalog packages through native provider packages", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
const packages = [
["@ai-sdk/google", "@opencode-ai/ai/providers/google", "gemini"],
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "openrouter"],
["@ai-sdk/xai", "@opencode-ai/ai/providers/xai", "xai"],
] as const
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, optionKey]) =>
ModelResolver.fromCatalogModel(
model(Provider.aisdk(catalogPackage), {
modelID: "api-model",
settings: { baseURL: "https://provider.example/v1", reasoningEffort: "high" },
headers: { "x-provider": "header" },
body: { custom: true },
}),
Credential.Key.make({ type: "key", key: "secret" }),
{
loadPackage: (specifier) => {
expect(specifier).toBe(nativePackage)
return Effect.succeed({
model: (modelID, settings) => {
expect(modelID).toBe("api-model")
expect(settings).toMatchObject({
apiKey: "secret",
baseURL: "https://provider.example/v1",
headers: { "x-provider": "header" },
body: { custom: true },
limits: { context: 100, output: 20 },
providerOptions: { [optionKey]: { reasoningEffort: "high" } },
})
return Model.make({ id: modelID, provider: "native-provider", route: native.route })
},
})
},
loadAISDK: () => Effect.die("AI SDK loader should not be called"),
},
),
)
}),
)
it.effect("loads supported AISDK catalog packages as native routes", () =>
Effect.gen(function* () {
const google = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/google"), { settings: { thinkingConfig: { thinkingBudget: 1_024 } } }),
)
const openrouter = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@openrouter/ai-sdk-provider"), {
settings: { reasoning: { effort: "high" } },
}),
)
const xai = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/xai"), { settings: { reasoningEffort: "high" } }),
)
expect(google.route.id).toBe("gemini")
expect(google.route.defaults.providerOptions).toEqual({
gemini: { thinkingConfig: { thinkingBudget: 1_024 } },
})
expect(openrouter.route.id).toBe("openrouter")
expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { reasoning: { effort: "high" } } })
expect(xai.route.id).toBe("openai-responses")
expect(xai.route.defaults.providerOptions).toEqual({
xai: { reasoningEffort: "high", store: false },
})
}),
)
it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(
@@ -564,8 +554,8 @@ describe("ModelResolver", () => {
}),
)
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/mistral"), {
modelID: "mistral-api-model",
model(Provider.aisdk("@ai-sdk/google"), {
modelID: "gemini-api-model",
settings: { project: "test" },
headers: { "x-aisdk": "header" },
body: { custom: true },
@@ -576,9 +566,9 @@ describe("ModelResolver", () => {
Effect.sync(() => {
expect(runtime).toMatchObject({
id: "test-model",
modelID: "mistral-api-model",
modelID: "gemini-api-model",
providerID: "test-provider",
package: Provider.aisdk("@ai-sdk/mistral"),
package: Provider.aisdk("@ai-sdk/google"),
settings: { project: "test", apiKey: "fallback-secret" },
headers: { "x-aisdk": "header" },
body: { custom: true },
@@ -592,15 +582,15 @@ describe("ModelResolver", () => {
},
)
expect(resolved).toMatchObject({ id: "mistral-api-model", provider: "test-provider" })
expect(resolved).toMatchObject({ id: "gemini-api-model", provider: "test-provider" })
}),
)
it.effect("rejects AISDK packages without an available loader", () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { baseURL: "https://mistral.example/v1" },
model(Provider.aisdk("@ai-sdk/google"), {
settings: { baseURL: "https://google.example/v1" },
}),
).pipe(Effect.flip)
@@ -608,9 +598,9 @@ describe("ModelResolver", () => {
_tag: "SessionRunnerModel.UnsupportedPackageError",
providerID: "test-provider",
modelID: "test-model",
package: "aisdk:@ai-sdk/mistral",
package: "aisdk:@ai-sdk/google",
})
expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/mistral")
expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/google")
}),
)
@@ -622,8 +612,8 @@ describe("ModelResolver", () => {
}),
)
yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { apiKey: "", baseURL: "https://mistral.example/v1" },
model(Provider.aisdk("@ai-sdk/google"), {
settings: { apiKey: "", baseURL: "https://google.example/v1" },
}),
undefined,
{
+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({
+3 -15
View File
@@ -121,11 +121,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 +163,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 +357,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 }))),
@@ -1,5 +1,7 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { Money } from "@opencode-ai/schema/money"
import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
@@ -16,6 +18,7 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
const integrations = yield* Integration.Service
yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations))
@@ -26,6 +29,19 @@ function required<T>(value: T | undefined): T {
return value
}
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
describe("OpenAIPlugin", () => {
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
Effect.gen(function* () {
@@ -45,6 +61,82 @@ describe("OpenAIPlugin", () => {
}),
)
it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-openai"), Model.ID.make("gpt-5")),
modelID: Model.ID.make("gpt-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/openai",
options: { name: "custom-openai", apiKey: "test" },
})
expect(result.sdk?.responses("gpt-5").provider).toBe("custom-openai.responses")
}),
)
it.effect("ignores non-OpenAI SDK packages", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5")),
modelID: Model.ID.make("gpt-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/openai-compatible",
options: { name: "openai" },
})
expect(result.sdk).toBeUndefined()
}),
)
it.effect("uses the Responses API for language models", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: Model.Info.make({
...Model.Info.default(Provider.ID.openai, Model.ID.make("alias")),
modelID: Model.ID.make("gpt-5"),
package: Provider.aisdk("test-provider"),
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
expect(calls).toEqual(["responses:gpt-5"])
expect(result.language).toBeDefined()
}),
)
it.effect("ignores non-OpenAI providers", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: Model.Info.make({
...Model.Info.default(Provider.ID.anthropic, Model.ID.make("gpt-5")),
modelID: Model.ID.make("gpt-5"),
package: Provider.aisdk("test-provider"),
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
expect(calls).toEqual([])
expect(result.language).toBeUndefined()
}),
)
it.effect("disables gpt-5-chat-latest during catalog transforms", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -113,12 +205,7 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" })
expect(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
expect(eligible.cost).toEqual([])
expect(eligible.enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
@@ -156,9 +243,7 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))).enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
}),
)
+3 -39
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")
}),
@@ -143,7 +141,6 @@ describe("Project.resolve", () => {
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.directory).toBe(yield* real(tmp.path))
expect(result.canonical).toBe(result.directory)
expect(result.previous).toBeUndefined()
expect(result.vcs?.type).toBe("git")
}),
@@ -330,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 -46
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,
}),
@@ -171,30 +171,6 @@ describe("Session.create", () => {
}),
)
it.effect("orders sessions by their latest prompt", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const { db } = yield* Database.Service
const active = yield* session.create({ location, title: "active" })
const newer = yield* session.create({ location, title: "newer" })
yield* db
.update(SessionTable)
.set({ time_created: -2, time_updated: -2 })
.where(eq(SessionTable.id, active.id))
.run()
yield* db
.update(SessionTable)
.set({ time_created: -1, time_updated: -1 })
.where(eq(SessionTable.id, newer.id))
.run()
yield* session.prompt({ sessionID: active.id, text: "continue", resume: false })
expect((yield* session.list()).data.map((item) => item.id)).toEqual([active.id, newer.id])
}),
)
it.effect("filters direct child sessions by parent ID", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -208,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,
}),
+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"])
})
})
+2 -74
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"
@@ -223,10 +222,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 +253,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 +325,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()])),
@@ -538,7 +466,7 @@ describe("ShellTool", () => {
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 500 : 50 })),
executeTool(registry, call({ command: timeoutOutputCommand, timeout: 50 })),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
-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),
+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) =>
-10
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"
@@ -649,15 +648,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}`,
@@ -0,0 +1,436 @@
import { OptimizedBuffer, RGBA, TextAttributes } from "@opentui/core"
import { go } from "../logo"
const PERIOD = 4600
const RINGS = 3
const WIDTH = 3.8
const TAIL = 9.5
const AMP = 0.55
const TAIL_AMP = 0.16
const BREATH_AMP = 0.05
const BREATH_SPEED = 0.0008
// Offset so the bg ring emits from the estimated GO center when the logo shimmer peaks.
const PHASE_OFFSET = 0.29
const LOGO_GAP = 1
const LOGO_TOP_BIAS = -1
const LOGO_LEFT_WIDTH = go.left[0]?.length ?? 0
const LOGO_LINES = go.left.map((line, index) => line + " ".repeat(LOGO_GAP) + go.right[index])
const LOGO_WIDTH = LOGO_LINES[0]?.length ?? 0
const LOGO_HEIGHT = LOGO_LINES.length
const SPACE = " ".codePointAt(0)!
const TOP_HALF = "▀".codePointAt(0)!
const FULL_BLOCK = "█".codePointAt(0)!
const RING_SCALE = 1 / RINGS
const TAIL_SCALE = 1 / TAIL
const LOGO_REACH = Math.hypot(LOGO_WIDTH, LOGO_HEIGHT * 2) + 3
const enum LogoCellKind {
Background,
Top,
ShadowTop,
Solid,
Char,
}
type LogoTemplateCell = {
x: number
y: number
kind: LogoCellKind
charCode: number
attributes: number
topDist: number
bottomDist: number
}
const LOGO_TEMPLATE: LogoTemplateCell[] = LOGO_LINES.flatMap((line, y) =>
Array.from(line)
.map((char, x) => {
if (char === " ") return
const kind =
char === "_"
? LogoCellKind.Background
: char === "^"
? LogoCellKind.Top
: char === "~"
? LogoCellKind.ShadowTop
: char === "█"
? LogoCellKind.Solid
: LogoCellKind.Char
return {
x,
y,
kind,
charCode: char.codePointAt(0) ?? SPACE,
attributes: x > LOGO_LEFT_WIDTH ? TextAttributes.BOLD : 0,
topDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 - LOGO_HEIGHT),
bottomDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 + 1 - LOGO_HEIGHT),
}
})
.filter((cell): cell is LogoTemplateCell => !!cell),
)
export type Rgb = [number, number, number]
export type GoUpsellArtRenderOptions = {
deltaTime?: number
rgb?: boolean
cache?: boolean
}
const CACHE_FRAME_COUNT = Math.round(PERIOD / (1000 / 30))
const CACHE_FRAMES_PER_RENDER = 1
export function toRgb(color: RGBA): Rgb {
const [r, g, b] = color.toInts()
return [r, g, b]
}
function clamp(n: number) {
return Math.max(0, Math.min(1, n))
}
function writeRgb(buffer: Uint16Array, offset: number, r: number, g: number, b: number, a = 255) {
buffer[offset] = r
buffer[offset + 1] = g
buffer[offset + 2] = b
buffer[offset + 3] = a
}
function mixChannel(base: number, overlay: number, alpha: number) {
return Math.round(base + (overlay - base) * clamp(alpha))
}
function writeLogoTint(
buffer: Uint16Array,
offset: number,
base: Rgb,
primary: Rgb,
primaryMix: number,
peakMix: number,
) {
const p = clamp(primaryMix)
const q = clamp(peakMix)
const r = mixChannel(mixChannel(base[0], primary[0], p), 255, q)
const g = mixChannel(mixChannel(base[1], primary[1], p), 255, q)
const b = mixChannel(mixChannel(base[2], primary[2], p), 255, q)
writeRgb(buffer, offset, r, g, b)
}
function sameRgb(a: Rgb, b: Rgb) {
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]
}
export class GoUpsellArtPainter {
private panelRgb: Rgb = [0, 0, 0]
private primaryRgb: Rgb = [255, 255, 255]
private logoBaseRgb: Rgb = [180, 180, 180]
private elapsed = 0
private distances = new Float32Array(0)
private edgeFalloff = new Float32Array(0)
private geometryWidth = 0
private geometryHeight = 0
private reach = 1
private logoX = 0
private logoY = 0
private logoIndexes = new Int32Array(0)
private logoRgb: boolean | undefined
private pulsePeak = 0
private pulsePrimary = 0
private cacheDirty = true
private frameCache: Array<{ fg: Uint16Array; bg: Uint16Array }> = []
private cacheBuildIndex = 0
setBackgroundPanel(value: RGBA | Rgb | undefined) {
if (!value) return false
const next = value instanceof RGBA ? toRgb(value) : value
if (sameRgb(this.panelRgb, next)) return false
this.panelRgb = next
this.invalidateCache()
return true
}
setLogoBase(value: RGBA | Rgb | undefined) {
if (!value) return false
const next = value instanceof RGBA ? toRgb(value) : value
if (sameRgb(this.logoBaseRgb, next)) return false
this.logoBaseRgb = next
this.invalidateCache()
return true
}
setPrimary(value: RGBA | Rgb | undefined) {
if (!value) return false
const next = value instanceof RGBA ? toRgb(value) : value
if (sameRgb(this.primaryRgb, next)) return false
this.primaryRgb = next
this.invalidateCache()
return true
}
render(frameBuffer: OptimizedBuffer, options: GoUpsellArtRenderOptions = {}) {
const rgb = options.rgb === true
this.elapsed = (this.elapsed + (options.deltaTime ?? 0)) % PERIOD
this.rebuildGeometry(frameBuffer, rgb)
if (options.cache !== false) {
this.drawCached(frameBuffer, rgb)
return
}
this.drawBackground(frameBuffer, this.elapsed)
this.drawLogo(frameBuffer, this.elapsed, rgb)
}
private invalidateCache() {
this.cacheDirty = true
this.cacheBuildIndex = 0
this.frameCache = []
}
private rebuildGeometry(frameBuffer: OptimizedBuffer, rgb: boolean) {
const width = frameBuffer.width
const height = frameBuffer.height
const geometryChanged = width !== this.geometryWidth || height !== this.geometryHeight
const logoTemplateChanged = this.logoRgb !== rgb
if (!geometryChanged && !logoTemplateChanged) return
if (geometryChanged) {
this.geometryWidth = width
this.geometryHeight = height
this.logoX = Math.max(0, Math.floor((width - LOGO_WIDTH) / 2))
this.logoY = Math.max(
0,
Math.min(Math.max(0, height - LOGO_HEIGHT), Math.round((height - LOGO_HEIGHT) / 2) + LOGO_TOP_BIAS),
)
const centerX = this.logoX + LOGO_WIDTH / 2
const centerY = this.logoY + LOGO_HEIGHT / 2
this.reach = Math.hypot(Math.max(centerX, width - centerX), Math.max(centerY, height - centerY) * 2) + TAIL
this.distances = new Float32Array(width * height)
this.edgeFalloff = new Float32Array(width * height)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const index = y * width + x
const dist = Math.hypot(x + 0.5 - centerX, (y + 0.5 - centerY) * 2)
this.distances[index] = dist
this.edgeFalloff[index] = Math.max(0, 1 - (dist / (this.reach * 0.85)) ** 2)
}
}
}
this.logoRgb = rgb
this.invalidateCache()
this.rebuildCellTemplate(frameBuffer, rgb)
}
private drawCached(frameBuffer: OptimizedBuffer, rgb: boolean) {
if (this.cacheDirty) this.startFrameCache(frameBuffer, rgb)
if (this.cacheBuildIndex < CACHE_FRAME_COUNT) {
this.buildFrameCache(frameBuffer, rgb)
this.drawBackground(frameBuffer, this.elapsed)
this.drawLogo(frameBuffer, this.elapsed, rgb)
return
}
const frame = this.frameCache[Math.floor((this.elapsed / PERIOD) * CACHE_FRAME_COUNT) % CACHE_FRAME_COUNT]
if (frame) {
frameBuffer.buffers.fg.set(frame.fg)
frameBuffer.buffers.bg.set(frame.bg)
}
}
private startFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) {
this.frameCache = []
this.cacheBuildIndex = 0
this.rebuildCellTemplate(frameBuffer, rgb)
this.cacheDirty = false
}
private buildFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) {
const end = Math.min(CACHE_FRAME_COUNT, this.cacheBuildIndex + CACHE_FRAMES_PER_RENDER)
for (; this.cacheBuildIndex < end; this.cacheBuildIndex++) {
const t = (this.cacheBuildIndex / CACHE_FRAME_COUNT) * PERIOD
this.drawBackground(frameBuffer, t)
this.drawLogo(frameBuffer, t, rgb)
this.frameCache.push({
fg: new Uint16Array(frameBuffer.buffers.fg),
bg: new Uint16Array(frameBuffer.buffers.bg),
})
}
}
private rebuildCellTemplate(frameBuffer: OptimizedBuffer, rgb: boolean) {
const buffers = frameBuffer.buffers
buffers.char.fill(SPACE)
buffers.attributes.fill(0)
if (this.geometryWidth < LOGO_WIDTH || this.geometryHeight < LOGO_HEIGHT) {
this.logoIndexes = new Int32Array(0)
return
}
this.logoIndexes = new Int32Array(LOGO_TEMPLATE.length)
for (let i = 0; i < LOGO_TEMPLATE.length; i++) {
const cell = LOGO_TEMPLATE[i]!
const index = (this.logoY + cell.y) * this.geometryWidth + this.logoX + cell.x
this.logoIndexes[i] = index
buffers.attributes[index] = cell.attributes
buffers.char[index] =
cell.kind === LogoCellKind.Background
? SPACE
: cell.kind === LogoCellKind.Top || cell.kind === LogoCellKind.ShadowTop
? TOP_HALF
: cell.kind === LogoCellKind.Solid
? rgb
? TOP_HALF
: FULL_BLOCK
: cell.charCode
}
}
private drawBackground(frameBuffer: OptimizedBuffer, t: number) {
const buffers = frameBuffer.buffers
const fg = buffers.fg
const bg = buffers.bg
const distances = this.distances
const edgeFalloff = this.edgeFalloff
const baseR = this.panelRgb[0]
const baseG = this.panelRgb[1]
const baseB = this.panelRgb[2]
const deltaR = this.primaryRgb[0] - baseR
const deltaG = this.primaryRgb[1] - baseG
const deltaB = this.primaryRgb[2] - baseB
const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP
const phase0 = (t / PERIOD - PHASE_OFFSET + 1) % 1
const phase1 = (t / PERIOD + 1 / RINGS - PHASE_OFFSET + 1) % 1
const phase2 = (t / PERIOD + 2 / RINGS - PHASE_OFFSET + 1) % 1
const envelope0 = Math.sin(phase0 * Math.PI)
const envelope1 = Math.sin(phase1 * Math.PI)
const envelope2 = Math.sin(phase2 * Math.PI)
const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0)
const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1)
const eased2 = envelope2 * envelope2 * (3 - 2 * envelope2)
const head0 = phase0 * this.reach
const head1 = phase1 * this.reach
const head2 = phase2 * this.reach
for (let index = 0; index < distances.length; index++) {
const dist = distances[index]
const delta0 = dist - head0
const abs0 = delta0 < 0 ? -delta0 : delta0
const crest0 = abs0 < WIDTH ? 0.5 + 0.5 * Math.cos((delta0 / WIDTH) * Math.PI) : 0
const tail0 = delta0 < 0 && delta0 > -TAIL ? (1 + delta0 * TAIL_SCALE) ** 2.3 : 0
const delta1 = dist - head1
const abs1 = delta1 < 0 ? -delta1 : delta1
const crest1 = abs1 < WIDTH ? 0.5 + 0.5 * Math.cos((delta1 / WIDTH) * Math.PI) : 0
const tail1 = delta1 < 0 && delta1 > -TAIL ? (1 + delta1 * TAIL_SCALE) ** 2.3 : 0
const delta2 = dist - head2
const abs2 = delta2 < 0 ? -delta2 : delta2
const crest2 = abs2 < WIDTH ? 0.5 + 0.5 * Math.cos((delta2 / WIDTH) * Math.PI) : 0
const tail2 = delta2 < 0 && delta2 > -TAIL ? (1 + delta2 * TAIL_SCALE) ** 2.3 : 0
const level =
(crest0 * AMP + tail0 * TAIL_AMP) * eased0 +
(crest1 * AMP + tail1 * TAIL_AMP) * eased1 +
(crest2 * AMP + tail2 * TAIL_AMP) * eased2
const rawStrength = (level * RING_SCALE + breath) * edgeFalloff[index]
const strength = (rawStrength > 1 ? 1 : rawStrength) * 0.7
const offset = index * 4
const r = Math.round(baseR + deltaR * strength)
const g = Math.round(baseG + deltaG * strength)
const b = Math.round(baseB + deltaB * strength)
bg[offset] = fg[offset] = r
bg[offset + 1] = fg[offset + 1] = g
bg[offset + 2] = fg[offset + 2] = b
bg[offset + 3] = fg[offset + 3] = 255
}
}
private setLogoPulse(dist: number, head0: number, eased0: number, head1: number, eased1: number) {
let peak = 0.04
let primary = 0
const delta0 = dist - head0
const core0 = Math.exp(-(Math.abs(delta0 / 1.2) ** 1.8))
const soft0 = Math.exp(-(Math.abs(delta0 / 7) ** 1.6))
const tail0 = delta0 < 0 && delta0 > -7 ? (1 + delta0 / 7) ** 2.6 : 0
peak += core0 * 0.65 * eased0
primary += (soft0 * 0.16 + tail0 * 0.22) * eased0
const delta1 = dist - head1
const core1 = Math.exp(-(Math.abs(delta1 / 1.2) ** 1.8))
const soft1 = Math.exp(-(Math.abs(delta1 / 7) ** 1.6))
const tail1 = delta1 < 0 && delta1 > -7 ? (1 + delta1 / 7) ** 2.6 : 0
peak += core1 * 0.65 * eased1
primary += (soft1 * 0.16 + tail1 * 0.22) * eased1
this.pulsePeak = peak > 1 ? 1 : peak
this.pulsePrimary = primary > 1 ? 1 : primary
}
private drawLogo(frameBuffer: OptimizedBuffer, t: number, rgb: boolean) {
if (this.logoIndexes.length === 0) return
const buffers = frameBuffer.buffers
const fg = buffers.fg
const bg = buffers.bg
const shadow: Rgb = [
mixChannel(this.panelRgb[0], this.logoBaseRgb[0], 0.25),
mixChannel(this.panelRgb[1], this.logoBaseRgb[1], 0.25),
mixChannel(this.panelRgb[2], this.logoBaseRgb[2], 0.25),
]
const phase0 = (t / PERIOD) % 1
const phase1 = (t / PERIOD + 0.5) % 1
const envelope0 = Math.sin(phase0 * Math.PI)
const envelope1 = Math.sin(phase1 * Math.PI)
const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0)
const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1)
const head0 = phase0 * LOGO_REACH
const head1 = phase1 * LOGO_REACH
for (let i = 0; i < LOGO_TEMPLATE.length; i++) {
const cell = LOGO_TEMPLATE[i]!
const index = this.logoIndexes[i]!
const offset = index * 4
this.setLogoPulse(cell.topDist, head0, eased0, head1, eased1)
const topPeak = this.pulsePeak
const topPrimary = this.pulsePrimary
this.setLogoPulse(cell.bottomDist, head0, eased0, head1, eased1)
const bottomPeak = this.pulsePeak
const bottomPrimary = this.pulsePrimary
if (cell.kind === LogoCellKind.Background) {
writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, Math.max(topPeak, bottomPeak) * 0.18)
continue
}
if (cell.kind === LogoCellKind.Top) {
writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak)
writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, bottomPeak * 0.18)
continue
}
if (cell.kind === LogoCellKind.ShadowTop) {
writeLogoTint(fg, offset, shadow, this.primaryRgb, 0, topPeak * 0.18)
continue
}
if (cell.kind === LogoCellKind.Solid && rgb) {
writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak)
writeLogoTint(bg, offset, this.logoBaseRgb, this.primaryRgb, bottomPrimary, bottomPeak)
continue
}
writeLogoTint(
fg,
offset,
this.logoBaseRgb,
this.primaryRgb,
(topPrimary + bottomPrimary) / 2,
(topPeak + bottomPeak) / 2,
)
}
}
}
+102
View File
@@ -0,0 +1,102 @@
import {
FrameBufferRenderable,
RGBA,
type OptimizedBuffer,
type RenderContext,
type RenderableOptions,
} from "@opentui/core"
import { extend, useRenderer } from "@opentui/solid"
import { onCleanup, onMount } from "solid-js"
import { useTheme, useThemes } from "../context/theme"
import { tint } from "../theme/color"
import { GoUpsellArtPainter } from "./bg-pulse-render"
type GoUpsellArtOptions = RenderableOptions<FrameBufferRenderable> & {
backgroundPanel?: RGBA
primary?: RGBA
logoBase?: RGBA
}
class GoUpsellArtRenderable extends FrameBufferRenderable {
private painter = new GoUpsellArtPainter()
constructor(ctx: RenderContext, options: GoUpsellArtOptions = {}) {
const width = typeof options.width === "number" ? options.width : 1
const height = typeof options.height === "number" ? options.height : 1
super(ctx, {
...options,
width,
height,
live: options.live ?? true,
respectAlpha: false,
})
if (options.width !== undefined && typeof options.width !== "number") this.width = options.width
if (options.height !== undefined && typeof options.height !== "number") this.height = options.height
this.painter.setBackgroundPanel(options.backgroundPanel)
this.painter.setPrimary(options.primary)
this.painter.setLogoBase(options.logoBase)
}
set backgroundPanel(value: RGBA | undefined) {
if (this.painter.setBackgroundPanel(value)) this.requestRender()
}
set logoBase(value: RGBA | undefined) {
if (this.painter.setLogoBase(value)) this.requestRender()
}
set primary(value: RGBA | undefined) {
if (this.painter.setPrimary(value)) this.requestRender()
}
protected override renderSelf(buffer: OptimizedBuffer, deltaTime = 0): void {
if (!this.visible || this.isDestroyed) return
this.painter.render(this.frameBuffer, {
deltaTime,
rgb: this._ctx.capabilities?.rgb === true,
})
super.renderSelf(buffer)
}
}
declare module "@opentui/solid" {
interface OpenTUIComponents {
go_upsell_art: typeof GoUpsellArtRenderable
}
}
extend({ go_upsell_art: GoUpsellArtRenderable })
export function BgPulse() {
const themes = useThemes()
const theme = useTheme("elevated")
const mode = themes.mode
const renderer = useRenderer()
let targetFps = renderer.targetFps
let maxFps = renderer.maxFps
onMount(() => {
targetFps = renderer.targetFps
maxFps = renderer.maxFps
renderer.targetFps = 30
renderer.maxFps = 30
})
onCleanup(() => {
renderer.targetFps = targetFps
renderer.maxFps = maxFps
})
return (
<go_upsell_art
width="100%"
height="100%"
backgroundPanel={theme.background.default}
primary={theme.hue.interactive[mode() === "light" ? 800 : 200]}
logoBase={tint(theme.background.default, theme.text.default, 0.62)}
live
/>
)
}
@@ -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,77 +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"
import { useTerminalDimensions } from "@opentui/solid"
import { truncateFilePath } from "../ui/file-path"
import { stringWidth } from "../util/string-width"
export function DialogProject() {
const dialog = useDialog()
const data = useData()
const route = useRoute()
const paths = useTuiPaths()
const location = useLocation()
const toast = useToast()
const dimensions = useTerminalDimensions()
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) => {
const title = project.name ?? path.basename(project.canonical)
const description = abbreviateHome(project.canonical, paths.home)
// Dialog padding, the current marker, title padding, and the separating space use nine columns.
const width = Math.min(60, dimensions().width - 2) - 9 - stringWidth(title)
return {
title,
description: truncateFilePath(description, width),
searchText: description,
value: project.canonical,
}
})
})
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)
}}
/>
)
}
@@ -0,0 +1,164 @@
import { RGBA, TextAttributes } from "@opentui/core"
import open from "open"
import { createSignal } from "solid-js"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "../ui/dialog"
import { Link } from "../ui/link"
import { BgPulse } from "./bg-pulse"
const GO_URL = "https://opencode.ai/go"
const PAD_X = 3
const PAD_TOP_OUTER = 1
const FOREGROUND_ALPHA = 186
export type DialogRetryActionProps = {
title: string
message: string
label: string
link?: string
onClose?: (dontShowAgain?: boolean) => void
}
function runAction(props: DialogRetryActionProps, dialog: ReturnType<typeof useDialog>) {
if (props.link) open(props.link).catch(() => {})
props.onClose?.()
dialog.clear()
}
function dismiss(props: DialogRetryActionProps, dialog: ReturnType<typeof useDialog>) {
props.onClose?.(true)
dialog.clear()
}
function panelOverlay(color: RGBA) {
const [r, g, b] = color.toInts()
return RGBA.fromInts(r, g, b, FOREGROUND_ALPHA)
}
export function DialogRetryAction(props: DialogRetryActionProps) {
const dialog = useDialog()
const theme = useTheme("elevated")
const showGoTreatment = () => props.link === GO_URL
const textBg = () => (showGoTreatment() ? panelOverlay(theme.background.default) : undefined)
const [selected, setSelected] = createSignal<"dismiss" | "action">("action")
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{
bind: "left",
title: "Previous retry option",
group: "Dialog",
run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
},
{
bind: "right",
title: "Next retry option",
group: "Dialog",
run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
},
{
bind: "tab",
title: "Next retry option",
group: "Dialog",
run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
},
{
bind: "return",
title: "Confirm retry option",
group: "Dialog",
run: () => {
if (selected() === "action") runAction(props, dialog)
else dismiss(props, dialog)
},
},
],
}))
return (
<box>
{showGoTreatment() ? (
<box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
<BgPulse />
</box>
) : null}
<box zIndex={1} paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default} bg={textBg()}>
{props.title}
</text>
<text fg={theme.text.subdued} bg={textBg()} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box gap={0}>
<text fg={theme.text.subdued} bg={textBg()}>
{props.message}
</text>
</box>
{props.link ? (
showGoTreatment() ? (
<box alignItems="center" justifyContent="flex-end" height={7} paddingBottom={1}>
<Link href={props.link} fg={theme.markdown.link} bg={textBg()} wrapMode="none" />
</box>
) : (
<box width="100%" flexDirection="row" justifyContent="center" paddingBottom={1}>
<Link href={props.link} fg={theme.markdown.link} wrapMode="none" />
</box>
)
) : (
<box paddingBottom={1} />
)}
<box flexDirection="row" justifyContent="space-between">
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={
selected() === "dismiss" ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
}
onMouseOver={() => setSelected("dismiss")}
onMouseUp={() => dismiss(props, dialog)}
>
<text
fg={selected() === "dismiss" ? theme.text.action.primary.focused : theme.text.subdued}
bg={selected() === "dismiss" ? undefined : textBg()}
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
>
don't show again
</text>
</box>
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={
selected() === "action" ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
}
onMouseOver={() => setSelected("action")}
onMouseUp={() => runAction(props, dialog)}
>
<text
fg={selected() === "action" ? theme.text.action.primary.focused : theme.text.default}
bg={selected() === "action" ? undefined : textBg()}
attributes={selected() === "action" ? TextAttributes.BOLD : undefined}
>
{props.label}
</text>
</box>
</box>
</box>
</box>
)
}
DialogRetryAction.show = (
dialog: DialogContext,
props: Pick<DialogRetryActionProps, "title" | "message" | "label" | "link">,
) => {
return new Promise<boolean>((resolve) => {
dialog.replace(
() => <DialogRetryAction {...props} onClose={(dontShow) => resolve(dontShow ?? false)} />,
() => resolve(false),
)
})
}
@@ -0,0 +1,113 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
export function DialogSessionDeleteFailed(props: {
session: string
workspace: string
onDelete?: () => boolean | void | Promise<boolean | void>
onRestore?: () => boolean | void | Promise<boolean | void>
onDone?: () => void
}) {
const dialog = useDialog()
const theme = useTheme("elevated")
const [store, setStore] = createStore({
active: "delete" as "delete" | "restore",
})
const options = [
{
id: "delete" as const,
title: "Delete workspace",
description: "Delete the workspace and all sessions attached to it.",
run: props.onDelete,
},
{
id: "restore" as const,
title: "Restore to new workspace",
description: "Try to restore this session into a new workspace.",
run: props.onRestore,
},
]
async function confirm() {
const result = await options.find((item) => item.id === store.active)?.run?.()
if (result === false) return
props.onDone?.()
if (!props.onDone) dialog.clear()
}
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{ bind: "return", title: "Confirm recovery option", group: "Dialog", run: () => void confirm() },
{ bind: "left", title: "Delete broken session", group: "Dialog", run: () => setStore("active", "delete") },
{ bind: "up", title: "Delete broken session", group: "Dialog", run: () => setStore("active", "delete") },
{
bind: "right",
title: "Restore broken session",
group: "Dialog",
run: () => setStore("active", "restore"),
},
{
bind: "down",
title: "Restore broken session",
group: "Dialog",
run: () => setStore("active", "restore"),
},
],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Failed to Delete Session
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={theme.text.subdued} wrapMode="word">
{`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
</text>
<text fg={theme.text.subdued} wrapMode="word">
Choose how you want to recover this broken workspace session.
</text>
<box flexDirection="column" paddingBottom={1} gap={1}>
<For each={options}>
{(item) => (
<box
flexDirection="column"
paddingLeft={1}
paddingRight={1}
paddingTop={1}
paddingBottom={1}
backgroundColor={item.id === store.active ? theme.background.action.primary.focused : undefined}
onMouseUp={() => {
setStore("active", item.id)
void confirm()
}}
>
<text
attributes={TextAttributes.BOLD}
fg={item.id === store.active ? theme.text.action.primary.focused : theme.text.default}
>
{item.title}
</text>
<text
fg={item.id === store.active ? theme.text.action.primary.focused : theme.text.subdued}
wrapMode="word"
>
{item.description}
</text>
</box>
)}
</For>
</box>
</box>
)
}
@@ -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>
}
+52
View File
@@ -0,0 +1,52 @@
import { createMemo, createResource } from "solid-js"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useClient } from "../context/client"
import { useData } from "../context/data"
import { createStore } from "solid-js/store"
export function DialogTag(props: { onSelect?: (value: string) => void }) {
const client = useClient()
const dialog = useDialog()
const data = useData()
const [store] = createStore({
filter: "",
})
const [files] = createResource(
() => [store.filter],
async () => {
const result = await client.api.file
.find({
query: store.filter,
type: "file",
limit: 5,
location: {
directory: data.location.default().directory,
workspace: data.location.default().workspaceID,
},
})
.catch(() => undefined)
return result?.data.map((item) => item.path) ?? []
},
)
const options = createMemo(() =>
(files() ?? []).map((file) => ({
value: file,
title: file,
})),
)
return (
<DialogSelect
title="Autocomplete"
options={options()}
onSelect={(option) => {
props.onSelect?.(option.value)
dialog.clear()
}}
/>
)
}
@@ -283,7 +283,6 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
<TabPulse
enabled={animations()}
active={status().busy && !status().attention}
promptPulse={status().promptPulse}
complete={status().complete && !status().attention}
glow={glows()}
breathe={status().attention}
-20
View File
@@ -4,7 +4,6 @@ import { extend } from "@opentui/solid"
type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
enabled?: boolean
active?: boolean
promptPulse?: number
complete?: boolean
glow?: boolean
breathe?: boolean
@@ -29,7 +28,6 @@ const COMPLETION_OPACITY = 0.18
const EDGE_FLASH_DURATION = 800
const EDGE_FLASH_ATTACK = 0.1
const EDGE_FLASH_OPACITY = 0.1
const PROMPT_FLASH_SCALE = 2
const GLOW_IGNITION_DURATION = 600
const GLOW_IGNITION_PEAK = 1.5
const GLOW_IGNITION_ATTACK = 0.3
@@ -104,11 +102,6 @@ class Envelope {
this.scale = scale
}
restart(scale = 1) {
this.clock = 0
this.scale = scale
}
stop() {
this.clock = undefined
}
@@ -134,7 +127,6 @@ const envelopeActive = (envelope: Envelope) => envelope.active
class TabPulseRenderable extends Renderable {
private _enabled: boolean
private _active: boolean
private _promptPulse: number
private _complete: boolean
private _glow: boolean
private _breathe: boolean
@@ -162,7 +154,6 @@ class TabPulseRenderable extends Renderable {
super(ctx, { ...options, height: 1, live: enabled && active })
this._enabled = enabled
this._active = active
this._promptPulse = options.promptPulse ?? 0
this._complete = options.complete ?? false
this._glow = options.glow ?? false
this._breathe = options.breathe ?? false
@@ -231,15 +222,6 @@ class TabPulseRenderable extends Renderable {
this.requestRender()
}
set promptPulse(value: number) {
if (value === this._promptPulse) return
this._promptPulse = value
if (!this._enabled) return
this.edgeFlash.restart(PROMPT_FLASH_SCALE)
this.live = true
this.requestRender()
}
set complete(value: boolean) {
if (value === this._complete) return
this._complete = value
@@ -386,7 +368,6 @@ extend({ tab_pulse: TabPulseRenderable })
export function TabPulse(props: {
enabled?: boolean
active: boolean
promptPulse?: number
complete?: boolean
glow?: boolean
breathe?: boolean
@@ -404,7 +385,6 @@ export function TabPulse(props: {
width="100%"
enabled={props.enabled ?? true}
active={props.active}
promptPulse={props.promptPulse ?? 0}
complete={props.complete ?? false}
glow={props.glow ?? false}
breathe={props.breathe ?? false}
+24 -45
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]
@@ -1089,11 +1055,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
list(location?: LocationRef) {
return Object.values(store.location[locationKey(location ?? defaultLocation())]?.shell ?? {})
},
listBySession(sessionID: string) {
return Object.values(store.location)
.flatMap((data) => Object.values(data.shell ?? {}))
.filter((shell) => shell.metadata.sessionID === sessionID)
},
get(id: string) {
return Object.values(store.location)
.map((data) => data.shell?.[id])
@@ -1357,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)
+13
View File
@@ -0,0 +1,13 @@
import { createMemo } from "solid-js"
import { useData } from "./data"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "./runtime"
export function useDirectory() {
const data = useData()
const paths = useTuiPaths()
return createMemo(() => {
const directory = data.location.info()?.directory ?? data.location.default().directory ?? paths.cwd
return abbreviateHome(directory, paths.home)
})
}
-2
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 = {
+1 -61
View File
@@ -1,7 +1,6 @@
import { createEffect, createMemo, createSignal, 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
@@ -55,7 +50,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
key: "sessionID",
})
const fallback = empty()
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
let history: SessionTabHistory = { entries: [], index: -1 }
function state() {
@@ -78,7 +72,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const family = members.length > 0 ? members : [session]
return {
unread: state().unread[session],
promptPulse: promptPulses()[session] ?? 0,
attention: family.some(
(id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0,
),
@@ -136,56 +129,9 @@ 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")))
onCleanup(
event.on("session.input.admitted", (evt) => {
if (!enabled() || evt.data.input.type !== "user") return
const sessionID = root(evt.data.sessionID)
if (current() === sessionID || !state().tabs.some((tab) => tab.sessionID === sessionID)) return
setPromptPulses((pulses) => ({ ...pulses, [sessionID]: (pulses[sessionID] ?? 0) + 1 }))
}),
)
onCleanup(
event.on("session.error", (evt) => {
if (evt.data.sessionID) markUnread(evt.data.sessionID, "error")
@@ -211,12 +157,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
draft.tabs = closeSessionTab(draft.tabs, target).tabs
delete draft.unread[target]
})
setPromptPulses((pulses) => {
if (pulses[target] === undefined) return pulses
const next = { ...pulses }
delete next[target]
return next
})
if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" })
}
@@ -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>
)
}
@@ -23,7 +23,7 @@ const FIXTURE_TABS = [
{ sessionID: "fixture-12", title: "Prepare review" },
]
const EMPTY_STATUS: FixtureStatus = { unread: undefined, promptPulse: 0, attention: false, busy: false }
const EMPTY_STATUS: FixtureStatus = { unread: undefined, attention: false, busy: false }
const RUN_DURATION = 1_800
const RESUME_DURATION = 900
+4 -47
View File
@@ -2,10 +2,8 @@ import { PluginContextProvider, type Plugin } from "@opencode-ai/plugin/tui"
import {
batch,
createContext,
createEffect,
createMemo,
For,
on,
onCleanup,
onMount,
useContext,
@@ -35,7 +33,6 @@ import { useDialog } from "../ui/dialog"
import { useToast } from "../ui/toast"
import { useAttention } from "../context/attention"
import { useStorage } from "../context/storage"
import { useSessionTabs } from "../context/session-tabs"
import { abbreviateHome } from "../util/path-format"
import { builtins } from "./builtins"
import { discoverTuiPlugins } from "./discovery"
@@ -97,7 +94,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
const toast = useToast()
const attention = useAttention()
const storage = useStorage()
const sessionTabs = useSessionTabs()
const directory = config.path ? path.dirname(config.path) : process.cwd()
const [store, setStore] = createStore({
ready: false,
@@ -282,33 +278,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
return route.data
},
},
tabs: {
enabled: sessionTabs.enabled,
list: () =>
sessionTabs.tabs().map((tab) => ({
...tab,
active: sessionTabs.current() === tab.sessionID,
...sessionTabs.status(tab.sessionID),
})),
open(sessionID) {
if (!sessionTabs.enabled()) return false
sessionTabs.select(sessionID)
return true
},
focus(sessionID) {
if (!sessionTabs.enabled()) return false
if (!sessionTabs.tabs().some((tab) => tab.sessionID === sessionID)) return false
sessionTabs.select(sessionID)
return true
},
close(sessionID) {
if (!sessionTabs.enabled()) return false
const target = sessionID ?? sessionTabs.current()
if (!target || !sessionTabs.tabs().some((tab) => tab.sessionID === target)) return false
sessionTabs.close(target)
return true
},
},
slot(name, render) {
if (store.registrations[item.plugin.id]?.slots[name]) throw new Error(`Slot already registered: ${name}`)
setStore("registrations", item.plugin.id, "slots", name, () => (input: SlotMap[typeof name]) => (
@@ -374,13 +343,13 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
return true
}
const reconcile = async (configured = config.data.plugins ?? []) => {
const reconcile = async () => {
await Promise.all(
Object.entries(store.registrations)
.filter(([, registration]) => registration.active)
.map(([id]) => deactivate(id)),
)
const entries = [...(await discoverTuiPlugins(paths.cwd)), ...configured]
const entries = [...(await discoverTuiPlugins(paths.cwd)), ...(config.data.plugins ?? [])]
batch(() => {
setStore("registrations", reconcileStore({}))
setStore("states", [])
@@ -454,21 +423,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
])
}
}
let loading = Promise.resolve()
createEffect(
on(
() => JSON.stringify(config.data.plugins ?? []),
() => {
const configured = config.data.plugins ?? []
loading = loading.catch(() => undefined).then(() => reconcile(configured))
void loading.then(
() => setStore("ready", true),
() => setStore("ready", true),
)
},
),
)
onMount(() => {
const loading = reconcile()
let disposing: Promise<void> | undefined
const dispose = () => {
if (disposing) return disposing
@@ -489,6 +445,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
unregister()
void dispose()
})
void loading.finally(() => setStore("ready", true))
})
return (
+2 -3
View File
@@ -27,11 +27,10 @@ export function Home() {
const data = useData()
const location = useLocation()
// Global MCP elicitations can arrive without a session route, so keep them reachable from Home.
const currentLocation = () => route.location ?? data.location.default()
const forms = createMemo(() => data.session.form.list("global", currentLocation()) ?? [])
const forms = createMemo(() => data.session.form.list("global", data.location.default()) ?? [])
let sent = false
createEffect(() => location.set(currentLocation()))
createEffect(() => location.set(data.location.default()))
onMount(() => {
editor.clearSelection()
@@ -16,7 +16,9 @@ export function ShellTab(props: { sessionID: string }) {
const composer = useComposerTab()
const shortcuts = Keymap.useShortcuts()
const entries = createMemo(() => data.shell.listBySession(props.sessionID).filter((shell) => shell.status === "running"))
const entries = createMemo(() =>
data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID && shell.status === "running"),
)
const [store, setStore] = createStore({ selected: 0 })
let scroll: ScrollBoxRenderable | undefined
@@ -0,0 +1,26 @@
import { DialogSelect } from "../../ui/dialog-select"
import { useRoute } from "../../context/route"
export function DialogSubagent(props: { sessionID: string }) {
const route = useRoute()
return (
<DialogSelect
title="Subagent Actions"
options={[
{
title: "Open",
value: "subagent.view",
description: "the subagent's session",
onSelect: (dialog) => {
route.navigate({
type: "session",
sessionID: props.sessionID,
})
dialog.clear()
},
},
]}
/>
)
}
@@ -0,0 +1,91 @@
import { createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js"
import { useTheme } from "../../context/theme"
import { useData } from "../../context/data"
import { useDirectory } from "../../context/directory"
import { useConnected } from "../../component/use-connected"
import { createStore } from "solid-js/store"
import { useRoute } from "../../context/route"
import { usePermission } from "../../context/permission"
export function Footer() {
const theme = useTheme()
const data = useData()
const route = useRoute()
const permission = usePermission()
const mcp = createMemo(
() => (data.location.mcp.server.list() ?? []).filter((x) => x.status.status === "connected").length,
)
const mcpError = createMemo(() => (data.location.mcp.server.list() ?? []).some((x) => x.status.status === "failed"))
const permissions = createMemo(() => {
if (route.data.type !== "session") return []
return data.session.permission.list(route.data.sessionID) ?? []
})
const directory = useDirectory()
const connected = useConnected()
const [store, setStore] = createStore({
welcome: false,
})
onMount(() => {
// Track all timeouts to ensure proper cleanup
const timeouts: ReturnType<typeof setTimeout>[] = []
function tick() {
if (connected()) return
if (!store.welcome) {
setStore("welcome", true)
timeouts.push(setTimeout(() => tick(), 5000))
return
}
if (store.welcome) {
setStore("welcome", false)
timeouts.push(setTimeout(() => tick(), 10_000))
return
}
}
timeouts.push(setTimeout(() => tick(), 10_000))
onCleanup(() => {
timeouts.forEach(clearTimeout)
})
})
return (
<box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}>
<text fg={theme.text.subdued}>{directory()}</text>
<box gap={2} flexDirection="row" flexShrink={0}>
<Switch>
<Match when={store.welcome}>
<text fg={theme.text.default}>
Get started <span style={{ fg: theme.text.subdued }}>/connect</span>
</text>
</Match>
<Match when={connected()}>
<Show when={permission.mode !== "auto" && permissions().length > 0}>
<text fg={theme.text.feedback.warning.default}>
<span style={{ fg: theme.text.feedback.warning.default }}></span> {permissions().length} Permission
{permissions().length > 1 ? "s" : ""}
</text>
</Show>
<Show when={mcp()}>
<text fg={theme.text.default}>
<Switch>
<Match when={mcpError()}>
<span style={{ fg: theme.text.feedback.error.default }}> </span>
</Match>
<Match when={true}>
<span style={{ fg: theme.text.feedback.success.default }}> </span>
</Match>
</Switch>
{mcp()} MCP
</text>
</Show>
<text fg={theme.text.subdued}>/status</text>
</Match>
</Switch>
</box>
</box>
)
}
+32 -77
View File
@@ -98,13 +98,6 @@ addDefaultParsers(parsers.parsers)
// Exclude temporary bottom space when measuring the real transcript height.
const NAVIGATION_SLACK_ID = "session-navigation-slack"
// Tail-first transcript mounting: rows mounted with the session, then backfill cadence.
// The tail comfortably overfills a tall viewport; backfill drains a 200-message transcript
// in a few hundred milliseconds without a perceptible pause.
const TRANSCRIPT_TAIL_ROWS = 40
const TRANSCRIPT_BACKFILL_CHUNK = 60
const TRANSCRIPT_BACKFILL_DELAY = 120
const context = createContext<{
width: number
sessionID: string
@@ -263,7 +256,7 @@ export function Session() {
const sessionID = route.sessionID
void (async () => {
await Promise.all([
data.session.sync(sessionID, { children: true }),
data.session.sync(sessionID),
data.session.permission.sync(sessionID),
data.session.form.sync(sessionID),
])
@@ -303,49 +296,6 @@ export function Session() {
r.set(route.prompt)
}
/** Runs after layout has settled (two frames), unless the transcript was torn down. */
const afterLayout = (continuation: () => void) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (!scroll || scroll.isDestroyed) return
continuation()
})
})
}
// Tail-first transcript mounting: only the newest rows mount when the session opens, and the
// rest backfill in chunks shortly after, so switching to a long session costs the visible tail
// instead of the whole transcript. Until backfill pins the count, the hidden span derives from
// the row count, so it needs no effect ordering; the clamp keeps at least a tail visible when a
// re-reduce shrinks the transcript. Streaming appends land at the end of the visible slice.
const [hiddenRows, setHiddenRows] = createSignal<number>()
const hidden = createMemo(() => Math.max(0, Math.min(hiddenRows() ?? Infinity, rows.length - TRANSCRIPT_TAIL_ROWS)))
const visibleRows = createMemo(() => (hidden() === 0 ? rows : rows.slice(hidden())))
createEffect(() => {
const current = hidden()
if (current === 0) return
// Until the first chunk pins hiddenRows, appends change hidden() and reset this timer, so
// backfill waits for a pause in streaming before starting. Once pinned, it drains on a fixed
// cadence undisturbed by appends.
const timer = setTimeout(() => {
const before = scroll && !scroll.isDestroyed ? scroll.scrollHeight : undefined
const viewportBottom = before === undefined ? 0 : scroll.scrollTop + scroll.viewport.height
setHiddenRows(Math.max(0, current - TRANSCRIPT_BACKFILL_CHUNK))
if (before === undefined) return
// Sticky scroll holds bottom-anchored readers through the mount; compensation is only for
// readers who have scrolled up.
if (viewportBottom >= before - 1) return
afterLayout(() => scroll.scrollBy(scroll.scrollHeight - before))
}, TRANSCRIPT_BACKFILL_DELAY)
onCleanup(() => clearTimeout(timer))
})
/** Message navigation needs the full transcript mounted before walking or jumping. */
const ensureAllRows = (continuation: () => void) => {
if (hidden() === 0) return continuation()
setHiddenRows(0)
afterLayout(continuation)
}
createEffect(() => {
const current = prompt()
if (sent || !current || !synced() || !local.model.ready) return
@@ -372,36 +322,41 @@ export function Session() {
currentSlack: scroll.getRenderable(NAVIGATION_SLACK_ID)?.height ?? 0,
}),
)
afterLayout(() => {
if (navigationMessage() !== messageID) return
scroll.scrollTo(top)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (scroll.isDestroyed || navigationMessage() !== messageID) return
scroll.scrollTo(top)
})
})
}
const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>, userOnly = false) =>
ensureAllRows(() => {
const target = findMessageBoundary({
direction,
children: scroll.getChildren(),
messages: messages(),
scrollTop: scroll.scrollTop,
viewportY: scroll.viewport.y,
currentID: navigationMessage(),
userOnly,
})
const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>, userOnly = false) => {
const target = findMessageBoundary({
direction,
children: scroll.getChildren(),
messages: messages(),
scrollTop: scroll.scrollTop,
viewportY: scroll.viewport.y,
currentID: navigationMessage(),
userOnly,
})
if (target) alignMessage(target.id, target.top)
if (!target) {
dialog.clear()
})
return
}
const jumpToMessage = (messageID: string) =>
ensureAllRows(() => {
const child = scroll.getRenderable(messageID)
if (!child) return
const y = scroll.scrollTop + child.y - scroll.viewport.y
const message = data.session.message.get(route.sessionID, messageID)
alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0)))
})
alignMessage(target.id, target.top)
dialog.clear()
}
const jumpToMessage = (messageID: string) => {
const child = scroll.getRenderable(messageID)
if (!child) return
const y = scroll.scrollTop + child.y - scroll.viewport.y
const message = data.session.message.get(route.sessionID, messageID)
alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0)))
}
function toBottom() {
clearMessageNavigation()
@@ -977,12 +932,12 @@ export function Session() {
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<For each={visibleRows()}>
<For each={rows}>
{(row, index) => (
<SessionRowView
row={row}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
boundaryID={boundaries()[index() + hidden()]}
boundaryID={boundaries()[index()]}
/>
)}
</For>
+7 -12
View File
@@ -97,16 +97,11 @@ export function createSessionRows(sessionID: Accessor<string>) {
}),
)
// Re-reduce when the revert boundary changes (stage/clear/commit). These reactions defer
// their first run: the mount effect above has already reduced the same state.
// Re-reduce when the revert boundary changes (stage/clear/commit).
createEffect(
on(
revertBoundary,
() => {
setRows(reconcile(reduce()))
},
{ defer: true },
),
on(revertBoundary, () => {
setRows(reconcile(reduce()))
}),
)
createEffect(
@@ -117,7 +112,6 @@ export function createSessionRows(sessionID: Accessor<string>) {
.filter((item) => item.type === "compaction")
.map((item) => item.id),
() => setRows(reconcile(reduce())),
{ defer: true },
),
)
@@ -143,11 +137,12 @@ export function createSessionRows(sessionID: Accessor<string>) {
: [],
),
() => setRows(reconcile(reduce())),
{ defer: true },
),
)
createEffect(on(turnTokens, () => setRows(reconcile(reduce())), { defer: true }))
createEffect(
on(turnTokens, () => setRows(reconcile(reduce()))),
)
const appendMessage = (messageID: string) =>
setRows(
@@ -0,0 +1,115 @@
import { createMemo, createSignal, Show } from "solid-js"
import { useRouteData } from "../../context/route"
import { useData } from "../../context/data"
import { useTheme } from "../../context/theme"
import { SplitBorder } from "../../ui/border"
import { Locale } from "../../util/locale"
import { useTerminalDimensions } from "@opentui/solid"
import { Keymap } from "../../context/keymap"
import { contextUsage, formatContextUsage } from "../../util/session"
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
})
export function SubagentFooter() {
const route = useRouteData("session")
const data = useData()
const session = createMemo(() => data.session.get(route.sessionID))
const subagentInfo = createMemo(() => {
const s = session()
if (!s) return "Subagent"
const agentMatch = s.title.match(/@(\w+) subagent/)
return agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
})
const usage = createMemo(() => {
const current = session()
if (!current) return
const cost = current.cost
const formattedCost = cost > 0 ? money.format(cost) : undefined
const context = contextUsage(
data.session.message.list(route.sessionID),
data.location.model.list(current.location),
current.revert?.messageID,
)
return {
context: context ? formatContextUsage(context.tokens, context.percent) : undefined,
cost: formattedCost,
}
})
const theme = useTheme("elevated")
const keymap = Keymap.use()
const shortcuts = Keymap.useShortcuts()
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
useTerminalDimensions()
return (
<box flexShrink={0}>
<box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={1}
{...SplitBorder}
border={["left"]}
borderColor={theme.border.default}
flexShrink={0}
backgroundColor={theme.background.default}
>
<box flexDirection="row" justifyContent="space-between" gap={1}>
<box flexDirection="row" gap={1}>
<text fg={theme.text.default}>
<b>{subagentInfo()}</b>
</text>
<Show when={usage()}>
{(item) => (
<text fg={theme.text.subdued} wrapMode="none">
{[item().context, item().cost].filter(Boolean).join(" · ")}
</text>
)}
</Show>
</box>
<box flexDirection="row" gap={2}>
<box
onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatch("session.parent")}
backgroundColor={
hover() === "parent" ? theme.background.action.primary.hovered : theme.background.default
}
>
<text fg={theme.text.default}>
Parent <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.parent")}</span>
</text>
</box>
<box
onMouseOver={() => setHover("prev")}
onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatch("session.child.previous")}
backgroundColor={hover() === "prev" ? theme.background.action.primary.hovered : theme.background.default}
>
<text fg={theme.text.default}>
Prev <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.child.previous")}</span>
</text>
</box>
<box
onMouseOver={() => setHover("next")}
onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatch("session.child.next")}
backgroundColor={hover() === "next" ? theme.background.action.primary.hovered : theme.background.default}
>
<text fg={theme.text.default}>
Next <span style={{ fg: theme.text.subdued }}>{shortcuts.get("session.child.next")}</span>
</text>
</box>
</box>
</box>
</box>
</box>
)
}
+2 -2
View File
@@ -615,14 +615,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
when={props.renderFilter !== false && store.filter.length > 0}
fallback={
props.emptyView ?? (
<box paddingLeft={4} paddingRight={4}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.subdued}>No items available</text>
</box>
)
}
>
{props.noMatchView ?? (
<box paddingLeft={4} paddingRight={4}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.subdued}>No results found</text>
</box>
)}
-56
View File
@@ -1,56 +0,0 @@
import type { RGBA } from "@opentui/core"
import { createEffect, createMemo, createSignal, Show } from "solid-js"
import { useConfig } from "../config"
import { tint } from "../theme/color"
import { createAnimatable, tween } from "./animation"
import { FilePath } from "./file-path"
// FilePath that crossfades when its value changes: the old path fades to the
// background, the text swaps at the midpoint, and the new path fades back in.
// The initial value renders immediately; only subsequent changes animate.
export function FadeFilePath(props: {
value: string | undefined
maxWidth: number
fg: RGBA
bg: RGBA
basenameFg?: RGBA
}) {
const config = useConfig().data
const fade = createAnimatable(
{ front: 1 },
{
enabled: () => config.animations ?? true,
transition: tween({ duration: 0.3 }),
},
)
const [text, setText] = createSignal(props.value)
const [previous, setPrevious] = createSignal<string>()
createEffect((current: string | undefined) => {
const next = props.value
if (next === undefined || next === current) return current
setText(next)
if (current === undefined) return next
setPrevious(current)
fade.jump({ front: 0 })
fade.animate({ front: 1 })
return next
}, props.value)
const display = createMemo(() => (previous() !== undefined && fade.value().front < 0.5 ? previous() : text()))
const fg = createMemo(() => {
if (previous() === undefined || fade.value().front >= 1) return props.fg
return tint(props.bg, props.fg, Math.abs(fade.value().front * 2 - 1))
})
return (
<Show when={display() !== undefined}>
<FilePath
value={display() ?? ""}
maxWidth={props.maxWidth}
fg={fg()}
basenameFg={fade.value().front >= 1 ? props.basenameFg : undefined}
/>
</Show>
)
}
+3 -8
View File
@@ -62,14 +62,9 @@ export function truncateFilePath(value: string, maxWidth: number) {
const separatorWidth = stringWidth(separator)
let width = stringWidth(prefix + basename)
for (let index = segments.length - 2; index >= 0; index--) {
const segment = segments[index]!
const next = stringWidth(segment) + separatorWidth
if (width + next > maxWidth) {
const available = maxWidth - width - separatorWidth
if (available > 1) selected.unshift(takeStart(segment, available - 1) + "…")
break
}
selected.unshift(segment)
const next = stringWidth(segments[index]!) + separatorWidth
if (width + next > maxWidth) break
selected.unshift(segments[index]!)
width += next
}
return prefix + selected.join(separator)
+9 -83
View File
@@ -11,7 +11,7 @@ import { ClientProvider, useClient } from "../../../src/context/client"
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
import { LocationProvider, useLocation } from "../../../src/context/location"
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client"
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
@@ -71,13 +71,11 @@ function durable(sessionID: string, seq = 0, version = 1) {
return { aggregateID: sessionID, seq, version }
}
test("does not preload session summaries into the data context", async () => {
test("preloads root sessions before applying the session limit", async () => {
const events = createEventStream()
let location = false
let sessions = false
let request: URL | undefined
const calls = createFetch((url) => {
if (url.pathname === "/api/location") location = true
if (url.pathname === "/api/session") sessions = true
if (url.pathname === "/api/session") request = url
return undefined
}, events)
@@ -94,58 +92,10 @@ test("does not preload session summaries into the data context", async () => {
))
try {
await wait(() => location)
await Bun.sleep(20)
expect(sessions).toBe(false)
} finally {
app.renderer.destroy()
}
})
test("proactively syncs project metadata", async () => {
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname !== "/api/project") return
return json([
{
id: "proj_test",
canonical: worktree,
name: "OpenCode",
time: { created: 1, updated: 2 },
sandboxes: [],
},
])
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
await wait(() => data.project.get("proj_test") !== undefined)
expect(data.project.list()).toEqual([
{
id: "proj_test",
canonical: worktree,
name: "OpenCode",
time: { created: 1, updated: 2 },
sandboxes: [],
},
])
await wait(() => request !== undefined)
expect(request?.searchParams.get("project")).toBe("proj_test")
expect(request?.searchParams.get("limit")).toBe("50")
expect(request?.searchParams.get("parentID")).toBe("null")
} finally {
app.renderer.destroy()
}
@@ -2669,18 +2619,8 @@ function sessionInfo(id: string, parentID: string | undefined, cost = 0) {
// the family-index tests below.
async function mountData(parents: Record<string, string>, costs: Record<string, number> = {}) {
const calls = createFetch((url) => {
if (url.pathname === "/api/session") {
const parentID = url.searchParams.get("parentID")
return json({
data: Object.entries(parents)
.filter(([, parent]) => parent === parentID)
.map(([id, parent]) => sessionInfo(id, parent, costs[id])),
cursor: {},
})
}
const match = url.pathname.match(/^\/api\/session\/([^/]+)$/)
if (match && match[1] !== "active")
return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) })
if (match && match[1] !== "active") return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) })
})
let data!: ReturnType<typeof useData>
let ready!: () => void
@@ -2707,20 +2647,6 @@ async function mountData(parents: Record<string, string>, costs: Record<string,
return { data, app }
}
test("syncs direct child session info with a navigated root", async () => {
const { data, app } = await mountData({ child: "root", sibling: "root", grandchild: "child" })
try {
await data.session.sync("root", { children: true })
expect(data.session.get("root")?.id).toBe("root")
expect(data.session.get("child")?.parentID).toBe("root")
expect(data.session.get("sibling")?.parentID).toBe("root")
expect(data.session.get("grandchild")).toBeUndefined()
expect(data.session.family("root")).toEqual(["root", "child", "sibling"])
} finally {
app.renderer.destroy()
}
})
test("groups an orphan child under its missing parent until the root arrives", async () => {
const { data, app } = await mountData({ child: "root" })
try {
@@ -185,19 +185,6 @@ test("dialog actions run without options while row actions still require a selec
}
})
test("renders one gap before an empty state", async () => {
await using tmp = await tmpdir()
const app = await renderSelect(tmp.path, [], () => {}, () => {})
try {
await app.waitForFrame((frame) => frame.includes("No items available"))
const lines = app.captureCharFrame().split("\n").map((line) => line.trim())
expect(lines.indexOf("No items available") - lines.indexOf("Search")).toBe(2)
} finally {
app.renderer.destroy()
}
})
test("footer actions run when filtering leaves no selected row", async () => {
await using tmp = await tmpdir()
let global = 0
@@ -1,10 +1,6 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { createSignal } from "solid-js"
import {
TabPulse,
blendTabPulseColor,
completionPulseOpacity,
glowIgnitionLevel,
@@ -12,50 +8,6 @@ import {
} from "../../src/component/tab-pulse"
import { tint } from "../../src/theme/color"
test("a prompt pulse restarts the neutral edge flash while the tab remains busy", async () => {
const background = RGBA.fromHex("#101010")
const flash = RGBA.fromHex("#f0f0f0")
const [promptPulse, setPromptPulse] = createSignal(0)
const app = await testRender(
() => (
<box width={8} height={1} backgroundColor={background}>
<TabPulse
active={true}
promptPulse={promptPulse()}
color={background}
flashColor={flash}
backgroundColor={background}
/>
</box>
),
{ width: 8, height: 1 },
)
const firstBackground = () => app.captureSpans().lines[0]?.spans[0]?.bg
try {
await app.renderOnce()
expect(firstBackground()?.equals(background)).toBeTrue()
setPromptPulse(1)
await Bun.sleep(80)
await app.renderOnce()
expect(firstBackground()?.equals(background)).toBeFalse()
expect(firstBackground()?.r ?? 0).toBeGreaterThan(0.17)
await Bun.sleep(800)
await app.renderOnce()
expect(firstBackground()?.equals(background)).toBeTrue()
setPromptPulse(2)
await Bun.sleep(80)
await app.renderOnce()
expect(firstBackground()?.equals(background)).toBeFalse()
} finally {
app.renderer.destroy()
}
})
test("completion pulse rises quickly and fades over the remaining duration", () => {
expect(completionPulseOpacity(0)).toBe(0)
expect(completionPulseOpacity(0.06)).toBeCloseTo(0.5)
@@ -1,110 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client"
import { testRender } from "@opentui/solid"
import { mkdtempSync, rmSync } from "fs"
import { tmpdir } from "os"
import path from "path"
import { ConfigProvider } from "../../src/config"
import { ClientProvider, useClient } from "../../src/context/client"
import { DataProvider } from "../../src/context/data"
import { RouteProvider, useRoute } from "../../src/context/route"
import { TuiAppProvider } from "../../src/context/runtime"
import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs"
import { StorageProvider } from "../../src/context/storage"
import { createApi, createEventStream, createFetch, directory } from "../fixture/tui-client"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
async function wait(fn: () => boolean, timeout = 2_000) {
const start = Date.now()
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
test("user prompt admissions pulse an already-busy background tab", async () => {
const state = mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-"))
const events = createEventStream()
const calls = createFetch(undefined, events)
let tabs!: ReturnType<typeof useSessionTabs>
let route!: ReturnType<typeof useRoute>
let client!: ReturnType<typeof useClient>
function Probe() {
tabs = useSessionTabs()
route = useRoute()
client = useClient()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts paths={{ state }}>
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<StorageProvider>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<RouteProvider initialRoute={{ type: "session", sessionID: "background" }}>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<SessionTabsProvider>
<Probe />
</SessionTabsProvider>
</DataProvider>
</ClientProvider>
</RouteProvider>
</ConfigProvider>
</StorageProvider>
</TuiAppProvider>
</TestTuiContexts>
))
const emit = (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } })
const admitted = (sessionID: string, inputID: string): OpenCodeEvent => ({
id: `evt_${inputID}`,
created: Date.now(),
type: "session.input.admitted",
durable: { aggregateID: sessionID, seq: Number(inputID.replace(/\D/g, "")), version: 1 },
data: {
sessionID,
inputID,
input: { type: "user", data: { text: inputID }, delivery: "steer" },
},
})
try {
await wait(
() => client.connection.status() === "connected" && tabs.tabs().some((tab) => tab.sessionID === "background"),
)
route.navigate({ type: "session", sessionID: "active" })
await wait(() => tabs.current() === "active" && tabs.tabs().length === 2)
emit({
id: "evt_context",
created: Date.now(),
type: "session.input.admitted",
durable: { aggregateID: "background", seq: 0, version: 1 },
data: {
sessionID: "background",
inputID: "msg_context",
input: { type: "synthetic", data: { text: "editor context" }, delivery: "steer" },
},
})
await Bun.sleep(20)
expect(tabs.status("background").promptPulse).toBe(0)
emit(admitted("background", "msg_1"))
await wait(() => tabs.status("background").promptPulse === 1 && tabs.status("background").busy)
emit(admitted("background", "msg_2"))
await wait(() => tabs.status("background").promptPulse === 2)
emit(admitted("active", "msg_3"))
await Bun.sleep(20)
expect(tabs.status("active").promptPulse).toBe(0)
expect(tabs.status("background")).toMatchObject({ promptPulse: 2, busy: true })
} finally {
app.renderer.destroy()
rmSync(state, { recursive: true, force: true })
}
})
+9 -11
View File
@@ -93,26 +93,24 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
if (url.pathname === "/experimental/console") return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
if (url.pathname === "/experimental/capabilities") return json({ backgroundSubagents: true })
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
if (url.pathname === "/api/location")
return json({ directory, project: { id: "proj_test", directory: worktree, canonical: worktree } })
if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } })
if (url.pathname === "/api/fs/list")
return json({ location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } }, data: [] })
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree })
if (url.pathname === "/api/project") return json([])
if (url.pathname === "/api/project/proj_test/directories") return json([{ directory: worktree }])
if (url.pathname === "/api/shell")
return json({ location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } }, data: [] })
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (url.pathname === "/api/mcp")
return json({ location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } }, data: [] })
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (url.pathname === "/api/mcp/resource")
return json({
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
location: { directory, project: { id: "proj_test", directory: worktree } },
data: { resources: [], templates: [] },
})
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/active") return json({ data: {} })
if (url.pathname === "/api/permission/request")
return json({ location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } }, data: [] })
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (url.pathname === "/api/form/request")
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (/^\/api\/session\/[^/]+\/form$/.test(url.pathname)) return json({ data: [] })
@@ -122,13 +120,13 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
)
)
return json({
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
location: { directory, project: { id: "proj_test", directory: worktree } },
data: [],
})
if (url.pathname === "/api/reference")
return json({ location: { directory, project: { id: "proj_test", directory, canonical: directory } }, data: [] })
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
if (url.pathname === "/api/websearch/provider") {
return json({ location: { directory, project: { id: "proj_test", directory, canonical: directory } }, data: [] })
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
}
if (url.pathname === "/provider") return json({ all: [], default: {}, connected: [] })
if (url.pathname === "/session") return json([])
+8 -8
View File
@@ -78,7 +78,7 @@ describe("run interactive runtime", () => {
directory: "/tmp",
target: async () => ({
sessionID: "ses_root",
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
agent: "build",
model: undefined,
variant: undefined,
@@ -130,7 +130,7 @@ describe("run interactive runtime", () => {
await refreshCatalog?.()
expect(defaultModel).toHaveBeenCalledTimes(1)
selected.resolve({
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
data: model,
})
while (defaultModel.mock.calls.length < 2) await Bun.sleep(0)
@@ -165,7 +165,7 @@ describe("run interactive runtime", () => {
directory: "/tmp",
target: async () => ({
sessionID: "ses_root",
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
agent: "build",
model: { providerID: "test", modelID: "model" },
variant: undefined,
@@ -261,7 +261,7 @@ describe("run interactive runtime", () => {
return {
sessionID: "ses-deferred",
sessionTitle: "Deferred",
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
agent: "build",
model: { providerID: "openai", modelID: "gpt-5" },
variant: undefined,
@@ -349,7 +349,7 @@ describe("run interactive runtime", () => {
target: async () => ({
sessionID: "ses-resume",
sessionTitle: "Resume",
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
agent: "review",
model: { providerID: "openai", modelID: "gpt-5" },
variant: "high",
@@ -432,7 +432,7 @@ describe("run interactive runtime", () => {
target: async () => ({
sessionID: "ses-resume-abort",
sessionTitle: "Cached title",
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
agent: "build",
model: undefined,
variant: undefined,
@@ -490,7 +490,7 @@ describe("run interactive runtime", () => {
location: {
directory: "/session",
workspaceID: "work-1",
project: { id: "pro-1", directory: "/session", canonical: "/session" },
project: { id: "pro-1", directory: "/session" },
},
data: [{ path: "src/index.ts", type: "file" }],
} as never)
@@ -507,7 +507,7 @@ describe("run interactive runtime", () => {
location: {
directory: "/session",
workspaceID: "work-1",
project: { id: "location-project", directory: "/session", canonical: "/session" },
project: { id: "location-project", directory: "/session" },
},
agent: "review",
model: { providerID: "openai", modelID: "gpt-5" },
@@ -167,11 +167,7 @@ function sdk(input: {
location: {
directory: input.globalLocation?.directory ?? "/tmp",
workspaceID: input.globalLocation?.workspaceID,
project: {
id: "proj_1",
directory: input.globalLocation?.directory ?? "/tmp",
canonical: input.globalLocation?.directory ?? "/tmp",
},
project: { id: "proj_1", directory: input.globalLocation?.directory ?? "/tmp" },
},
data: input.globals ?? [],
}),
-5
View File
@@ -14,11 +14,6 @@ describe("truncateFilePath", () => {
expect(truncateFilePath(path, 19)).toBe("…/dialog-select.tsx")
})
test("uses remaining width for part of a long parent segment", () => {
const path = "/private/var/folders/run-17f048ec-dbb2-4b36-860c-98637bb51a8d/files"
expect(truncateFilePath(path, 40)).toBe("/…/run-17f048ec-dbb2-4b36-860c-98…/files")
})
test("preserves the extension when the basename must shrink", () => {
expect(truncateFilePath(path, 16)).toBe("…/dialog-se….tsx")
expect(truncateFilePath("dialog-select.tsx", 12)).toBe("dialog-….tsx")
+17 -17
View File
@@ -28,13 +28,6 @@ export namespace FSUtil {
readonly type: "file" | "directory" | "symlink" | "other"
}
export interface UpOptions {
readonly targets: string[]
readonly start: string
readonly stop?: string
readonly mode?: "all" | "first"
}
export interface Interface extends FileSystem.FileSystem {
readonly isDir: (path: string) => Effect.Effect<boolean>
readonly isFile: (path: string) => Effect.Effect<boolean>
@@ -47,7 +40,7 @@ export namespace FSUtil {
readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
readonly resolve: (path: string) => Effect.Effect<string>
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
readonly up: (options: UpOptions) => Effect.Effect<string[], Error>
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
readonly scan: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
readonly globMatch: (pattern: string, filepath: string) => boolean
@@ -160,16 +153,27 @@ export namespace FSUtil {
})
})
const up = Effect.fn("FileSystem.up")(function* (options: UpOptions) {
const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
const result: string[] = []
let current = start
while (true) {
const search = join(current, target)
if (yield* fs.exists(search)) result.push(search)
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
return result
})
const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
const result: string[] = []
let current = options.start
while (true) {
for (const target of options.targets) {
const search = join(current, target)
if (yield* fs.exists(search)) {
result.push(search)
if (options.mode === "first") return result
}
if (yield* fs.exists(search)) result.push(search)
}
if (options.stop === current) break
const parent = dirname(current)
@@ -179,10 +183,6 @@ export namespace FSUtil {
return result
})
const findUp = Effect.fn("FileSystem.findUp")((target: string, start: string, stop?: string) =>
up({ targets: [target], start, stop }),
)
const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
const result: string[] = []
let current = start

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