Compare commits

...
136 changed files with 2678 additions and 4699 deletions
+8
View File
@@ -50,6 +50,14 @@ jobs:
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Test Effect simplification rules
if: runner.os == 'Linux'
run: bun run test:effect-simplification-rules
- name: Check Effect simplifications
if: runner.os == 'Linux'
run: bun run lint:effect-simplifications
- name: Configure git identity
run: |
git config --global user.email "bot@opencode.ai"
File diff suppressed because it is too large Load Diff
+1 -17
View File
@@ -1,19 +1,3 @@
{
"$schema": "https://opencode.ai/tui.json",
"plugin": [
[
"./plugins/tui-smoke.tsx",
{
"enabled": false,
"label": "workspace",
"keybinds": {
"smoke_modal": "ctrl+alt+m",
"smoke_screen": "ctrl+alt+o",
"smoke_screen_home": "escape,ctrl+shift+h",
"smoke_screen_modal": "ctrl+alt+m",
"smoke_dialog_close": "escape,q"
}
}
]
]
"$schema": "https://opencode.ai/tui.json"
}
+1 -1
View File
@@ -1,5 +1,5 @@
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit generated client files directly.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk` composes Client, Core, and Server.
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
- The default branch in this repo is `v2`.
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
+684 -1905
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-8pRvkbUX2aZhFTFtFuUM6mPqZZhfC4mFd1+BXVMzEJk=",
"aarch64-linux": "sha256-df25TWdjjLKeLZJEfrDpgVaV8ZAZhHWPoV2IPIQ4U2w=",
"aarch64-darwin": "sha256-VjbOx7Zi9eTiPxqpKN3+EQWweBfJHf7y36sGSN1peg0=",
"x86_64-darwin": "sha256-q7nW4AR2OnepnDcPDtYECgcsXI+JRHOCWhPsAX8t7q0="
"x86_64-linux": "sha256-PatsUdaitHvSUpS5gkC5J2rsUNB5vwJKqHdlOFaKk70=",
"aarch64-linux": "sha256-gTRQMAADH/SpQ8yh+YS2IcnmQxnJcU4FUah2YfrKeP8=",
"aarch64-darwin": "sha256-QTqlwmugYh+iu5Sh/Hxv01NXH/OhzcQ8ObVUbA9A8AM=",
"x86_64-darwin": "sha256-0DPAbNCVw2nUMWkIGEhB6saMdxRRwAJi7wAoWCQc7xQ="
}
}
+5 -4
View File
@@ -18,13 +18,16 @@
"bench:devex": "bun run --cwd packages/app test:bench:devex",
"lint": "oxlint",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
"lint:effect-simplifications": "ast-grep scan -c script/ast-grep/effect-simplifications/sgconfig.yml --off=unused-suppression packages",
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
"test:effect-simplification-rules": "ast-grep test -c script/ast-grep/effect-simplifications/sgconfig.yml",
"typecheck": "bun turbo typecheck --concurrency=3",
"typecheck:profile": "bun script/profile-typecheck.ts",
"typecheck:profile:packages": "bun script/profile-typecheck-packages.ts",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/core fix-node-pty",
"prepare": "husky",
"reserve-packages": "bun script/reserve-package-names.ts",
"random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser",
"test": "echo 'do not run tests from root' && exit 1"
@@ -33,8 +36,7 @@
"packages": [
"packages/*",
"packages/console/*",
"packages/stats/*",
"packages/slack"
"packages/stats/*"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-rc.110",
@@ -127,7 +129,6 @@
"@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "1.18.5",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:"
},
@@ -174,6 +175,6 @@
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch"
}
}
+1 -1
View File
@@ -370,7 +370,7 @@ const responseError = Effect.fn("RecordingEnv.responseError")(function* (
response: HttpClientResponse.HttpClientResponse,
) {
if (response.status >= 200 && response.status < 300) return undefined
const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")))
const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
return `${response.status}${body ? `: ${body.slice(0, 180)}` : ""}`
})
+28 -13
View File
@@ -217,6 +217,7 @@ interface ParserState {
readonly usage?: Usage
readonly lifecycle: Lifecycle.State
readonly reasoningSignature?: string
readonly textSignature?: string
}
// =============================================================================
@@ -328,7 +329,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
if (part.type === "text") {
parts.push({ text: part.text })
parts.push({ text: part.text, thoughtSignature: thoughtSignature(part.providerMetadata) })
continue
}
if (part.type === "reasoning") {
@@ -540,14 +541,16 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
if (finishReason === undefined && state.usage === undefined) return []
const events: LLMEvent[] = []
const lifecycle = state.reasoningSignature
? Lifecycle.reasoningEnd(
state.lifecycle,
events,
"reasoning-0",
googleMetadata({ thoughtSignature: state.reasoningSignature }),
)
: state.lifecycle
let lifecycle = state.lifecycle
if (state.reasoningSignature !== undefined)
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
googleMetadata({ thoughtSignature: state.reasoningSignature }),
)
if (state.textSignature !== undefined)
lifecycle = Lifecycle.textEnd(lifecycle, events, "text-0", googleMetadata({ thoughtSignature: state.textSignature }))
Lifecycle.finish(lifecycle, events, {
reason: {
normalized:
@@ -579,10 +582,14 @@ const step = (state: ParserState, event: GeminiEvent) => {
let lifecycle = nextState.lifecycle
let nextToolCallId = nextState.nextToolCallId
let reasoningSignature = nextState.reasoningSignature
let textSignature = nextState.textSignature
for (const part of candidate.content.parts) {
if ("thoughtSignature" in part && part.thoughtSignature && "thought" in part && part.thought)
reasoningSignature = part.thoughtSignature
const signature = "thoughtSignature" in part && part.thoughtSignature ? part.thoughtSignature : undefined
// Gemini attaches replay signatures to thought parts, visible text, or function calls;
// each block kind must retain the signature attached to its own parts.
if (signature !== undefined && "thought" in part && part.thought) reasoningSignature = signature
else if (signature !== undefined && "text" in part) textSignature = signature
if ("text" in part && part.text.length > 0) {
if (part.thought) {
lifecycle = Lifecycle.reasoningDelta(
@@ -590,7 +597,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
events,
"reasoning-0",
part.text,
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
signature ? googleMetadata({ thoughtSignature: signature }) : undefined,
)
continue
}
@@ -600,7 +607,14 @@ const step = (state: ParserState, event: GeminiEvent) => {
"reasoning-0",
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
lifecycle = Lifecycle.textDelta(
lifecycle,
events,
"text-0",
part.text,
textSignature ? googleMetadata({ thoughtSignature: textSignature }) : undefined,
)
textSignature = undefined
continue
}
@@ -637,6 +651,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
lifecycle,
nextToolCallId,
reasoningSignature,
textSignature,
finishReason: candidate.finishReason ?? nextState.finishReason,
},
events,
+8 -2
View File
@@ -21,9 +21,15 @@ export const textStart = (state: State, events: LLMEvent[], id: string, provider
return { ...stepped, text: new Set([...stepped.text, id]) }
}
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
export const textDelta = (
state: State,
events: LLMEvent[],
id: string,
text: string,
providerMetadata?: ProviderMetadata,
): State => {
const started = textStart(state, events, id)
events.push(LLMEvent.textDelta({ id, text }))
events.push(LLMEvent.textDelta({ id, text, providerMetadata }))
return started
}
+55
View File
@@ -905,6 +905,61 @@ describe("Gemini route", () => {
}),
)
it.effect("preserves thoughtSignature on visible text parts", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: { role: "model", parts: [{ text: "All done.", thoughtSignature: "text_sig" }] },
finishReason: "STOP",
},
],
})
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
const delta = response.events.find((event) => event.type === "text-delta")
expect(delta).toMatchObject({
id: "text-0",
text: "All done.",
providerMetadata: { google: { thoughtSignature: "text_sig" } },
})
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.assistant([{ type: "text", text: "All done.", providerMetadata: delta?.providerMetadata }])],
}),
)
expect(prepared.body.contents).toEqual([
{ role: "model", parts: [{ text: "All done.", thoughtSignature: "text_sig" }] },
])
}),
)
it.effect("flushes a trailing empty signed text part at block close", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: {
role: "model",
parts: [{ text: "Working." }, { text: "", thoughtSignature: "tail_sig" }],
},
finishReason: "STOP",
},
],
})
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
const delta = response.events.find((event) => event.type === "text-delta")
const end = response.events.find((event) => event.type === "text-end")
expect(delta).toMatchObject({ id: "text-0", text: "Working.", providerMetadata: undefined })
expect(end).toMatchObject({
id: "text-0",
providerMetadata: { google: { thoughtSignature: "tail_sig" } },
})
}),
)
it.effect("replays unsigned Gemini 3 tool calls with the validator bypass sentinel", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+5 -5
View File
@@ -58,11 +58,11 @@
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
"@ff-labs/fff-bin-darwin-arm64": "0.10.1",
"@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1",
"@ff-labs/fff-bin-linux-x64-gnu": "0.10.1",
"@ff-labs/fff-bin-win32-arm64": "0.10.1",
"@ff-labs/fff-bin-win32-x64": "0.10.1",
"@ff-labs/fff-bin-darwin-arm64": "0.10.5",
"@ff-labs/fff-bin-linux-arm64-gnu": "0.10.5",
"@ff-labs/fff-bin-linux-x64-gnu": "0.10.5",
"@ff-labs/fff-bin-win32-arm64": "0.10.5",
"@ff-labs/fff-bin-win32-x64": "0.10.5",
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
+2 -2
View File
@@ -31,7 +31,7 @@ export const layer = Layer.effect(
const file = path.join(global.config, "cli.json")
const readJson = Effect.fnUntraced(function* () {
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
const text = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => undefined))
if (text === undefined) return undefined
const errors: ParseError[] = []
const value: any = parse(text, errors, { allowTrailingComma: true })
@@ -87,7 +87,7 @@ export const layer = Layer.effect(
const next = produce(current, update)
const edits = changes(current, next)
if (!edits.length) return current
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
const text = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => "{}"))
const updated = edits.reduce(
(text, edit) =>
applyEdits(
+1 -1
View File
@@ -258,7 +258,7 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
const readJson = Effect.fnUntraced(function* (target: string) {
const fs = yield* FileSystem.FileSystem
const text = yield* fs.readFileString(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
const text = yield* fs.readFileString(target).pipe(Effect.orElseSucceed(() => undefined))
if (text === undefined) return undefined
const errors: ParseError[] = []
const value: any = parse(text, errors, { allowTrailingComma: true })
+1 -1
View File
@@ -119,7 +119,7 @@ export const read = Effect.fn("cli.service-config.read")(function* () {
if (legacyConfigFile) yield* migrateConfig(legacyConfigFile, configFile)
return yield* fs.readFileString(configFile).pipe(
Effect.flatMap(decodeInfo),
Effect.catch(() => Effect.succeed({} as Info)),
Effect.orElseSucceed(() => ({}) as Info),
)
})
+2 -2
View File
@@ -44,7 +44,7 @@ export const layer = Layer.effect(
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
fs.readFileString(path.join(global.config, name)).pipe(
Effect.map(decodePolicy),
Effect.catch(() => Effect.succeed(undefined)),
Effect.orElseSucceed(() => undefined),
),
)
return values.findLast((value) => value !== undefined) ?? true
@@ -63,7 +63,7 @@ export const layer = Layer.effect(
stdout: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
})),
Effect.catch(() => Effect.succeed({ code: 1, stdout: "", stderr: "" })),
Effect.orElseSucceed(() => ({ code: 1, stdout: "", stderr: "" })),
)
})
+3 -3
View File
@@ -80,7 +80,6 @@
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/which": "3.0.4",
"@opencode-ai/shell-scan": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
@@ -113,12 +112,13 @@
"@aws-sdk/credential-providers": "3.1057.0",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
"@ff-labs/fff-bun": "0.10.1",
"@ff-labs/fff-node": "0.10.1",
"@ff-labs/fff-bun": "0.10.5",
"@ff-labs/fff-node": "0.10.5",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/shell-scan": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"@parcel/watcher": "2.5.1",
-10
View File
@@ -20,16 +20,6 @@ const result = await Bun.build({
format: "esm",
packages: "external",
external: ["#sqlite", "#pty", "#fff", "#photon-wasm", "#shell-parser-wasm", "#process-lock-ffi", "#v1-migration"],
plugins: [
{
name: "bundle-shell-scan",
setup(build) {
build.onResolve({ filter: /^@opencode-ai\/shell-scan$/ }, () => ({
path: path.resolve("../shell-scan/src/index.ts"),
}))
},
},
],
splitting: true,
loader: {
".txt": "text",
+1 -1
View File
@@ -347,7 +347,7 @@ export const layer = (options?: Options) =>
Stream.filterEffect((event) =>
wellknown.entries().pipe(
Effect.map((entries) => entries.some((entry) => entry.integrationID === event.data.integrationID)),
Effect.catch(() => Effect.succeed(false)),
Effect.orElseSucceed(() => false),
),
),
Stream.runForEach(() =>
+2 -2
View File
@@ -59,7 +59,7 @@ export const Plugin = define({
return yield* Effect.forEach(files, (file) =>
fs.readFileStringSafe(file.filepath).pipe(
Effect.map((content) => (content ? decode(file, content) : undefined)),
Effect.catch(() => Effect.succeed(undefined)),
Effect.orElseSucceed(() => undefined),
),
).pipe(Effect.map((documents) => documents.filter((document): document is Document => document !== undefined)))
})
@@ -176,7 +176,7 @@ function discover(fs: FSUtil.Interface, directory: string) {
),
).pipe(
Effect.map((files) => files.flat()),
Effect.catch(() => Effect.succeed([])),
Effect.orElseSucceed(() => []),
)
}
+2 -2
View File
@@ -86,11 +86,11 @@ function loadDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const files = yield* fs
.scan("{command,commands}/**/*.md", { cwd: directory, absolute: true, dot: true, symlink: true })
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
.pipe(Effect.orElseSucceed(() => [] as string[]))
return yield* Effect.forEach(files.toSorted(), (filepath) =>
fs.readFileStringSafe(filepath).pipe(
Effect.map((content) => (content === undefined ? undefined : decode(directory, filepath, content))),
Effect.catch(() => Effect.succeed(undefined)),
Effect.orElseSucceed(() => undefined),
),
).pipe(
Effect.map((commands) =>
+1 -1
View File
@@ -148,7 +148,7 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
...operation,
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
})),
Effect.catch(() => Effect.succeed(operation)),
Effect.orElseSucceed(() => operation),
)
})
})
@@ -35,14 +35,12 @@ const layer = Layer.effect(
Effect.gen(function* () {
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
: undefined
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.orElseSucceed(() => resolved)) : undefined
if (vcs) return { path: path.join(vcs, "HEAD"), aliases: [".git", vcs, ...(resolved ? [resolved] : [])] }
}
if (location.vcs?.type === "hg") {
const store = location.vcs.store
const vcs = yield* fs.realPath(store).pipe(Effect.catch(() => Effect.succeed(store)))
const vcs = yield* fs.realPath(store).pipe(Effect.orElseSucceed(() => store))
return { path: path.join(vcs, "branch"), aliases: [".hg", vcs] }
}
}).pipe(
+12 -7
View File
@@ -9,6 +9,7 @@ import { AppProcess } from "@opencode-ai/util/process"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { File } from "./file.js"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { gitExecutable } from "./util/git-executable.js"
export class Repository extends Schema.Class<Repository>("Git.Repository")({
worktree: AbsolutePath,
@@ -314,7 +315,7 @@ const layer = Layer.effect(
) {
const result = yield* proc
.run(
ChildProcess.make("git", repositoryArgs(repository, args), {
ChildProcess.make(gitExecutable, repositoryArgs(repository, args), {
cwd: repository.worktree,
env: options?.env,
extendEnv: true,
@@ -449,10 +450,14 @@ const layer = Layer.effect(
if (!input.paths.length) return new Set<RelativePath>()
const result = yield* proc
.run(
ChildProcess.make("git", repositoryArgs(input.repository, ["check-ignore", "--no-index", "--stdin", "-z"]), {
cwd: input.repository.worktree,
extendEnv: true,
}),
ChildProcess.make(
gitExecutable,
repositoryArgs(input.repository, ["check-ignore", "--no-index", "--stdin", "-z"]),
{
cwd: input.repository.worktree,
extendEnv: true,
},
),
{ stdin: input.paths.join("\0") + "\0" },
)
.pipe(
@@ -625,7 +630,7 @@ const layer = Layer.effect(
cwd = repository.worktree,
) {
const result = yield* proc
.run(ChildProcess.make("git", args, { cwd, extendEnv: true, stdin: "ignore" }))
.run(ChildProcess.make(gitExecutable, args, { cwd, extendEnv: true, stdin: "ignore" }))
.pipe(
Effect.mapError(
(cause) => new WorktreeError({ operation, directory: worktreeDirectory, message: cause.message, cause }),
@@ -722,7 +727,7 @@ function execute(cwd: string, proc: AppProcess.Interface) {
return (args: string[]) =>
proc
.run(
ChildProcess.make("git", args, {
ChildProcess.make(gitExecutable, args, {
cwd,
extendEnv: true,
stdin: "ignore",
+1 -1
View File
@@ -622,7 +622,7 @@ export const layer = (options?: Options) =>
const loadFromFile = options?.file
? fs.readJson(options.file).pipe(
Effect.map((input) => input as Record<string, SourceProvider>),
Effect.catch(() => Effect.succeed(undefined)),
Effect.orElseSucceed(() => undefined),
)
: Effect.succeed(undefined)
+1 -2
View File
@@ -2,7 +2,6 @@ export * as PluginHost from "./host.js"
import { Plugin } from "@opencode-ai/plugin/effect"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Mcp } from "@opencode-ai/schema/mcp"
import { App } from "../app.js"
@@ -481,6 +480,6 @@ function methodImplementation(input: IntegrationMethodRegistration): Integration
}
}
function credential(value: CredentialOAuth) {
function credential(value: Credential.OAuth) {
return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
}
@@ -108,7 +108,7 @@ const oauth = (app: App.Info) =>
},
).pipe(
Effect.map((user) => Option.getOrUndefined(decodeUser(user))?.endpoints?.api?.replace(/\/+$/, "")),
Effect.catch(() => Effect.succeed(undefined)),
Effect.orElseSucceed(() => undefined),
Effect.map((apiEndpoint) =>
Credential.OAuth.make({
type: "oauth",
@@ -159,7 +159,7 @@ export const GithubCopilotPlugin = define({
const load = Effect.fn("GithubCopilotPlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("github-copilot")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
if (credential?.type !== "oauth") {
loaded.baseURL = undefined
+1 -1
View File
@@ -148,7 +148,7 @@ export function make(origin = "http://127.0.0.1:11434", interval: Duration.Input
)
shows.set(model.model, { digest: model.digest, info })
return { ...model, show: info }
}).pipe(Effect.catch(() => Effect.succeed(undefined))),
}).pipe(Effect.orElseSucceed(() => undefined)),
{ concurrency: 4 },
)
const filtered = models.filter(
+1 -1
View File
@@ -175,7 +175,7 @@ export const OpenAIPlugin = define({
const load = Effect.fn("OpenAIPlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("openai")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
chatgpt =
credential?.type === "oauth" &&
@@ -2,7 +2,6 @@ import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
import type { Scope } from "effect"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { define } from "@opencode-ai/plugin/effect/plugin"
import type { CredentialValue } from "@opencode-ai/sdk/v2/types"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
@@ -92,7 +91,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
const load = Effect.fn("OpencodePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("opencode")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
connected = connection !== undefined
providers = credential
@@ -199,7 +198,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
}),
})
function fetchProviders(http: HttpClient.HttpClient, value: CredentialValue) {
function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
const metadata = value.metadata
const server = typeof metadata?.server === "string" ? metadata.server : defaultServer
const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined
+1 -1
View File
@@ -144,7 +144,7 @@ function poll(device: typeof Device.Type, app: App.Info): Effect.Effect<Token, u
if (response.ok) return yield* decode(response, Token)
const error = yield* Effect.promise(() => response.text()).pipe(
Effect.map((body) => Option.getOrUndefined(decodeDeviceError(body))),
Effect.catch(() => Effect.succeed(undefined)),
Effect.orElseSucceed(() => undefined),
)
if (error?.error === "authorization_pending") {
return yield* Effect.sleep(interval + pollingSafetyMargin).pipe(Effect.andThen(loop(interval)))
+1 -1
View File
@@ -9,7 +9,7 @@ import type { Versioned } from "../plugin.js"
export const Updated = Bus.ephemeral({ type: "sdk.plugin.updated", schema: {} })
/**
* Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes,
* Holds the plugins an embedder (the `@opencode-ai/sdk` host) contributes,
* so `PluginSupervisor` can add them on every Location boot through the ordinary
* generation path that `PluginSupervisor` uses for plugins discovered from
* config. Registration publishes an unlocated update so every booted Location
+5 -5
View File
@@ -41,7 +41,7 @@ export interface Resolved {
export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, input: AbsolutePath) {
return yield* fs.up({ targets: [".git", ".hg"], start: input, mode: "first" }).pipe(
Effect.map((matches) => (matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined)),
Effect.catch(() => Effect.succeed(undefined)),
Effect.orElseSucceed(() => undefined),
)
})
@@ -149,7 +149,7 @@ const layer = Layer.effect(
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
Effect.map((value) => value.trim()),
Effect.map((value) => (value ? ID.make(value) : undefined)),
Effect.catch(() => Effect.succeed(undefined)),
Effect.orElseSucceed(() => undefined),
)
})
@@ -202,7 +202,7 @@ const layer = Layer.effect(
stdin: "ignore",
}),
)
.pipe(Effect.catch(() => Effect.succeed(undefined)))
.pipe(Effect.orElseSucceed(() => undefined))
if (!result || result.exitCode !== 0) return undefined
const node = result.stdout
.toString("utf8")
@@ -216,7 +216,7 @@ const layer = Layer.effect(
const hgDiscover = Effect.fnUntraced(function* (input: AbsolutePath) {
const dotHg = yield* fs.up({ targets: [".hg"], start: input, mode: "first" }).pipe(
Effect.map((matches) => matches[0]),
Effect.catch(() => Effect.succeed(undefined)),
Effect.orElseSucceed(() => undefined),
)
if (!dotHg) return undefined
const worktree = AbsolutePath.make(path.dirname(dotHg))
@@ -241,7 +241,7 @@ const layer = Layer.effect(
? 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)),
Effect.orElseSucceed(() => repo.worktree),
)
return yield* persist({
previous,
+2 -2
View File
@@ -768,7 +768,7 @@ const layer = Layer.effect(
const expanded =
value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded))
const info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
const info = yield* fs.stat(directory).pipe(Effect.orElseSucceed(() => undefined))
if (!info) return yield* new DestinationNotFoundError({ directory })
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
const project = yield* projects.resolve(directory)
@@ -787,7 +787,7 @@ const layer = Layer.effect(
input.sessionID,
Effect.gen(function* () {
const latest = yield* result.get(input.sessionID)
const source = yield* fs.stat(latest.location.directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
const source = yield* fs.stat(latest.location.directory).pipe(Effect.orElseSucceed(() => undefined))
if (!source || source.type !== "Directory") {
const cancellations = (yield* SessionInbox.moveIDs(db, input.sessionID)).map(
(item) => [SessionEvent.InboxCancelled, { sessionID: input.sessionID, inboxID: item.id }] as const,
+1 -1
View File
@@ -120,7 +120,7 @@ export const firstUserMessage = Effect.fn("SessionHistory.firstUserMessage")(fun
.get()
.pipe(Effect.orDie)
if (!row) return undefined
const message = yield* decodeMessageRow(row).pipe(Effect.catch(() => Effect.succeed(undefined)))
const message = yield* decodeMessageRow(row).pipe(Effect.orElseSucceed(() => undefined))
return message?.type === "user" ? message : undefined
})
+1 -1
View File
@@ -319,7 +319,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
}).pipe(
Effect.andThen(metric("connect_failure")),
Effect.andThen(metric("fallback")),
Effect.asVoid,
Effect.as(undefined),
),
),
)
+2
View File
@@ -181,6 +181,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
eq(SessionMessageTable.session_id, event.data.parentID),
gt(SessionMessageTable.seq, cursor),
lt(SessionMessageTable.seq, copiedSeq + 1),
// Terminal events for active projections stay on the parent, so forks copy only settled history.
sql`${SessionMessageTable.type} != 'assistant' or json_extract(${SessionMessageTable.data}, '$.time.completed') is not null`,
sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`,
),
)
@@ -152,7 +152,9 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
{
type: "text",
text: item.text,
providerMetadata: sameProvider ? providerMetadata(providerMetadataKey, item.state) : undefined,
// Text can carry provider-bound state (e.g. Gemini thought signatures),
// which is only replayable against the model that produced it.
providerMetadata: reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.state) : undefined,
},
]
if (item.type === "reasoning")
@@ -167,6 +169,9 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
: item.text.length > 0
? [{ type: "text", text: item.text }]
: []
// Call-side metadata is model-scoped proof of generation (Gemini thought
// signatures, OpenAI encrypted reasoning): only the producing model may
// replay it.
const reuseToolProviderMetadata =
reuseProviderMetadata ||
(sameModel && item.executed === true && (item.state.status === "completed" || item.state.status === "error"))
@@ -175,8 +180,12 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
reuseToolProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined,
)
if (item.executed !== true) return [call]
// Hosted result payloads are provider-format state, not model state:
// replay must survive a model switch within the same provider.
// Hosted tools (e.g. google_search) run inside the provider, so their
// result payload (`providerResultState`) is provider-format data rather
// than model-scoped proof: it stays replayable across models of the same
// provider. After a model switch, echo only that payload — never fall
// back to `providerState`, whose call-side values are bound to the old
// model.
const result = toolResult(
item,
reuseToolProviderMetadata
+2 -2
View File
@@ -77,7 +77,7 @@ export const cleanup = Effect.fn("Shell.cleanup")(function* () {
const directory = path.join(global.data, DIRECTORY)
const projects = yield* fs.readDirectoryEntries(directory).pipe(
Effect.map((entries) => entries.filter((entry) => entry.type === "directory")),
Effect.catch(() => Effect.succeed([])),
Effect.orElseSucceed(() => []),
)
const files = yield* Effect.forEach(
projects,
@@ -90,7 +90,7 @@ export const cleanup = Effect.fn("Shell.cleanup")(function* () {
: [],
),
),
Effect.catch(() => Effect.succeed([])),
Effect.orElseSucceed(() => []),
),
{ concurrency: 8 },
)
+1 -1
View File
@@ -36,7 +36,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/To
const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface, directory: string) {
const entries = yield* fs.readDirectory(directory).pipe(
Effect.map((entries) => entries.filter((entry) => /^tool_[0-9a-f]{12}/.test(entry))),
Effect.catch(() => Effect.succeed([])),
Effect.orElseSucceed(() => []),
)
yield* FileRetention.cleanup(
fs,
+1 -1
View File
@@ -142,7 +142,7 @@ export const Plugin = {
.map((entry) => join(dirname(input), entry))
.slice(0, 3),
),
Effect.catch(() => Effect.succeed([] as string[])),
Effect.orElseSucceed(() => [] as string[]),
)
const message =
suggestions.length === 0
+6
View File
@@ -0,0 +1,6 @@
import path from "path"
import { which } from "./which.js"
const resolved = process.platform === "win32" ? which("git") : undefined
export const gitExecutable = resolved ? path.resolve(resolved) : "git"
+1 -1
View File
@@ -49,7 +49,7 @@ const layer = Layer.effect(
const state = { info: impl ? yield* impl.info() : ({ branch: {} } satisfies Info) }
if (vcs && impl) {
const store = yield* fs.realPath(vcs.store).pipe(Effect.catch(() => Effect.succeed(vcs.store)))
const store = yield* fs.realPath(vcs.store).pipe(Effect.orElseSucceed(() => vcs.store))
const isBranchMetadata =
vcs.type === "git"
? (file: string) => path.basename(file) === "HEAD" && FSUtil.contains(store, file)
+3 -2
View File
@@ -8,6 +8,7 @@ import { AppProcess } from "@opencode-ai/util/process"
import type { DiffOptions, Interface } from "../vcs.js"
import { chunksByFile, emptyPatch, MAX_PATCH_BYTES, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./patch.js"
import type { Patch } from "./patch.js"
import { gitExecutable } from "../util/git-executable.js"
/**
* Git adapter for the Vcs service. Ported from the V1 pipeline: patches are
@@ -128,7 +129,7 @@ function makeGit(proc: AppProcess.Interface) {
const run = Effect.fnUntraced(
function* (args: string[], opts: { cwd: string; maxOutputBytes?: number }) {
const result = yield* proc.run(
ChildProcess.make("git", [...cfg, ...args], {
ChildProcess.make(gitExecutable, [...cfg, ...args], {
cwd: opts.cwd,
extendEnv: true,
stdin: "ignore",
@@ -141,7 +142,7 @@ function makeGit(proc: AppProcess.Interface) {
truncated: result.stdoutTruncated || result.stderrTruncated,
}
},
Effect.catch(() => Effect.succeed({ exitCode: 1, text: () => "", truncated: false })),
Effect.orElseSucceed(() => ({ exitCode: 1, text: () => "", truncated: false })),
)
const text = Effect.fnUntraced(function* (args: string[], opts: { cwd: string }) {
+1 -1
View File
@@ -32,7 +32,7 @@ describe("ConfigWebSearchPlugin.Plugin", () => {
yield* waitUntil(
websearch.default().pipe(
Effect.map((provider) => provider?.id === WebSearch.ID.make("test")),
Effect.catch(() => Effect.succeed(false)),
Effect.orElseSucceed(() => false),
),
)
}).pipe(Effect.provide(Config.testLayer([configured(false)]))),
@@ -0,0 +1,12 @@
import { expect, test } from "bun:test"
import { Effect } from "effect"
const source = Effect.succeed(1)
const exactUndefined: Effect.Effect<undefined> = source.pipe(Effect.as(undefined))
// @ts-expect-error Effect.asVoid widens the success type to void.
const voidSuccess: Effect.Effect<undefined> = source.pipe(Effect.asVoid)
test("Effect.as preserves the exact undefined success type", () => {
expect(Effect.runSync(exactUndefined)).toBeUndefined()
expect(Effect.runSync(voidSuccess)).toBeUndefined()
})
@@ -1,5 +1,6 @@
import { describe, expect, spyOn, test } from "bun:test"
import fuzzysort from "fuzzysort"
import { mkdir, mkdtemp, rm } from "node:fs/promises"
import os from "os"
import path from "path"
import { Deferred, Effect, Layer } from "effect"
@@ -14,6 +15,41 @@ import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
describe("FileSystemSearch", () => {
test("honors wildcard directory rules from .gitignore", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-fff-ignore-"))
try {
await mkdir(path.join(directory, "rust/target/debug/deps"), { recursive: true })
await Bun.write(path.join(directory, ".gitignore"), "**/target/\n")
await Bun.write(path.join(directory, "rust/target/debug/deps/ignored.rs"), "ignored")
const git = Bun.spawnSync(["git", "init", "-q"], { cwd: directory })
expect(git.exitCode).toBe(0)
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
const layer = FileSystemSearch.fffLayer.pipe(
Layer.provide(
Layer.succeed(
Location.Service,
Location.Service.of(
location(ref, {
vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) },
}),
),
),
),
)
const entries = await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
return yield* search.find({ query: "target" })
}).pipe(Effect.provide(layer), Effect.scoped),
)
expect(entries.every((entry) => !entry.path.startsWith("rust/target/"))).toBe(true)
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("bounds a home scan even when home is detected as a repository", async () => {
let observed: Ripgrep.FindInput | undefined
const home = AbsolutePath.make(os.homedir())
+55
View File
@@ -401,6 +401,56 @@ describe("Session.create", () => {
}),
)
it.effect("does not copy a running assistant into a fork", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const parent = yield* session.create({ location })
yield* session.prompt({ sessionID: parent.id, text: "Run both tools", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
const assistantMessageID = SessionMessage.ID.create()
const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") })
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: parent.id,
assistantMessageID,
agent: Agent.ID.make("build"),
model,
})
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID: parent.id,
assistantMessageID,
id: "call_running",
name: "shell",
})
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID: parent.id,
assistantMessageID,
id: "call_running",
text: '{"command":"sleep 10"}',
})
yield* bus.publish(SessionEvent.Tool.Called, {
sessionID: parent.id,
assistantMessageID,
id: "call_running",
input: { command: "sleep 10" },
executed: true,
})
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
expect(yield* session.context(parent.id)).toMatchObject([
{ type: "user", text: "Run both tools" },
{
type: "assistant",
content: [{ type: "tool", id: "call_running", state: { status: "running" } }],
},
])
expect(yield* session.context(forked.id)).toMatchObject([{ type: "user", text: "Run both tools" }])
}),
)
it.effect("rejects forking an empty session", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -470,6 +520,11 @@ describe("Session.create", () => {
expect(forked).toMatchObject({ cost: 0, tokens: { input: 0, output: 0, reasoning: 0 } })
expect(yield* session.context(beforeFirst.id)).toEqual([])
expect(beforeFirst).toMatchObject({ cost: 0, tokens: { input: 0, output: 0, reasoning: 0 } })
expect(yield* session.context(complete.id)).toMatchObject([
{ type: "user", text: "First" },
{ type: "user", text: "Second" },
{ type: "assistant", finish: "stop" },
])
expect(complete).toMatchObject({
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
@@ -1020,7 +1020,7 @@ Recent work
])
})
test("preserves assistant text provider state across same-provider model changes and failures", () => {
test("drops assistant text provider state across model changes and failures", () => {
const messages = toLLMMessages(
[
SessionMessage.Assistant.make({
@@ -1042,6 +1042,36 @@ Recent work
Model.Ref.make({ id: Model.ID.make("new"), providerID: Provider.ID.make("provider") }),
)
expect(messages[0]?.content).toEqual([
{
type: "text",
text: "Checking.",
providerMetadata: undefined,
},
])
})
test("preserves assistant text provider state for the same model", () => {
const messages = toLLMMessages(
[
SessionMessage.Assistant.make({
id: id("assistant-phase"),
type: "assistant",
agent: build,
model: { id: Model.ID.make("same"), providerID: Provider.ID.make("provider") },
content: [
SessionMessage.AssistantText.make({
type: "text",
text: "Checking.",
state: { phase: "commentary" },
}),
],
time: { created, completed: created },
}),
],
Model.Ref.make({ id: Model.ID.make("same"), providerID: Provider.ID.make("provider") }),
)
expect(messages[0]?.content).toEqual([
{
type: "text",
+4 -7
View File
@@ -28,7 +28,7 @@ const checkMacosApp = Effect.fn("DesktopFiles.checkMacosApp")(function* (appName
return yield* Effect.tryPromise(() => execFilePromise("which", [appName])).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
Effect.orElseSucceed(() => false),
)
})
@@ -36,7 +36,7 @@ const resolveWindowsAppPath = Effect.fn("DesktopFiles.resolveWindowsAppPath")(fu
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const result = yield* Effect.tryPromise(() => execFilePromise("where", [appName])).pipe(
Effect.catch(() => Effect.succeed(undefined)),
Effect.orElseSucceed(() => undefined),
)
if (!result) return null
@@ -110,7 +110,7 @@ const resolveWindowsAppPath = Effect.fn("DesktopFiles.resolveWindowsAppPath")(fu
for (const file of paths) {
const dirs = [path.dirname(file), path.dirname(path.dirname(file)), path.dirname(path.dirname(path.dirname(file)))]
for (const dir of dirs) {
const entries = yield* fs.readDirectory(dir).pipe(Effect.catch(() => Effect.succeed([])))
const entries = yield* fs.readDirectory(dir).pipe(Effect.orElseSucceed(() => []))
for (const entry of entries) {
const candidate = path.join(dir, entry)
if (!hasExt(candidate, "exe")) continue
@@ -130,8 +130,5 @@ const resolveWindowsAppPath = Effect.fn("DesktopFiles.resolveWindowsAppPath")(fu
})
function exists(fs: FileSystem.FileSystem, path: string) {
return fs.access(path).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
return fs.exists(path).pipe(Effect.orElseSucceed(() => false))
}
+1 -4
View File
@@ -85,10 +85,7 @@ function make(fs: FileSystem.FileSystem, path: Path.Path) {
)
}),
revealPath: Effect.fn("DesktopFiles.revealPath")(function* (target: string) {
const exists = yield* fs.stat(target).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
const exists = yield* fs.exists(target).pipe(Effect.orElseSucceed(() => false))
if (!exists) return false
shell.showItemInFolder(target)
return true
+1 -1
View File
@@ -163,7 +163,7 @@ export const tail = Effect.fn("DesktopLogging.tail")(function* () {
const contents = yield* fs.readFileString(path)
const lines = contents.split("\n")
return lines.slice(Math.max(0, lines.length - TAIL_LINES)).join("\n")
}).pipe(Effect.catch(() => Effect.succeed("")))
}).pipe(Effect.orElseSucceed(() => ""))
})
function initRunDirectory(fs: FileSystem.FileSystem, path: Path.Path) {
@@ -95,7 +95,7 @@ export const cleanStages = Effect.fn("DesktopCli.cleanStages")(function* (binary
Effect.fnUntraced(function* (entry) {
const target = path.join(root, entry)
if (target === current) return
const stat = yield* fs.stat(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
const stat = yield* fs.stat(target).pipe(Effect.orElseSucceed(() => undefined))
if (stat?.type !== "Directory") return
yield* fs
.remove(target, { recursive: true, force: true })
+4 -4
View File
@@ -19,7 +19,7 @@ export const cleanupStoreFiles = Effect.fn("Storage.cleanupStoreFiles")(function
) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const entries = yield* fs.readDirectory(userDataPath).pipe(Effect.catch(() => Effect.succeed([])))
const entries = yield* fs.readDirectory(userDataPath).pipe(Effect.orElseSucceed(() => []))
const candidates = (yield* Effect.forEach(
entries,
Effect.fnUntraced(function* (entry) {
@@ -27,7 +27,7 @@ export const cleanupStoreFiles = Effect.fn("Storage.cleanupStoreFiles")(function
if (!kind) return
const file = path.join(userDataPath, entry)
const stats = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
const stats = yield* fs.stat(file).pipe(Effect.orElseSucceed(() => undefined))
if (stats?.type !== "File") return
return {
@@ -74,7 +74,7 @@ export const deleteStoreFileIfEmpty = Effect.fn("Storage.deleteStoreFileIfEmpty"
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const file = path.join(userDataPath, name)
const stats = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
const stats = yield* fs.stat(file).pipe(Effect.orElseSucceed(() => undefined))
if (stats?.type !== "File") return false
if (!(yield* isEmptyStore(file, stats.size))) return false
@@ -91,7 +91,7 @@ const isEmptyStore = Effect.fn("Storage.isEmptyStore")(function* (file: string,
if (size > FileSystem.Size(EMPTY_STORE_MAX_BYTES)) return false
const fs = yield* FileSystem.FileSystem
const raw = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
const raw = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => undefined))
if (raw === undefined) return false
if (raw.trim() === "") return true
+1
View File
@@ -16,6 +16,7 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "1.18.21",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
+3 -7
View File
@@ -98,12 +98,12 @@ export const fileSystem = (
const pathFor = (name: string) => cassettePath(directory, name)
const walk = (current: string): Effect.Effect<ReadonlyArray<string>> =>
Effect.gen(function* () {
const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[])))
const entries = yield* fs.readDirectory(current).pipe(Effect.orElseSucceed(() => [] as string[]))
const nested = yield* Effect.forEach(entries, (entry) => {
const full = path.join(current, entry)
return fs.stat(full).pipe(
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
Effect.catch(() => Effect.succeed([] as string[])),
Effect.orElseSucceed(() => [] as string[]),
)
})
return nested.flat()
@@ -144,11 +144,7 @@ export const fileSystem = (
recorded.set(name, { interactions, findings: interactionFindings })
}),
),
exists: (name) =>
fs.access(pathFor(name)).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
),
exists: (name) => fs.exists(pathFor(name)).pipe(Effect.orElseSucceed(() => false)),
list: () =>
walk(directory).pipe(
Effect.map((files) =>
-7
View File
@@ -13,7 +13,6 @@
".": "./src/promise/index.ts",
"./effect": "./src/effect/index.ts",
"./tui": "./src/tui/index.ts",
"./v1": "./src/v1/index.ts",
"./*": "./src/*.ts"
},
"files": [
@@ -25,7 +24,6 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "1.18.5",
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
"zod": "catalog:"
@@ -33,7 +31,6 @@
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.5.6",
"@opentui/keymap": ">=0.5.6",
"@opentui/solid": ">=0.5.6",
"solid-js": ">=1.9.0"
},
@@ -44,9 +41,6 @@
"@opentui/core": {
"optional": true
},
"@opentui/keymap": {
"optional": true
},
"@opentui/solid": {
"optional": true
},
@@ -57,7 +51,6 @@
"devDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@tsconfig/bun": "catalog:",
"@tsconfig/node22": "catalog:",
-335
View File
@@ -1,335 +0,0 @@
import type {
Event,
createOpencodeClient,
Project,
Model,
Provider,
Permission,
UserMessage,
Message,
Part,
Config as SDKConfig,
} from "@opencode-ai/sdk"
import type { Provider as ProviderV2, Model as ModelV2, Auth } from "@opencode-ai/sdk/v2"
import type { BunShell } from "./shell.js"
import { type ToolDefinition } from "./tool.js"
export * from "./tool.js"
export type ProviderContext = {
source: "env" | "config" | "custom" | "api"
info: Provider
options: Record<string, any>
}
export type WorkspaceInfo = {
id: string
type: string
name: string
branch: string | null
directory: string | null
extra: unknown | null
projectID: string
}
export type WorkspaceTarget =
| {
type: "local"
directory: string
}
| {
type: "remote"
url: string | URL
headers?: HeadersInit
}
export type WorkspaceAdapter = {
name: string
description: string
configure(config: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
create(config: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>
remove(config: WorkspaceInfo): Promise<void>
target(config: WorkspaceInfo): WorkspaceTarget | Promise<WorkspaceTarget>
}
export type PluginInput = {
client: ReturnType<typeof createOpencodeClient>
project: Project
directory: string
worktree: string
experimental_workspace: {
register(type: string, adapter: WorkspaceAdapter): void
}
serverUrl: URL
$: BunShell
}
export type PluginOptions = Record<string, unknown>
export type Config = Omit<SDKConfig, "plugin"> & {
plugin?: Array<string | [string, PluginOptions]>
}
export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise<Hooks>
export type PluginModule = {
id?: string
server: Plugin
tui?: never
}
type Rule = {
key: string
op: "eq" | "neq"
value: string
}
export type AuthHook = {
provider: string
loader?: (auth: () => Promise<Auth>, provider: Provider) => Promise<Record<string, any>>
methods: (
| {
type: "oauth"
label: string
prompts?: Array<
| {
type: "text"
key: string
message: string
placeholder?: string
validate?: (value: string) => string | undefined
/** @deprecated Use `when` instead */
condition?: (inputs: Record<string, string>) => boolean
when?: Rule
}
| {
type: "select"
key: string
message: string
options: Array<{
label: string
value: string
hint?: string
}>
/** @deprecated Use `when` instead */
condition?: (inputs: Record<string, string>) => boolean
when?: Rule
}
>
authorize(inputs?: Record<string, string>): Promise<AuthOAuthResult>
}
| {
type: "api"
label: string
prompts?: Array<
| {
type: "text"
key: string
message: string
placeholder?: string
validate?: (value: string) => string | undefined
/** @deprecated Use `when` instead */
condition?: (inputs: Record<string, string>) => boolean
when?: Rule
}
| {
type: "select"
key: string
message: string
options: Array<{
label: string
value: string
hint?: string
}>
/** @deprecated Use `when` instead */
condition?: (inputs: Record<string, string>) => boolean
when?: Rule
}
>
authorize?(inputs?: Record<string, string>): Promise<
| {
type: "success"
key: string
provider?: string
metadata?: Record<string, string>
}
| {
type: "failed"
}
>
}
)[]
}
export type AuthOAuthResult = { url: string; instructions: string } & (
| {
method: "auto"
callback(): Promise<
| ({
type: "success"
provider?: string
} & (
| {
refresh: string
access: string
expires: number
accountId?: string
enterpriseUrl?: string
}
| { key: string; metadata?: Record<string, string> }
))
| {
type: "failed"
}
>
}
| {
method: "code"
callback(code: string): Promise<
| ({
type: "success"
provider?: string
} & (
| {
refresh: string
access: string
expires: number
accountId?: string
enterpriseUrl?: string
}
| { key: string; metadata?: Record<string, string> }
))
| {
type: "failed"
}
>
}
)
export type ProviderHookContext = {
auth?: Auth
}
export type ProviderHook = {
id: string
models?: (provider: ProviderV2, ctx: ProviderHookContext) => Promise<Record<string, ModelV2>>
}
/** @deprecated Use AuthOAuthResult instead. */
export type AuthOuathResult = AuthOAuthResult
export interface Hooks {
dispose?: () => Promise<void>
event?: (input: { event: Event }) => Promise<void>
config?: (input: Config) => Promise<void>
tool?: {
[key: string]: ToolDefinition
}
auth?: AuthHook
provider?: ProviderHook
/**
* Called when a new message is received
*/
"chat.message"?: (
input: {
sessionID: string
agent?: string
model?: { providerID: string; modelID: string }
messageID?: string
variant?: string
},
output: { message: UserMessage; parts: Part[] },
) => Promise<void>
/**
* Modify parameters sent to LLM
*/
"chat.params"?: (
input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage },
output: {
temperature: number
topP: number
topK: number
maxOutputTokens: number | undefined
options: Record<string, any>
},
) => Promise<void>
"chat.headers"?: (
input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage },
output: { headers: Record<string, string> },
) => Promise<void>
"permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise<void>
"command.execute.before"?: (
input: { command: string; sessionID: string; arguments: string },
output: { parts: Part[] },
) => Promise<void>
"tool.execute.before"?: (
input: { tool: string; sessionID: string; callID: string },
output: { args: any },
) => Promise<void>
"shell.env"?: (
input: { cwd: string; sessionID?: string; callID?: string },
output: { env: Record<string, string> },
) => Promise<void>
"tool.execute.after"?: (
input: { tool: string; sessionID: string; callID: string; args: any },
output: {
title: string
output: string
metadata: any
},
) => Promise<void>
"experimental.chat.messages.transform"?: (
input: {},
output: {
messages: {
info: Message
parts: Part[]
}[]
},
) => Promise<void>
"experimental.chat.system.transform"?: (
input: { sessionID?: string; model: Model },
output: {
system: string[]
},
) => Promise<void>
"experimental.provider.small_model"?: (input: { provider: ProviderV2 }, output: { model?: ModelV2 }) => Promise<void>
/**
* Called before session compaction starts. Allows plugins to customize
* the compaction prompt.
*
* - `context`: Additional context strings appended to the default prompt
* - `prompt`: If set, replaces the default compaction prompt entirely
*/
"experimental.session.compacting"?: (
input: { sessionID: string },
output: { context: string[]; prompt?: string },
) => Promise<void>
/**
* Called after compaction succeeds and before a synthetic user
* auto-continue message is added.
*
* - `enabled`: Defaults to `true`. Set to `false` to skip the synthetic
* user "continue" turn.
*/
"experimental.compaction.autocontinue"?: (
input: {
sessionID: string
agent: string
model: Model
provider: ProviderContext
message: UserMessage
overflow: boolean
},
output: { enabled: boolean },
) => Promise<void>
"experimental.text.complete"?: (
input: { sessionID: string; messageID: string; partID: string },
output: { text: string },
) => Promise<void>
/**
* Modify tool definitions (description and parameters) sent to LLM
*/
"tool.definition"?: (input: { toolID: string }, output: { description: string; parameters: any }) => Promise<void>
}
-136
View File
@@ -1,136 +0,0 @@
export type ShellFunction = (input: Uint8Array) => Uint8Array
export type ShellExpression =
| { toString(): string }
| Array<ShellExpression>
| string
| { raw: string }
| ReadableStream
export interface BunShell {
(strings: TemplateStringsArray, ...expressions: ShellExpression[]): BunShellPromise
/**
* Perform bash-like brace expansion on the given pattern.
* @param pattern - Brace pattern to expand
*/
braces(pattern: string): string[]
/**
* Escape strings for input into shell commands.
*/
escape(input: string): string
/**
* Change the default environment variables for shells created by this instance.
*/
env(newEnv?: Record<string, string | undefined>): BunShell
/**
* Default working directory to use for shells created by this instance.
*/
cwd(newCwd?: string): BunShell
/**
* Configure the shell to not throw an exception on non-zero exit codes.
*/
nothrow(): BunShell
/**
* Configure whether or not the shell should throw an exception on non-zero exit codes.
*/
throws(shouldThrow: boolean): BunShell
}
export interface BunShellPromise extends Promise<BunShellOutput> {
readonly stdin: WritableStream
/**
* Change the current working directory of the shell.
*/
cwd(newCwd: string): this
/**
* Set environment variables for the shell.
*/
env(newEnv: Record<string, string> | undefined): this
/**
* By default, the shell will write to the current process's stdout and stderr, as well as buffering that output.
* This configures the shell to only buffer the output.
*/
quiet(): this
/**
* Read from stdout as a string, line by line
* Automatically calls quiet() to disable echoing to stdout.
*/
lines(): AsyncIterable<string>
/**
* Read from stdout as a string.
* Automatically calls quiet() to disable echoing to stdout.
*/
text(encoding?: BufferEncoding): Promise<string>
/**
* Read from stdout as a JSON object
* Automatically calls quiet()
*/
json(): Promise<any>
/**
* Read from stdout as an ArrayBuffer
* Automatically calls quiet()
*/
arrayBuffer(): Promise<ArrayBuffer>
/**
* Read from stdout as a Blob
* Automatically calls quiet()
*/
blob(): Promise<Blob>
/**
* Configure the shell to not throw an exception on non-zero exit codes.
*/
nothrow(): this
/**
* Configure whether or not the shell should throw an exception on non-zero exit codes.
*/
throws(shouldThrow: boolean): this
}
export interface BunShellOutput {
readonly stdout: Buffer
readonly stderr: Buffer
readonly exitCode: number
/**
* Read from stdout as a string
*/
text(encoding?: BufferEncoding): string
/**
* Read from stdout as a JSON object
*/
json(): any
/**
* Read from stdout as an ArrayBuffer
*/
arrayBuffer(): ArrayBuffer
/**
* Read from stdout as an Uint8Array
*/
bytes(): Uint8Array
/**
* Read from stdout as a Blob
*/
blob(): Blob
}
export type BunShellError = Error & BunShellOutput
-54
View File
@@ -1,54 +0,0 @@
import { z } from "zod"
export type ToolContext = {
sessionID: string
messageID: string
agent: string
/**
* Current project directory for this session.
* Prefer this over process.cwd() when resolving relative paths.
*/
directory: string
/**
* Project worktree root for this session.
* Useful for generating stable relative paths (e.g. path.relative(worktree, absPath)).
*/
worktree: string
abort: AbortSignal
metadata(input: { title?: string; metadata?: { [key: string]: any } }): void
ask(input: AskInput): Promise<void>
}
type AskInput = {
permission: string
patterns: string[]
always: string[]
metadata: { [key: string]: any }
}
export type ToolAttachment = {
type: "file"
mime: string
url: string
filename?: string
}
export type ToolResult =
| string
| {
title?: string
output: string
metadata?: { [key: string]: any }
attachments?: ToolAttachment[]
}
export function tool<Args extends z.ZodRawShape>(input: {
description: string
args: Args
execute(args: z.infer<z.ZodObject<Args>>, context: ToolContext): Promise<ToolResult>
}) {
return input
}
tool.schema = z
export type ToolDefinition = ReturnType<typeof tool>
-655
View File
@@ -1,655 +0,0 @@
import type {
OpencodeClient,
LspStatus,
McpStatus,
Message,
Part,
Provider,
PermissionRequest,
QuestionRequest,
Session,
SessionStatus,
Config as SdkConfig,
} from "@opencode-ai/sdk/v2"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { PromptInput } from "@opencode-ai/schema"
import type { Types } from "effect"
import type { CliRenderer, KeyEvent, RGBA, Renderable, SlotMode } from "@opentui/core"
import type { Binding, Keymap } from "@opentui/keymap"
import {
createBindingLookup as createKeymapBindingLookup,
type BindingConfig,
type CreateBindingLookupOptions,
type KeySequenceFormatPart,
type SequenceBindingLike,
} from "@opentui/keymap/extras"
import type { JSX, SolidPlugin } from "@opentui/solid"
import type { Config as PluginConfig, PluginOptions } from "./index.js"
export type { CliRenderer, KeyEvent, Renderable, SlotMode } from "@opentui/core"
export { stringifyKeySequence, stringifyKeyStroke } from "@opentui/keymap"
export type { Binding, KeyLike, KeySequencePart, KeyStringifyInput, StringifyOptions } from "@opentui/keymap"
export { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras"
export type {
BindingConfig,
BindingLookup,
BindingValue,
CreateBindingLookupOptions,
FormatCommandBindingsOptions,
FormatKeySequenceOptions,
KeySequenceFormatPart,
SequenceBindingLike,
} from "@opentui/keymap/extras"
export function createBindingLookup(
config: BindingConfig<Renderable, KeyEvent> | undefined,
options?: CreateBindingLookupOptions<Renderable, KeyEvent>,
) {
return createKeymapBindingLookup<Renderable, KeyEvent>(config ?? {}, options)
}
export type TuiRouteCurrent =
| {
name: "home"
}
| {
name: "session"
params: {
sessionID: string
prompt?: unknown
}
}
| {
name: string
params?: Record<string, unknown>
}
export type TuiRouteDefinition = {
name: string
render: (input: { params?: Record<string, unknown> }) => JSX.Element
}
export type TuiKeys = {
formatSequence: (parts: readonly KeySequenceFormatPart[] | undefined) => string
formatBindings: (bindings: readonly SequenceBindingLike[] | undefined) => string | undefined
}
export type TuiKeymap = Keymap<Renderable, KeyEvent>
export type TuiModeApi = {
current: () => string
push: (mode: string) => () => void
}
/**
* Legacy `api.command` shape kept so v1 plugins can initialize. Remove in v2.
*
* @deprecated Use `api.keymap.registerLayer({ commands, bindings })` instead.
*/
export type TuiCommand = {
title: string
value: string
description?: string
category?: string
keybind?: string
suggested?: boolean
hidden?: boolean
enabled?: boolean
slash?: {
name: string
aliases?: string[]
}
onSelect?: (dialog?: TuiDialogStack) => void | Promise<void>
}
/**
* Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2.
*
* @deprecated Use `api.keymap.registerLayer`, `api.keymap.dispatchCommand`, and
* `api.keymap.dispatchCommand("command.palette.show")` instead.
*/
export type TuiCommandApi = {
/** @deprecated Use `api.keymap.registerLayer({ commands, bindings })` instead. */
register: (cb: () => TuiCommand[]) => () => void
/** @deprecated Use `api.keymap.dispatchCommand(name)` instead. */
trigger: (value: string) => void
/** @deprecated Use `api.keymap.dispatchCommand("command.palette.show")` instead. */
show: () => void
}
export type TuiDialogProps = {
size?: "medium" | "large" | "xlarge"
onClose: () => void
children?: JSX.Element
}
export type TuiDialogStack = {
replace: (render: () => JSX.Element, onClose?: () => void) => void
clear: () => void
setSize: (size: "medium" | "large" | "xlarge") => void
readonly size: "medium" | "large" | "xlarge"
readonly depth: number
readonly open: boolean
}
export type TuiDialogAlertProps = {
title: string
message: string
onConfirm?: () => void
}
export type TuiDialogConfirmProps = {
title: string
message: string
onConfirm?: () => void
onCancel?: () => void
}
export type TuiDialogPromptProps = {
title: string
description?: () => JSX.Element
placeholder?: string
value?: string
busy?: boolean
busyText?: string
onConfirm?: (value: string) => void
onCancel?: () => void
}
export type TuiDialogSelectOption<Value = unknown> = {
title: string
value: Value
description?: string
footer?: JSX.Element | string
category?: string
disabled?: boolean
onSelect?: () => void
}
export type TuiDialogSelectProps<Value = unknown> = {
title: string
placeholder?: string
options: TuiDialogSelectOption<Value>[]
flat?: boolean
onMove?: (option: TuiDialogSelectOption<Value>) => void
onFilter?: (query: string) => void
onSelect?: (option: TuiDialogSelectOption<Value>) => void
skipFilter?: boolean
current?: Value
}
export type TuiPromptInfo = Types.DeepMutable<PromptInput.Prompt> & {
pasted: {
text: string
source: {
start: number
end: number
text: string
}
}[]
mode?: "normal" | "shell"
}
export type TuiPromptRef = {
focused: boolean
current: TuiPromptInfo
set(prompt: TuiPromptInfo): void
reset(): void
blur(): void
focus(): void
submit(): void
}
export type TuiPromptProps = {
sessionID?: string
visible?: boolean
disabled?: boolean
onSubmit?: () => void
ref?: (ref: TuiPromptRef | undefined) => void
hint?: JSX.Element
right?: JSX.Element
showPlaceholder?: boolean
placeholders?: {
normal?: string[]
shell?: string[]
}
}
export type TuiToast = {
variant?: "info" | "success" | "warning" | "error"
title?: string
message: string
duration?: number
}
export type TuiAttentionWhen = "always" | "focused" | "blurred"
export const TuiAttentionSoundNames = ["default", "question", "permission", "error", "done", "subagent_done"] as const
export type TuiAttentionSoundName = (typeof TuiAttentionSoundNames)[number]
export type TuiAttentionSound =
| boolean
| {
name?: TuiAttentionSoundName
volume?: number
when?: TuiAttentionWhen
}
export type TuiAttentionNotification =
| boolean
| {
when?: TuiAttentionWhen
}
export type TuiAttentionSoundPack = {
id: string
name?: string
sounds: Partial<Record<TuiAttentionSoundName, string>>
}
export type TuiAttentionSoundPackInfo = {
id: string
name?: string
active: boolean
builtin: boolean
}
export type TuiAttentionSoundboardActivateOptions = {
persist?: boolean
}
export type TuiAttentionSoundboard = {
registerPack(pack: TuiAttentionSoundPack): () => void
activate(id: string, options?: TuiAttentionSoundboardActivateOptions): boolean
current(): string
list(): ReadonlyArray<TuiAttentionSoundPackInfo>
}
export type TuiAttentionNotifyInput = {
title?: string
message: string
notification?: TuiAttentionNotification
sound?: TuiAttentionSound
}
export type TuiAttentionNotifySkipReason =
| "attention_disabled"
| "empty_message"
| "blurred"
| "focused"
| "focus_unknown"
| "renderer_destroyed"
export type TuiAttentionNotifyResult = {
ok: boolean
notification: boolean
sound: boolean
skipped?: TuiAttentionNotifySkipReason
}
export type TuiAttention = {
notify(input: TuiAttentionNotifyInput): Promise<TuiAttentionNotifyResult>
soundboard: TuiAttentionSoundboard
}
export type TuiThemeCurrent = {
readonly primary: RGBA
readonly secondary: RGBA
readonly accent: RGBA
readonly error: RGBA
readonly warning: RGBA
readonly success: RGBA
readonly info: RGBA
readonly text: RGBA
readonly textMuted: RGBA
readonly selectedListItemText: RGBA
readonly background: RGBA
readonly backgroundPanel: RGBA
readonly backgroundElement: RGBA
readonly backgroundMenu: RGBA
readonly border: RGBA
readonly borderActive: RGBA
readonly borderSubtle: RGBA
readonly diffAdded: RGBA
readonly diffRemoved: RGBA
readonly diffContext: RGBA
readonly diffHunkHeader: RGBA
readonly diffHighlightAdded: RGBA
readonly diffHighlightRemoved: RGBA
readonly diffAddedBg: RGBA
readonly diffRemovedBg: RGBA
readonly diffContextBg: RGBA
readonly diffLineNumber: RGBA
readonly diffAddedLineNumberBg: RGBA
readonly diffRemovedLineNumberBg: RGBA
readonly markdownText: RGBA
readonly markdownHeading: RGBA
readonly markdownLink: RGBA
readonly markdownLinkText: RGBA
readonly markdownCode: RGBA
readonly markdownBlockQuote: RGBA
readonly markdownEmph: RGBA
readonly markdownStrong: RGBA
readonly markdownHorizontalRule: RGBA
readonly markdownListItem: RGBA
readonly markdownListEnumeration: RGBA
readonly markdownImage: RGBA
readonly markdownImageText: RGBA
readonly markdownCodeBlock: RGBA
readonly syntaxComment: RGBA
readonly syntaxKeyword: RGBA
readonly syntaxFunction: RGBA
readonly syntaxVariable: RGBA
readonly syntaxString: RGBA
readonly syntaxNumber: RGBA
readonly syntaxType: RGBA
readonly syntaxOperator: RGBA
readonly syntaxPunctuation: RGBA
readonly thinkingOpacity: number
}
export type TuiTheme = {
readonly current: TuiThemeCurrent
readonly selected: string
has: (name: string) => boolean
set: (name: string) => boolean
install: (jsonPath: string) => Promise<void>
mode: () => "dark" | "light"
readonly ready: boolean
}
/** @deprecated Persistent TUI KV storage is not supported in V2. */
export type TuiKV = {
get: <Value = unknown>(key: string, fallback?: Value) => Value
set: (key: string, value: unknown) => void
readonly ready: boolean
}
export type TuiState = {
readonly ready: boolean
readonly config: SdkConfig
readonly provider: ReadonlyArray<Provider>
readonly path: {
state: string
config: string
worktree: string
directory: string
}
readonly vcs: { branch?: string; default_branch?: string } | undefined
session: {
count: () => number
get: (sessionID: string) => Session | undefined
diff: (sessionID: string) => ReadonlyArray<TuiSidebarFileItem>
messages: (sessionID: string) => ReadonlyArray<Message>
status: (sessionID: string) => SessionStatus | undefined
permission: (sessionID: string) => ReadonlyArray<PermissionRequest>
question: (sessionID: string) => ReadonlyArray<QuestionRequest>
}
part: (messageID: string) => ReadonlyArray<Part>
lsp: () => ReadonlyArray<TuiSidebarLspItem>
mcp: () => ReadonlyArray<TuiSidebarMcpItem>
}
type TuiBindingLookupView = {
readonly bindings: ReadonlyArray<Binding<Renderable, KeyEvent>>
get: (command: string) => ReadonlyArray<Binding<Renderable, KeyEvent>>
has: (command: string) => boolean
gather: (name: string, commands: readonly string[]) => ReadonlyArray<Binding<Renderable, KeyEvent>>
pick: (name: string, commands: readonly string[]) => Binding<Renderable, KeyEvent>[]
omit: (name: string, commands: readonly string[]) => Binding<Renderable, KeyEvent>[]
}
type TuiAttentionConfigView = {
enabled: boolean
notifications: boolean
sound: boolean
volume: number
sound_pack: string
sounds: Partial<Record<TuiAttentionSoundName, string>>
}
type TuiConfigView = {
$schema?: string
theme?: string | { name?: string; mode?: "system" | "dark" | "light" }
plugin?: PluginConfig["plugin"]
plugins?: ReadonlyArray<string | { package: string; options?: Record<string, any> }>
plugin_enabled?: Record<string, boolean>
leader?: { timeout: number }
leader_timeout?: number
scroll?: { speed?: number; acceleration?: boolean }
scroll_speed?: number
scroll_acceleration?: { enabled: boolean }
attention: TuiAttentionConfigView
diffs?: {
wrap?: "word" | "none"
tree?: boolean
single?: boolean
view?: "auto" | "split" | "unified"
}
diff_style?: "auto" | "stacked"
terminal?: { title?: boolean }
prompt?: { editor?: boolean; paste?: "compact" | "full" } | { max_height?: number; max_width?: number | "auto" }
session?: {
sidebar?: "auto" | "hide"
scrollbar?: boolean
thinking?: "show" | "hide"
markdown?: "source" | "rendered"
grouping?: "auto" | "none"
}
hints?: { onboarding?: boolean }
animations?: boolean
mouse: boolean
keybinds: TuiBindingLookupView
}
export type TuiApp = {
readonly version: string
}
type Frozen<Value> = Value extends (...args: never[]) => unknown
? Value
: Value extends ReadonlyArray<infer Item>
? ReadonlyArray<Frozen<Item>>
: Value extends object
? { readonly [Key in keyof Value]: Frozen<Value[Key]> }
: Value
export type TuiSidebarMcpItem = {
name: string
status: McpStatus["status"]
error?: string
}
export type TuiSidebarLspItem = Pick<LspStatus, "id" | "root" | "status">
export type TuiSidebarFileItem = {
file: string
additions: number
deletions: number
}
export type TuiHostSlotMap = {
app: {}
app_bottom: {}
home_logo: {}
home_prompt: {
ref?: (ref: TuiPromptRef | undefined) => void
}
home_prompt_right: {}
session_prompt: {
session_id: string
visible?: boolean
disabled?: boolean
on_submit?: () => void
ref?: (ref: TuiPromptRef | undefined) => void
}
session_prompt_right: {
session_id: string
}
home_bottom: {}
home_footer: {}
sidebar_title: {
session_id: string
title: string
share_url?: string
}
sidebar_content: {
session_id: string
}
sidebar_footer: {
session_id: string
directory: string
}
}
export type TuiSlotMap<Slots extends Record<string, object> = {}> = TuiHostSlotMap & Slots
type TuiSlotShape<Name extends string, Slots extends Record<string, object>> = Name extends keyof TuiHostSlotMap
? TuiHostSlotMap[Name]
: Name extends keyof Slots
? Slots[Name]
: Record<string, unknown>
export type TuiSlotProps<Name extends string = string, Slots extends Record<string, object> = {}> = {
name: Name
mode?: SlotMode
children?: JSX.Element
} & TuiSlotShape<Name, Slots>
export type TuiSlotContext = {
theme: TuiTheme
}
type SlotCore<Slots extends Record<string, object> = {}> = SolidPlugin<TuiSlotMap<Slots>, TuiSlotContext>
export type TuiSlotPlugin<Slots extends Record<string, object> = {}> = Omit<SlotCore<Slots>, "id"> & {
id?: never
}
export type TuiSlots = {
register: {
(plugin: TuiSlotPlugin): string
<Slots extends Record<string, object>>(plugin: TuiSlotPlugin<Slots>): string
}
}
export type TuiEventBus = {
on: <Type extends OpenCodeEvent["type"]>(
type: Type,
handler: (event: Extract<OpenCodeEvent, { type: Type }>) => void,
) => () => void
}
export type TuiDispose = () => void | Promise<void>
export type TuiLifecycle = {
readonly signal: AbortSignal
onDispose: (fn: TuiDispose) => () => void
}
export type TuiPluginState = "first" | "updated" | "same"
export type TuiPluginEntry = {
id: string
source: "file" | "npm" | "internal"
spec: string
target: string
requested?: string
version?: string
modified?: number
first_time: number
last_time: number
time_changed: number
load_count: number
fingerprint: string
}
export type TuiPluginMeta = TuiPluginEntry & {
state: TuiPluginState
}
export type TuiPluginStatus = {
id: string
source: TuiPluginEntry["source"]
spec: string
target: string
enabled: boolean
active: boolean
}
export type TuiPluginInstallOptions = {
global?: boolean
}
export type TuiPluginInstallResult =
| {
ok: true
dir: string
tui: boolean
}
| {
ok: false
message: string
missing?: boolean
}
export type TuiWorkspace = {
current: () => string | undefined
set: (workspaceID?: string) => void
}
export type TuiPluginApi = {
app: TuiApp
attention: TuiAttention
/**
* Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2.
*
* @deprecated Use `api.keymap.registerLayer`, `api.keymap.dispatchCommand`, and
* `api.keymap.dispatchCommand("command.palette.show")` instead.
*/
command?: TuiCommandApi
keys: TuiKeys
keymap: TuiKeymap
mode: TuiModeApi
route: {
register: (routes: TuiRouteDefinition[]) => () => void
navigate: (name: string, params?: Record<string, unknown>) => void
readonly current: TuiRouteCurrent
}
ui: {
Dialog: (props: TuiDialogProps) => JSX.Element
DialogAlert: (props: TuiDialogAlertProps) => JSX.Element
DialogConfirm: (props: TuiDialogConfirmProps) => JSX.Element
DialogPrompt: (props: TuiDialogPromptProps) => JSX.Element
DialogSelect: <Value = unknown>(props: TuiDialogSelectProps<Value>) => JSX.Element
Slot: <Name extends string>(props: TuiSlotProps<Name>) => JSX.Element | null
Prompt: (props: TuiPromptProps) => JSX.Element
toast: (input: TuiToast) => void
dialog: TuiDialogStack
}
readonly tuiConfig: Frozen<TuiConfigView>
/** @deprecated Persistent TUI KV storage is not supported in V2. */
kv: TuiKV
state: TuiState
theme: TuiTheme
client: OpencodeClient
event: TuiEventBus
renderer: CliRenderer
slots: TuiSlots
plugins: {
list: () => ReadonlyArray<TuiPluginStatus>
activate: (id: string) => Promise<boolean>
deactivate: (id: string) => Promise<boolean>
add: (spec: string) => Promise<boolean>
install: (spec: string, options?: TuiPluginInstallOptions) => Promise<TuiPluginInstallResult>
}
lifecycle: TuiLifecycle
}
export type TuiPlugin = (api: TuiPluginApi, options: PluginOptions | undefined, meta: TuiPluginMeta) => Promise<void>
export type TuiPluginModule = {
id?: string
tui: TuiPlugin
server?: never
}
+1 -1
View File
@@ -16,7 +16,7 @@
- Do not preserve `V2` as the permanent name for the replacement architecture. Remove `V2` from current namespaces, brands, and identifiers as the contracts are normalized.
- Retained V1 contracts live under `src/v1/`. New/current code must not depend on that subtree.
- V1 coexistence is temporary. Keep compatibility entrypoints only where migration requires them, and delete the V1 subtree when the legacy runtime is retired.
- `@opencode-ai/protocol` and `@opencode-ai/sdk-next` are current `/api/...` surfaces.
- `@opencode-ai/protocol` and `@opencode-ai/sdk` are current `/api/...` surfaces.
## Events
@@ -1,11 +1,11 @@
# @opencode-ai/sdk-next
# @opencode-ai/sdk
Effect-native scoped OpenCode host for in-process applications. This transitional package will replace the existing generated `@opencode-ai/sdk` after its consumers migrate.
Effect-native scoped OpenCode host for in-process applications.
The SDK executes Server's assembled HTTP router in memory. It opens no listener and performs no network I/O, while preserving the same routing, middleware, handlers, codecs, and errors as the network client.
```ts
import { OpenCode } from "@opencode-ai/sdk-next"
import { OpenCode } from "@opencode-ai/sdk"
const opencode = yield * OpenCode.create()
const session = yield * opencode.sessions.get({ sessionID })
@@ -1,21 +1,32 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk-next",
"private": true,
"version": "1.18.4",
"name": "@opencode-ai/sdk",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/anomalyco/opencode.git",
"directory": "packages/sdk"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"exports": {
".": "./src/index.ts",
"./workerd": "./src/workerd.ts"
},
"scripts": {
"build": "bun run script/build.ts",
"test": "bun test --timeout 5000",
"typecheck": "tsgo -b"
},
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/util": "workspace:*",
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
await rm("dist", { recursive: true, force: true })
await $`bun tsc -p tsconfig.build.json`
const files = await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: "src" })).then((items) =>
items.map((item) => `src/${item}`),
)
const transpiler = new Bun.Transpiler({ loader: "ts", target: "bun" })
await Promise.all(
files.map(async (file) =>
Bun.write(
file.replace(/^src\//, "dist/").replace(/\.ts$/, ".js"),
await transpiler.transform(await Bun.file(file).text()),
),
),
)
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
const dryRun = Bun.argv.includes("--dry-run")
const originalText = await Bun.file("package.json").text()
const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string | { import: string; types: string }>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
const output = (value: string, types = false) =>
value.replace("./src/", "./dist/").replace(/\.ts$/, types ? ".d.ts" : ".js")
if (!dryRun && (await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
try {
await $`bun run typecheck`
await $`bun run build`
pkg.exports = Object.fromEntries(
Object.entries(pkg.exports).map(([key, value]) => {
if (typeof value !== "string") return [key, value]
return [key, { import: output(value), types: output(value, true) }]
}),
)
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
if (!dryRun) await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", originalText)
await rm(tarball, { force: true })
}
@@ -1,27 +1,69 @@
import { OpenCode } from "@opencode-ai/client/effect"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/effect"
import type { Database } from "@opencode-ai/core/database/database"
import type { ModelsDev } from "@opencode-ai/core/models-dev"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import type { ServerFetch } from "@opencode-ai/server/fetch"
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
import type { ServerOptions } from "@opencode-ai/server/options"
import { Context, Effect, Layer, ManagedRuntime, Scope } from "effect"
import type { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Config, Context, Effect, Layer, ManagedRuntime, Scope } from "effect"
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
import * as Logging from "./logging"
export type { LogEntry, LogLevel, LogOptions, LogWriter } from "./logging"
import type { LogOptions } from "./logging"
export type CreateOptions = ServerOptions & {
export interface CreateOptions {
readonly app?: {
readonly name?: string
readonly version?: string
readonly channel?: string
}
readonly hostname?: string
readonly port?: number
readonly password?: string
readonly simulation?: boolean
readonly database?: Database.Options
readonly events?: { readonly persist?: boolean }
readonly models?: ModelsDev.Options
readonly config?: {
readonly directory?: string
readonly project?: boolean
readonly file?: string
readonly content?: string
}
readonly windows?: { readonly gitbash?: string }
readonly fs?: {
readonly filewatcher?: boolean
readonly fff?: boolean
}
readonly log?: LogOptions
readonly workspaceProviders?: Readonly<Record<string, WorkspaceDriver.Interface>>
}
/** Host hooks for embedding opencode on a non-default runtime profile (e.g. workerd). */
export type EmbedOptions = ServerFetch.BootOptions
export interface EmbedOptions {
readonly overrides?: LayerNode.Replacements
}
export const create = Effect.fn("OpenCode.create")(function* (options: CreateOptions = {}, embed: EmbedOptions = {}) {
export type Interface = Omit<OpenCodeClient, "plugin" | "workspace"> & {
readonly sessions: OpenCodeClient["session"]
readonly events: OpenCodeClient["event"]
readonly workspace: {
readonly create: (options: { readonly provider: string }) => ReturnType<Workspace.Interface["create"]>
readonly destroy: (options: { readonly workspaceID: Workspace.ID }) => ReturnType<Workspace.Interface["destroy"]>
}
readonly plugin: SdkPlugins.Interface["register"] & OpenCodeClient["plugin"]
}
export const create: (
options?: CreateOptions,
embed?: EmbedOptions,
) => Effect.Effect<Interface, Config.ConfigError | Error, Scope.Scope> = Effect.fn("OpenCode.create")(function* (
options: CreateOptions = {},
embed: EmbedOptions = {},
) {
const { log, workspaceProviders, ...server } = options
const runtime = yield* Effect.acquireRelease(
Effect.sync(() =>
@@ -75,8 +117,7 @@ export const create = Effect.fn("OpenCode.create")(function* (options: CreateOpt
}
})
export type Interface = Effect.Success<ReturnType<typeof create>>
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/sdk/OpenCode") {}
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/sdk-next/OpenCode") {}
export const layer = (options: CreateOptions = {}) => Layer.effect(Service, create(options))
export const layer = (options: CreateOptions = {}): Layer.Layer<Service, Config.ConfigError | Error> =>
Layer.effect(Service, create(options))
@@ -1,10 +1,17 @@
export * as OpenCodeWorkerd from "./workerd"
import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd"
import { ServerWorkerd } from "@opencode-ai/server/workerd"
import { Layer } from "effect"
import { Config, Effect, Layer, Scope } from "effect"
import * as OpenCode from "./opencode"
export type CreateOptions = ServerWorkerd.Options & Pick<OpenCode.CreateOptions, "log" | "workspaceProviders">
export interface CreateOptions extends Pick<OpenCode.CreateOptions, "log" | "workspaceProviders"> {
readonly storage: DurableObjectStorage
readonly app?: OpenCode.CreateOptions["app"]
readonly password?: string
readonly config?: { readonly content?: string }
readonly models?: OpenCode.CreateOptions["models"]
}
/**
* Boots the embedded opencode SDK on the workerd runtime profile: the full
@@ -20,10 +27,17 @@ export type CreateOptions = ServerWorkerd.Options & Pick<OpenCode.CreateOptions,
* session operations plus the live `events.subscribe()` stream served over
* an in-process fetch transport, so no request leaves the isolate.
*/
export const create = ({ log, workspaceProviders, ...options }: CreateOptions) =>
export const create: (
options: CreateOptions,
) => Effect.Effect<OpenCode.Interface, Config.ConfigError | Error, Scope.Scope> = ({
log,
workspaceProviders,
...options
}) =>
OpenCode.create(
{ ...ServerWorkerd.serverOptions(options), log, workspaceProviders },
{ overrides: ServerWorkerd.replacements(options) },
)
export const layer = (options: CreateOptions) => Layer.effect(OpenCode.Service, create(options))
export const layer = (options: CreateOptions): Layer.Layer<OpenCode.Service, Config.ConfigError | Error> =>
Layer.effect(OpenCode.Service, create(options))
@@ -20,7 +20,7 @@ async function bundleInputs() {
const entrypoint = join(temporary, "index.ts")
const metafile = join(temporary, "meta.json")
try {
await Bun.write(entrypoint, 'export * from "@opencode-ai/sdk-next"')
await Bun.write(entrypoint, 'export * from "@opencode-ai/sdk"')
const child = Bun.spawn(
[
process.execPath,
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"declaration": true,
"emitDeclarationOnly": true,
"incremental": false,
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"tsBuildInfoFile": null
},
"include": ["src"]
}
+12 -1
View File
@@ -2,13 +2,24 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server",
"version": "1.18.4",
"private": true,
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/anomalyco/opencode.git",
"directory": "packages/server"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"exports": {
"./*": "./src/*.ts"
},
"scripts": {
"build": "bun run script/build.ts",
"test": "bun test --only-failures",
"typecheck": "tsgo -b",
"probe:workerd": "bun run script/workerd-probe.ts"
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
await rm("dist", { recursive: true, force: true })
await $`bun tsc -p tsconfig.build.json`
const files = await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: "src" })).then((items) =>
items.map((item) => `src/${item}`),
)
const transpiler = new Bun.Transpiler({ loader: "ts", target: "bun" })
await Promise.all(
files.map(async (file) =>
Bun.write(
file.replace(/^src\//, "dist/").replace(/\.ts$/, ".js"),
await transpiler.transform(await Bun.file(file).text()),
),
),
)
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
const dryRun = Bun.argv.includes("--dry-run")
const originalText = await Bun.file("package.json").text()
const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string | { import: string; types: string }>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
if (!dryRun && (await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
try {
await $`bun run typecheck`
await $`bun run build`
pkg.exports = Object.fromEntries(
Object.entries(pkg.exports).map(([key, value]) => {
if (typeof value !== "string") return [key, value]
return [
key,
{
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
},
]
}),
)
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
if (!dryRun) await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", originalText)
await rm(tarball, { force: true })
}
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"incremental": false,
"rootDir": "src",
"outDir": "dist",
"noEmit": false,
"declaration": true,
"emitDeclarationOnly": true,
"tsBuildInfoFile": null
},
"include": ["src"]
}
+13 -1
View File
@@ -2,12 +2,24 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/shell-scan",
"version": "0.0.0",
"private": true,
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/anomalyco/opencode.git",
"directory": "packages/shell-scan"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "bun run script/build.ts",
"test": "bun test --only-failures",
"typecheck": "tsgo --noEmit"
},
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
await rm("dist", { recursive: true, force: true })
await $`bun tsc -p tsconfig.build.json`
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
const dryRun = Bun.argv.includes("--dry-run")
const originalText = await Bun.file("package.json").text()
const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string | { import: string; types: string }>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
if (!dryRun && (await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
try {
await $`bun run typecheck`
await $`bun run build`
pkg.exports = Object.fromEntries(
Object.entries(pkg.exports).map(([key, value]) => {
if (typeof value !== "string") return [key, value]
return [
key,
{
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
},
]
}),
)
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
if (!dryRun) await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", originalText)
await rm(tarball, { force: true })
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"noEmit": false,
"declaration": true
},
"include": ["src"]
}
+12 -1
View File
@@ -2,9 +2,19 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/simulation",
"version": "1.17.13",
"private": true,
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/anomalyco/opencode.git",
"directory": "packages/simulation"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"exports": {
"./backend": "./src/backend/index.ts",
"./backend/*": "./src/backend/*.ts",
@@ -14,6 +24,7 @@
"./recording": "./src/recording.ts"
},
"scripts": {
"build": "bun run script/build.ts",
"test": "bun test --timeout 30000 --only-failures",
"typecheck": "tsgo -b"
},
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
await rm("dist", { recursive: true, force: true })
await $`bun tsc -p tsconfig.build.json`
const files = await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: "src" })).then((items) =>
items.map((item) => `src/${item}`),
)
const transpiler = new Bun.Transpiler({ loader: "ts", target: "bun" })
await Promise.all(
files.map(async (file) =>
Bun.write(
file.replace(/^src\//, "dist/").replace(/\.ts$/, ".js"),
await transpiler.transform(await Bun.file(file).text()),
),
),
)
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
const dryRun = Bun.argv.includes("--dry-run")
const originalText = await Bun.file("package.json").text()
const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string | { import: string; types: string }>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
if (!dryRun && (await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
try {
await $`bun run typecheck`
await $`bun run build`
pkg.exports = Object.fromEntries(
Object.entries(pkg.exports).map(([key, value]) => {
if (typeof value !== "string") return [key, value]
return [
key,
{
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
},
]
}),
)
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
if (!dryRun) await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", originalText)
await rm(tarball, { force: true })
}
+4 -2
View File
@@ -2,7 +2,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { Config, Effect, Layer } from "effect"
import { Config, Effect, FileSystem, Layer } from "effect"
import { HttpClient } from "effect/unstable/http"
import { DriveManifest } from "../manifest"
import { SimulationNetwork } from "./network"
@@ -21,7 +21,9 @@ import { SimulatedProvider } from "./simulated-provider"
*
*/
export const simulationReplacements = Effect.fn("Simulation.replacements")(function* (app: {
export const simulationReplacements: (app: {
readonly version: string
}) => Effect.Effect<LayerNode.Replacements, Error, FileSystem.FileSystem> = Effect.fn("Simulation.replacements")(function* (app: {
readonly version: string
}) {
// ModelsDev dies when its catalog fetch fails, so simulation answers it with
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"incremental": false,
"rootDir": "src",
"outDir": "dist",
"noEmit": false,
"declaration": true,
"emitDeclarationOnly": true,
"tsBuildInfoFile": null
},
"include": ["src"]
}
-3
View File
@@ -1,3 +0,0 @@
SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_SIGNING_SECRET=your-signing-secret
SLACK_APP_TOKEN=xapp-your-app-token
-4
View File
@@ -1,4 +0,0 @@
node_modules
dist
.env
.DS_Store
-27
View File
@@ -1,27 +0,0 @@
# @opencode-ai/slack
Slack bot integration for opencode that creates threaded conversations.
## Setup
1. Create a Slack app at https://api.slack.com/apps
2. Enable Socket Mode
3. Add the following OAuth scopes:
- `chat:write`
- `app_mentions:read`
- `channels:history`
- `groups:history`
4. Install the app to your workspace
5. Set environment variables in `.env`:
- `SLACK_BOT_TOKEN` - Bot User OAuth Token
- `SLACK_SIGNING_SECRET` - Signing Secret from Basic Information
- `SLACK_APP_TOKEN` - App-Level Token from Basic Information
## Usage
```bash
# Edit .env with your Slack app credentials
bun dev
```
The bot will respond to messages in channels where it's added, creating separate opencode sessions for each thread.
-19
View File
@@ -1,19 +0,0 @@
{
"name": "@opencode-ai/slack",
"version": "1.18.15",
"type": "module",
"license": "MIT",
"scripts": {
"dev": "bun run src/index.ts",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@opencode-ai/sdk": "1.18.5",
"@slack/bolt": "^3.17.1"
},
"devDependencies": {
"@types/node": "catalog:",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
-139
View File
@@ -1,139 +0,0 @@
import { App } from "@slack/bolt"
import { createOpencode, type ToolPart } from "@opencode-ai/sdk"
const app = new App({
token: process.env.SLACK_BOT_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET,
socketMode: true,
appToken: process.env.SLACK_APP_TOKEN,
})
console.log("🔧 Bot configuration:")
console.log("- Bot token present:", !!process.env.SLACK_BOT_TOKEN)
console.log("- Signing secret present:", !!process.env.SLACK_SIGNING_SECRET)
console.log("- App token present:", !!process.env.SLACK_APP_TOKEN)
console.log("🚀 Starting opencode server...")
const opencode = await createOpencode({
port: 0,
})
console.log("✅ Opencode server ready")
const sessions = new Map<string, { sessionId: string; channel: string; thread: string }>()
void (async () => {
const events = await opencode.client.event.subscribe()
for await (const event of events.stream) {
if (event.type === "message.part.updated") {
const part = event.properties.part
if (part.type === "tool") {
// Find the session for this tool update
for (const session of sessions.values()) {
if (session.sessionId === part.sessionID) {
void handleToolUpdate(part, session.channel, session.thread)
break
}
}
}
}
}
})()
async function handleToolUpdate(part: ToolPart, channel: string, thread: string) {
if (part.state.status !== "completed") return
const toolMessage = `*${part.tool}* - ${part.state.title}`
await app.client.chat
.postMessage({
channel,
thread_ts: thread,
text: toolMessage,
})
.catch(() => {})
}
app.use(async ({ next, context }) => {
console.log("📡 Raw Slack event:", JSON.stringify(context, null, 2))
await next()
})
app.message(async ({ message, say }) => {
console.log("📨 Received message event:", JSON.stringify(message, null, 2))
if (message.subtype || !("text" in message) || !message.text) {
console.log("⏭️ Skipping message - no text or has subtype")
return
}
console.log("✅ Processing message:", message.text)
const channel = message.channel
const thread = ("thread_ts" in message && typeof message.thread_ts === "string" && message.thread_ts) || message.ts
const sessionKey = `${channel}-${thread}`
let session = sessions.get(sessionKey)
if (!session) {
console.log("🆕 Creating new opencode session...")
const createResult = await opencode.client.session.create({
body: { title: `Slack thread ${thread}` },
})
if (createResult.error) {
console.error("❌ Failed to create session:", createResult.error)
await say({
text: "Sorry, I had trouble creating a session. Please try again.",
thread_ts: thread,
})
return
}
console.log("✅ Created opencode session:", createResult.data.id)
session = { sessionId: createResult.data.id, channel, thread }
sessions.set(sessionKey, session)
const shareResult = await opencode.client.session.share({ path: { id: createResult.data.id } })
if (!shareResult.error && shareResult.data) {
const sessionUrl = shareResult.data.share?.url
console.log("🔗 Session shared:", sessionUrl)
await app.client.chat.postMessage({ channel, thread_ts: thread, text: sessionUrl })
}
}
console.log("📝 Sending to opencode:", message.text)
const result = await opencode.client.session.prompt({
path: { id: session.sessionId },
body: { parts: [{ type: "text", text: message.text }] },
})
console.log("📤 Opencode response:", JSON.stringify(result, null, 2))
if (result.error) {
console.error("❌ Failed to send message:", result.error)
await say({
text: "Sorry, I had trouble processing your message. Please try again.",
thread_ts: thread,
})
return
}
// Build response text
const responseText =
result.data.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n") || "I received your message but didn't have a response."
console.log("💬 Sending response:", responseText)
// Send main response (tool updates will come via live events)
await say({ text: responseText, thread_ts: thread })
})
app.command("/test", async ({ command, ack, say }) => {
await ack()
console.log("🧪 Test command received:", JSON.stringify(command, null, 2))
await say("🤖 Bot is working! I can hear you loud and clear.")
})
await app.start()
console.log("⚡️ Slack bot is running!")
-10
View File
@@ -1,10 +0,0 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
-8
View File
@@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
}
+26 -100
View File
@@ -1,15 +1,13 @@
/// <reference path="./audio.d.ts" />
import type {
TuiAttention,
TuiAttentionNotifyInput,
TuiAttentionNotifyResult,
TuiAttentionNotifySkipReason,
TuiAttentionWhen,
TuiAttentionSoundName,
TuiAttentionSoundPack,
TuiAttentionSoundPackInfo,
} from "@opencode-ai/plugin/v1/tui"
import { AttentionSoundName, type Config } from "./config"
Attention,
AttentionNotifyOptions,
AttentionNotifyResult,
AttentionNotifySkipReason,
AttentionWhen,
AttentionSoundName,
} from "@opencode-ai/plugin/tui/context"
import { Config } from "./config"
import { Schema } from "effect"
import stripAnsi from "strip-ansi"
import * as TuiAudio from "./audio"
@@ -30,33 +28,23 @@ type AttentionRenderer = {
triggerNotification(message: string, title?: string): boolean
}
type RegisteredSoundPack = TuiAttentionSoundPack & {
builtin: boolean
}
type TuiAttentionHost = TuiAttention & {
type AttentionHost = Attention & {
dispose(): void
}
const DEFAULT_TITLE = "OpenCode"
const DEFAULT_PACK_ID = "opencode.default"
const TITLE_LIMIT = 80
const MESSAGE_LIMIT = 240
const BUILTIN_PACK: RegisteredSoundPack = {
id: DEFAULT_PACK_ID,
name: "OpenCode Default",
builtin: true,
sounds: {
default: defaultSoundPath,
question: questionSoundPath,
permission: permissionSoundPath,
error: errorSoundPath,
done: defaultSoundPath,
subagent_done: subagentDoneSoundPath,
},
const BUILTIN_SOUNDS: Record<AttentionSoundName, string> = {
default: defaultSoundPath,
question: questionSoundPath,
permission: permissionSoundPath,
error: errorSoundPath,
done: defaultSoundPath,
subagent_done: subagentDoneSoundPath,
}
function skipped(reason: TuiAttentionNotifySkipReason): TuiAttentionNotifyResult {
function skipped(reason: AttentionNotifySkipReason): AttentionNotifyResult {
return {
ok: false,
notification: false,
@@ -79,7 +67,7 @@ function clampVolume(volume: number) {
return Math.min(1, Math.max(0, volume))
}
function soundVolume(input: TuiAttentionNotifyInput, config: Pick<Config.Resolved, "attention">) {
function soundVolume(input: AttentionNotifyOptions, config: Pick<Config.Resolved, "attention">) {
if (!config.attention.sound) return
if (input.sound === false) return
if (input.sound === undefined) return clampVolume(config.attention.volume)
@@ -87,23 +75,7 @@ function soundVolume(input: TuiAttentionNotifyInput, config: Pick<Config.Resolve
return clampVolume(input.sound.volume ?? config.attention.volume)
}
function normalizePack(pack: TuiAttentionSoundPack): RegisteredSoundPack | undefined {
const id = pack.id.trim()
if (!id) return
return {
id,
name: pack.name?.trim() || undefined,
builtin: false,
sounds: Object.fromEntries(
Object.entries(pack.sounds).filter(
(item): item is [TuiAttentionSoundName, string] =>
Schema.is(AttentionSoundName)(item[0]) && typeof item[1] === "string" && item[1].trim().length > 0,
),
),
}
}
function focusSkip(when: TuiAttentionWhen, focus: FocusState) {
function focusSkip(when: AttentionWhen, focus: FocusState) {
if (when === "always") return
if (focus === "unknown") return "focus_unknown"
if (when === "blurred" && focus === "focused") return "focused"
@@ -113,13 +85,10 @@ function focusSkip(when: TuiAttentionWhen, focus: FocusState) {
export function createTuiAttention(input: {
renderer: AttentionRenderer
config: Pick<Config.Resolved, "attention">
update?: Config.Interface["update"]
audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play">
}): TuiAttentionHost {
}): AttentionHost {
let focus: FocusState = "unknown"
let disposed = false
let activePackID: string | undefined
const packs = new Map<string, RegisteredSoundPack>([[BUILTIN_PACK.id, BUILTIN_PACK]])
const audio = input.audio ?? TuiAudio
const onFocus = () => {
@@ -132,21 +101,13 @@ export function createTuiAttention(input: {
input.renderer.on("focus", onFocus)
input.renderer.on("blur", onBlur)
function configuredPackID() {
return activePackID ?? input.config.attention.sound_pack
}
function currentPack() {
return packs.get(configuredPackID()) ?? BUILTIN_PACK
}
function soundCandidates(name: TuiAttentionSoundName) {
return [input.config.attention.sounds[name], currentPack().sounds[name], BUILTIN_PACK.sounds[name]].filter(
function soundCandidates(name: AttentionSoundName) {
return [input.config.attention.sounds[name], BUILTIN_SOUNDS[name]].filter(
(item, index, list): item is string => typeof item === "string" && list.indexOf(item) === index,
)
}
async function playSound(name: TuiAttentionSoundName, volume: number) {
async function playSound(name: AttentionSoundName, volume: number) {
try {
for (const file of soundCandidates(name)) {
const current = await audio.loadSoundFile(file).catch((error) => {
@@ -194,7 +155,9 @@ export function createTuiAttention(input: {
const requestedSound = typeof request.sound === "object" ? request.sound : undefined
const soundSkip = volume === undefined ? undefined : focusSkip(requestedSound?.when ?? "always", focus)
const soundName =
requestedSound?.name && Schema.is(AttentionSoundName)(requestedSound.name) ? requestedSound.name : "default"
requestedSound?.name && Schema.is(Config.AttentionSoundName)(requestedSound.name)
? requestedSound.name
: "default"
const sound = volume === undefined || soundSkip ? false : await playSound(soundName, volume)
if (!notification && !sound) {
@@ -216,43 +179,6 @@ export function createTuiAttention(input: {
}
}
},
soundboard: {
registerPack(pack) {
const next = normalizePack(pack)
if (!next) return () => {}
packs.set(next.id, next)
let disposed = false
return () => {
if (disposed) return
disposed = true
if (packs.get(next.id) === next) packs.delete(next.id)
}
},
activate(id, options) {
const pack = packs.get(id)
if (!pack) return false
activePackID = pack.id
if (options?.persist)
void input
.update?.((draft) => {
draft.attention = { ...draft.attention, sound_pack: pack.id }
})
.catch(() => {})
return true
},
current() {
return currentPack().id
},
list(): TuiAttentionSoundPackInfo[] {
const current = currentPack().id
return Array.from(packs.values()).map((pack) => ({
id: pack.id,
name: pack.name,
active: pack.id === current,
builtin: pack.builtin,
}))
},
},
dispose() {
if (disposed) return
disposed = true
+61 -33
View File
@@ -58,7 +58,7 @@ import {
MAX_LOCAL_ATTACHMENT_BYTES,
type LocalAttachment,
} from "./local-attachment"
import { useData } from "../../context/data"
import { locationKey, useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime"
@@ -262,6 +262,7 @@ export function Prompt(props: PromptProps) {
(props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? data.location.info()?.project.id,
sessionID: () => props.sessionID,
})
const [pendingDirectory, setPendingDirectory] = createSignal<string>()
Keymap.createLayer(() => ({
mode: "global",
commands: [
@@ -285,13 +286,18 @@ export function Prompt(props: PromptProps) {
expanded,
)
if (!sessionID) {
setPendingDirectory(directory)
const location = await client.api.location.get({ location: { directory } }).catch((error) => {
toast.show({ title: "Failed to change directory", message: errorMessage(error), variant: "error" })
return undefined
})
if (!location) return
if (!location) {
setPendingDirectory(undefined)
return
}
if (sourceProjectID) directoryRecents.touch(sourceProjectID, location.directory)
currentLocation.set(location)
setPendingDirectory(undefined)
return
}
const error = await client.api.session.move({ sessionID, directory: input }).then(
@@ -308,7 +314,6 @@ export function Prompt(props: PromptProps) {
],
}))
const [cursorVersion, setCursorVersion] = createSignal(0)
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const connected = useConnected()
const hasRightContent = createMemo(() => Boolean(props.right))
@@ -1023,7 +1028,7 @@ export function Prompt(props: PromptProps) {
return
}
const item = history.move(-1, input.plainText)
const item = history.move(props.sessionID, -1, input.plainText)
if (!item) return false
input.setText(item.text)
setStore("prompt", item)
@@ -1062,7 +1067,7 @@ export function Prompt(props: PromptProps) {
return
}
const item = history.move(1, input.plainText)
const item = history.move(props.sessionID, 1, input.plainText)
if (!item) return false
input.setText(item.text)
setStore("prompt", item)
@@ -1181,7 +1186,6 @@ export function Prompt(props: PromptProps) {
// snapshot unless the user has started typing something new.
const currentMode = store.mode
const entry = { ...store.prompt, mode: currentMode }
history.append(entry)
resetComposer()
props.onSubmit?.()
const restoreEntry = () => {
@@ -1256,6 +1260,7 @@ export function Prompt(props: PromptProps) {
}
const target = sessionID
history.append(target, entry)
const dispatch = (send: () => Promise<unknown>) => {
const setup = newSession
if (setup) void setup.gate.then(send).catch(setup.recover)
@@ -1557,7 +1562,7 @@ export function Prompt(props: PromptProps) {
(store.prompt.files?.length ?? 0) > 0 ||
(store.prompt.agents?.length ?? 0) > 0
) {
history.append({
history.append(props.sessionID, {
...store.prompt,
mode: store.mode,
})
@@ -1565,30 +1570,53 @@ export function Prompt(props: PromptProps) {
resetComposer()
}
// Keep the last resolved prompt display visible while destination catalogs load;
// availability and submission still use the live location-scoped catalog.
const promptDisplay = createMemo<{
agentLabel: string | undefined
agentColor: RGBA | undefined
modelLabel: string
providerLabel: string
variant: string | undefined
}>(
(previous) => {
const location = currentLocation.ref ?? data.location.default()
const sessionLocation = props.sessionID ? data.session.get(props.sessionID)?.location : location
if (!sessionLocation || locationKey(sessionLocation) !== locationKey(location)) return previous
const loading = data.location.agent.list(location) === undefined || !local.model.catalogReady
const error = currentLocation.error
const failed = error && locationKey(error.location) === locationKey(location)
if (loading && !failed) return previous
const agent = local.agent.current()
const model = local.model.parsed()
return {
agentLabel: agent ? Locale.titlecase(agent.id) : undefined,
agentColor: agent ? local.agent.color(agent.id) : undefined,
modelLabel: model.model,
providerLabel: model.provider,
variant: local.model.variant.current(),
}
},
{
agentLabel: undefined,
agentColor: undefined,
modelLabel: local.model.parsed().model,
providerLabel: local.model.parsed().provider,
variant: undefined,
},
)
const highlight = createMemo(() => {
if (leader()) return theme.border.default
if (store.mode === "shell") return theme.text.action.primary.selected
const agent = local.agent.current()
if (!agent) return theme.border.default
return local.agent.color(agent.id)
return promptDisplay().agentColor ?? theme.border.default
})
const agentLabel = createMemo(() => {
if (store.mode === "shell") return "Shell"
const agent = local.agent.current()
return agent ? Locale.titlecase(agent.id) : undefined
})
const showVariant = createMemo(() => {
const variants = local.model.variant.list()
if (variants.length === 0) return false
const current = local.model.variant.current()
return !!current
})
const agentMetaAlpha = createFadeIn(() => store.mode === "shell" || !!local.agent.current(), animationsEnabled)
const modelMetaAlpha = createFadeIn(() => !!local.agent.current() && store.mode === "normal", animationsEnabled)
const agentLabel = createMemo(() => (store.mode === "shell" ? "Shell" : promptDisplay().agentLabel))
const agentMetaAlpha = createFadeIn(() => !!agentLabel(), animationsEnabled)
const modelMetaAlpha = createFadeIn(() => !!promptDisplay().agentLabel && store.mode === "normal", animationsEnabled)
const variantMetaAlpha = createFadeIn(
() => !!local.agent.current() && store.mode === "normal" && showVariant(),
() => !!promptDisplay().agentLabel && store.mode === "normal" && !!promptDisplay().variant,
animationsEnabled,
)
const borderHighlight = createMemo(() => tint(theme.border.default, highlight(), agentMetaAlpha()))
@@ -1617,7 +1645,8 @@ export function Prompt(props: PromptProps) {
return data.session.get(props.sessionID)?.location
})
const locationLabel = createMemo(() => {
const location = footerLocation()
const pending = pendingDirectory()
const location = pending ? { directory: pending } : footerLocation()
if (!location) return
const directory = abbreviateHome(location.directory, paths.home)
const branch = data.location.vcs.info(location)?.branch.current
@@ -1635,8 +1664,7 @@ export function Prompt(props: PromptProps) {
})
const spinnerDef = createMemo(() => {
const agent = status() === "running" ? local.agent.current() : local.agent.current()
const color = agent ? local.agent.color(agent.id) : theme.border.default
const color = promptDisplay().agentColor ?? theme.border.default
return {
frames: createFrames({
color,
@@ -1851,14 +1879,14 @@ export function Prompt(props: PromptProps) {
truncate
fg={fadeColor(leader() ? theme.text.subdued : theme.text.default, modelMetaAlpha())}
>
{local.model.parsed().model}
{promptDisplay().modelLabel}
</text>
<Show when={dimensions().width >= 50}>
<text flexShrink={0} fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>
{currentProviderLabel()}
{promptDisplay().providerLabel}
</text>
</Show>
<Show when={showVariant() && dimensions().width >= 70}>
<Show when={promptDisplay().variant && dimensions().width >= 70}>
<text fg={fadeColor(theme.text.subdued, variantMetaAlpha())}>·</text>
<text>
<span
@@ -1867,7 +1895,7 @@ export function Prompt(props: PromptProps) {
bold: true,
}}
>
{local.model.variant.current()}
{promptDisplay().variant}
</span>
</text>
</Show>

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