Compare commits

...

29 Commits

Author SHA1 Message Date
Simon Klee f870e771e9 tui: simplify prompt input sync
Drop the burst-aware key interceptor that tried to keep derived
prompt state current for every control binding. Frame-batch content
updates only, and let exit, clear, stash, and autocomplete read or
flush live textarea state at the command boundary instead.
2026-07-30 20:42:51 +02:00
Simon Klee 9f72479515 tui: flush prompt sync before commands
Coalescing burst text to one frame update hid pending
input from submit and other prompt keys. Flush before
bound commands so they see the full text while plain
typing stays frame-batched.
2026-07-30 20:42:51 +02:00
Simon Klee 0fc1d61fdc tui: coalesce prompt sync on text bursts
Per-keystroke store, autocomplete, and extmark work made large
stdin paste bursts stall the input path. Defer one microtask
sync per burst and skip mention scanning when text has no @.
2026-07-30 20:42:50 +02:00
Kit Langton 188c642d8c refactor(tui): flatten project picker list (#39726) 2026-07-30 14:18:16 -04:00
Kit Langton 9d55b223bb fix(core): stop repository discovery at nearest marker (#39714) 2026-07-30 13:28:29 -04:00
Kit Langton ce7a7e4e23 fix(tui): truncate project picker paths (#39678) 2026-07-30 13:13:31 -04:00
Kit Langton cba5ba03e3 fix(tui): show shells from session location (#39691) 2026-07-30 13:12:01 -04:00
Kit Langton 49081a4e24 fix(tui): flash tabs on prompt admission (#39702) 2026-07-30 16:57:36 +00:00
opencode-agent[bot] d6371f2fcd fix(session): update activity on prompt (#39539)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-07-30 16:28:10 +00:00
Aiden Cline 7625cbdf47 feat(core): route providers through native AI (#39615) 2026-07-30 11:26:18 -05:00
opencode-agent[bot] 0ffec67ca3 refactor: remove unused V2 code (#39699)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-30 11:25:03 -05:00
Kit Langton bd132f2614 refactor(core): skip redundant worktree lookup (#39692) 2026-07-30 16:15:07 +00:00
opencode-agent[bot] dcb0df4ac1 fix(core): stop MCP SSE error reconnect loops (#39671)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-30 10:53:33 -05:00
Kit Langton 02f3504055 fix(tui): hide redundant session directory labels (#39686) 2026-07-30 11:37:33 -04:00
James Long f23ee5e4a8 refactor(core): simplify formatter selection (#39575) 2026-07-30 10:59:01 -04:00
Kit Langton 77aa85c589 feat(tui): project picker with footer crossfade (#39566) 2026-07-30 10:39:22 -04:00
Dax Raad a618946b7e fix(tui): reload plugins with config 2026-07-30 10:31:09 -04:00
opencode-agent[bot] f9de608dea chore: refresh bun lockfile (#39617)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-30 04:36:21 +00:00
Aiden Cline 906dc8f5b2 fix(ai): apply catalog settings to provider models (#39613) 2026-07-29 22:42:47 -05:00
Aiden Cline 12971935ab feat(core): parse shell permission commands (#39567) 2026-07-29 22:42:18 -05:00
Kit Langton 78c139b8b5 feat(plugin): add ui.tabs API for session tab control (#39591) 2026-07-29 22:57:39 -04:00
Kit Langton c161978852 feat(tui): prefetch open session tabs after connect (#39589) 2026-07-29 22:23:13 -04:00
Kit Langton cb37a7166a feat(tui): make session tab switching fast for long transcripts (#39568) 2026-07-29 22:23:10 -04:00
Dax Raad 488445a679 fix(tui): correct project-aware session lists 2026-07-29 21:59:33 -04:00
opencode-agent[bot] 97786afdd8 refactor(core): share file diff construction (#39586)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-30 00:28:33 +00:00
Aiden Cline b1a5e8a6ae test(tui): restore compaction event lifecycle (#39581) 2026-07-29 18:11:58 -05:00
Aiden Cline 0ece10af43 fix(core): add mutation permission previews (#39578) 2026-07-29 18:04:13 -05:00
Aiden Cline d1a02b149c fix(core): clarify subagent tool guidance (#39572) 2026-07-29 16:48:49 -05:00
James Long 94ee274aeb feat(core): add V2 formatter runtime (#39564) 2026-07-29 17:23:04 -04:00
113 changed files with 3048 additions and 1614 deletions
+16 -2
View File
@@ -143,7 +143,10 @@
"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": {
@@ -402,8 +405,11 @@
"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:",
},
@@ -1073,9 +1079,11 @@
},
"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",
@@ -4072,10 +4080,10 @@
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"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=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"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=="],
@@ -5634,6 +5642,10 @@
"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=="],
@@ -6856,6 +6868,8 @@
"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=="],
+19 -1
View File
@@ -5,6 +5,7 @@ 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"
@@ -30,6 +31,12 @@ 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),
])
@@ -113,4 +120,15 @@ export const configure = (input: ModelOptions = {}) => {
}
export const provider = configure()
export const model = provider.model
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)
+50 -12
View File
@@ -1,31 +1,62 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { RouteDefaultsInput } from "../route/client"
import { HttpOptions, ProviderID, type ModelID } from "../schema"
import { Route, type RouteDefaultsInput } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { HttpOptions, ProviderID, type ModelID, type ProviderOptions } 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 { OpenAIProviderOptionsInput } from "./openai-options"
import type { OpenAIOptionsInput } from "./openai-options"
import type { ProviderPackage } from "../provider-package"
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?: OpenAIProviderOptionsInput
readonly providerOptions?: XAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: XAIProviderOptionsInput
}
export type { XAIImageOptions } from "../protocols/xai-images"
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
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]
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
const configuredResponsesRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return OpenAIResponses.route.with({
return responsesRoute.with({
...rest,
provider: id,
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input),
})
@@ -33,9 +64,8 @@ const configuredResponsesRoute = (input: ModelOptions) => {
const configuredChatRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return OpenAICompatibleChat.route.with({
return chatRoute.with({
...rest,
provider: id,
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input),
})
@@ -44,8 +74,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<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) => chatRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
const responses = (modelID: string | ModelID) => responsesRoute.model<XAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) => chatRoute.model<XAIProviderOptionsInput>({ id: modelID })
const image = (modelID: string | ModelID) =>
XAIImages.model({
id: modelID,
@@ -65,7 +95,15 @@ export const configure = (input: ModelOptions = {}) => {
}
export const provider = configure()
export const model = provider.model
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 responses = provider.responses
export const chat = provider.chat
export const image = provider.image
-2
View File
@@ -53,9 +53,7 @@ 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: { openai: { reasoningEffort: "high" } } })
LLM.request({ model, prompt: "Hello", providerOptions: { xai: { reasoningEffort: "high" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error xAI's OpenAI-compatible reasoning effort must be a string.
providerOptions: { openai: { reasoningEffort: true } },
providerOptions: { xai: { reasoningEffort: true } },
})
+31
View File
@@ -21,6 +21,8 @@ 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()
@@ -29,6 +31,35 @@ 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)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.map(normalizeProjectInfo)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
}),
@@ -168,6 +168,7 @@ 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,
}
}
+10 -3
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) ?? "" },
project: { id: "", directory: directory(value) ?? "", canonical: directory(value) ?? "" },
},
data,
})
@@ -298,12 +298,19 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
project: {
...input.current.project,
async list() {
return ((await legacy().project.list()).data ?? []) as Project[]
return ((await legacy().project.list()).data ?? []).map((project) => ({
...project,
canonical: project.worktree,
}))
},
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 } satisfies ProjectCurrent
return {
id: result.data.id,
directory: result.data.worktree,
canonical: 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,6 +40,9 @@
"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 build(mainConfig(input))
await copyNodeAssets(assets)
await build(mainConfig(input))
const host = target.platform === process.platform && target.arch === process.arch
if (host) {
+5 -1
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 } from "../src/node/target"
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
const dir = path.resolve(import.meta.dirname, "..")
@@ -43,6 +43,10 @@ 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,6 +29,11 @@ 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 } }
return { directory, workspaceID, project: { id: "project", directory, canonical: directory } }
}
function session(id: string, directory: string, workspaceID?: string, model?: ModelRef): SessionInfo {
+10 -1
View File
@@ -3,7 +3,13 @@ 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 } from "./src/node/target"
import {
nodeExecArgv,
nodeTarget,
type NodeTarget,
photonWasmAsset,
shellParserWasmAssets,
} from "./src/node/target"
const dir = import.meta.dirname
@@ -194,6 +200,9 @@ 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 {
+43 -39
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 }
export type ProjectCurrent = { id: string; directory: string; canonical: 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
worktree: string
canonical: string
vcs?: ProjectVcs
name?: string
icon?: ProjectIcon
@@ -2515,7 +2515,11 @@ export type LocationGetInput = {
}["location"]
}
export type LocationGetOutput = { directory: string; workspaceID?: string; project: { id: string; directory: string } }
export type LocationGetOutput = {
directory: string
workspaceID?: string
project: { id: string; directory: string; canonical: string }
}
export type AgentListInput = {
readonly location?: {
@@ -2524,7 +2528,7 @@ export type AgentListInput = {
}
export type AgentListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<AgentInfo>
}
@@ -2536,7 +2540,7 @@ export type AgentGetInput = {
}
export type AgentGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: AgentInfo
}
@@ -2547,7 +2551,7 @@ export type PluginListInput = {
}
export type PluginListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<PluginInfo>
}
@@ -3231,7 +3235,7 @@ export type ModelListInput = {
}
export type ModelListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<ModelInfo>
}
@@ -3242,7 +3246,7 @@ export type ModelDefaultInput = {
}
export type ModelDefaultOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: ModelInfo | null
}
@@ -3269,7 +3273,7 @@ export type ProviderListInput = {
}
export type ProviderListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<ProviderInfo>
}
@@ -3281,7 +3285,7 @@ export type ProviderGetInput = {
}
export type ProviderGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: ProviderInfo
}
@@ -3292,7 +3296,7 @@ export type IntegrationListInput = {
}
export type IntegrationListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<IntegrationInfo>
}
@@ -3304,7 +3308,7 @@ export type IntegrationGetInput = {
}
export type IntegrationGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: IntegrationInfo | null
}
@@ -3351,7 +3355,7 @@ export type IntegrationOauthConnectInput = {
}
export type IntegrationOauthConnectOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: {
attemptID: string
url: string
@@ -3370,7 +3374,7 @@ export type IntegrationOauthStatusInput = {
}
export type IntegrationOauthStatusOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: IntegrationAttemptStatus
}
@@ -3405,7 +3409,7 @@ export type IntegrationCommandConnectInput = {
}
export type IntegrationCommandConnectOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: IntegrationCommandAttempt
}
@@ -3418,7 +3422,7 @@ export type IntegrationCommandStatusInput = {
}
export type IntegrationCommandStatusOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: IntegrationCommandAttemptStatus
}
@@ -3439,7 +3443,7 @@ export type McpListInput = {
}
export type McpListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<McpServer>
}
@@ -3528,7 +3532,7 @@ export type McpResourceCatalogInput = {
}
export type McpResourceCatalogOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: McpResourceCatalog
}
@@ -3577,7 +3581,7 @@ export type FormRequestListInput = {
}
export type FormRequestListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<FormInfo>
}
@@ -4433,7 +4437,7 @@ export type PermissionRequestListInput = {
}
export type PermissionRequestListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<PermissionRequest>
}
@@ -4555,7 +4559,7 @@ export type FileListInput = {
}
export type FileListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<FileSystemEntry>
}
@@ -4587,7 +4591,7 @@ export type FileFindInput = {
}
export type FileFindOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<FileSystemEntry>
}
@@ -4598,7 +4602,7 @@ export type CommandListInput = {
}
export type CommandListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<CommandInfo>
}
@@ -4609,7 +4613,7 @@ export type SkillListInput = {
}
export type SkillListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<SkillInfo>
}
@@ -4622,7 +4626,7 @@ export type PtyListInput = {
}
export type PtyListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<Pty>
}
@@ -4668,7 +4672,7 @@ export type PtyCreateInput = {
}
export type PtyCreateOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Pty
}
@@ -4680,7 +4684,7 @@ export type PtyGetInput = {
}
export type PtyGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Pty
}
@@ -4697,7 +4701,7 @@ export type PtyUpdateInput = {
}
export type PtyUpdateOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Pty
}
@@ -4717,7 +4721,7 @@ export type ShellListInput = {
}
export type ShellListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<ShellInfo1>
}
@@ -4752,7 +4756,7 @@ export type ShellCreateInput = {
}
export type ShellCreateOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: ShellInfo1
}
@@ -4764,7 +4768,7 @@ export type ShellGetInput = {
}
export type ShellGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: ShellInfo1
}
@@ -4777,7 +4781,7 @@ export type ShellTimeoutInput = {
}
export type ShellTimeoutOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: ShellInfo1
}
@@ -4801,7 +4805,7 @@ export type ShellOutputInput = {
}
export type ShellOutputOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: { output: string; cursor: number; size: number; truncated: boolean }
}
@@ -4821,7 +4825,7 @@ export type QuestionRequestListInput = {
}
export type QuestionRequestListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<QuestionRequest>
}
@@ -4851,7 +4855,7 @@ export type ReferenceListInput = {
}
export type ReferenceListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<ReferenceInfo>
}
@@ -4894,7 +4898,7 @@ export type VcsStatusInput = {
}
export type VcsStatusOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<VcsFileStatus>
}
@@ -4917,7 +4921,7 @@ export type VcsDiffInput = {
}
export type VcsDiffOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<FileDiffInfo>
}
@@ -4938,7 +4942,7 @@ export type WebsearchProvidersInput = {
}
export type WebsearchProvidersOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<WebSearchProvider>
}
@@ -4951,6 +4955,6 @@ export type WebsearchQueryInput = {
}
export type WebsearchQueryOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: { providerID: string; results: Array<WebSearchResult> }
}
+8
View File
@@ -41,6 +41,11 @@
"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",
@@ -119,7 +124,10 @@
"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 -16
View File
@@ -5,6 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect"
import { dirname } from "path"
import { KeyedMutex } from "./effect/keyed-mutex"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bom } from "@opencode-ai/util/bom"
export interface Target {
readonly canonical: string
@@ -108,13 +109,13 @@ const layer = Layer.effect(
const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
withTargetLock(input.target)(
Effect.gen(function* () {
const next = splitBom(input.content)
const next = Bom.split(input.content)
const current = yield* fs
.readFile(input.target.canonical)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
yield* fs.writeWithDirs(
input.target.canonical,
joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom),
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
)
return writeResult(input.target, current !== undefined)
}),
@@ -172,20 +173,6 @@ const layer = Layer.effect(
}),
)
function splitBom(text: string) {
const stripped = text.replace(/^\uFEFF+/, "")
return { bom: stripped.length !== text.length, text: stripped }
}
function joinBom(text: string, bom: boolean) {
const stripped = splitBom(text).text
return bom ? `\uFEFF${stripped}` : stripped
}
function hasUtf8Bom(content: Uint8Array) {
return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
}
function sameBytes(left: Uint8Array, right: Uint8Array) {
if (left.length !== right.length) return false
return left.every((byte, index) => byte === right[index])
+154
View File
@@ -0,0 +1,154 @@
export * as Formatter from "./formatter"
import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import path from "path"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Npm } from "@opencode-ai/util/npm"
import { AppProcess } from "@opencode-ai/util/process"
import { Config } from "./config"
import { Location } from "./location"
import { make, type Info } from "./formatter/builtins"
export const Status = Schema.Struct({
name: Schema.String,
extensions: Schema.Array(Schema.String),
enabled: Schema.Boolean,
}).annotate({ identifier: "FormatterStatus" })
export type Status = typeof Status.Type
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<Status[]>
readonly file: (filepath: string) => Effect.Effect<boolean>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Formatter") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const processes = yield* AppProcess.Service
const commands = new Map<string, string[] | false>()
let formatters: Info[] = []
const load = yield* Effect.cached(
Effect.gen(function* () {
const configured = Config.latest(yield* config.entries(), "formatter")
if (!configured) {
yield* Effect.logInfo("all formatters are disabled")
return
}
const builtIns = make({
directory: location.directory,
worktree: location.project.directory,
fs,
npm,
processes,
})
formatters = builtIns
if (configured === true) return
for (const [name, entry] of Object.entries(configured)) {
const index = formatters.findIndex((formatter) => formatter.name === name)
if (entry.disabled) {
if (index !== -1) formatters.splice(index, 1)
continue
}
const builtIn = builtIns.find((formatter) => formatter.name === name)
const formatter: Info = {
name,
extensions: entry.extensions ?? builtIn?.extensions ?? [],
environment: { ...builtIn?.environment, ...entry.environment },
enabled:
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
}
if (index === -1) formatters.push(formatter)
else formatters[index] = formatter
}
}).pipe(Effect.withSpan("Formatter.load")),
)
const command = Effect.fnUntraced(function* (formatter: Info) {
const cached = commands.get(formatter.name)
if (cached !== undefined) return cached
const result = yield* formatter.enabled
if (result !== false) commands.set(formatter.name, result)
return result
})
const init = Effect.fn("Formatter.init")(function* () {
yield* load
})
const status = Effect.fn("Formatter.status")(function* () {
yield* load
return yield* Effect.forEach(formatters, (formatter) =>
command(formatter).pipe(
Effect.map((enabled) => ({
name: formatter.name,
extensions: [...formatter.extensions],
enabled: enabled !== false,
})),
),
)
})
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
yield* load
const matching = formatters.filter((formatter) =>
formatter.extensions.includes(path.extname(filepath)),
)
for (const formatter of matching) {
const enabled = yield* command(formatter)
if (enabled === false) continue
const cmd = enabled.map((argument) => argument.replace("$FILE", filepath))
yield* Effect.logInfo("formatting file", { file: filepath, command: cmd })
const result = yield* processes
.run(
ChildProcess.make(cmd[0], cmd.slice(1), {
cwd: location.directory,
env: formatter.environment,
extendEnv: true,
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
}),
)
.pipe(
Effect.catch((error) =>
Effect.logError("failed to format file", {
file: filepath,
command: cmd,
error: error.message,
}).pipe(Effect.as(undefined)),
),
)
if (!result) continue
if (result.exitCode === 0) return true
yield* Effect.logError("formatter exited unsuccessfully", {
file: filepath,
command: cmd,
exitCode: result.exitCode,
})
}
return false
})
return Service.of({ init, status, file })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node],
})
+314
View File
@@ -0,0 +1,314 @@
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Npm } from "@opencode-ai/util/npm"
import { AppProcess } from "@opencode-ai/util/process"
import { which } from "../util/which"
export interface Info {
readonly name: string
readonly environment?: Record<string, string>
readonly extensions: readonly string[]
readonly enabled: Effect.Effect<string[] | false>
}
export function make(input: {
readonly directory: string
readonly worktree: string
readonly fs: FSUtil.Interface
readonly npm: Npm.Interface
readonly processes: AppProcess.Interface
}) {
const disabled = false as const
const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree)
const readText = (file: string) => input.fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))
const commandOutput = (command: string[]) =>
input.processes
.run(
ChildProcess.make(command[0], command.slice(1), {
cwd: input.directory,
extendEnv: true,
stdin: "ignore",
}),
)
.pipe(Effect.option)
const gofmt: Info = {
name: "gofmt",
extensions: [".go"],
enabled: Effect.sync(() => {
const match = which("gofmt")
return match ? [match, "-w", "$FILE"] : disabled
}),
}
const mix: Info = {
name: "mix",
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
enabled: Effect.sync(() => {
const match = which("mix")
return match ? [match, "format", "$FILE"] : disabled
}),
}
const prettier: Info = {
name: "prettier",
environment: { BUN_BE_BUN: "1" },
extensions: [
".js",
".jsx",
".mjs",
".cjs",
".ts",
".tsx",
".mts",
".cts",
".html",
".htm",
".css",
".scss",
".sass",
".less",
".vue",
".svelte",
".json",
".jsonc",
".yaml",
".yml",
".toml",
".xml",
".md",
".mdx",
".graphql",
".gql",
],
enabled: Effect.gen(function* () {
for (const file of yield* findUp("package.json")) {
if (!hasDependency(yield* input.fs.readJson(file), "prettier")) continue
const bin = yield* input.npm.which("prettier")
if (bin) return [bin, "--write", "$FILE"]
}
return disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const oxfmt: Info = {
name: "oxfmt",
environment: { BUN_BE_BUN: "1" },
extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"],
enabled: Effect.gen(function* () {
for (const file of yield* findUp("package.json")) {
if (!hasDependency(yield* input.fs.readJson(file), "oxfmt")) continue
const bin = yield* input.npm.which("oxfmt")
if (bin) return [bin, "$FILE"]
}
return disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const biome: Info = {
name: "biome",
environment: { BUN_BE_BUN: "1" },
extensions: [
".js",
".jsx",
".mjs",
".cjs",
".ts",
".tsx",
".mts",
".cts",
".html",
".htm",
".css",
".scss",
".sass",
".less",
".vue",
".svelte",
".json",
".jsonc",
".yaml",
".yml",
".toml",
".xml",
".md",
".mdx",
".graphql",
".gql",
],
enabled: Effect.gen(function* () {
const found = yield* Effect.forEach(["biome.json", "biome.jsonc"], findUp, { concurrency: "unbounded" })
if (!found.some((items) => items.length > 0)) return disabled
const bin = yield* input.npm.which("@biomejs/biome")
return bin ? [bin, "format", "--write", "$FILE"] : disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const zig: Info = {
name: "zig",
extensions: [".zig", ".zon"],
enabled: Effect.sync(() => {
const match = which("zig")
return match ? [match, "fmt", "$FILE"] : disabled
}),
}
const clang: Info = {
name: "clang-format",
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
enabled: Effect.gen(function* () {
if (!(yield* findUp(".clang-format")).length) return disabled
const match = which("clang-format")
return match ? [match, "-i", "$FILE"] : disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const ktlint: Info = {
name: "ktlint",
extensions: [".kt", ".kts"],
enabled: Effect.sync(() => {
const match = which("ktlint")
return match ? [match, "-F", "$FILE"] : disabled
}),
}
const ruff: Info = {
name: "ruff",
extensions: [".py", ".pyi"],
enabled: Effect.gen(function* () {
if (!which("ruff")) return disabled
for (const config of ["pyproject.toml", "ruff.toml", ".ruff.toml"]) {
const found = yield* findUp(config)
if (!found.length) continue
if (config !== "pyproject.toml" || (yield* readText(found[0])).includes("[tool.ruff]")) {
return ["ruff", "format", "$FILE"]
}
}
for (const dependency of ["requirements.txt", "pyproject.toml", "Pipfile"]) {
const found = yield* findUp(dependency)
if (found.length && (yield* readText(found[0])).includes("ruff")) return ["ruff", "format", "$FILE"]
}
return disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const air: Info = {
name: "air",
extensions: [".R"],
enabled: Effect.gen(function* () {
const bin = which("air")
if (!bin) return disabled
const output = yield* commandOutput([bin, "--help"])
if (output._tag === "None" || output.value.exitCode !== 0) return disabled
const first = output.value.stdout.toString("utf8").split("\n")[0]
return first.includes("R language") && first.includes("formatter") ? [bin, "format", "$FILE"] : disabled
}),
}
const uv: Info = {
name: "uv",
extensions: [".py", ".pyi"],
enabled: Effect.gen(function* () {
const bin = which("uv")
if (!bin) return disabled
const output = yield* commandOutput([bin, "format", "--help"])
return output._tag === "Some" && output.value.exitCode === 0
? [bin, "format", "--", "$FILE"]
: disabled
}),
}
const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"])
const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"])
const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"])
const dart = executable("dart", [".dart"], ["format", "$FILE"])
const ocamlformat: Info = {
name: "ocamlformat",
extensions: [".ml", ".mli"],
enabled: Effect.gen(function* () {
if (!(yield* findUp(".ocamlformat")).length) return disabled
const match = which("ocamlformat")
return match ? [match, "-i", "$FILE"] : disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"])
const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"])
const gleam = executable("gleam", [".gleam"], ["format", "$FILE"])
const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"])
const nixfmt = executable("nixfmt", [".nix"], ["$FILE"])
const rustfmt = executable("rustfmt", [".rs"], ["$FILE"])
const pint: Info = {
name: "pint",
extensions: [".php"],
enabled: Effect.gen(function* () {
for (const file of yield* findUp("composer.json")) {
const json = yield* input.fs.readJson(file)
if (hasRecordKey(json, "require", "laravel/pint") || hasRecordKey(json, "require-dev", "laravel/pint")) {
return ["./vendor/bin/pint", "$FILE"]
}
}
return disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"])
const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"])
const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"])
return [
gofmt,
mix,
oxfmt,
prettier,
biome,
zig,
clang,
ktlint,
ruff,
air,
uv,
rubocop,
standardrb,
htmlbeautifier,
dart,
ocamlformat,
terraform,
latexindent,
gleam,
shfmt,
nixfmt,
rustfmt,
pint,
ormolu,
cljfmt,
dfmt,
] satisfies Info[]
}
function executable(name: string, extensions: readonly string[], args: string[]): Info {
return {
name,
extensions,
enabled: Effect.sync(() => {
const match = which(name)
return match ? [match, ...args] : false
}),
}
}
function hasDependency(input: unknown, dependency: string) {
return hasRecordKey(input, "dependencies", dependency) || hasRecordKey(input, "devDependencies", dependency)
}
function hasRecordKey(input: unknown, field: string, key: string) {
if (!isRecord(input)) return false
return isRecord(input[field]) && key in input[field]
}
function isRecord(input: unknown): input is Record<string, unknown> {
return Boolean(input && typeof input === "object" && !Array.isArray(input))
}
+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 }).pipe(
const dotgit = yield* fs.up({ targets: [".git"], start: input, mode: "first" }).pipe(
Effect.map((matches) => matches[0]),
Effect.catch(() => Effect.succeed(undefined)),
)
+2
View File
@@ -8,6 +8,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Node } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus"
import { FileMutation } from "./file-mutation"
import { Formatter } from "./formatter"
import { FileSystem } from "./filesystem"
import { FileSystemSearch } from "./filesystem/search"
import { Generate } from "./generate"
@@ -73,6 +74,7 @@ const locationServiceNodes = [
InstructionDiscovery.node,
LocationMutation.node,
FileMutation.node,
Formatter.node,
MCP.node,
Permission.node,
Tool.node,
+1 -1
View File
@@ -25,7 +25,7 @@ const layer = (ref: Ref) =>
return Service.of({
directory: ref.directory,
workspaceID: ref.workspaceID,
project: { id: resolved.id, directory: resolved.directory },
project: { id: resolved.id, directory: resolved.directory, canonical: resolved.canonical },
vcs: resolved.vcs,
})
}),
+26 -3
View File
@@ -182,7 +182,8 @@ export const fromCatalogModel = (
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (Provider.isAISDK(resolved.package)) {
const native = Provider.isAISDK(resolved.package) ? nativePackage(packageName) : resolved.package
if (Provider.isAISDK(resolved.package) && !native) {
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
const runtime = produce(resolved, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
@@ -193,20 +194,22 @@ export const fromCatalogModel = (
})
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
}
if (!resolved.package) return Effect.fail(unsupported(resolved))
if (!native) return Effect.fail(unsupported(resolved))
const specifier = resolved.package
const specifier = native
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: () => {
@@ -223,6 +226,26 @@ 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
-2
View File
@@ -11,8 +11,6 @@ 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.
+3
View File
@@ -16,6 +16,7 @@ import { ConfigSkillPlugin } from "../config/plugin/skill"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
import { Bus } from "../bus"
import { FileMutation } from "../file-mutation"
import { Formatter } from "../formatter"
import { Form } from "../form"
import { FileSystem } from "../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -68,6 +69,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const config = yield* Config.Service
const bus = yield* Bus.Service
const mutation = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const filesystem = yield* FileSystem.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
@@ -98,6 +100,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Config.Service, config),
Context.make(Bus.Service, bus),
Context.make(FileMutation.Service, mutation),
Context.make(Formatter.Service, formatter),
Context.make(FileSystem.Service, filesystem),
Context.make(FSUtil.Service, fs),
Context.make(Global.Service, global),
+2
View File
@@ -14,6 +14,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Bus } from "../bus"
import { FileMutation } from "../file-mutation"
import { Formatter } from "../formatter"
import { FileSystem } from "../filesystem"
import { Watcher } from "../filesystem/watcher"
import { Form } from "../form"
@@ -318,6 +319,7 @@ export const node = makeLocationNode({
Config.node,
Bus.node,
FileMutation.node,
Formatter.node,
FileSystem.node,
FSUtil.node,
Global.node,
+52 -8
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 } from "drizzle-orm"
import { asc, desc, isNotNull, isNull, ne, or } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema"
import { Database } from "./database/database"
@@ -40,6 +40,7 @@ export interface Resolved {
readonly previous?: ID
readonly id: ID
readonly directory: AbsolutePath
readonly canonical: AbsolutePath
readonly vcs?: Vcs
}
@@ -48,7 +49,7 @@ export const root = Effect.fn("Project.root")(function* (
fs: FSUtil.Interface,
input: AbsolutePath,
) {
return yield* fs.up({ targets: [".git", ".hg"], start: input }).pipe(
return yield* fs.up({ targets: [".git", ".hg"], start: input, mode: "first" }).pipe(
Effect.map((matches) => matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined),
Effect.catch(() => Effect.succeed(undefined)),
)
@@ -83,7 +84,7 @@ function fromRow(row: typeof ProjectTable.$inferSelect): Info {
: undefined
return {
id: row.id,
worktree: row.worktree,
canonical: row.worktree,
vcs: row.vcs ?? undefined,
name: row.name ?? undefined,
icon,
@@ -106,6 +107,40 @@ 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()
@@ -189,7 +224,7 @@ const layer = Layer.effect(
})
const hgDiscover = Effect.fnUntraced(function* (input: AbsolutePath) {
const dotHg = yield* fs.up({ targets: [".hg"], start: input }).pipe(
const dotHg = yield* fs.up({ targets: [".hg"], start: input, mode: "first" }).pipe(
Effect.map((matches) => matches[0]),
Effect.catch(() => Effect.succeed(undefined)),
)
@@ -211,17 +246,26 @@ const layer = Layer.effect(
if (repo) {
const previous = yield* cached(repo.commonDirectory)
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
return {
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({
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 hg
return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
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 })
})
const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
+20 -13
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, isNull, like, lt, or, type SQL } from "drizzle-orm"
import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, ne, or, type SQL } from "drizzle-orm"
import { Project } from "./project"
import { Workspace } from "./workspace"
import { Model } from "./model"
@@ -325,6 +325,22 @@ 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(
@@ -347,12 +363,7 @@ 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* db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* persistProject(project)
const now = Date.now()
const info = SessionV1.SessionInfo.make({
id: sessionID,
@@ -451,6 +462,7 @@ 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(
@@ -732,12 +744,7 @@ const layer = Layer.effect(
)
return
const project = yield* projects.resolve(directory)
yield* db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* persistProject(project)
if ((yield* execution.active).has(input.sessionID)) {
yield* execution.interrupt(input.sessionID)
yield* execution.awaitIdle(input.sessionID)
+6
View File
@@ -657,6 +657,12 @@ 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
@@ -0,0 +1,296 @@
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 }
}
@@ -0,0 +1,8 @@
// @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 }
@@ -0,0 +1,12 @@
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"),
}
+32 -37
View File
@@ -9,12 +9,14 @@ export * as EditTool from "./edit"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Bom } from "@opencode-ai/util/bom"
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"
@@ -99,7 +101,6 @@ const findLineOccurrences = (content: string, search: string) => {
}
/** Deferred edit behavior and UX integrations remain visible at the model-facing seam. */
// TODO: Add formatter integration after formatter runtime exists.
// TODO: Publish watcher/file-edit events after watcher integration exists.
// TODO: Add snapshots / undo after design exists.
// TODO: Add LSP notification and diagnostics after LSP runtime exists.
@@ -109,6 +110,7 @@ export const Plugin = {
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const fs = yield* FSUtil.Service
const permission = yield* Permission.Service
@@ -151,14 +153,6 @@ export const Plugin = {
})
}
yield* permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
})
const info = yield* fs.stat(target.canonical).pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
@@ -167,9 +161,8 @@ export const Plugin = {
if (info.type === "Directory") {
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
}
const bytes = yield* fs.readFile(target.canonical)
const bom = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
const source = new TextDecoder().decode(bom ? bytes.slice(3) : bytes)
const original = yield* Bom.readFile(fs, target.canonical)
const source = original.text
const ending = source.includes(crlf) ? crlf : "\n"
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
@@ -183,6 +176,26 @@ export const Plugin = {
: findLineOccurrences(source, oldString)
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
const replacements = matches.length
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
.toReversed()
.reduce(
(content, match) =>
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
source,
)
const preview =
replacements > 0 && (replacements === 1 || input.replaceAll === true)
? fileDiff(target.resource, source, replaced)
: undefined
yield* permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
metadata: preview ? { files: [preview] } : undefined,
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
})
if (replacements === 0) {
return yield* new ToolFailure({
message: `Could not find oldString in ${input.path}. It must match exactly, including whitespace and indentation.`,
@@ -193,35 +206,17 @@ export const Plugin = {
message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`,
})
}
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
.toReversed()
.reduce(
(content, match) =>
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
source,
)
const counts = diffLines(source, replaced).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
)
const replacementBom = replaced.startsWith("\uFEFF")
const result = yield* files.write({
target,
content: `${bom || replacementBom ? "\uFEFF" : ""}${replacementBom ? replaced.slice(1) : replaced}`,
content: Bom.join(replaced, original.bom || replacementBom),
})
const bom = original.bom || replacementBom
const formatted = (yield* formatter.file(target.canonical))
? yield* Bom.syncFile(fs, target.canonical, bom)
: (yield* Bom.readFile(fs, target.canonical)).text
return {
files: [
{
file: result.resource,
patch: createTwoFilesPatch(result.resource, result.resource, source, replaced),
status: "modified" as const,
...counts,
},
],
files: [fileDiff(result.resource, source, formatted)],
replacements,
} satisfies Output
}).pipe(
@@ -0,0 +1,23 @@
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,
}
}
+47 -21
View File
@@ -7,7 +7,9 @@ import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Schema } from "effect"
import { PlatformError } from "effect/PlatformError"
import path from "path"
import { Bom } from "@opencode-ai/util/bom"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Formatter } from "../../formatter"
import { Location } from "../../location"
import { Patch } from "@opencode-ai/util/patch"
import { Permission } from "../../permission"
@@ -68,6 +70,7 @@ export const Plugin = {
id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
const fs = yield* FSUtil.Service
const formatter = yield* Formatter.Service
const location = yield* Location.Service
const permission = yield* Permission.Service
@@ -129,15 +132,16 @@ export const Plugin = {
...hunk,
target,
before: "",
after: (hunk.contents.endsWith("\n") || hunk.contents === ""
? hunk.contents
: `${hunk.contents}\n`
).replace(/^\uFEFF/, ""),
after: Bom.split(
hunk.contents.endsWith("\n") || hunk.contents === ""
? hunk.contents
: `${hunk.contents}\n`,
).text,
})
return
}
if (hunk.type === "delete") {
const content = yield* fs.readFile(target.canonical).pipe(
const content = yield* Bom.readFile(fs, target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
@@ -145,8 +149,7 @@ export const Plugin = {
}),
),
)
const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(content)
prepared.push({ ...hunk, target, before: original.replace(/^\uFEFF/, ""), after: "" })
prepared.push({ ...hunk, target, before: content.text, after: "" })
return
}
const previous = updates.get(target.canonical)
@@ -166,18 +169,17 @@ export const Plugin = {
message: `patch verification failed: Failed to read file to update ${target.canonical}: path is a directory`,
})
}
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(
yield* fs.readFile(target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
}),
),
const content = yield* Bom.readFile(fs, target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
}),
),
)
return Bom.join(content.text, content.bom)
}))
const before = original.replace(/^\uFEFF/, "")
const before = Bom.split(original).text
const update = yield* Effect.try({
try: () => Patch.derive(hunk.path, hunk.chunks, original),
catch: (error) =>
@@ -217,7 +219,7 @@ export const Plugin = {
)
}
const patchFiles = prepared.map(patchFile)
const patchFiles = prepared.map((change) => patchFile(change))
yield* permission.assert({
action: "edit",
resources: [...new Set(targets.map((target) => target.resource))],
@@ -295,7 +297,31 @@ export const Plugin = {
}),
{ discard: true },
)
return { applied, files: patchFiles }
const formatted = new Map<string, string>()
yield* Effect.forEach(
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
(target) =>
Effect.gen(function* () {
const current = yield* Bom.readFile(fs, target).pipe(
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
)
formatted.set(
target,
(yield* formatter.file(target))
? yield* Bom.syncFile(fs, target, current.bom).pipe(
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
)
: current.text,
)
}),
{ discard: true },
)
const files = yield* Effect.forEach(prepared, (change) => {
if (change.type === "delete") return Effect.succeed(patchFile(change))
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
return Effect.succeed(patchFile(change, formatted.get(target.canonical)))
})
return { applied, files }
}).pipe(
Effect.map((output) => ({
output,
@@ -337,15 +363,15 @@ function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error)
}
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
const patch = trimDiff(
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, change.after),
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, after),
)
const counts =
change.type === "delete"
? { additions: 0, deletions: change.before.split("\n").length }
: diffLines(change.before, change.after).reduce(
: diffLines(change.before, after).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
+23 -23
View File
@@ -1,5 +1,6 @@
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"
@@ -11,6 +12,7 @@ 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
@@ -75,18 +77,6 @@ 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) {
@@ -158,24 +148,34 @@ 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.externalDirectory
if (external)
const external = [target, ...directories]
.map((item) => item.externalDirectory)
.filter((item) => item !== undefined)
.filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index)
if (external.length > 0)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
action: "external_directory",
resources: external.map((item) => item.resource),
save: external.map((item) => item.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
if (parsed.commands.length > 0)
yield* permission.assert({
action: name,
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.map((command) => command.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
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}`))
}),
+10 -5
View File
@@ -13,15 +13,19 @@ export const name = "subagent"
const NO_TEXT = "Subagent completed without a text response."
const backgroundStarted = (sessionID: SessionSchema.ID) =>
`The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress.`
[
`The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes.`,
"DO NOT sleep, poll for progress, ask the subagent for status, or duplicate this subagent's work; avoid working with the same files or topics it is using.",
"Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.",
].join("\n")
export const Input = Schema.Struct({
agent: Schema.String.annotate({ description: "The configured agent to run as the subagent" }),
description: Schema.String.annotate({ description: "A short description of the subagent's task" }),
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }),
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
background: Schema.optionalKey(Schema.Boolean).annotate({
description:
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.",
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress.",
}),
})
@@ -31,7 +35,8 @@ export const Output = Schema.Struct({
output: Schema.String,
})
export const description = [
"Spawn a subagent: a child session running a configured agent with fresh context.",
"Spawns an agent in a child session to work on the specified task.",
"Include all relevant context and instructions in the prompt because the child starts with fresh context.",
"Foreground (default) runs the subagent to completion and returns its final response.",
"Background mode (background=true) launches it asynchronously and returns immediately; you are notified when it finishes.",
"Use background only for independent work that can run while you continue elsewhere.",
+23 -5
View File
@@ -9,17 +9,20 @@ export * as WriteTool from "./write"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { Bom } from "@opencode-ai/util/bom"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { FileMutation } from "../../file-mutation"
import { Formatter } from "../../formatter"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { fileDiff } from "./file-diff"
export const name = "write"
// TODO: Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.
export const Input = Schema.Struct({
path: Schema.String.annotate({
description:
"File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval.",
description: "Path to the file to write to",
}),
content: Schema.String.annotate({ description: "Content to write to the file" }),
})
@@ -36,7 +39,6 @@ export const toModelOutput = (output: Output) =>
`${output.existed ? "Wrote" : "Created"} file successfully: ${output.resource}`
/** Deferred write UX integrations remain visible at the model-facing seam. */
// TODO: Add formatter integration after formatter runtime exists.
// TODO: Publish watcher/file-edit events after watcher integration exists.
// TODO: Add snapshots / undo after design exists.
// TODO: Add LSP notification and diagnostics after LSP runtime exists.
@@ -46,6 +48,8 @@ export const Plugin = {
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const fs = yield* FSUtil.Service
const permission = yield* Permission.Service
yield* ctx.tool
@@ -55,7 +59,7 @@ export const Plugin = {
name,
options: { codemode: false, permission: "edit" },
description:
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
"Writes a file to the local filesystem, overwriting if one exists.\n\nMissing parent directories are created automatically.\n\nUse this tool to create new files or overwrite existing files. For partial changes, use the edit tool instead.",
input: Input,
output: Output,
execute: (input, context) =>
@@ -74,15 +78,29 @@ export const Plugin = {
agent: context.agent,
source,
})
const current = yield* Bom.readFile(fs, target.canonical).pipe(
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
)
const next = Bom.split(input.content)
const preview = fileDiff(
target.resource,
current?.text ?? "",
next.text,
current ? "modified" : "added",
)
yield* permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
metadata: { files: [preview] },
sessionID: context.sessionID,
agent: context.agent,
source,
})
return yield* files.writeTextPreservingBom({ target, content: input.content })
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
const bom = (yield* Bom.readFile(fs, target.canonical)).bom
if (yield* formatter.file(target.canonical)) yield* Bom.syncFile(fs, target.canonical, bom)
return result
}).pipe(
Effect.map((output) => ({ output, content: toModelOutput(output) })),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
@@ -56,7 +56,7 @@ describe("node build", () => {
Location.Service.of({
directory: ref.directory,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: service.directory },
project: { id: Project.ID.global, directory: service.directory, canonical: 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 }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
commit: () => Effect.void,
})
}),
@@ -1,5 +1,5 @@
import { describe, test, expect } from "bun:test"
import { Effect, FileSystem } from "effect"
import { Effect, FileSystem, Layer } 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,6 +267,33 @@ 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", () => {
+2 -1
View File
@@ -5,10 +5,11 @@ 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: input.projectDirectory ?? ref.directory },
project: { id: Project.ID.global, directory, canonical: directory },
vcs: input.vcs,
} satisfies Location.Interface
}
+199
View File
@@ -0,0 +1,199 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer, Schema, Stream } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Npm } from "@opencode-ai/util/npm"
import { Config } from "../src/config"
import { Formatter } from "../src/formatter"
import { Location } from "../src/location"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
type ConfigInput = typeof Config.Info.Encoded
function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) {
const entries =
configured === undefined
? []
: [
new Config.Document({
type: "document",
info: Schema.decodeUnknownSync(Config.Info)({ formatter: configured }),
}),
]
return AppNodeBuilder.build(Formatter.node, [
[
Config.node,
Layer.succeed(
Config.Service,
Config.Service.of({
entries: () => Effect.succeed(entries),
changes: () => Stream.empty,
}),
),
],
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
),
],
[Npm.node, Layer.mock(Npm.Service, { which: () => Effect.succeed(undefined) })],
])
}
function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => body(tmp.path),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
}
describe("Formatter", () => {
it.live("status() returns empty list when no formatters are configured", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) => formatter.status()).pipe(Effect.provide(formatterLayer(directory))),
),
)
it.live("status() returns built-in formatters when formatter is true", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) =>
Effect.gen(function* () {
const statuses = yield* formatter.status()
const gofmt = statuses.find((item) => item.name === "gofmt")
expect(gofmt).toBeDefined()
expect(gofmt?.extensions).toContain(".go")
}),
).pipe(Effect.provide(formatterLayer(directory, true))),
),
)
it.live("status() keeps built-in formatters when config object is provided", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) =>
Effect.gen(function* () {
const statuses = yield* formatter.status()
expect(statuses.find((item) => item.name === "gofmt")?.extensions).toContain(".go")
expect(statuses.find((item) => item.name === "mix")).toBeDefined()
}),
).pipe(Effect.provide(formatterLayer(directory, { gofmt: {} }))),
),
)
it.live("status() excludes formatters marked as disabled in config", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) =>
Effect.gen(function* () {
const statuses = yield* formatter.status()
expect(statuses.find((item) => item.name === "gofmt")).toBeUndefined()
expect(statuses.find((item) => item.name === "mix")).toBeDefined()
}),
).pipe(Effect.provide(formatterLayer(directory, { gofmt: { disabled: true } }))),
),
)
it.live("service initializes without error", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) => formatter.init()).pipe(Effect.provide(formatterLayer(directory))),
),
)
it.live("file() returns false when no formatter runs", () =>
withTemp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.txt")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
}).pipe(Effect.provide(formatterLayer(directory, false))),
),
)
it.live("status() initializes formatter state per directory", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([off, on]) =>
Effect.gen(function* () {
const disabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
Effect.provide(formatterLayer(off.path, false)),
)
const enabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
Effect.provide(formatterLayer(on.path, true)),
)
expect(disabled).toEqual([])
expect(enabled.find((item) => item.name === "gofmt")).toBeDefined()
}),
(directories) =>
Effect.promise(() => Promise.all(directories.map((tmp) => tmp[Symbol.asyncDispose]())).then(() => undefined)),
),
)
it.live("stops after the first matching formatter succeeds", () =>
withTemp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.seq")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA")
}).pipe(
Effect.provide(
formatterLayer(directory, {
first: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')",
"$FILE",
],
extensions: [".seq"],
},
second: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
"$FILE",
],
extensions: [".seq"],
},
}),
),
),
),
)
it.live("tries the next matching formatter when the first fails", () =>
withTemp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.fallback")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB")
}).pipe(
Effect.provide(
formatterLayer(directory, {
first: {
command: [process.execPath, "-e", "process.exit(1)", "$FILE"],
extensions: [".fallback"],
},
second: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
"$FILE",
],
extensions: [".fallback"],
},
}),
),
),
),
)
})
+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/google"),
package: Provider.aisdk("@ai-sdk/mistral"),
})
const runtime = Model.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route })
+2
View File
@@ -18,6 +18,7 @@ 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,
@@ -34,6 +35,7 @@ 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,6 +1,7 @@
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"
@@ -559,6 +560,42 @@ 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(
+81 -11
View File
@@ -546,6 +546,76 @@ 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(
@@ -554,8 +624,8 @@ describe("ModelResolver", () => {
}),
)
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/google"), {
modelID: "gemini-api-model",
model(Provider.aisdk("@ai-sdk/mistral"), {
modelID: "mistral-api-model",
settings: { project: "test" },
headers: { "x-aisdk": "header" },
body: { custom: true },
@@ -566,9 +636,9 @@ describe("ModelResolver", () => {
Effect.sync(() => {
expect(runtime).toMatchObject({
id: "test-model",
modelID: "gemini-api-model",
modelID: "mistral-api-model",
providerID: "test-provider",
package: Provider.aisdk("@ai-sdk/google"),
package: Provider.aisdk("@ai-sdk/mistral"),
settings: { project: "test", apiKey: "fallback-secret" },
headers: { "x-aisdk": "header" },
body: { custom: true },
@@ -582,15 +652,15 @@ describe("ModelResolver", () => {
},
)
expect(resolved).toMatchObject({ id: "gemini-api-model", provider: "test-provider" })
expect(resolved).toMatchObject({ id: "mistral-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/google"), {
settings: { baseURL: "https://google.example/v1" },
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { baseURL: "https://mistral.example/v1" },
}),
).pipe(Effect.flip)
@@ -598,9 +668,9 @@ describe("ModelResolver", () => {
_tag: "SessionRunnerModel.UnsupportedPackageError",
providerID: "test-provider",
modelID: "test-model",
package: "aisdk:@ai-sdk/google",
package: "aisdk:@ai-sdk/mistral",
})
expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/google")
expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/mistral")
}),
)
@@ -612,8 +682,8 @@ describe("ModelResolver", () => {
}),
)
yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/google"), {
settings: { apiKey: "", baseURL: "https://google.example/v1" },
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { apiKey: "", baseURL: "https://mistral.example/v1" },
}),
undefined,
{
-21
View File
@@ -11,7 +11,6 @@ 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"
@@ -88,11 +87,6 @@ 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({
@@ -144,11 +138,6 @@ 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({
@@ -204,11 +193,6 @@ 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({
@@ -268,11 +252,6 @@ 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({
+15 -3
View File
@@ -121,7 +121,11 @@ 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("/") },
project: {
id: Project.ID.make("test"),
directory: AbsolutePath.make("/"),
canonical: AbsolutePath.make("/"),
},
}),
data: agentInfo(value),
})
@@ -163,7 +167,11 @@ 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("/") },
project: {
id: Project.ID.make("test"),
directory: AbsolutePath.make("/"),
canonical: AbsolutePath.make("/"),
},
}),
data: data.map(modelInfo),
})),
@@ -357,7 +365,11 @@ 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") },
project: {
id: Project.ID.make("websearch-test"),
directory: AbsolutePath.make("/tmp/websearch-test"),
canonical: AbsolutePath.make("/tmp/websearch-test"),
},
})
return {
providers: () => websearch.providers().pipe(Effect.map((data) => ({ location, data }))),
+39 -3
View File
@@ -47,13 +47,13 @@ describe("Project.list", () => {
expect(yield* project.list()).toEqual([
{
id: Project.ID.make("newer"),
worktree: abs("/newer"),
canonical: abs("/newer"),
time: { created: 2, updated: 2, initialized: 3 },
sandboxes: [],
},
{
id: Project.ID.make("older"),
worktree: abs("/older"),
canonical: abs("/older"),
vcs: "git",
name: "Older",
icon: { color: "#000000" },
@@ -105,6 +105,7 @@ 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()
}),
@@ -123,6 +124,7 @@ 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")
}),
@@ -141,6 +143,7 @@ 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")
}),
@@ -327,13 +330,46 @@ 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(remoteID("github.com/owner/repo"))
expect(result.id).toBe(id)
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 }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
+46 -2
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 } from "@opencode-ai/core/schema"
import { AbsolutePath, RelativePath } 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 }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
@@ -171,6 +171,30 @@ 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
@@ -184,6 +208,26 @@ 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,7 +15,6 @@ 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"
@@ -191,11 +190,6 @@ 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 }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: 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 }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: 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 }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: 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 }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: 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 }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
})
const execution = Layer.mock(SessionExecution.Service, {
awaitIdle: (sessionID) => Effect.sync(() => awaited.push(sessionID)),
+55
View File
@@ -0,0 +1,55 @@
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"])
})
})
+61 -4
View File
@@ -5,6 +5,7 @@ import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
@@ -22,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const editToolNode = makeLocationNode({
name: "test/edit-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
deps: [Tool.node, LocationMutation.node, FileMutation.node, FSUtil.node, Permission.node],
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
})
const sessionID = Session.ID.make("ses_edit_tool_test")
@@ -31,6 +32,7 @@ const writes: string[] = []
let reads = 0
let denyAction: string | undefined
let afterRead = (_target: string, _content: Uint8Array): Effect.Effect<void> => Effect.void
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
const permission = Layer.succeed(
Permission.Service,
@@ -57,12 +59,17 @@ const permission = Layer.succeed(
}),
)
const formatter = Layer.mock(Formatter.Service, {
file: (target) => formatFile(target),
})
const reset = () => {
assertions.length = 0
writes.length = 0
reads = 0
denyAction = undefined
afterRead = () => Effect.void
formatFile = () => Effect.succeed(false)
}
const filesystem = Layer.effect(
@@ -109,6 +116,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
[
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[Formatter.node, formatter],
[Permission.node, permission],
],
),
@@ -171,6 +179,17 @@ describe("EditTool", () => {
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
expect(assertions[0]?.metadata).toMatchObject({
files: [
{
file: "hello.txt",
status: "modified",
additions: 1,
deletions: 1,
patch: expect.stringContaining("-before\n+after"),
},
],
})
expect(writes).toEqual([yield* Effect.promise(() => fs.realpath(target))])
}),
),
@@ -181,6 +200,39 @@ describe("EditTool", () => {
),
)
it.live("returns the diff for final formatted content", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "formatted.txt")
formatFile = (file) =>
Effect.promise(async () => {
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace("after", "AFTER"))
return true
})
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const settled = yield* executeTool(
registry,
call({ path: "formatted.txt", oldString: "before", newString: "after" }),
)
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.output.files[0]?.patch).toContain("-before\n+AFTER")
expect(settled.metadata?.files?.[0]?.patch).toContain("-before\n+AFTER")
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("AFTER\n")
}),
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("accepts an absolute file path inside the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -302,7 +354,7 @@ describe("EditTool", () => {
error: { type: "permission.rejected", message: "Permission denied: edit" },
})
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(reads).toBe(0)
expect(reads).toBe(1)
expect(writes).toEqual([])
expect(yield* Effect.promise(() => fs.readFile(external, "utf8"))).toBe("before")
}),
@@ -313,7 +365,7 @@ describe("EditTool", () => {
),
)
it.live("denied edit reads no target content and does not disclose whether oldString matches", () =>
it.live("denied edit does not disclose whether oldString matches", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
@@ -339,7 +391,7 @@ describe("EditTool", () => {
})
expect(missing).toEqual(matching)
expect(assertions.map((input) => input.action)).toEqual(["edit", "edit"])
expect(reads).toBe(0)
expect(reads).toBe(2)
expect(writes).toEqual([])
}),
),
@@ -574,6 +626,11 @@ describe("EditTool", () => {
(tmp) => {
reset()
const target = path.join(tmp.path, "windows.txt")
formatFile = (file) =>
Effect.promise(async () => {
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace(/^\uFEFF/, ""))
return true
})
return Effect.promise(() => fs.writeFile(target, "\uFEFFbefore\r\nrest\r\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
+36 -1
View File
@@ -6,6 +6,7 @@ import { systemError } from "effect/PlatformError"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Formatter } from "@opencode-ai/core/formatter"
import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -21,7 +22,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const patchToolNode = makeLocationNode({
name: "test/patch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [Tool.node, FSUtil.node, Location.node, Permission.node],
deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
})
const sessionID = Session.ID.make("ses_patch_tool_test")
@@ -33,6 +34,7 @@ let failWriteTarget: string | undefined
let readsBeforeEditApproval = 0
let editApproved = false
let afterEditApproval = (): Effect.Effect<void> => Effect.void
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
const permission = Layer.succeed(
Permission.Service,
@@ -63,6 +65,10 @@ const permission = Layer.succeed(
}),
)
const formatter = Layer.mock(Formatter.Service, {
file: (target) => formatFile(target),
})
const reset = () => {
assertions.length = 0
denyAction = undefined
@@ -72,6 +78,7 @@ const reset = () => {
readsBeforeEditApproval = 0
editApproved = false
afterEditApproval = () => Effect.void
formatFile = () => Effect.succeed(false)
}
const filesystem = Layer.effect(
@@ -135,6 +142,7 @@ const withTool = <A, E, R>(
AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[Formatter.node, formatter],
[Permission.node, permission],
]),
),
@@ -254,6 +262,28 @@ describe("PatchTool", () => {
),
)
it.live("returns file diffs for final formatted content", () =>
withTempTool((directory, registry) => {
const target = path.join(directory, "formatted.txt")
formatFile = (file) =>
Effect.promise(async () => {
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace("created", "FORMATTED"))
return true
})
return Effect.gen(function* () {
const settled = yield* executeTool(
registry,
call("*** Begin Patch\n*** Add File: formatted.txt\n+created\n*** End Patch"),
)
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.output.files[0]?.patch).toContain("+FORMATTED")
expect(settled.metadata?.files?.[0]?.patch).toContain("+FORMATTED")
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMATTED\n")
})
}),
)
it.live("moves and updates a file", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -552,6 +582,11 @@ describe("PatchTool", () => {
const bom = "\uFEFF"
const target = path.join(directory, "example.cs")
yield* Effect.promise(() => fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`))
formatFile = (file) =>
Effect.promise(async () => {
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace(/^\uFEFF/, ""))
return true
})
const settled = yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"),
+74 -2
View File
@@ -1,5 +1,6 @@
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"
@@ -222,7 +223,10 @@ describe("ShellTool", () => {
type: "text",
text: expect.stringContaining("Command exited with code 0."),
})
expect(assertions).toMatchObject([{ sessionID, action: "shell", resources: [helloCommand] }])
expect(assertions).toMatchObject([
{ sessionID, action: "shell", resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand] },
])
expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"])
}),
)
},
@@ -253,6 +257,29 @@ 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()),
@@ -325,6 +352,51 @@ 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()])),
@@ -466,7 +538,7 @@ describe("ShellTool", () => {
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: timeoutOutputCommand, timeout: 50 })),
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 500 : 50 })),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
+60 -1
View File
@@ -3,6 +3,7 @@ import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -22,12 +23,13 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const writeToolNode = makeLocationNode({
name: "test/write-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
deps: [Tool.node, LocationMutation.node, FileMutation.node, Permission.node],
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
})
const sessionID = Session.ID.make("ses_write_tool_test")
const assertions: Permission.AssertInput[] = []
const writes: string[] = []
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
let denyAction: string | undefined
const permission = Layer.succeed(
@@ -55,9 +57,14 @@ const permission = Layer.succeed(
}),
)
const formatter = Layer.mock(Formatter.Service, {
file: (target) => formatFile(target),
})
const reset = () => {
assertions.length = 0
writes.length = 0
formatFile = () => Effect.succeed(false)
denyAction = undefined
}
@@ -93,6 +100,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
[
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[Formatter.node, formatter],
[Permission.node, permission],
],
),
@@ -132,6 +140,17 @@ describe("WriteTool", () => {
"created",
)
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }])
expect(assertions[0]?.metadata).toMatchObject({
files: [
{
file: "src/new.txt",
status: "added",
additions: 1,
deletions: 0,
patch: expect.stringContaining("+created"),
},
],
})
expect(writes).toEqual([path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt")])
}),
)
@@ -140,6 +159,30 @@ describe("WriteTool", () => {
),
)
it.live("formats the committed file", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "formatted.txt")
formatFile = (file) =>
Effect.promise(async () => {
await fs.writeFile(file, (await fs.readFile(file, "utf8")).toUpperCase())
return true
})
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(yield* executeTool(registry, call({ path: "formatted.txt", content: "format me" }))).toMatchObject({
status: "completed",
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME")
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("overwrites a relative existing file and reports that it wrote the file", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -155,6 +198,17 @@ describe("WriteTool", () => {
if (settled.status !== "completed") return
expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }])
expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true })
expect(assertions[0]?.metadata).toMatchObject({
files: [
{
file: "existing.txt",
status: "modified",
additions: 1,
deletions: 1,
patch: expect.stringMatching(/-before[\s\S]*\+after/),
},
],
})
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe(
"after",
)
@@ -174,6 +228,11 @@ describe("WriteTool", () => {
reset()
const preserved = path.join(tmp.path, "preserved.txt")
const deduplicated = path.join(tmp.path, "deduplicated.txt")
formatFile = (target) =>
Effect.promise(async () => {
await fs.writeFile(target, `\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`)
return true
})
return Effect.promise(() =>
Promise.all([fs.writeFile(preserved, "\uFEFFbefore"), fs.writeFile(deduplicated, "\uFEFFbefore")]),
).pipe(
+24
View File
@@ -11,6 +11,7 @@ import type {
OpenCodeEvent,
PermissionSavedInfo,
PermissionRequest,
Project,
ProviderInfo,
ReferenceInfo,
SessionInfo,
@@ -77,6 +78,10 @@ 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>
@@ -346,6 +351,25 @@ 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,6 +17,7 @@ export class Info extends Schema.Class<Info>("Location.Info")({
project: Schema.Struct({
id: ProjectID,
directory: AbsolutePath,
canonical: AbsolutePath,
}),
}) {}
+2 -1
View File
@@ -12,6 +12,7 @@ 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({
@@ -46,7 +47,7 @@ export interface Time extends Schema.Schema.Type<typeof Time> {}
export const Info = Schema.Struct({
id: ID,
worktree: Schema.String,
canonical: AbsolutePath,
vcs: optional(Vcs),
name: optional(Schema.String),
icon: optional(Icon),
+5 -1
View File
@@ -9,7 +9,11 @@ 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 }),
Effect.succeed({
id: location.project.id,
directory: location.project.directory,
canonical: location.project.canonical,
}),
),
)
.handle("project.directories", (ctx) =>
+17 -9
View File
@@ -15,6 +15,7 @@ import {
MouseButton,
type CliRenderer,
type CliRendererConfig,
type KeyEvent,
type ThemeMode,
} from "@opentui/core"
import { RouteProvider, useRoute } from "./context/route"
@@ -66,6 +67,7 @@ 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"
@@ -648,6 +650,15 @@ 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}`,
@@ -952,7 +963,11 @@ function App(props: { pair?: DialogPairCredentials }) {
name: "app.exit",
title: "Exit the app",
slash: { name: "exit", aliases: ["quit", "q"] },
run: () => exit(),
run: (_input: string | undefined, event?: KeyEvent) => {
const current = promptRef.current
if (event?.sequence && current?.focused && !current.empty) return false
exit()
},
category: "System",
},
{
@@ -1108,14 +1123,7 @@ function App(props: { pair?: DialogPairCredentials }) {
bindings: pinnedSessionBindingCommands,
}))
Keymap.createLayer(() => ({
enabled: () => {
const current = promptRef.current
if (!current?.focused) return true
return current.current.text === ""
},
bindings: ["app.exit"],
}))
Keymap.createLayer(() => ({ bindings: ["app.exit"] }))
event.on("tui.command.execute", (evt, { workspace }) => {
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
@@ -1,436 +0,0 @@
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
@@ -1,102 +0,0 @@
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} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No integrations available</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<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} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<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} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>Loading project directories</text>
</box>
) : (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No project directories available</text>
</box>
)
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No project directories found</text>
</box>
}
@@ -0,0 +1,77 @@
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)
}}
/>
)
}
@@ -1,164 +0,0 @@
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),
)
})
}
@@ -1,113 +0,0 @@
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,6 +1,7 @@
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import { createMemo, createResource, createSignal, onMount, Show } 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"
@@ -32,52 +33,69 @@ export function DialogSessionList() {
const shortcuts = Keymap.useShortcuts()
const [search, setSearch] = createDebouncedSignal("", 150)
const [toDelete, setToDelete] = createSignal<string>()
const [allProjects, setAllProjects] = createSignal(false)
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 [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 currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const localSessions = createMemo(() => {
const query = filter().trim().toLowerCase()
const sessions = data.session.list()
const current = data.location.info()
const sessions = data.session
.list()
.filter(
(session) =>
allProjects() ||
(session.projectID === current?.project.id && session.location.directory === current.directory),
)
if (!query) return sessions
return sessions.filter((session) => !session.parentID && session.title.toLowerCase().includes(query))
})
const sessions = createMemo(() => {
const query = filter()
const query = filter().trim()
const local = localSessions()
if (!query) return local
if (query !== search() || searchResults.loading) return local
if (query !== search().trim() || searchResults.loading) return searchResults.latest?.sessions ?? local
const result = searchResults()
if (result?.query !== query || result.error) return local
if (result?.query !== query || result.allProjects !== allProjects() || result.error) return local
return result.sessions
})
const searchState = createMemo(() => {
const query = filter()
if (!query) return { message: "No sessions available", error: false }
if (query !== search() || searchResults.loading) return { message: "Searching sessions…", error: false }
const query = filter().trim()
if (query !== search().trim() || searchResults.loading)
return { message: query ? "Searching sessions…" : "Loading sessions…", error: false }
const result = searchResults()
if (result?.query === query && result.error)
return { message: "Could not search sessions. Change the search to try again.", error: true }
return { message: "No sessions found", error: false }
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 }
})
const quickSwitchHint = createMemo(() => {
@@ -91,6 +109,13 @@ 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()
@@ -105,8 +130,10 @@ export function DialogSessionList() {
const option = (session: SessionInfo, category: string) => {
const directory = session.location.directory
const footer =
directory !== data.location.info()?.project.directory ? Locale.truncate(path.basename(directory), 20) : ""
const project = data.project.get(session.projectID)
const footer = allProjects()
? Locale.truncate(project?.name || path.basename(project?.canonical ?? directory), 20)
: undefined
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
const deleting = toDelete() === session.id
return {
@@ -139,6 +166,16 @@ 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()}
@@ -146,13 +183,25 @@ 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} paddingTop={1}>
<text fg={theme.text.subdued}>No sessions available</text>
<box paddingLeft={4} paddingRight={4}>
<text fg={searchState().error ? theme.text.feedback.error.default : theme.text.subdued}>
{searchState().message}
</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={searchState().error ? theme.text.feedback.error.default : theme.text.subdued}>
{searchState().message}
</text>
@@ -178,24 +227,34 @@ export function DialogSessionList() {
setToDelete(option.value)
return
}
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,
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,
})
})
})
},
},
{
command: "session.rename",
title: "rename",
onTrigger: (option: { value: string }) =>
DialogSessionRename.show(dialog, option.value, data.session.get(option.value)?.title),
onTrigger: (option: { value: string; title: string }) =>
DialogSessionRename.show(dialog, option.value, option.title),
},
]}
footerHints={quickSwitchFooterHints()}
footerHints={[
...quickSwitchFooterHints(),
{ title: allProjects() ? "current directory" : "all projects", label: "ctrl+a", side: "right" },
]}
/>
)
}
+4 -4
View File
@@ -62,13 +62,13 @@ export function DialogSkill(props: DialogSkillProps) {
emptyView={
<Switch
fallback={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No skills available</text>
</box>
}
>
<Match when={showError()}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<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} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>Loading skills</text>
</box>
</Match>
</Switch>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No skills found</text>
</box>
}
-52
View File
@@ -1,52 +0,0 @@
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()
}}
/>
)
}
@@ -69,15 +69,10 @@ export function Autocomplete(props: {
visible: false as AutocompleteRef["visible"],
input: "keyboard" as "keyboard" | "mouse",
})
let popMode: (() => void) | undefined
const [positionTick, setPositionTick] = createSignal(0)
createEffect(() => {
if (!store.visible) return
const popMode = keymap.mode.push("autocomplete")
onCleanup(popMode)
})
createEffect(() => {
if (store.visible) {
let lastPos = { x: 0, y: 0, width: 0 }
@@ -271,7 +266,7 @@ export function Autocomplete(props: {
const { filename, part } = createFilePart({ path: item, type: "file" }, input.filePath, lineRange)
const index = store.visible === "@" ? store.index : props.input().cursorOffset
setStore("visible", false)
hide(false)
setStore("index", index)
insertPart(filename, part)
}
@@ -500,6 +495,7 @@ export function Autocomplete(props: {
function move(direction: -1 | 1) {
if (!store.visible) return
syncSearch()
if (!options().length) return
let next = store.selected + direction
if (next < 0) next = options().length - 1
@@ -520,6 +516,7 @@ export function Autocomplete(props: {
}
function select() {
syncSearch()
const selected = options()[store.selected]
if (!selected) return
hide()
@@ -591,6 +588,7 @@ export function Autocomplete(props: {
title: "Complete autocomplete item",
group: "Autocomplete",
run() {
syncSearch()
const selected = options()[store.selected]
if (selected?.isDirectory) {
expandDirectory()
@@ -604,15 +602,16 @@ export function Autocomplete(props: {
}))
function show(mode: "@" | "/") {
popMode ??= keymap.mode.push("autocomplete")
setStore({
visible: mode,
index: props.input().cursorOffset,
})
}
function hide() {
function hide(clear = true) {
const text = props.input().plainText
if (store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) {
if (clear && store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) {
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
// Sync the prompt store immediately since onContentChange is async
@@ -621,6 +620,8 @@ export function Autocomplete(props: {
})
}
setStore("visible", false)
popMode?.()
popMode = undefined
}
onMount(() => {
@@ -632,35 +633,33 @@ export function Autocomplete(props: {
unsubscribeMention()
})
props.ref({
const ref = {
get visible() {
return store.visible
},
onInput(value) {
onInput(value?: string) {
if (!props.input().focused) return
if (store.visible) {
if (
// Typed text before the trigger
props.input().cursorOffset <= store.index ||
// There is a space between the trigger and the cursor
props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/) ||
// "/<command>" is not the sole content
(store.visible === "/" && value.match(/^\S+\s+\S+\s*$/))
props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/)
) {
hide()
hide(false)
}
return
}
// Check if autocomplete should reopen (e.g., after backspace deleted a space)
const offset = props.input().cursorOffset
if (offset === 0) return
// Check for "/" at position 0 - reopen slash commands
if (value.startsWith("/") && !value.slice(0, offset).match(/\s/)) {
const text = value ?? (props.input().getTextRange(0, 1) === "/" ? props.input().getTextRange(0, offset) : "")
if (text.startsWith("/") && !text.slice(0, offset).match(/\s/)) {
show("/")
setStore("index", 0)
return
}
if (value === undefined) return
// Check for "@" trigger - find the nearest "@" before cursor with no whitespace between
const idx = mentionTriggerIndex(value, offset)
@@ -669,9 +668,22 @@ export function Autocomplete(props: {
setStore("index", idx)
}
},
}
props.ref(ref)
const stopInputSync = keymap.intercept("key", () => ref.onInput())
onCleanup(() => {
stopInputSync()
popMode?.()
})
})
function syncSearch() {
const next = props.input().getTextRange(store.index + 1, props.input().cursorOffset)
if (next === search()) return
setSearch(next)
setStore("selected", 0)
}
const height = createMemo(() => {
const count = options().length || 1
if (!store.visible) return Math.min(10, count)
+34 -10
View File
@@ -85,6 +85,7 @@ function pastedFilepath(value: string, platform: string) {
export type PromptRef = {
focused: boolean
empty: boolean
current: PromptInfo
set(prompt: PromptInfo): void
reset(): void
@@ -147,6 +148,7 @@ function argumentSlash(input: string, commands: readonly KeymapCommand[]) {
export function Prompt(props: PromptProps) {
let input: TextareaRenderable
let anchor: BoxRenderable
let promptSyncQueued = false
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
const leader = Keymap.useLeaderActive()
@@ -346,6 +348,7 @@ export function Prompt(props: PromptProps) {
category: "Prompt",
palette: undefined,
run: () => {
if (input.getTextRange(0, 1) === "") return false
clearPrompt()
dialog.clear()
},
@@ -450,6 +453,7 @@ export function Prompt(props: PromptProps) {
name: "prompt.editor",
slash: { name: "editor" },
run: async () => {
if (promptSyncQueued) await flushPromptSync()
dialog.clear()
const editorPrompt = expandPromptInputPastedText(store.prompt, store.prompt.pasted)
@@ -542,6 +546,9 @@ export function Prompt(props: PromptProps) {
get focused() {
return input.focused
},
get empty() {
return input.getTextRange(0, 1) === ""
},
get current() {
return store.prompt
},
@@ -581,6 +588,7 @@ export function Prompt(props: PromptProps) {
})
onCleanup(() => {
if (promptSyncQueued) void flushPromptSync()
if (store.prompt.text) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
@@ -706,9 +714,9 @@ export function Prompt(props: PromptProps) {
title: "Stash prompt",
name: "prompt.stash",
category: "Prompt",
enabled: !!store.prompt.text,
run: () => {
if (!store.prompt.text) return
if (input.getTextRange(0, 1) === "") return false
void flushPromptSync()
stash.push({ prompt: store.prompt })
input.extmarks.clear()
input.clear()
@@ -779,7 +787,7 @@ export function Prompt(props: PromptProps) {
Keymap.createLayer(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "",
enabled: () => inputTarget() !== undefined && !props.disabled,
bindings: ["prompt.clear"],
}
})
@@ -803,6 +811,10 @@ export function Prompt(props: PromptProps) {
title: "Shell mode",
group: "Prompt",
run: () => {
if (input.visualCursor.offset !== 0) {
input.insertText("!")
return
}
setStore("placeholder", randomIndex(shell().length))
setStore("mode", "shell")
},
@@ -925,6 +937,7 @@ export function Prompt(props: PromptProps) {
}
async function submitInner() {
if (promptSyncQueued) await flushPromptSync()
// IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read
// plainText directly and sync before any downstream reads.
@@ -1202,6 +1215,22 @@ export function Prompt(props: PromptProps) {
}, 0)
}
async function flushPromptSync() {
promptSyncQueued = false
renderer.removeFrameCallback(flushPromptSync)
if (!input || input.isDestroyed) return
const value = input.plainText
setStore("prompt", "text", value)
auto()?.onInput(value)
syncExtmarksWithPromptParts()
}
function queuePromptSync() {
if (promptSyncQueued) return
promptSyncQueued = true
renderer.setFrameCallback(flushPromptSync)
}
async function pasteAttachment(file: { filename?: string; uri: string }) {
const currentOffset = input.cursorOffset
const extmarkStart = currentOffset
@@ -1243,6 +1272,7 @@ export function Prompt(props: PromptProps) {
}
function clearPrompt() {
if (promptSyncQueued) void flushPromptSync()
if (
store.prompt.text.trim().length >= DRAFT_RETENTION_MIN_CHARS ||
store.prompt.pasted.length > 0 ||
@@ -1357,13 +1387,7 @@ export function Prompt(props: PromptProps) {
focusedTextColor={leader() ? theme.text.subdued : theme.text.default}
minHeight={1}
maxHeight={maxHeight()}
onContentChange={() => {
const value = input.plainText
setStore("prompt", "text", value)
auto()?.onInput(value)
syncExtmarksWithPromptParts()
setCursorVersion((value) => value + 1)
}}
onContentChange={queuePromptSync}
onCursorChange={() => setCursorVersion((value) => value + 1)}
onKeyDown={(e: { preventDefault(): void }) => {
if (props.disabled) {
@@ -283,6 +283,7 @@ 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,6 +4,7 @@ import { extend } from "@opentui/solid"
type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
enabled?: boolean
active?: boolean
promptPulse?: number
complete?: boolean
glow?: boolean
breathe?: boolean
@@ -28,6 +29,7 @@ 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
@@ -102,6 +104,11 @@ class Envelope {
this.scale = scale
}
restart(scale = 1) {
this.clock = 0
this.scale = scale
}
stop() {
this.clock = undefined
}
@@ -127,6 +134,7 @@ 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
@@ -154,6 +162,7 @@ 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
@@ -222,6 +231,15 @@ 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
@@ -368,6 +386,7 @@ extend({ tab_pulse: TabPulseRenderable })
export function TabPulse(props: {
enabled?: boolean
active: boolean
promptPulse?: number
complete?: boolean
glow?: boolean
breathe?: boolean
@@ -385,6 +404,7 @@ 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}
+45 -24
View File
@@ -15,6 +15,7 @@ import type {
ModelInfo,
PermissionSavedInfo,
PermissionRequest,
Project,
ProviderInfo,
ReferenceInfo,
SessionMessageInfo,
@@ -80,6 +81,7 @@ type Store = {
form: Record<string, FormWithLocation[]>
}
project: {
info: Record<string, Project>
permission: Record<string, PermissionSavedInfo[]>
}
location: Record<string, LocationData>
@@ -139,6 +141,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
form: {},
},
project: {
info: {},
permission: {},
},
location: {},
@@ -954,10 +957,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
sync.invalidate(`session.pending:${sessionID}`)
},
},
sync(sessionID: string) {
return sync.run(`session:${sessionID}`, async () => {
setStore("session", "info", sessionID, await client.api.session.get({ sessionID }))
registerSession(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)
}
})
},
invalidate(sessionID: string) {
@@ -1037,6 +1056,21 @@ 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]
@@ -1055,6 +1089,11 @@ 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])
@@ -1318,27 +1357,9 @@ 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,
})
})
.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))
.catch((error) => console.error("Failed to preload location", error))
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
return
}
handleEvent(details)
-13
View File
@@ -1,13 +0,0 @@
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,4 +1,5 @@
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"
@@ -6,6 +7,7 @@ import { useTuiStartup } from "./runtime"
export type HomeRoute = {
type: "home"
prompt?: PromptInfo
location?: LocationRef
}
export type SessionRoute = {
+61 -1
View File
@@ -1,6 +1,7 @@
import { createEffect, onCleanup } from "solid-js"
import { createEffect, createMemo, createSignal, 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"
@@ -31,10 +32,14 @@ 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
@@ -50,6 +55,7 @@ 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() {
@@ -72,6 +78,7 @@ 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,
),
@@ -129,9 +136,56 @@ 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")
@@ -157,6 +211,12 @@ 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,9 +10,12 @@ function Directory(props: { context: Plugin.Context; maxWidth: number }) {
)
return (
<Show when={directory()}>
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.text.subdued} />}
</Show>
<FadeFilePath
value={directory()}
maxWidth={props.maxWidth}
fg={props.context.theme.text.subdued}
bg={props.context.theme.background.default}
/>
)
}
@@ -23,7 +23,7 @@ const FIXTURE_TABS = [
{ sessionID: "fixture-12", title: "Prepare review" },
]
const EMPTY_STATUS: FixtureStatus = { unread: undefined, attention: false, busy: false }
const EMPTY_STATUS: FixtureStatus = { unread: undefined, promptPulse: 0, attention: false, busy: false }
const RUN_DURATION = 1_800
const RESUME_DURATION = 900
+47 -4
View File
@@ -2,8 +2,10 @@ import { PluginContextProvider, type Plugin } from "@opencode-ai/plugin/tui"
import {
batch,
createContext,
createEffect,
createMemo,
For,
on,
onCleanup,
onMount,
useContext,
@@ -33,6 +35,7 @@ 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"
@@ -94,6 +97,7 @@ 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,
@@ -278,6 +282,33 @@ 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]) => (
@@ -343,13 +374,13 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
return true
}
const reconcile = async () => {
const reconcile = async (configured = config.data.plugins ?? []) => {
await Promise.all(
Object.entries(store.registrations)
.filter(([, registration]) => registration.active)
.map(([id]) => deactivate(id)),
)
const entries = [...(await discoverTuiPlugins(paths.cwd)), ...(config.data.plugins ?? [])]
const entries = [...(await discoverTuiPlugins(paths.cwd)), ...configured]
batch(() => {
setStore("registrations", reconcileStore({}))
setStore("states", [])
@@ -423,8 +454,21 @@ 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
@@ -445,7 +489,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
unregister()
void dispose()
})
void loading.finally(() => setStore("ready", true))
})
return (
+3 -2
View File
@@ -37,8 +37,9 @@ export function displayCharAt(value: string, offset: number) {
}
}
export function mentionTriggerIndex(value: string, offset = promptOffsetWidth(value)) {
const text = displaySlice(value, 0, offset)
export function mentionTriggerIndex(value: string, offset?: number) {
if (!value.includes("@")) return
const text = displaySlice(value, 0, offset ?? promptOffsetWidth(value))
const index = text.lastIndexOf("@")
if (index === -1) return
+3 -2
View File
@@ -27,10 +27,11 @@ 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 forms = createMemo(() => data.session.form.list("global", data.location.default()) ?? [])
const currentLocation = () => route.location ?? data.location.default()
const forms = createMemo(() => data.session.form.list("global", currentLocation()) ?? [])
let sent = false
createEffect(() => location.set(data.location.default()))
createEffect(() => location.set(currentLocation()))
onMount(() => {
editor.clearSelection()
@@ -16,9 +16,7 @@ export function ShellTab(props: { sessionID: string }) {
const composer = useComposerTab()
const shortcuts = Keymap.useShortcuts()
const entries = createMemo(() =>
data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID && shell.status === "running"),
)
const entries = createMemo(() => data.shell.listBySession(props.sessionID).filter((shell) => shell.status === "running"))
const [store, setStore] = createStore({ selected: 0 })
let scroll: ScrollBoxRenderable | undefined
@@ -1,26 +0,0 @@
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()
},
},
]}
/>
)
}
@@ -1,91 +0,0 @@
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>
)
}
+79 -34
View File
@@ -98,6 +98,13 @@ 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
@@ -256,7 +263,7 @@ export function Session() {
const sessionID = route.sessionID
void (async () => {
await Promise.all([
data.session.sync(sessionID),
data.session.sync(sessionID, { children: true }),
data.session.permission.sync(sessionID),
data.session.form.sync(sessionID),
])
@@ -296,6 +303,49 @@ 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
@@ -322,41 +372,36 @@ export function Session() {
currentSlack: scroll.getRenderable(NAVIGATION_SLACK_ID)?.height ?? 0,
}),
)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (scroll.isDestroyed || navigationMessage() !== messageID) return
scroll.scrollTo(top)
afterLayout(() => {
if (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) {
if (target) alignMessage(target.id, target.top)
dialog.clear()
return
}
})
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)))
}
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)))
})
function toBottom() {
clearMessageNavigation()
@@ -932,12 +977,12 @@ export function Session() {
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<For each={rows}>
<For each={visibleRows()}>
{(row, index) => (
<SessionRowView
row={row}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
boundaryID={boundaries()[index()]}
boundaryID={boundaries()[index() + hidden()]}
/>
)}
</For>
+12 -7
View File
@@ -97,11 +97,16 @@ export function createSessionRows(sessionID: Accessor<string>) {
}),
)
// Re-reduce when the revert boundary changes (stage/clear/commit).
// 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.
createEffect(
on(revertBoundary, () => {
setRows(reconcile(reduce()))
}),
on(
revertBoundary,
() => {
setRows(reconcile(reduce()))
},
{ defer: true },
),
)
createEffect(
@@ -112,6 +117,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
.filter((item) => item.type === "compaction")
.map((item) => item.id),
() => setRows(reconcile(reduce())),
{ defer: true },
),
)
@@ -137,12 +143,11 @@ export function createSessionRows(sessionID: Accessor<string>) {
: [],
),
() => setRows(reconcile(reduce())),
{ defer: true },
),
)
createEffect(
on(turnTokens, () => setRows(reconcile(reduce()))),
)
createEffect(on(turnTokens, () => setRows(reconcile(reduce())), { defer: true }))
const appendMessage = (messageID: string) =>
setRows(
@@ -1,115 +0,0 @@
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} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No items available</text>
</box>
)
}
>
{props.noMatchView ?? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No results found</text>
</box>
)}
+56
View File
@@ -0,0 +1,56 @@
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>
)
}

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