fix: resolve merge conflicts with dev in serve.ts and status.ts

serve.ts: keep push relay additions (imports, types, helpers)
status.ts: use consolidated @/effect import path from dev, keep Log import
This commit is contained in:
Ryan Vogel 2026-04-16 13:35:57 +00:00
commit 18563363da
544 changed files with 23016 additions and 21999 deletions

View file

@ -1,6 +1,9 @@
research
dist
dist-*
gen
app.log
src/provider/models-snapshot.js
src/provider/models-snapshot.d.ts
script/build-*.ts
temporary-*.md

View file

@ -1,31 +0,0 @@
{
"name": ".opencode",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@opencode-ai/plugin": "*"
}
},
"node_modules/@opencode-ai/plugin": {
"version": "1.2.6",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.2.6",
"zod": "4.1.8"
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.2.6",
"license": "MIT"
},
"node_modules/zod": {
"version": "4.1.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View file

@ -39,6 +39,12 @@ See `specs/effect/migration.md` for the compact pattern reference and examples.
- Do the work directly in the `InstanceState.make` closure — `ScopedCache` handles run-once semantics. Don't add fibers, `ensure()` callbacks, or `started` flags on top.
- Use `Effect.addFinalizer` or `Effect.acquireRelease` inside the `InstanceState.make` closure for cleanup (subscriptions, process teardown, etc.).
- Use `Effect.forkScoped` inside the closure for background stream consumers — the fiber is interrupted when the instance is disposed.
- To make a service's `init()` non-blocking, fork `InstanceState.get(state)` at the `init()` call site (e.g. `Effect.forkIn(scope)`), not by forking work inside the `InstanceState.make` closure. Forking inside the closure leaves state incomplete for other methods that read it.
- `src/project/bootstrap.ts` already wraps every service `init()` in `Effect.forkDetach`, so `init()` is fire-and-forget in production. Keep `init()` methods synchronous internally; the caller controls concurrency.
## Effect v4 beta API
- `Effect.fork` and `Effect.forkDaemon` do not exist. Use `Effect.forkIn(scope)` to fork a fiber into a specific scope.
## Preferred Effect services

View file

@ -14,6 +14,7 @@
"fix-node-pty": "bun run script/fix-node-pty.ts",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"dev": "bun run --conditions=browser ./src/index.ts",
"dev:temporary": "bun run --conditions=browser ./src/temporary.ts",
"db": "bun drizzle-kit"
},
"bin": {
@ -116,7 +117,6 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@openrouter/ai-sdk-provider": "2.5.1",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",

View file

@ -64,36 +64,7 @@ function findBinary() {
return { binaryPath, binaryName }
} catch (error) {
throw new Error(`Could not find package ${packageName}: ${error.message}`)
}
}
function prepareBinDirectory(binaryName) {
const binDir = path.join(__dirname, "bin")
const targetPath = path.join(binDir, binaryName)
// Ensure bin directory exists
if (!fs.existsSync(binDir)) {
fs.mkdirSync(binDir, { recursive: true })
}
// Remove existing binary/symlink if it exists
if (fs.existsSync(targetPath)) {
fs.unlinkSync(targetPath)
}
return { binDir, targetPath }
}
function symlinkBinary(sourcePath, binaryName) {
const { targetPath } = prepareBinDirectory(binaryName)
fs.symlinkSync(sourcePath, targetPath)
console.log(`opencode binary symlinked: ${targetPath} -> ${sourcePath}`)
// Verify the file exists after operation
if (!fs.existsSync(targetPath)) {
throw new Error(`Failed to symlink binary to ${targetPath}`)
throw new Error(`Could not find package ${packageName}: ${error.message}`, { cause: error })
}
}
@ -124,7 +95,7 @@ async function main() {
}
try {
main()
void main()
} catch (error) {
console.error("Postinstall script error:", error.message)
process.exit(0)

View file

@ -107,7 +107,7 @@ if (!Script.preview) {
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${Script.version}"`
await $`cd ./dist/aur-${pkg} && git push`
break
} catch (e) {
} catch {
continue
}
}

View file

@ -1,8 +1,8 @@
#!/usr/bin/env bun
import { z } from "zod"
import { Config } from "../src/config/config"
import { TuiConfig } from "../src/config/tui"
import { Config } from "../src/config"
import { TuiConfig } from "../src/cli/cmd/tui/config/tui"
function generate(schema: z.ZodType) {
const result = z.toJSONSchema(schema, {
@ -33,7 +33,7 @@ function generate(schema: z.ZodType) {
schema.examples = [schema.default]
}
schema.description = [schema.description || "", `default: \`${schema.default}\``]
schema.description = [schema.description || "", `default: \`${String(schema.default)}\``]
.filter(Boolean)
.join("\n\n")
.trim()

View file

@ -0,0 +1,305 @@
#!/usr/bin/env bun
/**
* Unwrap a TypeScript `export namespace` into flat exports + barrel.
*
* Usage:
* bun script/unwrap-namespace.ts src/bus/index.ts
* bun script/unwrap-namespace.ts src/bus/index.ts --dry-run
* bun script/unwrap-namespace.ts src/pty/index.ts --name service # avoid collision with pty.ts
*
* What it does:
* 1. Reads the file and finds the `export namespace Foo { ... }` block
* (uses ast-grep for accurate AST-based boundary detection)
* 2. Removes the namespace wrapper and dedents the body
* 3. Fixes self-references (e.g. Config.PermissionAction PermissionAction)
* 4. If the file is index.ts, renames it to <lowercase-name>.ts
* 5. Creates/updates index.ts with `export * as Foo from "./<file>"`
* 6. Rewrites import paths across src/, test/, and script/
* 7. Fixes sibling imports within the same directory
*
* Requires: ast-grep (`brew install ast-grep` or `cargo install ast-grep`)
*/
import path from "path"
import fs from "fs"
const args = process.argv.slice(2)
const dryRun = args.includes("--dry-run")
const nameFlag = args.find((a, i) => args[i - 1] === "--name")
const filePath = args.find((a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--name")
if (!filePath) {
console.error("Usage: bun script/unwrap-namespace.ts <file> [--dry-run] [--name <impl-name>]")
process.exit(1)
}
const absPath = path.resolve(filePath)
if (!fs.existsSync(absPath)) {
console.error(`File not found: ${absPath}`)
process.exit(1)
}
const src = fs.readFileSync(absPath, "utf-8")
const lines = src.split("\n")
// Use ast-grep to find the namespace boundaries accurately.
// This avoids false matches from braces in strings, templates, comments, etc.
const astResult = Bun.spawnSync(
["ast-grep", "run", "--pattern", "export namespace $NAME { $$$BODY }", "--lang", "typescript", "--json", absPath],
{ stdout: "pipe", stderr: "pipe" },
)
if (astResult.exitCode !== 0) {
console.error("ast-grep failed:", astResult.stderr.toString())
process.exit(1)
}
const matches = JSON.parse(astResult.stdout.toString()) as Array<{
text: string
range: { start: { line: number; column: number }; end: { line: number; column: number } }
metaVariables: { single: Record<string, { text: string }>; multi: Record<string, Array<{ text: string }>> }
}>
if (matches.length === 0) {
console.error("No `export namespace Foo { ... }` found in file")
process.exit(1)
}
if (matches.length > 1) {
console.error(`Found ${matches.length} namespaces — this script handles one at a time`)
console.error("Namespaces found:")
for (const m of matches) console.error(` ${m.metaVariables.single.NAME.text} (line ${m.range.start.line + 1})`)
process.exit(1)
}
const match = matches[0]
const nsName = match.metaVariables.single.NAME.text
const nsLine = match.range.start.line // 0-indexed
const closeLine = match.range.end.line // 0-indexed, the line with closing `}`
console.log(`Found: export namespace ${nsName} { ... }`)
console.log(` Lines ${nsLine + 1}${closeLine + 1} (${closeLine - nsLine + 1} lines)`)
// Build the new file content:
// 1. Everything before the namespace declaration (imports, etc.)
// 2. The namespace body, dedented by one level (2 spaces)
// 3. Everything after the closing brace (rare, but possible)
const before = lines.slice(0, nsLine)
const body = lines.slice(nsLine + 1, closeLine)
const after = lines.slice(closeLine + 1)
// Dedent: remove exactly 2 leading spaces from each line
const dedented = body.map((line) => {
if (line === "") return ""
if (line.startsWith(" ")) return line.slice(2)
return line
})
let newContent = [...before, ...dedented, ...after].join("\n")
// --- Fix self-references ---
// After unwrapping, references like `Config.PermissionAction` inside the same file
// need to become just `PermissionAction`. Only fix code positions, not strings.
const exportedNames = new Set<string>()
const exportRegex = /export\s+(?:const|function|class|interface|type|enum|abstract\s+class)\s+(\w+)/g
for (const line of dedented) {
for (const m of line.matchAll(exportRegex)) exportedNames.add(m[1])
}
const reExportRegex = /export\s*\{\s*([^}]+)\}/g
for (const line of dedented) {
for (const m of line.matchAll(reExportRegex)) {
for (const name of m[1].split(",")) {
const trimmed = name
.trim()
.split(/\s+as\s+/)
.pop()!
.trim()
if (trimmed) exportedNames.add(trimmed)
}
}
}
let selfRefCount = 0
if (exportedNames.size > 0) {
const fixedLines = newContent.split("\n").map((line) => {
// Split line into string-literal and code segments to avoid replacing inside strings
const segments: Array<{ text: string; isString: boolean }> = []
let i = 0
let current = ""
let inString: string | null = null
while (i < line.length) {
const ch = line[i]
if (inString) {
current += ch
if (ch === "\\" && i + 1 < line.length) {
current += line[i + 1]
i += 2
continue
}
if (ch === inString) {
segments.push({ text: current, isString: true })
current = ""
inString = null
}
i++
continue
}
if (ch === '"' || ch === "'" || ch === "`") {
if (current) segments.push({ text: current, isString: false })
current = ch
inString = ch
i++
continue
}
if (ch === "/" && i + 1 < line.length && line[i + 1] === "/") {
current += line.slice(i)
segments.push({ text: current, isString: true })
current = ""
i = line.length
continue
}
current += ch
i++
}
if (current) segments.push({ text: current, isString: !!inString })
return segments
.map((seg) => {
if (seg.isString) return seg.text
let result = seg.text
for (const name of exportedNames) {
const pattern = `${nsName}.${name}`
while (result.includes(pattern)) {
const idx = result.indexOf(pattern)
const charBefore = idx > 0 ? result[idx - 1] : " "
const charAfter = idx + pattern.length < result.length ? result[idx + pattern.length] : " "
if (/\w/.test(charBefore) || /\w/.test(charAfter)) break
result = result.slice(0, idx) + name + result.slice(idx + pattern.length)
selfRefCount++
}
}
return result
})
.join("")
})
newContent = fixedLines.join("\n")
}
// Figure out file naming
const dir = path.dirname(absPath)
const basename = path.basename(absPath, ".ts")
const isIndex = basename === "index"
const implName = nameFlag ?? (isIndex ? nsName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() : basename)
const implFile = path.join(dir, `${implName}.ts`)
const indexFile = path.join(dir, "index.ts")
const barrelLine = `export * as ${nsName} from "./${implName}"\n`
console.log("")
if (isIndex) {
console.log(`Plan: rename ${basename}.ts → ${implName}.ts, create new index.ts barrel`)
} else {
console.log(`Plan: rewrite ${basename}.ts in place, create index.ts barrel`)
}
if (selfRefCount > 0) console.log(`Fixed ${selfRefCount} self-reference(s) (${nsName}.X → X)`)
console.log("")
if (dryRun) {
console.log("--- DRY RUN ---")
console.log("")
console.log(`=== ${implName}.ts (first 30 lines) ===`)
newContent
.split("\n")
.slice(0, 30)
.forEach((l, i) => console.log(` ${i + 1}: ${l}`))
console.log(" ...")
console.log("")
console.log(`=== index.ts ===`)
console.log(` ${barrelLine.trim()}`)
console.log("")
if (!isIndex) {
const relDir = path.relative(path.resolve("src"), dir)
console.log(`=== Import rewrites (would apply) ===`)
console.log(` ${relDir}/${basename}" → ${relDir}" across src/, test/, script/`)
} else {
console.log("No import rewrites needed (was index.ts)")
}
} else {
if (isIndex) {
fs.writeFileSync(implFile, newContent)
fs.writeFileSync(indexFile, barrelLine)
console.log(`Wrote ${implName}.ts (${newContent.split("\n").length} lines)`)
console.log(`Wrote index.ts (barrel)`)
} else {
fs.writeFileSync(absPath, newContent)
if (fs.existsSync(indexFile)) {
const existing = fs.readFileSync(indexFile, "utf-8")
if (!existing.includes(`export * as ${nsName}`)) {
fs.appendFileSync(indexFile, barrelLine)
console.log(`Appended to existing index.ts`)
} else {
console.log(`index.ts already has ${nsName} export`)
}
} else {
fs.writeFileSync(indexFile, barrelLine)
console.log(`Wrote index.ts (barrel)`)
}
console.log(`Rewrote ${basename}.ts (${newContent.split("\n").length} lines)`)
}
// --- Rewrite import paths across src/, test/, script/ ---
const relDir = path.relative(path.resolve("src"), dir)
if (!isIndex) {
const oldTail = `${relDir}/${basename}`
const searchDirs = ["src", "test", "script"].filter((d) => fs.existsSync(d))
const rgResult = Bun.spawnSync(["rg", "-l", `from.*${oldTail}"`, ...searchDirs], {
stdout: "pipe",
stderr: "pipe",
})
const filesToRewrite = rgResult.stdout
.toString()
.trim()
.split("\n")
.filter((f) => f.length > 0)
if (filesToRewrite.length > 0) {
console.log(`\nRewriting imports in ${filesToRewrite.length} file(s)...`)
for (const file of filesToRewrite) {
const content = fs.readFileSync(file, "utf-8")
fs.writeFileSync(file, content.replaceAll(`${oldTail}"`, `${relDir}"`))
}
console.log(` Done: ${oldTail}" → ${relDir}"`)
} else {
console.log("\nNo import rewrites needed")
}
} else {
console.log("\nNo import rewrites needed (was index.ts)")
}
// --- Fix sibling imports within the same directory ---
const siblingFiles = fs.readdirSync(dir).filter((f) => {
if (!f.endsWith(".ts")) return false
if (f === "index.ts" || f === `${implName}.ts`) return false
return true
})
let siblingFixCount = 0
for (const sibFile of siblingFiles) {
const sibPath = path.join(dir, sibFile)
const content = fs.readFileSync(sibPath, "utf-8")
const pattern = new RegExp(`from\\s+["']\\./${basename}["']`, "g")
if (pattern.test(content)) {
fs.writeFileSync(sibPath, content.replace(pattern, `from "."`))
siblingFixCount++
}
}
if (siblingFixCount > 0) {
console.log(`Fixed ${siblingFixCount} sibling import(s) in ${path.basename(dir)}/ (./${basename} → .)`)
}
}
console.log("")
console.log("=== Verify ===")
console.log("")
console.log("bunx --bun tsgo --noEmit # typecheck")
console.log("bun run test # run tests")

View file

@ -121,17 +121,46 @@ Why `question` first:
Do not re-architect business logic during the HTTP migration. `HttpApi` handlers should call the same Effect services already used by the Hono handlers.
### 4. Build in parallel, do not bridge into Hono
### 4. Bridge into Hono behind a feature flag
The `HttpApi` implementation lives under `src/server/instance/httpapi/` as a standalone Effect HTTP server. It is **not mounted into the Hono app**. There is no `toWebHandler` bridge, no Hono `Handler` export, and no `.route()` call wiring it into `experimental.ts`.
The `HttpApi` routes are bridged into the Hono server via `HttpRouter.toWebHandler` with a shared `memoMap`. This means:
The standalone server (`httpapi/server.ts`) can be started independently and proves the routes work. Tests exercise it via `HttpRouter.serve` with `NodeHttpServer.layerTest`.
- one process, one port — no separate server
- the Effect handler shares layer instances with `AppRuntime` (same `Question.Service`, etc.)
- Effect middleware handles auth and instance lookup independently from Hono middleware
- Hono's `.all()` catch-all intercepts matching paths before the Hono route handlers
The goal is to build enough route coverage in the Effect server that the Hono server can eventually be replaced entirely. Until then, the two implementations exist side by side but are completely separate processes.
The bridge is gated behind `OPENCODE_EXPERIMENTAL_HTTPAPI` (or `OPENCODE_EXPERIMENTAL`). When the flag is off (default), all requests go through the original Hono handlers unchanged.
### 5. Migrate JSON route groups gradually
```ts
// in instance/index.ts
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
const handler = ExperimentalHttpApiServer.webHandler().handler
app.all("/question", (c) => handler(c.req.raw)).all("/question/*", (c) => handler(c.req.raw))
}
```
If the parallel slice works well, migrate additional JSON route groups one at a time. Leave streaming-style endpoints on Hono until there is a clear reason to move them.
The Hono route handlers are always registered (after the bridge) so `hono-openapi` generates the OpenAPI spec entries that feed SDK codegen. When the flag is on, these handlers are dead code — the `.all()` bridge matches first.
### 5. Observability
The `webHandler` provides `Observability.layer` via `Layer.provideMerge`. Since the `memoMap` is shared with `AppRuntime`, the tracing provider is deduplicated — no extra initialization cost.
This gives:
- **spans**: `Effect.fn("QuestionHttpApi.list")` etc. appear in traces alongside service-layer spans
- **HTTP logs**: `HttpMiddleware.logger` emits structured `Effect.log` entries with `http.method`, `http.url`, `http.status` annotations, flowing to motel via `OtlpLogger`
### 6. Migrate JSON route groups gradually
As each route group is ported to `HttpApi`:
1. change its `root` path from `/experimental/httpapi/<group>` to `/<group>`
2. add `.all("/<group>", handler)` / `.all("/<group>/*", handler)` to the flag block in `instance/index.ts`
3. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path
4. verify SDK output is unchanged
Leave streaming-style endpoints on Hono until there is a clear reason to move them.
## Schema rule for HttpApi work
@ -156,6 +185,14 @@ Ordering for a route-group migration:
3. move tagged route-facing errors to `Schema.TaggedErrorClass` where needed
4. switch existing Zod boundary validators to derived `.zod`
5. define the `HttpApi` contract from the canonical Effect schemas
6. regenerate the SDK (`./packages/sdk/js/script/build.ts`) and verify zero diff against `dev`
SDK shape rule:
- every schema migration must preserve the generated SDK output byte-for-byte
- `Schema.Class` emits a named `$ref` in OpenAPI via its identifier — use it only for types that already had `.meta({ ref })` in the old Zod schema
- inner / nested types that were anonymous in the old Zod schema should stay as `Schema.Struct` (not `Schema.Class`) to avoid introducing new named components in the OpenAPI spec
- if a diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
Temporary exception:
@ -195,8 +232,9 @@ Use the same sequence for each route group.
4. Define the `HttpApi` contract separately from the handlers.
5. Implement handlers by yielding the existing service from context.
6. Mount the new surface in parallel under an experimental prefix.
7. Add one end-to-end test and one OpenAPI-focused test.
8. Compare ergonomics before migrating the next endpoint.
7. Regenerate the SDK and verify zero diff against `dev` (see SDK shape rule above).
8. Add one end-to-end test and one OpenAPI-focused test.
9. Compare ergonomics before migrating the next endpoint.
Rule of thumb:
@ -293,36 +331,43 @@ The first slice is successful if:
- OpenAPI is generated from the `HttpApi` contract
- the tests are straightforward enough that the next slice feels mechanical
## Learnings from the question slice
## Learnings
The first parallel `question` spike gave us a concrete pattern to reuse.
### Schema
- `Schema.Class` works well for route DTOs such as `Question.Request`, `Question.Info`, and `Question.Reply`.
- scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes.
- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects.
- internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes.
- the experimental slice should stay as a standalone Effect server and keep calling the existing service layer unchanged.
- compare generated OpenAPI semantically at the route and schema level.
- `Schema.Class` emits named `$ref` in OpenAPI — only use it for types that already had `.meta({ ref })` in the old Zod schema. Inner/nested types should stay as `Schema.Struct` to avoid SDK shape changes.
### Integration
- `HttpRouter.toWebHandler` with the shared `memoMap` from `run-service.ts` cleanly bridges Effect routes into Hono — one process, one port, shared layer instances.
- `Observability.layer` must be explicitly provided via `Layer.provideMerge` in the routes layer for OTEL spans and HTTP logs to flow. The `memoMap` deduplicates it with `AppRuntime` — no extra cost.
- `HttpMiddleware.logger` (enabled by default when `disableLogger` is not set) emits structured `Effect.log` entries with `http.method`, `http.url`, `http.status` — these flow through `OtlpLogger` to motel.
- Hono OpenAPI stubs must remain registered for SDK codegen until the SDK pipeline reads from the Effect OpenAPI spec instead.
- the `OPENCODE_EXPERIMENTAL_HTTPAPI` flag gates the bridge at the Hono router level — default off, no behavior change unless opted in.
## Route inventory
Status legend:
- `done` - parallel `HttpApi` slice exists
- `bridged` - Effect HttpApi slice exists and is bridged into Hono behind the flag
- `done` - Effect HttpApi slice exists but not yet bridged
- `next` - good near-term candidate
- `later` - possible, but not first wave
- `defer` - not a good early `HttpApi` target
Current instance route inventory:
- `question` - `done`
endpoints in slice: `GET /question`, `POST /question/:requestID/reply`
- `permission` - `done`
endpoints in slice: `GET /permission`, `POST /permission/:requestID/reply`
- `provider` - `next`
best next endpoint: `GET /provider/auth`
later endpoint: `GET /provider`
defer first-wave OAuth mutations
- `question` - `bridged`
endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject`
- `permission` - `bridged`
endpoints: `GET /permission`, `POST /permission/:requestID/reply`
- `provider` - `bridged` (partial)
bridged endpoint: `GET /provider/auth`
not yet ported: `GET /provider`, OAuth mutations
- `config` - `next`
best next endpoint: `GET /config/providers`
later endpoint: `GET /config`
@ -362,7 +407,13 @@ Recommended near-term sequence after the first spike:
- [x] keep the underlying service calls identical to the current handlers
- [x] compare generated OpenAPI against the current Hono/OpenAPI setup
- [x] document how auth, instance lookup, and error mapping would compose in the new stack
- [ ] decide after the spike whether `HttpApi` should stay parallel, replace only some groups, or become the long-term default
- [x] bridge Effect routes into Hono via `toWebHandler` with shared `memoMap`
- [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
- [x] verify OTEL spans and HTTP logs flow to motel
- [x] bridge question, permission, and provider auth routes
- [ ] port remaining provider endpoints (`GET /provider`, OAuth mutations)
- [ ] port `config` read endpoints
- [ ] decide when to remove the flag and make Effect routes the default
## Rule of thumb

View file

@ -0,0 +1,499 @@
# Namespace → flat export migration
Migrate `export namespace` to the `export * as` / flat-export pattern used by
effect-smol. Primary goal: tree-shakeability. Secondary: consistency with Effect
conventions, LLM-friendliness for future migrations.
## What changes and what doesn't
The **consumer API stays the same**. You still write `Provider.ModelNotFoundError`,
`Config.JsonError`, `Bus.publish`, etc. The namespace ergonomics are preserved.
What changes is **how** the namespace is constructed — the TypeScript
`export namespace` keyword is replaced by `export * as` in a barrel file. This
is a mechanical change: unwrap the namespace body into flat exports, add a
one-line barrel. Consumers that import `{ Provider }` don't notice.
Import paths actually get **nicer**. Today most consumers import from the
explicit file (`"../provider/provider"`). After the migration, each module has a
barrel `index.ts`, so imports become `"../provider"` or `"@/provider"`:
```ts
// BEFORE — points at the file directly
import { Provider } from "../provider/provider"
// AFTER — resolves to provider/index.ts, same Provider namespace
import { Provider } from "../provider"
```
## Why this matters right now
The CLI binary startup time (TOI) is too slow. Profiling shows we're loading
massive dependency graphs that are never actually used at runtime — because
bundlers cannot tree-shake TypeScript `export namespace` bodies.
### The problem in one sentence
`cli/error.ts` needs 6 lightweight `.isInstance()` checks on error classes, but
importing `{ Provider }` from `provider.ts` forces the bundler to include **all
20+ `@ai-sdk/*` packages**, `@aws-sdk/credential-providers`,
`google-auth-library`, and every other top-level import in that 1709-line file.
### Why `export namespace` defeats tree-shaking
TypeScript compiles `export namespace Foo { ... }` to an IIFE:
```js
// TypeScript output
export var Provider;
(function (Provider) {
Provider.ModelNotFoundError = NamedError.create(...)
// ... 1600 more lines of assignments ...
})(Provider || (Provider = {}))
```
This is **opaque to static analysis**. The bundler sees one big function call
whose return value populates an object. It cannot determine which properties are
used downstream, so it keeps everything. Every `import` statement at the top of
`provider.ts` executes unconditionally — that's 20+ AI SDK packages loaded into
memory just so the CLI can check `Provider.ModelNotFoundError.isInstance(x)`.
### What `export * as` does differently
`export * as Provider from "./provider"` compiles to a static re-export. The
bundler knows the exact shape of `Provider` at compile time — it's the named
export list of `./provider.ts`. When it sees `Provider.ModelNotFoundError` used
but `Provider.layer` unused, it can trace that `ModelNotFoundError` doesn't
reference `createAnthropic` or any AI SDK import, and drop them. The namespace
object still exists at runtime — same API — but the bundler can see inside it.
### Concrete impact
The worst import chain in the codebase:
```
src/index.ts (entry point)
└── FormatError from src/cli/error.ts
├── { Provider } from provider/provider.ts (1709 lines)
│ ├── 20+ @ai-sdk/* packages
│ ├── @aws-sdk/credential-providers
│ ├── google-auth-library
│ ├── gitlab-ai-provider, venice-ai-sdk-provider
│ └── fuzzysort, remeda, etc.
├── { Config } from config/config.ts (1663 lines)
│ ├── jsonc-parser
│ ├── LSPServer (all server definitions)
│ └── Plugin, Auth, Env, Account, etc.
└── { MCP } from mcp/index.ts (930 lines)
├── @modelcontextprotocol/sdk (3 transports)
└── open (browser launcher)
```
All of this gets pulled in to check `.isInstance()` on 6 error classes — code
that needs maybe 200 bytes total. This inflates the binary, increases startup
memory, and slows down initial module evaluation.
### Why this also hurts memory
Every module-level import is eagerly evaluated. Even with Bun's fast module
loader, evaluating 20+ AI SDK factory functions, the AWS credential chain, and
Google's auth library allocates objects, closures, and prototype chains that
persist for the lifetime of the process. Most CLI commands never use a provider
at all.
## What effect-smol does
effect-smol achieves tree-shakeable namespaced APIs via three structural choices.
### 1. Each module is a separate file with flat named exports
```ts
// Effect.ts — no namespace wrapper, just flat exports
export const gen: { ... } = internal.gen
export const fail: <E>(error: E) => Effect<never, E> = internal.fail
export const succeed: <A>(value: A) => Effect<A> = internal.succeed
// ... 230+ individual named exports
```
### 2. Barrel file uses `export * as` (not `export namespace`)
```ts
// index.ts
export * as Effect from "./Effect.ts"
export * as Schema from "./Schema.ts"
export * as Stream from "./Stream.ts"
// ~134 modules
```
This creates a namespace-like API (`Effect.gen`, `Schema.parse`) but the
bundler knows the **exact shape** at compile time — it's the static export list
of that file. It can trace property accesses (`Effect.gen` → keep `gen`,
drop `timeout` if unused). With `export namespace`, the IIFE is opaque and
nothing can be dropped.
### 3. `sideEffects: []` and deep imports
```jsonc
// package.json
{ "sideEffects": [] }
```
Plus `"./*": "./src/*.ts"` in the exports map, enabling
`import * as Effect from "effect/Effect"` to bypass the barrel entirely.
### 4. Errors as flat exports, not class declarations
```ts
// Cause.ts
export const NoSuchElementErrorTypeId = core.NoSuchElementErrorTypeId
export interface NoSuchElementError extends YieldableError { ... }
export const NoSuchElementError: new(msg?: string) => NoSuchElementError = core.NoSuchElementError
export const isNoSuchElementError: (u: unknown) => u is NoSuchElementError = core.isNoSuchElementError
```
Each error is 4 independent exports: TypeId, interface, constructor (as const),
type guard. All individually shakeable.
## The plan
The core migration is **Phase 1** — convert `export namespace` to
`export * as`. Once that's done, the bundler can tree-shake individual exports
within each module. You do NOT need to break things into subfiles for
tree-shaking to work — the bundler traces which exports you actually access on
the namespace object and drops the rest, including their transitive imports.
Splitting errors/schemas into separate files (Phase 0) is optional — it's a
lower-risk warmup step that can be done before or after the main conversion, and
it provides extra resilience against bundler edge cases. But the big win comes
from Phase 1.
### Phase 0 (optional): Pre-split errors into subfiles
This is a low-risk warmup that provides immediate benefit even before the full
`export * as` conversion. It's optional because Phase 1 alone is sufficient for
tree-shaking. But it's a good starting point if you want incremental progress:
**For each namespace that defines errors** (15 files, ~30 error classes total):
1. Create a sibling `errors.ts` file (e.g. `provider/errors.ts`) with the error
definitions as top-level named exports:
```ts
// provider/errors.ts
import z from "zod"
import { NamedError } from "@opencode-ai/shared/util/error"
import { ProviderID, ModelID } from "./schema"
export const ModelNotFoundError = NamedError.create(
"ProviderModelNotFoundError",
z.object({
providerID: ProviderID.zod,
modelID: ModelID.zod,
suggestions: z.array(z.string()).optional(),
}),
)
export const InitError = NamedError.create("ProviderInitError", z.object({ providerID: ProviderID.zod }))
```
2. In the namespace file, re-export from the errors file to maintain backward
compatibility:
```ts
// provider/provider.ts — inside the namespace
export { ModelNotFoundError, InitError } from "./errors"
```
3. Update `cli/error.ts` (and any other light consumers) to import directly:
```ts
// BEFORE
import { Provider } from "../provider/provider"
Provider.ModelNotFoundError.isInstance(input)
// AFTER
import { ModelNotFoundError as ProviderModelNotFoundError } from "../provider/errors"
ProviderModelNotFoundError.isInstance(input)
```
**Files to split (Phase 0):**
| Current file | New errors file | Errors to extract |
| ----------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `provider/provider.ts` | `provider/errors.ts` | ModelNotFoundError, InitError |
| `provider/auth.ts` | `provider/auth-errors.ts` | OauthMissing, OauthCodeMissing, OauthCallbackFailed, ValidationFailed |
| `config/config.ts` | (already has `config/paths.ts`) | ConfigDirectoryTypoError → move to paths.ts |
| `config/markdown.ts` | `config/markdown-errors.ts` | FrontmatterError |
| `mcp/index.ts` | `mcp/errors.ts` | Failed |
| `session/message-v2.ts` | `session/message-errors.ts` | OutputLengthError, AbortedError, StructuredOutputError, AuthError, APIError, ContextOverflowError |
| `session/message.ts` | (shares with message-v2) | OutputLengthError, AuthError |
| `cli/ui.ts` | `cli/ui-errors.ts` | CancelledError |
| `skill/index.ts` | `skill/errors.ts` | InvalidError, NameMismatchError |
| `worktree/index.ts` | `worktree/errors.ts` | NotGitError, NameGenerationFailedError, CreateFailedError, StartCommandFailedError, RemoveFailedError, ResetFailedError |
| `storage/storage.ts` | `storage/errors.ts` | NotFoundError |
| `npm/index.ts` | `npm/errors.ts` | InstallFailedError |
| `ide/index.ts` | `ide/errors.ts` | AlreadyInstalledError, InstallFailedError |
| `lsp/client.ts` | `lsp/errors.ts` | InitializeError |
### Phase 1: The real migration — `export namespace``export * as`
This is the phase that actually fixes tree-shaking. For each module:
1. **Unwrap** the `export namespace Foo { ... }` — remove the namespace wrapper,
keep all the members as top-level `export const` / `export function` / etc.
2. **Rename** the file if it's currently `index.ts` (e.g. `bus/index.ts`
`bus/bus.ts`), so the barrel can take `index.ts`.
3. **Create the barrel** `index.ts` with one line: `export * as Foo from "./foo"`
The file structure change for a module that's currently a single file:
```
# BEFORE
provider/
provider.ts ← 1709-line file with `export namespace Provider { ... }`
# AFTER
provider/
index.ts ← NEW: `export * as Provider from "./provider"`
provider.ts ← SAME file, same name, just unwrap the namespace
```
And the code change is purely removing the wrapper:
```ts
// BEFORE: provider/provider.ts
export namespace Provider {
export class Service extends Context.Service<...>()("@opencode/Provider") {}
export const layer = Layer.effect(Service, ...)
export const ModelNotFoundError = NamedError.create(...)
export function parseModel(model: string) { ... }
}
// AFTER: provider/provider.ts — identical exports, no namespace keyword
export class Service extends Context.Service<...>()("@opencode/Provider") {}
export const layer = Layer.effect(Service, ...)
export const ModelNotFoundError = NamedError.create(...)
export function parseModel(model: string) { ... }
```
```ts
// NEW: provider/index.ts
export * as Provider from "./provider"
```
Consumer code barely changes — import path gets shorter:
```ts
// BEFORE
import { Provider } from "../provider/provider"
// AFTER — resolves to provider/index.ts, same Provider object
import { Provider } from "../provider"
```
All access like `Provider.ModelNotFoundError`, `Provider.Service`,
`Provider.layer` works exactly as before. The difference is invisible to
consumers but lets the bundler see inside the namespace.
**Once this is done, you don't need to break anything into subfiles for
tree-shaking.** The bundler traces that `Provider.ModelNotFoundError` only
depends on `NamedError` + `zod` + the schema file, and drops
`Provider.layer` + all 20 AI SDK imports when they're unused. This works because
`export * as` gives the bundler a static export list it can do inner-graph
analysis on — it knows which exports reference which imports.
**Order of conversion** (by risk / size, do small modules first):
1. Tiny utilities: `Archive`, `Color`, `Token`, `Rpc`, `LocalContext` (~7-66 lines each)
2. Small services: `Auth`, `Env`, `BusEvent`, `SessionStatus`, `SessionRunState`, `Editor`, `Selection` (~25-91 lines)
3. Medium services: `Bus`, `Format`, `FileTime`, `FileWatcher`, `Command`, `Question`, `Permission`, `Vcs`, `Project`
4. Large services: `Config`, `Provider`, `MCP`, `Session`, `SessionProcessor`, `SessionPrompt`, `ACP`
### Phase 2: Build configuration
After the module structure supports tree-shaking:
1. Add `"sideEffects": []` to `packages/opencode/package.json` (or
`"sideEffects": false`) — this is safe because our services use explicit
layer composition, not import-time side effects.
2. Verify Bun's bundler respects the new structure. If Bun's tree-shaking is
insufficient, evaluate whether the compiled binary path needs an esbuild
pre-pass.
3. Consider adding `/*#__PURE__*/` annotations to `NamedError.create(...)` calls
— these are factory functions that return classes, and bundlers may not know
they're side-effect-free without the annotation.
## Automation
The transformation is scripted. From `packages/opencode`:
```bash
bun script/unwrap-namespace.ts <file> [--dry-run]
```
The script uses ast-grep for accurate AST-based namespace boundary detection
(no false matches from braces in strings/templates/comments), then:
1. Removes the `export namespace Foo {` line and its closing `}`
2. Dedents the body by one indent level (2 spaces)
3. If the file is `index.ts`, renames it to `<name>.ts` and creates a new
`index.ts` barrel
4. If the file is NOT `index.ts`, rewrites it in place and creates `index.ts`
5. Prints the exact commands to find and rewrite import paths
### Walkthrough: converting a module
Using `Provider` as an example:
```bash
# 1. Preview what will change
bun script/unwrap-namespace.ts src/provider/provider.ts --dry-run
# 2. Apply the transformation
bun script/unwrap-namespace.ts src/provider/provider.ts
# 3. Rewrite import paths (script prints the exact command)
rg -l 'from.*provider/provider' src/ | xargs sed -i '' 's|provider/provider"|provider"|g'
# 4. Verify
bun typecheck
bun run test
```
**What changes on disk:**
```
# BEFORE
provider/
provider.ts ← 1709 lines, `export namespace Provider { ... }`
# AFTER
provider/
index.ts ← NEW: `export * as Provider from "./provider"`
provider.ts ← same file, namespace unwrapped to flat exports
```
**What changes in consumer code:**
```ts
// BEFORE
import { Provider } from "../provider/provider"
// AFTER — shorter path, same Provider object
import { Provider } from "../provider"
```
All property access (`Provider.Service`, `Provider.ModelNotFoundError`, etc.)
stays identical.
### Two cases the script handles
**Case A: file is NOT `index.ts`** (e.g. `provider/provider.ts`)
- Rewrites the file in place (unwrap + dedent)
- Creates `provider/index.ts` as the barrel
- Import paths change: `"../provider/provider"``"../provider"`
**Case B: file IS `index.ts`** (e.g. `bus/index.ts`)
- Renames `index.ts``bus.ts` (kebab-case of namespace name)
- Creates new `index.ts` as the barrel
- **No import rewrites needed**`"@/bus"` already resolves to `bus/index.ts`
## Do I need to split errors/schemas into subfiles?
**No.** Once you do the `export * as` conversion, the bundler can tree-shake
individual exports within the file. If `cli/error.ts` only accesses
`Provider.ModelNotFoundError`, the bundler traces that `ModelNotFoundError`
doesn't reference `createAnthropic` and drops the AI SDK imports.
Splitting into subfiles (errors.ts, schema.ts) is still a fine idea for **code
organization** — smaller files are easier to read and review. But it's not
required for tree-shaking. The `export * as` conversion alone is sufficient.
The one case where subfile splitting provides extra tree-shake value is if an
imported package has module-level side effects that the bundler can't prove are
unused. In practice this is rare — most npm packages are side-effect-free — and
adding `"sideEffects": []` to package.json handles the common cases.
## Scope
| Metric | Count |
| ----------------------------------------------- | --------------- |
| Files with `export namespace` | 106 |
| Total namespace declarations | 118 (12 nested) |
| Files with `NamedError.create` inside namespace | 15 |
| Total error classes to extract | ~30 |
| Files using `export * as` today | 0 |
Phase 1 (the `export * as` conversion) is the main change. It's mechanical and
LLM-friendly but touches every import site, so it should be done module by
module with type-checking between each step. Each module is an independent PR.
## Rules for new code
Going forward:
- **No new `export namespace`**. Use a file with flat named exports and
`export * as` in the barrel.
- Keep the service, layer, errors, schemas, and runtime wiring together in one
file if you want — that's fine now. The `export * as` barrel makes everything
individually shakeable regardless of file structure.
- If a file grows large enough that it's hard to navigate, split by concern
(errors.ts, schema.ts, etc.) for readability. Not for tree-shaking — the
bundler handles that.
## Circular import rules
Barrel files (`index.ts` with `export * as`) introduce circular import risks.
These cause `ReferenceError: Cannot access 'X' before initialization` at
runtime — not caught by the type checker.
### Rule 1: Sibling files never import through their own barrel
Files in the same directory must import directly from the source file, never
through `"."` or `"@/<own-dir>"`:
```ts
// BAD — circular: index.ts re-exports both files, so A → index → B → index → A
import { Sibling } from "."
// GOOD — direct, no cycle
import * as Sibling from "./sibling"
```
### Rule 2: Cross-directory imports must not form cycles through barrels
If `src/lsp/lsp.ts` imports `Config` from `"../config"`, and
`src/config/config.ts` imports `LSPServer` from `"../lsp"`, that's a cycle:
```
lsp/lsp.ts → config/index.ts → config/config.ts → lsp/index.ts → lsp/lsp.ts 💥
```
Fix by importing the specific file, breaking the cycle:
```ts
// In config/config.ts — import directly, not through the lsp barrel
import * as LSPServer from "../lsp/server"
```
### Why the type checker doesn't catch this
TypeScript resolves types lazily — it doesn't evaluate module-scope
expressions. The `ReferenceError` only happens at runtime when a module-scope
`const` or function call accesses a value from a circular dependency that
hasn't finished initializing. The SDK build step (`bun run --conditions=browser
./src/index.ts generate`) is the reliable way to catch these because it
evaluates all modules eagerly.
### How to verify
After any namespace conversion, run:
```bash
cd packages/opencode
bun run --conditions=browser ./src/index.ts generate
```
If this completes without `ReferenceError`, the module graph is safe.

View file

@ -0,0 +1,454 @@
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect"
import {
FetchHttpClient,
HttpClient,
HttpClientError,
HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import { normalizeServerUrl } from "./url"
import {
type AccountError,
AccessToken,
AccountID,
DeviceCode,
Info,
RefreshToken,
AccountServiceError,
AccountTransportError,
Login,
Org,
OrgID,
PollDenied,
PollError,
PollExpired,
PollPending,
type PollResult,
PollSlow,
PollSuccess,
UserCode,
} from "./schema"
export {
AccountID,
type AccountError,
AccountRepoError,
AccountServiceError,
AccountTransportError,
AccessToken,
RefreshToken,
DeviceCode,
UserCode,
Info,
Org,
OrgID,
Login,
PollSuccess,
PollPending,
PollSlow,
PollExpired,
PollDenied,
PollError,
PollResult,
} from "./schema"
export type AccountOrgs = {
account: Info
orgs: readonly Org[]
}
export type ActiveOrg = {
account: Info
org: Org
}
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
config: Schema.Record(Schema.String, Schema.Json),
}) {}
const DurationFromSeconds = Schema.Number.pipe(
Schema.decodeTo(Schema.Duration, {
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
}),
)
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
access_token: AccessToken,
refresh_token: RefreshToken,
expires_in: DurationFromSeconds,
}) {}
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
device_code: DeviceCode,
user_code: UserCode,
verification_uri_complete: Schema.String,
expires_in: DurationFromSeconds,
interval: DurationFromSeconds,
}) {}
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
access_token: AccessToken,
refresh_token: RefreshToken,
token_type: Schema.Literal("Bearer"),
expires_in: DurationFromSeconds,
}) {}
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
error: Schema.String,
error_description: Schema.String,
}) {
toPollResult(): PollResult {
if (this.error === "authorization_pending") return new PollPending()
if (this.error === "slow_down") return new PollSlow()
if (this.error === "expired_token") return new PollExpired()
if (this.error === "access_denied") return new PollDenied()
return new PollError({ cause: this.error })
}
}
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
class User extends Schema.Class<User>("User")({
id: AccountID,
email: Schema.String,
}) {}
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
grant_type: Schema.String,
device_code: DeviceCode,
client_id: Schema.String,
}) {}
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
grant_type: Schema.String,
refresh_token: RefreshToken,
client_id: Schema.String,
}) {}
const clientId = "opencode-cli"
const eagerRefreshThreshold = Duration.minutes(5)
const eagerRefreshThresholdMs = Duration.toMillis(eagerRefreshThreshold)
const isTokenFresh = (tokenExpiry: number | null, now: number) =>
tokenExpiry != null && tokenExpiry > now + eagerRefreshThresholdMs
const mapAccountServiceError =
(message = "Account service operation failed") =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountError, R> =>
effect.pipe(Effect.mapError((cause) => accountErrorFromCause(cause, message)))
const accountErrorFromCause = (cause: unknown, message: string): AccountError => {
if (cause instanceof AccountServiceError || cause instanceof AccountTransportError) {
return cause
}
if (HttpClientError.isHttpClientError(cause)) {
switch (cause.reason._tag) {
case "TransportError": {
return AccountTransportError.fromHttpClientError(cause.reason)
}
default: {
return new AccountServiceError({ message, cause })
}
}
}
return new AccountServiceError({ message, cause })
}
export interface Interface {
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
readonly activeOrg: () => Effect.Effect<Option.Option<ActiveOrg>, AccountError>
readonly list: () => Effect.Effect<Info[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
readonly config: (
accountID: AccountID,
orgID: OrgID,
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
readonly login: (url: string) => Effect.Effect<Login, AccountError>
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Account") {}
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
Service,
Effect.gen(function* () {
const repo = yield* AccountRepo
const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http)
const httpOk = HttpClient.filterStatusOk(http)
const httpReadOk = HttpClient.filterStatusOk(httpRead)
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => httpOk.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => http.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const refreshToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
const response = yield* executeEffectOk(
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
new TokenRefreshRequest({
grant_type: "refresh_token",
refresh_token: row.refresh_token,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
yield* repo.persistToken({
accountID: row.id,
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token,
expiry,
})
return parsed.access_token
})
const refreshTokenCache = yield* Cache.make<AccountID, AccessToken, AccountError>({
capacity: Number.POSITIVE_INFINITY,
timeToLive: Duration.zero,
lookup: Effect.fnUntraced(function* (accountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) {
return yield* Effect.fail(new AccountServiceError({ message: "Account not found during token refresh" }))
}
const account = maybeAccount.value
const now = yield* Clock.currentTimeMillis
if (isTokenFresh(account.token_expiry, now)) {
return account.access_token
}
return yield* refreshToken(account)
}),
})
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (isTokenFresh(row.token_expiry, now)) {
return row.access_token
}
return yield* Cache.get(refreshTokenCache, row.id)
})
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) return Option.none()
const account = maybeAccount.value
const accessToken = yield* resolveToken(account)
return Option.some({ account, accessToken })
})
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/user`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const token = Effect.fn("Account.token")((accountID: AccountID) =>
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
)
const activeOrg = Effect.fn("Account.activeOrg")(function* () {
const activeAccount = yield* repo.active()
if (Option.isNone(activeAccount)) return Option.none<ActiveOrg>()
const account = activeAccount.value
if (!account.active_org_id) return Option.none<ActiveOrg>()
const accountOrgs = yield* orgs(account.id)
const org = accountOrgs.find((item) => item.id === account.active_org_id)
if (!org) return Option.none<ActiveOrg>()
return Option.some({ account, org })
})
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
const accounts = yield* repo.list()
return yield* Effect.forEach(
accounts,
(account) =>
orgs(account.id).pipe(
Effect.catch(() => Effect.succeed([] as readonly Org[])),
Effect.map((orgs) => ({ account, orgs })),
),
{ concurrency: 3 },
)
})
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return []
const { account, accessToken } = resolved.value
return yield* fetchOrgs(account.url, accessToken)
})
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
const { account, accessToken } = resolved.value
const response = yield* executeRead(
HttpClientRequest.get(`${account.url}/api/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
),
)
if (response.status === 404) return Option.none()
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
mapAccountServiceError("Failed to decode response"),
)
return Option.some(parsed.config)
})
const login = Effect.fn("Account.login")(function* (server: string) {
const normalizedServer = normalizeServerUrl(server)
const response = yield* executeEffectOk(
HttpClientRequest.post(`${normalizedServer}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
return new Login({
code: parsed.device_code,
user: parsed.user_code,
url: `${normalizedServer}${parsed.verification_uri_complete}`,
server: normalizedServer,
expiry: parsed.expires_in,
interval: parsed.interval,
})
})
const poll = Effect.fn("Account.poll")(function* (input: Login) {
const response = yield* executeEffect(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
new DeviceTokenRequest({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: input.code,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
const accessToken = parsed.access_token
const user = fetchUser(input.server, accessToken)
const orgs = fetchOrgs(input.server, accessToken)
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
// TODO: When there are multiple orgs, let the user choose
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
const now = yield* Clock.currentTimeMillis
const expiry = now + Duration.toMillis(parsed.expires_in)
const refreshToken = parsed.refresh_token
yield* repo.persistAccount({
id: account.id,
email: account.email,
url: input.server,
accessToken,
refreshToken,
expiry,
orgID: firstOrgID,
})
return new PollSuccess({ email: account.email })
})
return Service.of({
active: repo.active,
activeOrg,
list: repo.list,
orgsByAccount,
remove: repo.remove,
use: repo.use,
orgs,
config,
token,
login,
poll,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))

View file

@ -1,37 +1,4 @@
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect"
import {
FetchHttpClient,
HttpClient,
HttpClientError,
HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import { normalizeServerUrl } from "./url"
import {
type AccountError,
AccessToken,
AccountID,
DeviceCode,
Info,
RefreshToken,
AccountServiceError,
AccountTransportError,
Login,
Org,
OrgID,
PollDenied,
PollError,
PollExpired,
PollPending,
type PollResult,
PollSlow,
PollSuccess,
UserCode,
} from "./schema"
export * as Account from "./account"
export {
AccountID,
type AccountError,
@ -52,405 +19,6 @@ export {
PollExpired,
PollDenied,
PollError,
PollResult,
type PollResult,
} from "./schema"
export type AccountOrgs = {
account: Info
orgs: readonly Org[]
}
export type ActiveOrg = {
account: Info
org: Org
}
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
config: Schema.Record(Schema.String, Schema.Json),
}) {}
const DurationFromSeconds = Schema.Number.pipe(
Schema.decodeTo(Schema.Duration, {
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
}),
)
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
access_token: AccessToken,
refresh_token: RefreshToken,
expires_in: DurationFromSeconds,
}) {}
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
device_code: DeviceCode,
user_code: UserCode,
verification_uri_complete: Schema.String,
expires_in: DurationFromSeconds,
interval: DurationFromSeconds,
}) {}
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
access_token: AccessToken,
refresh_token: RefreshToken,
token_type: Schema.Literal("Bearer"),
expires_in: DurationFromSeconds,
}) {}
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
error: Schema.String,
error_description: Schema.String,
}) {
toPollResult(): PollResult {
if (this.error === "authorization_pending") return new PollPending()
if (this.error === "slow_down") return new PollSlow()
if (this.error === "expired_token") return new PollExpired()
if (this.error === "access_denied") return new PollDenied()
return new PollError({ cause: this.error })
}
}
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
class User extends Schema.Class<User>("User")({
id: AccountID,
email: Schema.String,
}) {}
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
grant_type: Schema.String,
device_code: DeviceCode,
client_id: Schema.String,
}) {}
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
grant_type: Schema.String,
refresh_token: RefreshToken,
client_id: Schema.String,
}) {}
const clientId = "opencode-cli"
const eagerRefreshThreshold = Duration.minutes(5)
const eagerRefreshThresholdMs = Duration.toMillis(eagerRefreshThreshold)
const isTokenFresh = (tokenExpiry: number | null, now: number) =>
tokenExpiry != null && tokenExpiry > now + eagerRefreshThresholdMs
const mapAccountServiceError =
(message = "Account service operation failed") =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountError, R> =>
effect.pipe(Effect.mapError((cause) => accountErrorFromCause(cause, message)))
const accountErrorFromCause = (cause: unknown, message: string): AccountError => {
if (cause instanceof AccountServiceError || cause instanceof AccountTransportError) {
return cause
}
if (HttpClientError.isHttpClientError(cause)) {
switch (cause.reason._tag) {
case "TransportError": {
return AccountTransportError.fromHttpClientError(cause.reason)
}
default: {
return new AccountServiceError({ message, cause })
}
}
}
return new AccountServiceError({ message, cause })
}
export namespace Account {
export interface Interface {
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
readonly activeOrg: () => Effect.Effect<Option.Option<ActiveOrg>, AccountError>
readonly list: () => Effect.Effect<Info[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
readonly config: (
accountID: AccountID,
orgID: OrgID,
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
readonly login: (url: string) => Effect.Effect<Login, AccountError>
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Account") {}
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
Service,
Effect.gen(function* () {
const repo = yield* AccountRepo
const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http)
const httpOk = HttpClient.filterStatusOk(http)
const httpReadOk = HttpClient.filterStatusOk(httpRead)
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => httpOk.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => http.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const refreshToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
const response = yield* executeEffectOk(
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
new TokenRefreshRequest({
grant_type: "refresh_token",
refresh_token: row.refresh_token,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
yield* repo.persistToken({
accountID: row.id,
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token,
expiry,
})
return parsed.access_token
})
const refreshTokenCache = yield* Cache.make<AccountID, AccessToken, AccountError>({
capacity: Number.POSITIVE_INFINITY,
timeToLive: Duration.zero,
lookup: Effect.fnUntraced(function* (accountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) {
return yield* Effect.fail(new AccountServiceError({ message: "Account not found during token refresh" }))
}
const account = maybeAccount.value
const now = yield* Clock.currentTimeMillis
if (isTokenFresh(account.token_expiry, now)) {
return account.access_token
}
return yield* refreshToken(account)
}),
})
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (isTokenFresh(row.token_expiry, now)) {
return row.access_token
}
return yield* Cache.get(refreshTokenCache, row.id)
})
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) return Option.none()
const account = maybeAccount.value
const accessToken = yield* resolveToken(account)
return Option.some({ account, accessToken })
})
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/user`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const token = Effect.fn("Account.token")((accountID: AccountID) =>
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
)
const activeOrg = Effect.fn("Account.activeOrg")(function* () {
const activeAccount = yield* repo.active()
if (Option.isNone(activeAccount)) return Option.none<ActiveOrg>()
const account = activeAccount.value
if (!account.active_org_id) return Option.none<ActiveOrg>()
const accountOrgs = yield* orgs(account.id)
const org = accountOrgs.find((item) => item.id === account.active_org_id)
if (!org) return Option.none<ActiveOrg>()
return Option.some({ account, org })
})
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
const accounts = yield* repo.list()
return yield* Effect.forEach(
accounts,
(account) =>
orgs(account.id).pipe(
Effect.catch(() => Effect.succeed([] as readonly Org[])),
Effect.map((orgs) => ({ account, orgs })),
),
{ concurrency: 3 },
)
})
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return []
const { account, accessToken } = resolved.value
return yield* fetchOrgs(account.url, accessToken)
})
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
const { account, accessToken } = resolved.value
const response = yield* executeRead(
HttpClientRequest.get(`${account.url}/api/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
),
)
if (response.status === 404) return Option.none()
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
mapAccountServiceError("Failed to decode response"),
)
return Option.some(parsed.config)
})
const login = Effect.fn("Account.login")(function* (server: string) {
const normalizedServer = normalizeServerUrl(server)
const response = yield* executeEffectOk(
HttpClientRequest.post(`${normalizedServer}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
return new Login({
code: parsed.device_code,
user: parsed.user_code,
url: `${normalizedServer}${parsed.verification_uri_complete}`,
server: normalizedServer,
expiry: parsed.expires_in,
interval: parsed.interval,
})
})
const poll = Effect.fn("Account.poll")(function* (input: Login) {
const response = yield* executeEffect(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
new DeviceTokenRequest({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: input.code,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
const accessToken = parsed.access_token
const user = fetchUser(input.server, accessToken)
const orgs = fetchOrgs(input.server, accessToken)
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
// TODO: When there are multiple orgs, let the user choose
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
const now = yield* Clock.currentTimeMillis
const expiry = now + Duration.toMillis(parsed.expires_in)
const refreshToken = parsed.refresh_token
yield* repo.persistAccount({
id: account.id,
email: account.email,
url: input.server,
accessToken,
refreshToken,
expiry,
orgID: firstOrgID,
})
return new PollSuccess({ email: account.email })
})
return Service.of({
active: repo.active,
activeOrg,
list: repo.list,
orgsByAccount,
remove: repo.remove,
use: repo.use,
orgs,
config,
token,
login,
poll,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
}
export type { AccountOrgs, ActiveOrg } from "./account"

View file

@ -1,7 +1,7 @@
import { eq } from "drizzle-orm"
import { Effect, Layer, Option, Schema, Context } from "effect"
import { Database } from "@/storage/db"
import { Database } from "@/storage"
import { AccountStateTable, AccountTable } from "./account.sql"
import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema"
import { normalizeServerUrl } from "./url"

View file

@ -31,24 +31,25 @@ import {
type Usage,
} from "@agentclientprotocol/sdk"
import { Log } from "../util/log"
import { Log } from "../util"
import { pathToFileURL } from "url"
import { Filesystem } from "../util/filesystem"
import { Filesystem } from "../util"
import { Hash } from "@opencode-ai/shared/util/hash"
import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
import { Provider } from "../provider/provider"
import { Provider } from "../provider"
import { ModelID, ProviderID } from "../provider/schema"
import { Agent as AgentModule } from "../agent/agent"
import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "@/installation"
import { MessageV2 } from "@/session/message-v2"
import { Config } from "@/config/config"
import { Config } from "@/config"
import { Todo } from "@/session/todo"
import { z } from "zod"
import { LoadAPIKeyError } from "ai"
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff"
import { InstallationVersion } from "@/installation/version"
type ModeOption = { id: string; name: string; description?: string }
type ModelOption = { modelId: string; name: string }
@ -242,7 +243,7 @@ export namespace ACP {
const newContent = getNewContent(content, diff)
if (newContent) {
this.connection.writeTextFile({
void this.connection.writeTextFile({
sessionId: session.id,
path: filepath,
content: newContent,
@ -570,7 +571,7 @@ export namespace ACP {
authMethods: [authMethod],
agentInfo: {
name: "OpenCode",
version: Installation.VERSION,
version: InstallationVersion,
},
}
}
@ -1253,7 +1254,7 @@ export namespace ACP {
)
setTimeout(() => {
this.connection.sessionUpdate({
void this.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "available_commands_update",

View file

@ -1,6 +1,6 @@
import { RequestError, type McpServer } from "@agentclientprotocol/sdk"
import type { ACPSessionState } from "./types"
import { Log } from "@/util/log"
import { Log } from "@/util"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
const log = Log.create({ service: "acp-session-manager" })

View file

@ -1,12 +1,12 @@
import { Config } from "../config/config"
import { Config } from "../config"
import z from "zod"
import { Provider } from "../provider/provider"
import { Provider } from "../provider"
import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai"
import { Instance } from "../project/instance"
import { Truncate } from "../tool/truncate"
import { Truncate } from "../tool"
import { Auth } from "../auth"
import { ProviderTransform } from "../provider/transform"
import { ProviderTransform } from "../provider"
import PROMPT_GENERATE from "./generate.txt"
import PROMPT_COMPACTION from "./prompt/compaction.txt"
@ -20,7 +20,7 @@ import path from "path"
import { Plugin } from "@/plugin"
import { Skill } from "../skill"
import { Effect, Context, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
@ -80,7 +80,7 @@ export namespace Agent {
const provider = yield* Provider.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Agent.state")(function* (ctx) {
Effect.fn("Agent.state")(function* (_ctx) {
const cfg = yield* config.get()
const skillDirs = yield* skill.dirs()
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]

View file

@ -0,0 +1,89 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { zod } from "@/util/effect-zod"
import { Global } from "../global"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: Schema.Number,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
export class Api extends Schema.Class<Api>("ApiAuth")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
type: Schema.Literal("wellknown"),
key: Schema.String,
token: Schema.String,
}) {}
const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" })
export const Info = Object.assign(_Info, { zod: zod(_Info) })
export type Info = Schema.Schema.Type<typeof _Info>
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export interface Interface {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
readonly remove: (key: string) => Effect.Effect<void, AuthError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(function* () {
const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
})
const get = Effect.fn("Auth.get")(function* (providerID: string) {
return (yield* all())[providerID]
})
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* fsys
.writeJson(file, { ...data, [norm]: info }, 0o600)
.pipe(Effect.mapError(fail("Failed to write auth data")))
})
const remove = Effect.fn("Auth.remove")(function* (key: string) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
delete data[key]
delete data[norm]
yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data")))
})
return Service.of({ get, all, set, remove })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))

View file

@ -1,91 +1,2 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { zod } from "@/util/effect-zod"
import { Global } from "../global"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new Auth.AuthError({ message, cause })
export namespace Auth {
export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: Schema.Number,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
export class Api extends Schema.Class<Api>("ApiAuth")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
type: Schema.Literal("wellknown"),
key: Schema.String,
token: Schema.String,
}) {}
const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" })
export const Info = Object.assign(_Info, { zod: zod(_Info) })
export type Info = Schema.Schema.Type<typeof _Info>
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export interface Interface {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
readonly remove: (key: string) => Effect.Effect<void, AuthError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(function* () {
const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
})
const get = Effect.fn("Auth.get")(function* (providerID: string) {
return (yield* all())[providerID]
})
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* fsys
.writeJson(file, { ...data, [norm]: info }, 0o600)
.pipe(Effect.mapError(fail("Failed to write auth data")))
})
const remove = Effect.fn("Auth.remove")(function* (key: string) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
delete data[key]
delete data[norm]
yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data")))
})
return Service.of({ get, all, set, remove })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
}
export * as Auth from "./auth"
export { OAUTH_DUMMY_KEY } from "./auth"

View file

@ -25,7 +25,7 @@ export namespace BusEvent {
properties: def.properties,
})
.meta({
ref: "Event" + "." + def.type,
ref: `Event.${def.type}`,
})
})
.toArray()

View file

@ -0,0 +1,191 @@
import z from "zod"
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
import { EffectBridge } from "@/effect"
import { Log } from "../util"
import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
import { InstanceState } from "@/effect"
import { makeRuntime } from "@/effect/run-service"
const log = Log.create({ service: "bus" })
export const InstanceDisposed = BusEvent.define(
"server.instance.disposed",
z.object({
directory: z.string(),
}),
)
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
type: D["type"]
properties: z.infer<D["properties"]>
}
type State = {
wildcard: PubSub.PubSub<Payload>
typed: Map<string, PubSub.PubSub<Payload>>
}
export interface Interface {
readonly publish: <D extends BusEvent.Definition>(
def: D,
properties: z.output<D["properties"]>,
) => Effect.Effect<void>
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
readonly subscribeAll: () => Stream.Stream<Payload>
readonly subscribeCallback: <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) => Effect.Effect<() => void>
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("Bus.state")(function* (ctx) {
const wildcard = yield* PubSub.unbounded<Payload>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
// Publish InstanceDisposed before shutting down so subscribers see it
yield* PubSub.publish(wildcard, {
type: InstanceDisposed.type,
properties: { directory: ctx.directory },
})
yield* PubSub.shutdown(wildcard)
for (const ps of typed.values()) {
yield* PubSub.shutdown(ps)
}
}),
)
return { wildcard, typed }
}),
)
function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
return Effect.gen(function* () {
let ps = state.typed.get(def.type)
if (!ps) {
ps = yield* PubSub.unbounded<Payload>()
state.typed.set(def.type, ps)
}
return ps as unknown as PubSub.PubSub<Payload<D>>
})
}
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const payload: Payload = { type: def.type, properties }
log.info("publishing", { type: def.type })
const ps = s.typed.get(def.type)
if (ps) yield* PubSub.publish(ps, payload)
yield* PubSub.publish(s.wildcard, payload)
const dir = yield* InstanceState.directory
const context = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
GlobalBus.emit("event", {
directory: dir,
project: context.project.id,
workspace,
payload,
})
})
}
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
log.info("subscribing", { type: def.type })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return Stream.fromPubSub(ps)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
}
function subscribeAll(): Stream.Stream<Payload> {
log.info("subscribing", { type: "*" })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
return Stream.fromPubSub(s.wildcard)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
}
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
return Effect.gen(function* () {
log.info("subscribing", { type })
const bridge = yield* EffectBridge.make()
const scope = yield* Scope.make()
const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
yield* Scope.provide(scope)(
Stream.fromSubscription(subscription).pipe(
Stream.runForEach((msg) =>
Effect.tryPromise({
try: () => Promise.resolve().then(() => callback(msg)),
catch: (cause) => {
log.error("subscriber failed", { type, cause })
},
}).pipe(Effect.ignore),
),
Effect.forkScoped,
),
)
return () => {
log.info("unsubscribing", { type })
bridge.fork(Scope.close(scope, Exit.void))
}
})
}
const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) {
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return yield* on(ps, def.type, callback)
})
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
const s = yield* InstanceState.get(state)
return yield* on(s.wildcard, "*", callback)
})
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
}),
)
export const defaultLayer = layer
const { runPromise, runSync } = makeRuntime(Service, layer)
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return runPromise((svc) => svc.publish(def, properties))
}
export function subscribe<D extends BusEvent.Definition>(
def: D,
callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
) {
return runSync((svc) => svc.subscribeCallback(def, callback))
}
export function subscribeAll(callback: (event: any) => unknown) {
return runSync((svc) => svc.subscribeAllCallback(callback))
}

View file

@ -1,12 +1,12 @@
import { EventEmitter } from "events"
export type GlobalEvent = {
directory?: string
project?: string
workspace?: string
payload: any
}
export const GlobalBus = new EventEmitter<{
event: [
{
directory?: string
project?: string
workspace?: string
payload: any
},
]
event: [GlobalEvent]
}>()

View file

@ -1,194 +1 @@
import z from "zod"
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { Log } from "../util/log"
import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
export namespace Bus {
const log = Log.create({ service: "bus" })
export const InstanceDisposed = BusEvent.define(
"server.instance.disposed",
z.object({
directory: z.string(),
}),
)
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
type: D["type"]
properties: z.infer<D["properties"]>
}
type State = {
wildcard: PubSub.PubSub<Payload>
typed: Map<string, PubSub.PubSub<Payload>>
}
export interface Interface {
readonly publish: <D extends BusEvent.Definition>(
def: D,
properties: z.output<D["properties"]>,
) => Effect.Effect<void>
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
readonly subscribeAll: () => Stream.Stream<Payload>
readonly subscribeCallback: <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) => Effect.Effect<() => void>
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("Bus.state")(function* (ctx) {
const wildcard = yield* PubSub.unbounded<Payload>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
// Publish InstanceDisposed before shutting down so subscribers see it
yield* PubSub.publish(wildcard, {
type: InstanceDisposed.type,
properties: { directory: ctx.directory },
})
yield* PubSub.shutdown(wildcard)
for (const ps of typed.values()) {
yield* PubSub.shutdown(ps)
}
}),
)
return { wildcard, typed }
}),
)
function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
return Effect.gen(function* () {
let ps = state.typed.get(def.type)
if (!ps) {
ps = yield* PubSub.unbounded<Payload>()
state.typed.set(def.type, ps)
}
return ps as unknown as PubSub.PubSub<Payload<D>>
})
}
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const payload: Payload = { type: def.type, properties }
log.info("publishing", { type: def.type })
const ps = s.typed.get(def.type)
if (ps) yield* PubSub.publish(ps, payload)
yield* PubSub.publish(s.wildcard, payload)
const dir = yield* InstanceState.directory
const context = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
GlobalBus.emit("event", {
directory: dir,
project: context.project.id,
workspace,
payload,
})
})
}
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
log.info("subscribing", { type: def.type })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return Stream.fromPubSub(ps)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
}
function subscribeAll(): Stream.Stream<Payload> {
log.info("subscribing", { type: "*" })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
return Stream.fromPubSub(s.wildcard)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
}
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
return Effect.gen(function* () {
log.info("subscribing", { type })
const bridge = yield* EffectBridge.make()
const scope = yield* Scope.make()
const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
yield* Scope.provide(scope)(
Stream.fromSubscription(subscription).pipe(
Stream.runForEach((msg) =>
Effect.tryPromise({
try: () => Promise.resolve().then(() => callback(msg)),
catch: (cause) => {
log.error("subscriber failed", { type, cause })
},
}).pipe(Effect.ignore),
),
Effect.forkScoped,
),
)
return () => {
log.info("unsubscribing", { type })
bridge.fork(Scope.close(scope, Exit.void))
}
})
}
const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) {
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return yield* on(ps, def.type, callback)
})
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
const s = yield* InstanceState.get(state)
return yield* on(s.wildcard, "*", callback)
})
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
}),
)
export const defaultLayer = layer
const { runPromise, runSync } = makeRuntime(Service, layer)
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return runPromise((svc) => svc.publish(def, properties))
}
export function subscribe<D extends BusEvent.Definition>(
def: D,
callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
) {
return runSync((svc) => svc.subscribeCallback(def, callback))
}
export function subscribeAll(callback: (event: any) => unknown) {
return runSync((svc) => svc.subscribeAllCallback(callback))
}
}
export * as Bus from "./bus"

View file

@ -1,4 +1,4 @@
import { Log } from "@/util/log"
import { Log } from "@/util"
import { bootstrap } from "../bootstrap"
import { cmd } from "./cmd"
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"

View file

@ -4,10 +4,10 @@ import { AppRuntime } from "@/effect/app-runtime"
import { UI } from "../ui"
import { Global } from "../../global"
import { Agent } from "../../agent/agent"
import { Provider } from "../../provider/provider"
import { Provider } from "../../provider"
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../../util/filesystem"
import { Filesystem } from "../../util"
import matter from "gray-matter"
import { Instance } from "../../project/instance"
import { EOL } from "os"

View file

@ -1,11 +1,11 @@
import type { Argv } from "yargs"
import { spawn } from "child_process"
import { Database } from "../../storage/db"
import { Database } from "../../storage"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { Database as BunDatabase } from "bun:sqlite"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { JsonMigration } from "../../storage/json-migration"
import { JsonMigration } from "../../storage"
import { EOL } from "os"
import { errorMessage } from "../../util/error"

View file

@ -2,11 +2,11 @@ import { EOL } from "os"
import { basename } from "path"
import { Effect } from "effect"
import { Agent } from "../../../agent/agent"
import { Provider } from "../../../provider/provider"
import { Provider } from "../../../provider"
import { Session } from "../../../session"
import type { MessageV2 } from "../../../session/message-v2"
import { MessageID, PartID } from "../../../session/schema"
import { ToolRegistry } from "../../../tool/registry"
import { ToolRegistry } from "../../../tool"
import { Instance } from "../../../project/instance"
import { Permission } from "../../../permission"
import { iife } from "../../../util/iife"
@ -111,6 +111,7 @@ function parseToolParams(input?: string) {
} catch (evalError) {
throw new Error(
`Failed to parse --params. Use JSON or a JS object literal. JSON error: ${jsonError}. Eval error: ${evalError}.`,
{ cause: evalError },
)
}
}

View file

@ -1,5 +1,5 @@
import { EOL } from "os"
import { Config } from "../../../config/config"
import { Config } from "../../../config"
import { AppRuntime } from "@/effect/app-runtime"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"

View file

@ -1,10 +1,9 @@
import { EOL } from "os"
import { Effect } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { File } from "../../../file"
import { Ripgrep } from "@/file/ripgrep"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { Ripgrep } from "@/file/ripgrep"
const FileSearchCommand = cmd({
command: "search <query>",
@ -17,11 +16,7 @@ const FileSearchCommand = cmd({
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const results = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.search({ query: args.query }))
}),
)
const results = await AppRuntime.runPromise(File.Service.use((svc) => svc.search({ query: args.query })))
process.stdout.write(results.join(EOL) + EOL)
})
},
@ -38,11 +33,7 @@ const FileReadCommand = cmd({
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const content = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.read(args.path))
}),
)
const content = await AppRuntime.runPromise(File.Service.use((svc) => svc.read(args.path)))
process.stdout.write(JSON.stringify(content, null, 2) + EOL)
})
},
@ -54,11 +45,7 @@ const FileStatusCommand = cmd({
builder: (yargs) => yargs,
async handler() {
await bootstrap(process.cwd(), async () => {
const status = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.status())
}),
)
const status = await AppRuntime.runPromise(File.Service.use((svc) => svc.status()))
process.stdout.write(JSON.stringify(status, null, 2) + EOL)
})
},
@ -75,11 +62,7 @@ const FileListCommand = cmd({
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const files = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.list(args.path))
}),
)
const files = await AppRuntime.runPromise(File.Service.use((svc) => svc.list(args.path)))
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
})
},
@ -95,8 +78,10 @@ const FileTreeCommand = cmd({
default: process.cwd(),
}),
async handler(args) {
const files = await Ripgrep.tree({ cwd: args.dir, limit: 200 })
console.log(JSON.stringify(files, null, 2))
await bootstrap(process.cwd(), async () => {
const tree = await AppRuntime.runPromise(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 })))
console.log(JSON.stringify(tree, null, 2))
})
},
})

View file

@ -3,9 +3,8 @@ import { AppRuntime } from "../../../effect/app-runtime"
import { Effect } from "effect"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { Log } from "../../../util/log"
import { Log } from "../../../util"
import { EOL } from "os"
import { setTimeout as sleep } from "node:timers/promises"
export const LSPCommand = cmd({
command: "lsp",

View file

@ -1,4 +1,5 @@
import { EOL } from "os"
import { Effect, Stream } from "effect"
import { AppRuntime } from "../../../effect/app-runtime"
import { Ripgrep } from "../../../file/ripgrep"
import { Instance } from "../../../project/instance"
@ -21,7 +22,10 @@ const TreeCommand = cmd({
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
process.stdout.write((await Ripgrep.tree({ cwd: Instance.directory, limit: args.limit })) + EOL)
const tree = await AppRuntime.runPromise(
Ripgrep.Service.use((svc) => svc.tree({ cwd: Instance.directory, limit: args.limit })),
)
process.stdout.write(tree + EOL)
})
},
})
@ -45,14 +49,21 @@ const FilesCommand = cmd({
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const files: string[] = []
for await (const file of await Ripgrep.files({
cwd: Instance.directory,
glob: args.glob ? [args.glob] : undefined,
})) {
files.push(file)
if (args.limit && files.length >= args.limit) break
}
const files = await AppRuntime.runPromise(
Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg
.files({
cwd: Instance.directory,
glob: args.glob ? [args.glob] : undefined,
})
.pipe(
Stream.take(args.limit ?? Infinity),
Stream.runCollect,
Effect.map((c) => [...c]),
)
}),
)
process.stdout.write(files.join(EOL) + EOL)
})
},

View file

@ -1,6 +1,6 @@
import { EOL } from "os"
import { Project } from "../../../project/project"
import { Log } from "../../../util/log"
import { Project } from "../../../project"
import { Log } from "../../../util"
import { cmd } from "../cmd"
export const ScrapCommand = cmd({

View file

@ -297,7 +297,7 @@ export const ExportCommand = cmd({
process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2))
process.stdout.write(EOL)
} catch (error) {
} catch {
UI.error(`Session not found: ${sessionID!}`)
process.exit(1)
}

View file

@ -1,6 +1,6 @@
import path from "path"
import { exec } from "child_process"
import { Filesystem } from "../../util/filesystem"
import { Filesystem } from "../../util"
import * as prompts from "@clack/prompts"
import { map, pipe, sortBy, values } from "remeda"
import { Octokit } from "@octokit/rest"
@ -18,21 +18,21 @@ import type {
} from "@octokit/webhooks-types"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { ModelsDev } from "../../provider/models"
import { ModelsDev } from "../../provider"
import { Instance } from "@/project/instance"
import { bootstrap } from "../bootstrap"
import { SessionShare } from "@/share/session"
import { SessionShare } from "@/share"
import { Session } from "../../session"
import type { SessionID } from "../../session/schema"
import { MessageID, PartID } from "../../session/schema"
import { Provider } from "../../provider/provider"
import { Provider } from "../../provider"
import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "@/session/prompt"
import { AppRuntime } from "@/effect/app-runtime"
import { Git } from "@/git"
import { setTimeout as sleep } from "node:timers/promises"
import { Process } from "@/util/process"
import { Process } from "@/util"
import { Effect } from "effect"
type GitHubAuthor = {
@ -362,7 +362,7 @@ export const GithubInstallCommand = cmd({
retries++
await sleep(1000)
} while (true)
} while (true) // oxlint-disable-line no-constant-condition
s.stop("Installed GitHub app")
@ -931,7 +931,7 @@ export const GithubRunCommand = cmd({
async function summarize(response: string) {
try {
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
} catch (e) {
} catch {
const title = issueEvent
? issueEvent.issue.title
: (payload as PullRequestReviewCommentEvent).pull_request.title
@ -1031,6 +1031,7 @@ export const GithubRunCommand = cmd({
console.error("Failed to get OIDC token:", error instanceof Error ? error.message : error)
throw new Error(
"Could not fetch an OIDC token. Make sure to add `id-token: write` to your workflow permissions.",
{ cause: error },
)
}
}
@ -1221,7 +1222,7 @@ export const GithubRunCommand = cmd({
console.log(` permission: ${permission}`)
} catch (error) {
console.error(`Failed to check permissions: ${error}`)
throw new Error(`Failed to check permissions for user ${actor}: ${error}`)
throw new Error(`Failed to check permissions for user ${actor}: ${error}`, { cause: error })
}
if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`)

View file

@ -4,12 +4,12 @@ import { Session } from "../../session"
import { MessageV2 } from "../../session/message-v2"
import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap"
import { Database } from "../../storage/db"
import { Database } from "../../storage"
import { SessionTable, MessageTable, PartTable } from "../../session/session.sql"
import { Instance } from "../../project/instance"
import { ShareNext } from "../../share/share-next"
import { ShareNext } from "../../share"
import { EOL } from "os"
import { Filesystem } from "../../util/filesystem"
import { Filesystem } from "../../util"
import { AppRuntime } from "@/effect/app-runtime"
/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */

View file

@ -7,13 +7,14 @@ import { UI } from "../ui"
import { MCP } from "../../mcp"
import { McpAuth } from "../../mcp/auth"
import { McpOAuthProvider } from "../../mcp/oauth-provider"
import { Config } from "../../config/config"
import { Config } from "../../config"
import { Instance } from "../../project/instance"
import { Installation } from "../../installation"
import { InstallationVersion } from "../../installation/version"
import path from "path"
import { Global } from "../../global"
import { modify, applyEdits } from "jsonc-parser"
import { Filesystem } from "../../util/filesystem"
import { Filesystem } from "../../util"
import { Bus } from "../../bus"
import { AppRuntime } from "../../effect/app-runtime"
import { Effect } from "effect"
@ -697,7 +698,7 @@ export const McpDebugCommand = cmd({
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "opencode-debug", version: Installation.VERSION },
clientInfo: { name: "opencode-debug", version: InstallationVersion },
},
id: 1,
}),
@ -746,7 +747,7 @@ export const McpDebugCommand = cmd({
try {
const client = new Client({
name: "opencode-debug",
version: Installation.VERSION,
version: InstallationVersion,
})
await client.connect(transport)
prompts.log.success("Connection successful (already authenticated)")

View file

@ -1,8 +1,8 @@
import type { Argv } from "yargs"
import { Instance } from "../../project/instance"
import { Provider } from "../../provider/provider"
import { Provider } from "../../provider"
import { ProviderID } from "../../provider/schema"
import { ModelsDev } from "../../provider/models"
import { ModelsDev } from "../../provider"
import { cmd } from "./cmd"
import { UI } from "../ui"
import { EOL } from "os"

View file

@ -1,14 +1,14 @@
import { intro, log, outro, spinner } from "@clack/prompts"
import type { Argv } from "yargs"
import { ConfigPaths } from "../../config/paths"
import { ConfigPaths } from "../../config"
import { Global } from "../../global"
import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install"
import { resolvePluginTarget } from "../../plugin/shared"
import { Instance } from "../../project/instance"
import { errorMessage } from "../../util/error"
import { Filesystem } from "../../util/filesystem"
import { Process } from "../../util/process"
import { Filesystem } from "../../util"
import { Process } from "../../util"
import { UI } from "../ui"
import { cmd } from "./cmd"

View file

@ -3,7 +3,7 @@ import { cmd } from "./cmd"
import { AppRuntime } from "@/effect/app-runtime"
import { Git } from "@/git"
import { Instance } from "@/project/instance"
import { Process } from "@/util/process"
import { Process } from "@/util"
export const PrCommand = cmd({
command: "pr <number>",

View file

@ -3,16 +3,16 @@ import { AppRuntime } from "../../effect/app-runtime"
import { cmd } from "./cmd"
import * as prompts from "@clack/prompts"
import { UI } from "../ui"
import { ModelsDev } from "../../provider/models"
import { ModelsDev } from "../../provider"
import { map, pipe, sortBy, values } from "remeda"
import path from "path"
import os from "os"
import { Config } from "../../config/config"
import { Config } from "../../config"
import { Global } from "../../global"
import { Plugin } from "../../plugin"
import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process"
import { Process } from "../../util"
import { text } from "node:stream/consumers"
import { Effect } from "effect"

View file

@ -6,13 +6,13 @@ import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
import { bootstrap } from "../bootstrap"
import { EOL } from "os"
import { Filesystem } from "../../util/filesystem"
import { Filesystem } from "../../util"
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server"
import { Provider } from "../../provider/provider"
import { Provider } from "../../provider"
import { Agent } from "../../agent/agent"
import { Permission } from "../../permission"
import { Tool } from "../../tool/tool"
import { Tool } from "../../tool"
import { GlobTool } from "../../tool/glob"
import { GrepTool } from "../../tool/grep"
import { ReadTool } from "../../tool/read"
@ -25,7 +25,7 @@ import { TaskTool } from "../../tool/task"
import { SkillTool } from "../../tool/skill"
import { BashTool } from "../../tool/bash"
import { TodoWriteTool } from "../../tool/todo"
import { Locale } from "../../util/locale"
import { Locale } from "../../util"
import { AppRuntime } from "@/effect/app-runtime"
type ToolProps<T> = {

View file

@ -4,10 +4,10 @@ import { Session } from "../../session"
import { SessionID } from "../../session/schema"
import { bootstrap } from "../bootstrap"
import { UI } from "../ui"
import { Locale } from "../../util/locale"
import { Locale } from "../../util"
import { Flag } from "../../flag/flag"
import { Filesystem } from "../../util/filesystem"
import { Process } from "../../util/process"
import { Filesystem } from "../../util"
import { Process } from "../../util"
import { EOL } from "os"
import path from "path"
import { which } from "../../util/which"

View file

@ -2,9 +2,9 @@ import type { Argv } from "yargs"
import { cmd } from "./cmd"
import { Session } from "../../session"
import { bootstrap } from "../bootstrap"
import { Database } from "../../storage/db"
import { Database } from "../../storage"
import { SessionTable } from "../../session/session.sql"
import { Project } from "../../project/project"
import { Project } from "../../project"
import { Instance } from "../../project/instance"
import { AppRuntime } from "@/effect/app-runtime"

View file

@ -1,7 +1,7 @@
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { Selection } from "@tui/util/selection"
import { Terminal } from "@tui/util/terminal"
import * as Clipboard from "@tui/util/clipboard"
import * as Selection from "@tui/util/selection"
import * as Terminal from "@tui/util/terminal"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import {
@ -23,7 +23,7 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { ErrorComponent } from "@tui/component/error-component"
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
import { ProjectProvider, useProject } from "@tui/context/project"
import { ProjectProvider } from "@tui/context/project"
import { useEvent } from "@tui/context/event"
import { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading"
@ -53,12 +53,12 @@ import { ExitProvider, useExit } from "./context/exit"
import { Session as SessionApi } from "@/session"
import { TuiEvent } from "./event"
import { KVProvider, useKV } from "./context/kv"
import { Provider } from "@/provider/provider"
import { Provider } from "@/provider"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
import { FormatError, FormatUnknownError } from "@/cli/error"
@ -116,6 +116,7 @@ export function tui(input: {
events?: EventSource
}) {
// promise to prevent immediate exit
// oxlint-disable-next-line no-async-promise-executor -- intentional: async executor used for sequential setup before resolve
return new Promise<void>(async (resolve) => {
const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput()
@ -235,7 +236,10 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
renderer,
})
const [ready, setReady] = createSignal(false)
TuiPluginRuntime.init(api)
TuiPluginRuntime.init({
api,
config: tuiConfig,
})
.catch((error) => {
console.error("Failed to load TUI plugins", error)
})
@ -350,7 +354,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
if (match) {
continued = true
if (args.fork) {
sdk.client.session.fork({ sessionID: match }).then((result) => {
void sdk.client.session.fork({ sessionID: match }).then((result) => {
if (result.data?.id) {
route.navigate({ type: "session", sessionID: result.data.id })
} else {
@ -370,7 +374,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
createEffect(() => {
if (forked || sync.status !== "complete" || !args.sessionID || !args.fork) return
forked = true
sdk.client.session.fork({ sessionID: args.sessionID }).then((result) => {
void sdk.client.session.fork({ sessionID: args.sessionID }).then((result) => {
if (result.data?.id) {
route.navigate({ type: "session", sessionID: result.data.id })
} else {
@ -829,7 +833,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
`Successfully updated to OpenCode v${result.data.version}. Please restart the application.`,
)
exit()
void exit()
})
const plugin = createMemo(() => {

View file

@ -2,9 +2,7 @@ import { cmd } from "../cmd"
import { UI } from "@/cli/ui"
import { tui } from "./app"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui"
import { Instance } from "@/project/instance"
import { existsSync } from "fs"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
export const AttachCommand = cmd({
command: "attach <url>",
@ -66,10 +64,7 @@ export const AttachCommand = cmd({
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
return { Authorization: auth }
})()
const config = await Instance.provide({
directory: directory && existsSync(directory) ? directory : process.cwd(),
fn: () => TuiConfig.get(),
})
const config = await TuiConfig.get()
await tui({
url: args.url,
config,

View file

@ -20,7 +20,7 @@ export function DialogAgent() {
return (
<DialogSelect
title="Select agent"
current={local.agent.current().name}
current={local.agent.current()?.name}
options={options()}
onSelect={(option) => {
local.agent.set(option.value)

View file

@ -4,7 +4,7 @@ import { useSync } from "@tui/context/sync"
import { map, pipe, entries, sortBy } from "remeda"
import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "@tui/ui/dialog-select"
import { useTheme } from "../context/theme"
import { Keybind } from "@/util/keybind"
import { Keybind } from "@/util"
import { TextAttributes } from "@opentui/core"
import { useSDK } from "@tui/context/sdk"
@ -78,7 +78,7 @@ export function DialogMcp() {
title="MCPs"
options={options()}
keybind={keybinds()}
onSelect={(option) => {
onSelect={(_option) => {
// Don't close on select, only on escape
}}
/>

View file

@ -11,7 +11,7 @@ import { TextAttributes } from "@opentui/core"
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2"
import { DialogModel } from "./dialog-model"
import { useKeyboard } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import * as Clipboard from "@tui/util/clipboard"
import { useToast } from "../ui/toast"
import { isConsoleManagedProvider } from "@tui/util/provider-origin"

View file

@ -3,14 +3,14 @@ import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import { Locale } from "@/util/locale"
import { Locale } from "@/util"
import { useProject } from "@tui/context/project"
import { useKeybind } from "../context/keybind"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { Flag } from "@/flag/flag"
import { DialogSessionRename } from "./dialog-session-rename"
import { Keybind } from "@/util/keybind"
import { Keybind } from "@/util"
import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create"
@ -145,7 +145,7 @@ export function DialogSessionList() {
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
sdk.client.session.delete({
void sdk.client.session.delete({
sessionID: option.value,
})
setToDelete(undefined)

View file

@ -19,7 +19,7 @@ export function DialogSessionRename(props: DialogSessionRenameProps) {
title="Rename Session"
value={session()?.title}
onConfirm={(value) => {
sdk.client.session.update({
void sdk.client.session.update({
sessionID: props.session,
title: value,
})

View file

@ -1,7 +1,7 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { createMemo, createSignal } from "solid-js"
import { Locale } from "@/util/locale"
import { Locale } from "@/util"
import { useTheme } from "../context/theme"
import { useKeybind } from "../context/keybind"
import { usePromptStash, type StashEntry } from "./prompt/stash"

View file

@ -1,7 +1,7 @@
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { onCleanup, onMount } from "solid-js"
import { onCleanup } from "solid-js"
export function DialogThemeList() {
const theme = useTheme()

View file

@ -1,8 +1,8 @@
import { TextAttributes } from "@opentui/core"
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import * as Clipboard from "@tui/util/clipboard"
import { createSignal } from "solid-js"
import { Installation } from "@/installation"
import { InstallationVersion } from "@/installation/version"
import { win32FlushInputBuffer } from "../win32"
import { getScrollAcceleration } from "../util/scroll"
@ -26,7 +26,7 @@ export function ErrorComponent(props: {
useKeyboard((evt) => {
if (evt.ctrl && evt.name === "c") {
handleExit()
void handleExit()
}
})
const [copied, setCopied] = createSignal(false)
@ -53,10 +53,10 @@ export function ErrorComponent(props: {
)
}
issueURL.searchParams.set("opencode-version", Installation.VERSION)
issueURL.searchParams.set("opencode-version", InstallationVersion)
const copyIssueURL = () => {
Clipboard.copy(issueURL.toString()).then(() => {
void Clipboard.copy(issueURL.toString()).then(() => {
setCopied(true)
})
}

View file

@ -1,7 +1,7 @@
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
import { useTheme, tint } from "@tui/context/theme"
import { Sound } from "@tui/util/sound"
import * as Sound from "@tui/util/sound"
import { logo } from "@/cli/logo"
// Shadow markers (rendered chars in parens):
@ -520,7 +520,7 @@ export function Logo() {
const shadow = tint(theme.background, ink, 0.25)
const attrs = bold ? TextAttributes.BOLD : undefined
return [...line].map((char, i) => {
return Array.from(line).map((char, i) => {
const h = field(off + i, y, frame)
const n = wave(off + i, y, frame, lit(char)) + h
const s = wave(off + i, y, dusk, false) + h

View file

@ -12,7 +12,7 @@ import { useTheme, selectedForeground } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "@/util/locale"
import { Locale } from "@/util"
import type { PromptInfo } from "./history"
import { useFrecency } from "./frecency"
@ -111,7 +111,7 @@ export function Autocomplete(props: {
const position = createMemo(() => {
if (!store.visible) return { x: 0, y: 0, width: 0 }
const dims = dimensions()
dimensions()
positionTick()
const anchor = props.anchor()
const parent = anchor.parent

View file

@ -1,6 +1,6 @@
import path from "path"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Filesystem } from "@/util"
import { onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "../../context/helper"

View file

@ -1,6 +1,6 @@
import path from "path"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Filesystem } from "@/util"
import { onMount } from "solid-js"
import { createStore, produce, unwrap } from "solid-js/store"
import { createSimpleContext } from "../../context/helper"

View file

@ -1,9 +1,9 @@
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, t, dim, fg } from "@opentui/core"
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
import "opentui-spinner/solid"
import path from "path"
import { fileURLToPath } from "url"
import { Filesystem } from "@/util/filesystem"
import { Filesystem } from "@/util"
import { useLocal } from "@tui/context/local"
import { useTheme } from "@tui/context/theme"
import { EmptyBorder, SplitBorder } from "@tui/component/border"
@ -21,13 +21,13 @@ import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useCommandDialog } from "../dialog-command"
import { useRenderer, type JSX } from "@opentui/solid"
import { Editor } from "@tui/util/editor"
import * as Editor from "@tui/util/editor"
import { useExit } from "../../context/exit"
import { Clipboard } from "../../util/clipboard"
import * as Clipboard from "../../util/clipboard"
import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2"
import { TuiEvent } from "../../event"
import { iife } from "@/util/iife"
import { Locale } from "@/util/locale"
import { Locale } from "@/util"
import { formatDuration } from "@/util/format"
import { createColors, createFrames } from "../../ui/spinner.ts"
import { useDialog } from "@tui/ui/dialog"
@ -235,7 +235,7 @@ export function Prompt(props: PromptProps) {
hidden: true,
onSelect: (dialog) => {
if (!input.focused) return
submit()
void submit()
dialog.clear()
},
},
@ -280,7 +280,7 @@ export function Prompt(props: PromptProps) {
}, 5000)
if (store.interrupt >= 2) {
sdk.client.session.abort({
void sdk.client.session.abort({
sessionID: props.sessionID,
})
setStore("interrupt", 0)
@ -429,7 +429,7 @@ export function Prompt(props: PromptProps) {
setStore("extmarkToPartIndex", new Map())
},
submit() {
submit()
void submit()
},
}
@ -602,14 +602,16 @@ export function Prompt(props: PromptProps) {
if (props.disabled) return
if (autocomplete?.visible) return
if (!store.prompt.input) return
const agent = local.agent.current()
if (!agent) return
const trimmed = store.prompt.input.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
exit()
void exit()
return
}
const selectedModel = local.model.current()
if (!selectedModel) {
promptModelWarning()
void promptModelWarning()
return
}
@ -660,9 +662,9 @@ export function Prompt(props: PromptProps) {
const variant = local.model.variant.current()
if (store.mode === "shell") {
sdk.client.session.shell({
void sdk.client.session.shell({
sessionID,
agent: local.agent.current().name,
agent: agent.name,
model: {
providerID: selectedModel.providerID,
modelID: selectedModel.modelID,
@ -685,11 +687,11 @@ export function Prompt(props: PromptProps) {
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
sdk.client.session.command({
void sdk.client.session.command({
sessionID,
command: command.slice(1),
arguments: args,
agent: local.agent.current().name,
agent: agent.name,
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
messageID,
variant,
@ -706,7 +708,7 @@ export function Prompt(props: PromptProps) {
sessionID,
...selectedModel,
messageID,
agent: local.agent.current().name,
agent: agent.name,
model: selectedModel,
variant,
parts: [
@ -829,7 +831,9 @@ export function Prompt(props: PromptProps) {
const highlight = createMemo(() => {
if (keybind.leader) return theme.border
if (store.mode === "shell") return theme.primary
return local.agent.color(local.agent.current().name)
const agent = local.agent.current()
if (!agent) return theme.border
return local.agent.color(agent.name)
})
const showVariant = createMemo(() => {
@ -851,7 +855,8 @@ export function Prompt(props: PromptProps) {
})
const spinnerDef = createMemo(() => {
const color = local.agent.color(local.agent.current().name)
const agent = local.agent.current()
const color = agent ? local.agent.color(agent.name) : theme.border
return {
frames: createFrames({
color,
@ -1041,7 +1046,7 @@ export function Prompt(props: PromptProps) {
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
try {
const mime = Filesystem.mimeType(filepath)
const mime = await Filesystem.mimeType(filepath)
const filename = path.basename(filepath)
// Handle SVG as raw text content, not as base64 image
if (mime === "image/svg+xml") {
@ -1107,22 +1112,26 @@ export function Prompt(props: PromptProps) {
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
<box flexDirection="row" gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
</text>
<text fg={theme.textMuted}>{currentProviderLabel()}</text>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
</text>
</Show>
</box>
<Show when={local.agent.current()} fallback={<box height={1} />}>
{(agent) => (
<>
<text fg={highlight()}>{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)} </text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
</text>
<text fg={theme.textMuted}>{currentProviderLabel()}</text>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
</text>
</Show>
</box>
</Show>
</>
)}
</Show>
</box>
<Show when={hasRightContent()}>
@ -1208,7 +1217,7 @@ export function Prompt(props: PromptProps) {
const r = retry()
if (!r) return
if (isTruncated()) {
DialogAlert.show(dialog, "Retry Error", r.message)
void DialogAlert.show(dialog, "Retry Error", r.message)
}
}

View file

@ -1,6 +1,6 @@
import path from "path"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Filesystem } from "@/util"
import { onMount } from "solid-js"
import { createStore, produce, unwrap } from "solid-js/store"
import { createSimpleContext } from "../../context/helper"

View file

@ -1,7 +1,7 @@
import { createMemo } from "solid-js"
import type { KeyBinding } from "@opentui/core"
import { useKeybind } from "../context/keybind"
import { Keybind } from "@/util/keybind"
import { Keybind } from "@/util"
const TEXTAREA_ACTIONS = [
"submit",

View file

@ -0,0 +1,5 @@
import { Context } from "effect"
export const CurrentWorkingDirectory = Context.Reference<string>("CurrentWorkingDirectory", {
defaultValue: () => process.cwd(),
})

View file

@ -2,13 +2,11 @@ import path from "path"
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
import { unique } from "remeda"
import z from "zod"
import { ConfigPaths } from "./paths"
import { TuiInfo, TuiOptions } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { Filesystem } from "@/util/filesystem"
import { Global } from "@/global"
import { Filesystem, Log } from "@/util"
import * as ConfigPaths from "@/config/paths"
const log = Log.create({ service: "tui.migrate" })
@ -26,9 +24,9 @@ const TuiLegacy = z
.strip()
interface MigrateInput {
cwd: string
directories: string[]
custom?: string
managed: string
}
/**
@ -134,16 +132,13 @@ async function backupAndStripLegacy(file: string, source: string) {
})
}
async function opencodeFiles(input: { directories: string[]; managed: string }) {
const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)
async function opencodeFiles(input: { directories: string[]; cwd: string }) {
const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("opencode", input.cwd)
const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
for (const dir of unique(input.directories)) {
files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
}
if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG)
files.push(...ConfigPaths.fileInDirectory(input.managed, "opencode"))
const existing = await Promise.all(
unique(files).map(async (file) => {

View file

@ -1,9 +1,10 @@
import z from "zod"
import { Config } from "./config"
import { ConfigPlugin } from "@/config/plugin"
import { ConfigKeybinds } from "@/config/keybinds"
const KeybindOverride = z
.object(
Object.fromEntries(Object.keys(Config.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record<
Object.fromEntries(Object.keys(ConfigKeybinds.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record<
string,
z.ZodOptional<z.ZodString>
>,
@ -30,7 +31,7 @@ export const TuiInfo = z
$schema: z.string().optional(),
theme: z.string().optional(),
keybinds: KeybindOverride.optional(),
plugin: Config.PluginSpec.array().optional(),
plugin: ConfigPlugin.Spec.array().optional(),
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
})
.extend(TuiOptions.shape)

View file

@ -1,18 +1,20 @@
import { existsSync } from "fs"
import z from "zod"
import { mergeDeep, unique } from "remeda"
import { Context, Effect, Fiber, Layer } from "effect"
import { Config } from "./config"
import { ConfigPaths } from "./paths"
import * as ConfigPaths from "@/config/paths"
import { migrateTuiConfig } from "./tui-migrate"
import { TuiInfo } from "./tui-schema"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Npm } from "@opencode-ai/shared/npm"
import { CurrentWorkingDirectory } from "./cwd"
import { ConfigPlugin } from "@/config/plugin"
import { ConfigKeybinds } from "@/config/keybinds"
import { InstallationLocal, InstallationVersion } from "@/installation/version"
import { makeRuntime } from "@/cli/effect/runtime"
import { Filesystem, Log } from "@/util"
export namespace TuiConfig {
const log = Log.create({ service: "tui.config" })
@ -30,19 +32,19 @@ export namespace TuiConfig {
export type Info = z.output<typeof Info> & {
// Internal resolved plugin list used by runtime loading.
plugin_origins?: Config.PluginOrigin[]
plugin_origins?: ConfigPlugin.Origin[]
}
export interface Interface {
readonly get: () => Effect.Effect<Info>
readonly waitForDependencies: () => Effect.Effect<void, AppFileSystem.Error>
readonly waitForDependencies: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
function pluginScope(file: string, ctx: { directory: string; worktree: string }): Config.PluginScope {
if (AppFileSystem.contains(ctx.directory, file)) return "local"
if (ctx.worktree !== "/" && AppFileSystem.contains(ctx.worktree, file)) return "local"
function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope {
if (Filesystem.contains(ctx.directory, file)) return "local"
// if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
return "global"
}
@ -66,13 +68,13 @@ export namespace TuiConfig {
}
}
async function mergeFile(acc: Acc, file: string, ctx: { directory: string; worktree: string }) {
async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
const data = await loadFile(file)
acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return
const scope = pluginScope(file, ctx)
const plugins = Config.deduplicatePluginOrigins([
const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(acc.result.plugin_origins ?? []),
...data.plugin.map((spec) => ({ spec, scope, source: file })),
])
@ -80,18 +82,13 @@ export namespace TuiConfig {
acc.result.plugin_origins = plugins
}
async function loadState(ctx: { directory: string; worktree: string }) {
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
const directories = await ConfigPaths.directories(ctx.directory, ctx.worktree)
async function loadState(ctx: { directory: string }) {
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
const directories = await ConfigPaths.directories(ctx.directory)
const custom = customPath()
const managed = Config.managedConfigDir()
await migrateTuiConfig({ directories, custom, managed })
await migrateTuiConfig({ directories, custom, cwd: ctx.directory })
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
const acc: Acc = {
result: {},
@ -119,21 +116,16 @@ export namespace TuiConfig {
}
}
if (existsSync(managed)) {
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
await mergeFile(acc, file, ctx)
}
}
const keybinds = { ...acc.result.keybinds }
const keybinds = { ...(acc.result.keybinds ?? {}) }
if (process.platform === "win32") {
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
keybinds.terminal_suspend = "none"
keybinds.input_undo ??= unique(["ctrl+z", ...Config.Keybinds.shape.input_undo.parse(undefined).split(",")]).join(
",",
)
keybinds.input_undo ??= unique([
"ctrl+z",
...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
]).join(",")
}
acc.result.keybinds = Config.Keybinds.parse(keybinds)
acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
return {
config: acc.result,
@ -144,41 +136,43 @@ export namespace TuiConfig {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const cfg = yield* Config.Service
const state = yield* InstanceState.make<State>(
Effect.fn("TuiConfig.state")(function* (ctx) {
const data = yield* Effect.promise(() => loadState(ctx))
const deps = yield* Effect.forEach(data.dirs, (dir) => cfg.installDependencies(dir).pipe(Effect.forkScoped), {
concurrency: "unbounded",
})
return { config: data.config, deps }
}),
const directory = yield* CurrentWorkingDirectory
const npm = yield* Npm.Service
const data = yield* Effect.promise(() => loadState({ directory }))
const deps = yield* Effect.forEach(
data.dirs,
(dir) =>
npm
.install(dir, {
add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
})
.pipe(Effect.forkScoped),
{
concurrency: "unbounded",
},
)
const get = Effect.fn("TuiConfig.get")(() => InstanceState.use(state, (s) => s.config))
const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
InstanceState.useEffect(state, (s) =>
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
),
Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
)
return Service.of({ get, waitForDependencies })
}),
}).pipe(Effect.withSpan("TuiConfig.layer")),
)
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get() {
return runPromise((svc) => svc.get())
}
export async function waitForDependencies() {
await runPromise((svc) => svc.waitForDependencies())
}
export async function get() {
return runPromise((svc) => svc.get())
}
async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
@ -205,7 +199,7 @@ export namespace TuiConfig {
const data = parsed.data
if (data.plugin) {
for (let i = 0; i < data.plugin.length; i++) {
data.plugin[i] = await Config.resolvePluginSpec(data.plugin[i], configFilepath)
data.plugin[i] = await ConfigPlugin.resolvePluginSpec(data.plugin[i], configFilepath)
}
}

View file

@ -1,7 +1,7 @@
import { createMemo } from "solid-js"
import { Keybind } from "@/util/keybind"
import { Keybind } from "@/util"
import { pipe, mapValues } from "remeda"
import type { TuiConfig } from "@/config/tui"
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
import type { ParsedKey, Renderable } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useKeyboard, useRenderer } from "@opentui/solid"

View file

@ -1,5 +1,5 @@
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Filesystem } from "@/util"
import { createSignal, type Setter } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
@ -44,7 +44,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
},
set(key: string, value: any) {
setStore(key, value)
Filesystem.writeJson(filePath, store)
void Filesystem.writeJson(filePath, store)
},
}
return result

View file

@ -1,4 +1,5 @@
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { batch, createEffect, createMemo } from "solid-js"
import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme"
@ -6,13 +7,19 @@ import { uniqueBy } from "remeda"
import path from "path"
import { Global } from "@/global"
import { iife } from "@/util/iife"
import { createSimpleContext } from "./helper"
import { useToast } from "../ui/toast"
import { Provider } from "@/provider/provider"
import { useArgs } from "./args"
import { useSDK } from "./sdk"
import { RGBA } from "@opentui/core"
import { Filesystem } from "@/util/filesystem"
import { Filesystem } from "@/util"
export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")
return {
providerID: providerID,
modelID: rest.join("/"),
}
}
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
@ -37,10 +44,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = iife(() => {
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
const visibleAgents = createMemo(() => sync.data.agent.filter((x) => !x.hidden))
const [agentStore, setAgentStore] = createStore<{
current: string
}>({
current: agents()[0].name,
const [agentStore, setAgentStore] = createStore({
current: undefined as string | undefined,
})
const { theme } = useTheme()
const colors = createMemo(() => [
@ -57,7 +62,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return agents()
},
current() {
return agents().find((x) => x.name === agentStore.current) ?? agents()[0]
return agents().find((x) => x.name === agentStore.current) ?? agents().at(0)
},
set(name: string) {
if (!agents().some((x) => x.name === name))
@ -131,7 +136,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return
}
state.pending = false
Filesystem.writeJson(filePath, {
void Filesystem.writeJson(filePath, {
recent: modelStore.recent,
favorite: modelStore.favorite,
variant: modelStore.variant,
@ -153,7 +158,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const args = useArgs()
const fallbackModel = createMemo(() => {
if (args.model) {
const { providerID, modelID } = Provider.parseModel(args.model)
const { providerID, modelID } = parseModel(args.model)
if (isModelValid({ providerID, modelID })) {
return {
providerID,
@ -163,7 +168,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
if (sync.data.config.model) {
const { providerID, modelID } = Provider.parseModel(sync.data.config.model)
const { providerID, modelID } = parseModel(sync.data.config.model)
if (isModelValid({ providerID, modelID })) {
return {
providerID,
@ -194,8 +199,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const a = agent.current()
return (
getFirstValidModel(
() => modelStore.model[a.name],
() => a.model,
() => a && modelStore.model[a.name],
() => a && a.model,
fallbackModel,
) ?? undefined
)
@ -240,7 +245,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (next >= recent.length) next = 0
const val = recent[next]
if (!val) return
setModelStore("model", agent.current().name, { ...val })
const a = agent.current()
if (!a) return
setModelStore("model", a.name, { ...val })
},
cycleFavorite(direction: 1 | -1) {
const favorites = modelStore.favorite.filter((item) => isModelValid(item))
@ -266,7 +273,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const next = favorites[index]
if (!next) return
setModelStore("model", agent.current().name, { ...next })
const a = agent.current()
if (!a) return
setModelStore("model", a.name, { ...next })
const uniq = uniqueBy([next, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
if (uniq.length > 10) uniq.pop()
setModelStore(
@ -285,7 +294,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
return
}
setModelStore("model", agent.current().name, model)
const a = agent.current()
if (!a) return
setModelStore("model", a.name, model)
if (options?.recent) {
const uniq = uniqueBy([model, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
if (uniq.length > 10) uniq.pop()
@ -387,6 +398,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
// Automatically update model when agent changes
createEffect(() => {
const value = agent.current()
if (!value) return
if (value.model) {
if (isModelValid(value.model))
model.set({

View file

@ -1,5 +1,5 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { GlobalEvent, Event } from "@opencode-ai/sdk/v2"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup, onMount } from "solid-js"

View file

@ -28,8 +28,8 @@ import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { batch, createEffect, on } from "solid-js"
import { Log } from "@/util/log"
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
import { Log } from "@/util"
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@ -39,7 +39,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
provider: Provider[]
provider_default: Record<string, string>
provider_next: ProviderListResponse
console_state: ConsoleStateType
console_state: ConsoleState
provider_auth: Record<string, ProviderAuthMethod[]>
agent: Agent[]
command: Command[]
@ -111,7 +111,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
event.subscribe((event) => {
switch (event.type) {
case "server.instance.disposed":
bootstrap()
void bootstrap()
break
case "permission.replied": {
const requests = store.permission[event.properties.sessionID]
@ -336,7 +336,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
case "lsp.updated": {
const workspace = project.workspace.current()
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data ?? []))
void sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data ?? []))
break
}
@ -363,7 +363,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
const consoleStatePromise = sdk.client.experimental.console
.get({ workspace }, { throwOnError: true })
.then((x) => ConsoleState.parse(x.data))
.then((x) => x.data)
.catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
@ -378,7 +378,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
]
await Promise.all(blockingRequests)
.then(() => {
.then(async () => {
const providersResponse = providersPromise.then((x) => x.data!)
const providerListResponse = providerListPromise.then((x) => x.data!)
const consoleStateResponse = consoleStatePromise
@ -415,7 +415,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.then(() => {
if (store.status !== "complete") setStore("status", "partial")
// non-blocking
Promise.all([
void Promise.all([
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),

View file

@ -40,7 +40,7 @@ import { useKV } from "./kv"
import { useRenderer } from "@opentui/solid"
import { createStore, produce } from "solid-js/store"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Filesystem } from "@/util"
import { useTuiConfig } from "./tui-config"
import { isRecord } from "@/util/record"
import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
@ -329,7 +329,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
})
function init() {
Promise.allSettled([
void Promise.allSettled([
resolveSystemTheme(store.mode),
getCustomThemes()
.then((custom) => {
@ -377,7 +377,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
if (store.mode === mode) return
setStore("mode", mode)
renderer.clearPaletteCache()
resolveSystemTheme(mode)
void resolveSystemTheme(mode)
}
function pin(mode: "dark" | "light" = store.mode) {

View file

@ -1,4 +1,4 @@
import { TuiConfig } from "@/config/tui"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { createSimpleContext } from "./helper"
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({

View file

@ -1,5 +1,4 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { SessionID } from "@/session/schema"
import z from "zod"

View file

@ -1,4 +1,4 @@
import { Keybind } from "@/util/keybind"
import { Keybind } from "@/util"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule, TuiPluginStatus } from "@opencode-ai/plugin/tui"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { fileURLToPath } from "url"
@ -78,7 +78,7 @@ function Install(props: { api: TuiPluginApi }) {
}
setBusy(true)
props.api.plugins
void props.api.plugins
.install(mod, { global: global() })
.then((out) => {
if (!out.ok) {
@ -188,7 +188,7 @@ function View(props: { api: TuiPluginApi }) {
if (!item) return
setLock(true)
const task = item.active ? props.api.plugins.deactivate(x) : props.api.plugins.activate(x)
task
void task
.then((ok) => {
if (!ok) {
props.api.ui.toast({

View file

@ -0,0 +1,6 @@
import { Layer } from "effect"
import { TuiConfig } from "./config/tui"
import { Npm } from "@opencode-ai/shared/npm"
import { Observability } from "@/effect/observability"
export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))

View file

@ -8,7 +8,7 @@ import type { useSDK } from "@tui/context/sdk"
import type { useSync } from "@tui/context/sync"
import type { useTheme } from "@tui/context/theme"
import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog"
import type { TuiConfig } from "@/config/tui"
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { createPluginKeybind } from "../context/plugin-keybinds"
import type { useKV } from "../context/kv"
import { DialogAlert } from "../ui/dialog-alert"
@ -18,8 +18,7 @@ import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dia
import { Prompt } from "../component/prompt"
import { Slot as HostSlot } from "./slots"
import type { useToast } from "../ui/toast"
import { Installation } from "@/installation"
import { type OpencodeClient } from "@opencode-ai/sdk/v2"
import { InstallationVersion } from "@/installation/version"
type RouteEntry = {
key: symbol
@ -190,7 +189,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
function appApi(): TuiPluginApi["app"] {
return {
get version() {
return Installation.VERSION
return InstallationVersion
},
}
}

View file

@ -1,4 +1,4 @@
import "@opentui/solid/runtime-plugin-support"
// import "@opentui/solid/runtime-plugin-support"
import {
type TuiDispose,
type TuiPlugin,
@ -12,13 +12,10 @@ import {
} from "@opencode-ai/plugin/tui"
import path from "path"
import { fileURLToPath } from "url"
import { Config } from "@/config/config"
import { TuiConfig } from "@/config/tui"
import { Log } from "@/util/log"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { Log } from "@/util"
import { errorData, errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance"
import {
readPackageThemes,
readPluginId,
@ -32,23 +29,24 @@ import { PluginMeta } from "@/plugin/meta"
import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
import { hasTheme, upsertTheme } from "../context/theme"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { Filesystem } from "@/util"
import { Process } from "@/util"
import { Flock } from "@opencode-ai/shared/util/flock"
import { Flag } from "@/flag/flag"
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
import { setupSlots, Slot as View } from "./slots"
import type { HostPluginApi, HostSlots } from "./slots"
import { ConfigPlugin } from "@/config/plugin"
type PluginLoad = {
options: Config.PluginOptions | undefined
options: ConfigPlugin.Options | undefined
spec: string
target: string
retry: boolean
source: PluginSource | "internal"
id: string
module: TuiPluginModule
origin: Config.PluginOrigin
origin: ConfigPlugin.Origin
theme_root: string
theme_files: string[]
}
@ -77,7 +75,7 @@ type RuntimeState = {
slots: HostSlots
plugins: PluginEntry[]
plugins_by_id: Map<string, PluginEntry>
pending: Map<string, Config.PluginOrigin>
pending: Map<string, ConfigPlugin.Origin>
}
const log = Log.create({ service: "tui.plugin" })
@ -147,7 +145,7 @@ function resolveRoot(root: string) {
}
function createThemeInstaller(
meta: Config.PluginOrigin,
meta: ConfigPlugin.Origin,
root: string,
spec: string,
plugin: PluginEntry,
@ -590,7 +588,7 @@ function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.I
}
}
async function resolveExternalPlugins(list: Config.PluginOrigin[], wait: () => Promise<void>) {
async function resolveExternalPlugins(list: ConfigPlugin.Origin[], wait: () => Promise<void>) {
return PluginLoader.loadExternal({
items: list,
kind: "tui",
@ -745,7 +743,7 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
return { plugins, ok }
}
function defaultPluginOrigin(state: RuntimeState, spec: string): Config.PluginOrigin {
function defaultPluginOrigin(state: RuntimeState, spec: string): ConfigPlugin.Origin {
return {
spec,
scope: "local",
@ -786,19 +784,12 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
if (!spec) return false
const cfg = state.pending.get(spec) ?? defaultPluginOrigin(state, spec)
const next = Config.pluginSpecifier(cfg.spec)
const next = ConfigPlugin.pluginSpecifier(cfg.spec)
if (state.plugins.some((plugin) => plugin.load.spec === next)) {
state.pending.delete(spec)
return true
}
const ready = await Instance.provide({
directory: state.directory,
fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
}).catch((error) => {
fail("failed to add tui plugin", { path: next, error })
return [] as PluginLoad[]
})
const ready = await resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies())
if (!ready.length) {
return false
}
@ -905,7 +896,7 @@ async function installPluginBySpec(
const tui = manifest.targets.find((item) => item.kind === "tui")
if (tui) {
const file = patch.items.find((item) => item.kind === "tui")?.file
const next = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec
const next = tui.opts ? ([spec, tui.opts] as ConfigPlugin.Spec) : spec
state.pending.set(spec, {
spec: next,
scope: global ? "global" : "local",
@ -926,7 +917,7 @@ export namespace TuiPluginRuntime {
let runtime: RuntimeState | undefined
export const Slot = View
export async function init(api: HostPluginApi) {
export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) {
const cwd = process.cwd()
if (loaded) {
if (dir !== cwd) {
@ -936,7 +927,7 @@ export namespace TuiPluginRuntime {
}
dir = cwd
loaded = load(api)
loaded = load(input)
return loaded
}
@ -975,7 +966,8 @@ export namespace TuiPluginRuntime {
}
}
async function load(api: Api) {
async function load(input: { api: Api; config: TuiConfig.Info }) {
const { api, config } = input
const cwd = process.cwd()
const slots = setupSlots(api)
const next: RuntimeState = {
@ -987,45 +979,40 @@ export namespace TuiPluginRuntime {
pending: new Map(),
}
runtime = next
try {
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
}
await Instance.provide({
directory: cwd,
fn: async () => {
const config = await TuiConfig.get()
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
}
for (const item of INTERNAL_TUI_PLUGINS) {
log.info("loading internal tui plugin", { id: item.id })
const entry = loadInternalPlugin(item)
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
addPluginEntry(next, {
id: entry.id,
load: entry,
meta,
themes: {},
plugin: entry.module.tui,
enabled: true,
})
}
for (const item of INTERNAL_TUI_PLUGINS) {
log.info("loading internal tui plugin", { id: item.id })
const entry = loadInternalPlugin(item)
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
addPluginEntry(next, {
id: entry.id,
load: entry,
meta,
themes: {},
plugin: entry.module.tui,
enabled: true,
})
}
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
await addExternalPluginEntries(next, ready)
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
await addExternalPluginEntries(next, ready)
applyInitialPluginEnabledState(next, config)
for (const plugin of next.plugins) {
if (!plugin.enabled) continue
// Keep plugin execution sequential for deterministic side effects:
// command registration order affects keybind/command precedence,
// route registration is last-wins when ids collide,
// and hook chains rely on stable plugin ordering.
await activatePluginEntry(next, plugin, false)
}
},
}).catch((error) => {
applyInitialPluginEnabledState(next, config)
for (const plugin of next.plugins) {
if (!plugin.enabled) continue
// Keep plugin execution sequential for deterministic side effects:
// command registration order affects keybind/command precedence,
// route registration is last-wins when ids collide,
// and hook chains rely on stable plugin ordering.
await activatePluginEntry(next, plugin, false)
}
} catch (error) {
fail("failed to load tui plugins", { directory: cwd, error })
})
}
}
}

View file

@ -2,7 +2,7 @@ import { createMemo, onMount } from "solid-js"
import { useSync } from "@tui/context/sync"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import type { TextPart } from "@opencode-ai/sdk/v2"
import { Locale } from "@/util/locale"
import { Locale } from "@/util"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useDialog } from "../../ui/dialog"

View file

@ -3,7 +3,7 @@ import { useSync } from "@tui/context/sync"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { Clipboard } from "@tui/util/clipboard"
import * as Clipboard from "@tui/util/clipboard"
import type { PromptInfo } from "@tui/component/prompt/history"
import { strip } from "@tui/component/prompt/part"
@ -29,7 +29,7 @@ export function DialogMessage(props: {
const msg = message()
if (!msg) return
sdk.client.session.revert({
void sdk.client.session.revert({
sessionID: props.sessionID,
messageID: msg.id,
})

View file

@ -2,7 +2,7 @@ import { createMemo, onMount } from "solid-js"
import { useSync } from "@tui/context/sync"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import type { TextPart } from "@opencode-ai/sdk/v2"
import { Locale } from "@/util/locale"
import { Locale } from "@/util"
import { DialogMessage } from "./dialog-message"
import { useDialog } from "../../ui/dialog"
import type { PromptInfo } from "../../component/prompt/history"

View file

@ -33,8 +33,8 @@ import type {
ReasoningPart,
} from "@opencode-ai/sdk/v2"
import { useLocal } from "@tui/context/local"
import { Locale } from "@/util/locale"
import type { Tool } from "@/tool/tool"
import { Locale } from "@/util"
import type { Tool } from "@/tool"
import type { ReadTool } from "@/tool/read"
import type { WriteTool } from "@/tool/write"
import { BashTool } from "@/tool/bash"
@ -66,14 +66,14 @@ import { SubagentFooter } from "./subagent-footer.tsx"
import { Flag } from "@/flag/flag"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import parsers from "../../../../../../parsers-config.ts"
import { Clipboard } from "../../util/clipboard"
import * as Clipboard from "../../util/clipboard"
import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx"
import { Editor } from "../../util/editor"
import * as Editor from "../../util/editor"
import stripAnsi from "strip-ansi"
import { usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit"
import { Filesystem } from "@/util/filesystem"
import { Filesystem } from "@/util"
import { Global } from "@/global"
import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question"
@ -157,10 +157,10 @@ export function Session() {
const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true)
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
const [_animationsEnabled, _setAnimationsEnabled] = kv.signal("animations_enabled", true)
const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)
const wide = createMemo(() => dimensions().width > 120)
@ -241,7 +241,7 @@ export function Session() {
if (kv.get(GO_UPSELL_DONT_SHOW)) return
DialogGoUpsell.show(dialog).then((dontShowAgain) => {
void DialogGoUpsell.show(dialog).then((dontShowAgain) => {
if (dontShowAgain) kv.set(GO_UPSELL_DONT_SHOW, true)
kv.set(GO_UPSELL_LAST_SEEN_AT, Date.now())
})
@ -272,7 +272,7 @@ export function Session() {
useKeyboard((evt) => {
if (!session()?.parentID) return
if (keybind.match("app_exit", evt)) {
exit()
void exit()
}
})
@ -483,7 +483,7 @@ export function Session() {
})
return
}
sdk.client.session.summarize({
void sdk.client.session.summarize({
sessionID: route.sessionID,
modelID: selectedModel.modelID,
providerID: selectedModel.providerID,
@ -529,7 +529,7 @@ export function Session() {
const revert = session()?.revert?.messageID
const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user")
if (!message) return
sdk.client.session
void sdk.client.session
.revert({
sessionID: route.sessionID,
messageID: message.id,
@ -568,13 +568,13 @@ export function Session() {
if (!messageID) return
const message = messages().find((x) => x.role === "user" && x.id > messageID)
if (!message) {
sdk.client.session.unrevert({
void sdk.client.session.unrevert({
sessionID: route.sessionID,
})
prompt?.set({ input: "", parts: [] })
return
}
sdk.client.session.revert({
void sdk.client.session.revert({
sessionID: route.sessionID,
messageID: message.id,
})
@ -863,7 +863,7 @@ export function Session() {
)
await Clipboard.copy(transcript)
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
} catch (error) {
} catch {
toast.show({ message: "Failed to copy session transcript", variant: "error" })
}
dialog.clear()
@ -925,7 +925,7 @@ export function Session() {
toast.show({ message: `Session exported to ${filename}`, variant: "success" })
}
} catch (error) {
} catch {
toast.show({ message: "Failed to export session", variant: "error" })
}
dialog.clear()
@ -1010,7 +1010,7 @@ export function Session() {
),
}
})
} catch (error) {
} catch {
return []
}
})
@ -1966,7 +1966,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
onMount(() => {
if (props.metadata.sessionId && !sync.data.message[props.metadata.sessionId]?.length)
sync.session.sync(props.metadata.sessionId)
void sync.session.sync(props.metadata.sessionId)
})
const messages = createMemo(() => sync.data.message[props.metadata.sessionId ?? ""] ?? [])

View file

@ -11,8 +11,8 @@ import { useSync } from "../../context/sync"
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
import path from "path"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { Keybind } from "@/util"
import { Locale } from "@/util"
import { Global } from "@/global"
import { useDialog } from "../../ui/dialog"
import { getScrollAcceleration } from "../../util/scroll"
@ -184,7 +184,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
onSelect={(option) => {
setStore("stage", "permission")
if (option === "cancel") return
sdk.client.permission.reply({
void sdk.client.permission.reply({
reply: "always",
requestID: props.request.id,
})
@ -194,7 +194,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
<Match when={store.stage === "reject"}>
<RejectPrompt
onConfirm={(message) => {
sdk.client.permission.reply({
void sdk.client.permission.reply({
reply: "reject",
requestID: props.request.id,
message: message || undefined,
@ -447,13 +447,13 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
setStore("stage", "reject")
return
}
sdk.client.permission.reply({
void sdk.client.permission.reply({
reply: "reject",
requestID: props.request.id,
})
return
}
sdk.client.permission.reply({
void sdk.client.permission.reply({
reply: "once",
requestID: props.request.id,
})
@ -599,7 +599,7 @@ function Prompt<const T extends Record<string, string>>(props: {
})
const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen"))
const renderer = useRenderer()
useRenderer()
const content = () => (
<box

View file

@ -45,14 +45,14 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
function submit() {
const answers = questions().map((_, i) => store.answers[i] ?? [])
sdk.client.question.reply({
void sdk.client.question.reply({
requestID: props.request.id,
answers,
})
}
function reject() {
sdk.client.question.reject({
void sdk.client.question.reject({
requestID: props.request.id,
})
}
@ -67,7 +67,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
setStore("custom", inputs)
}
if (single()) {
sdk.client.question.reply({
void sdk.client.question.reply({
requestID: props.request.id,
answers: [[answer]],
})

View file

@ -2,7 +2,7 @@ import { useSync } from "@tui/context/sync"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useTuiConfig } from "../../context/tui-config"
import { Installation } from "@/installation"
import { InstallationVersion } from "@/installation/version"
import { TuiPluginRuntime } from "../../plugin"
import { getScrollAcceleration } from "../../util/scroll"
@ -64,7 +64,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<span style={{ fg: theme.text }}>
<b>Code</b>
</span>{" "}
<span>{Installation.VERSION}</span>
<span>{InstallationVersion}</span>
</text>
</TuiPluginRuntime.Slot>
</box>

View file

@ -6,7 +6,7 @@ import { SplitBorder } from "@tui/component/border"
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useKeybind } from "../../context/keybind"
import { Locale } from "@/util/locale"
import { Locale } from "@/util"
import { useTerminalDimensions } from "@opentui/solid"
export function SubagentFooter() {
@ -59,7 +59,7 @@ export function SubagentFooter() {
const keybind = useKeybind()
const command = useCommandDialog()
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
const dimensions = useTerminalDimensions()
useTerminalDimensions()
return (
<box flexShrink={0}>

View file

@ -1,21 +1,20 @@
import { cmd } from "@/cli/cmd/cmd"
import { tui } from "./app"
import { Rpc } from "@/util/rpc"
import { Rpc } from "@/util"
import { type rpc } from "./worker"
import path from "path"
import { fileURLToPath } from "url"
import { UI } from "@/cli/ui"
import { Log } from "@/util/log"
import { Log } from "@/util"
import { errorMessage } from "@/util/error"
import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import { Filesystem } from "@/util/filesystem"
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
import { Filesystem } from "@/util"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui"
import { Instance } from "@/project/instance"
import { writeHeapSnapshot } from "v8"
import { TuiConfig } from "./config/tui"
declare global {
const OPENCODE_WORKER_PATH: string
@ -177,12 +176,9 @@ export const TuiThreadCommand = cmd({
}
const prompt = await input(args.prompt)
const config = await Instance.provide({
directory: cwd,
fn: () => TuiConfig.get(),
})
const config = await TuiConfig.get()
const network = await resolveNetworkOptions(args)
const network = resolveNetworkOptionsNoConfig(args)
const external =
process.argv.includes("--port") ||
process.argv.includes("--hostname") ||
@ -237,3 +233,4 @@ export const TuiThreadCommand = cmd({
process.exit(0)
},
})
// scratch

View file

@ -4,7 +4,7 @@ import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { Locale } from "@/util/locale"
import { Locale } from "@/util"
export type DialogConfirmProps = {
title: string
@ -54,7 +54,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
paddingLeft={1}
paddingRight={1}
backgroundColor={key === store.active ? theme.primary : undefined}
onMouseUp={(evt) => {
onMouseUp={(_evt) => {
if (key === "confirm") props.onConfirm?.()
if (key === "cancel") props.onCancel?.()
dialog.clear()

View file

@ -2,7 +2,7 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { onMount, Show, type JSX } from "solid-js"
import { onMount, Show } from "solid-js"
import { useKeyboard } from "@opentui/solid"
export type DialogExportOptionsProps = {

View file

@ -1,6 +1,6 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { useTheme, selectedForeground } from "@tui/context/theme"
import { entries, filter, flatMap, groupBy, pipe, take } from "remeda"
import { entries, filter, flatMap, groupBy, pipe } from "remeda"
import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js"
import { createStore } from "solid-js/store"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
@ -8,8 +8,8 @@ import * as fuzzysort from "fuzzysort"
import { isDeepEqual } from "remeda"
import { useDialog, type DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind"
import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { Keybind } from "@/util"
import { Locale } from "@/util"
import { getScrollAcceleration } from "../util/scroll"
import { useTuiConfig } from "../context/tui-config"

View file

@ -5,7 +5,7 @@ import { MouseButton, Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useToast } from "./toast"
import { Flag } from "@/flag/flag"
import { Selection } from "@tui/util/selection"
import * as Selection from "@tui/util/selection"
export function Dialog(
props: ParentProps<{

View file

@ -5,7 +5,7 @@ import { useTerminalDimensions } from "@opentui/solid"
import { SplitBorder } from "../component/border"
import { TextAttributes } from "@opentui/core"
import z from "zod"
import { TuiEvent } from "../event"
import { type TuiEvent } from "../event"
export type ToastOptions = z.infer<typeof TuiEvent.ToastShow.properties>
@ -56,8 +56,7 @@ function init() {
const toast = {
show(options: ToastOptions) {
const parsedOptions = TuiEvent.ToastShow.properties.parse(options)
const { duration, ...currentToast } = parsedOptions
const { duration, ...currentToast } = options
setStore("currentToast", currentToast)
if (timeoutHandle) clearTimeout(timeoutHandle)
timeoutHandle = setTimeout(() => {

View file

@ -1,12 +1,21 @@
import { platform, release } from "os"
import clipboardy from "clipboardy"
import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os"
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../../../../util/filesystem"
import { Process } from "../../../../util/process"
import { which } from "../../../../util/which"
import * as Filesystem from "../../../../util/filesystem"
import * as Process from "../../../../util/process"
// Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup
const getWhich = lazy(async () => {
const { which } = await import("../../../../util/which")
return which
})
const getClipboardy = lazy(async () => {
const { default: clipboardy } = await import("clipboardy")
return clipboardy
})
/**
* Writes text to clipboard via OSC 52 escape sequence.
@ -22,171 +31,173 @@ function writeOsc52(text: string): void {
process.stdout.write(sequence)
}
export namespace Clipboard {
export interface Content {
data: string
mime: string
}
export interface Content {
data: string
mime: string
}
// Checks clipboard for images first, then falls back to text.
//
// On Windows prompt/ can call this from multiple paste signals because
// terminals surface image paste differently:
// 1. A forwarded Ctrl+V keypress
// 2. An empty bracketed-paste hint for image-only clipboard in Windows
// Terminal <1.25
// 3. A kitty Ctrl+V key-release fallback for Windows Terminal 1.25+
export async function read(): Promise<Content | undefined> {
const os = platform()
// Checks clipboard for images first, then falls back to text.
//
// On Windows prompt/ can call this from multiple paste signals because
// terminals surface image paste differently:
// 1. A forwarded Ctrl+V keypress
// 2. An empty bracketed-paste hint for image-only clipboard in Windows
// Terminal <1.25
// 3. A kitty Ctrl+V key-release fallback for Windows Terminal 1.25+
export async function read(): Promise<Content | undefined> {
const os = platform()
if (os === "darwin") {
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
try {
await Process.run(
[
"osascript",
"-e",
'set imageData to the clipboard as "PNGf"',
"-e",
`set fileRef to open for access POSIX file "${tmpfile}" with write permission`,
"-e",
"set eof fileRef to 0",
"-e",
"write imageData to fileRef",
"-e",
"close access fileRef",
],
{ nothrow: true },
)
const buffer = await Filesystem.readBytes(tmpfile)
return { data: buffer.toString("base64"), mime: "image/png" }
} catch {
} finally {
await fs.rm(tmpfile, { force: true }).catch(() => {})
}
}
// Windows/WSL: probe clipboard for images via PowerShell.
// Bracketed paste can't carry image data so we read it directly.
if (os === "win32" || release().includes("WSL")) {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
nothrow: true,
})
if (base64.text) {
const imageBuffer = Buffer.from(base64.text.trim(), "base64")
if (imageBuffer.length > 0) {
return { data: imageBuffer.toString("base64"), mime: "image/png" }
}
}
}
if (os === "linux") {
const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true })
if (wayland.stdout.byteLength > 0) {
return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" }
}
const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], {
nothrow: true,
})
if (x11.stdout.byteLength > 0) {
return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" }
}
}
const text = await clipboardy.read().catch(() => {})
if (text) {
return { data: text, mime: "text/plain" }
if (os === "darwin") {
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
try {
await Process.run(
[
"osascript",
"-e",
'set imageData to the clipboard as "PNGf"',
"-e",
`set fileRef to open for access POSIX file "${tmpfile}" with write permission`,
"-e",
"set eof fileRef to 0",
"-e",
"write imageData to fileRef",
"-e",
"close access fileRef",
],
{ nothrow: true },
)
const buffer = await Filesystem.readBytes(tmpfile)
return { data: buffer.toString("base64"), mime: "image/png" }
} catch {
} finally {
await fs.rm(tmpfile, { force: true }).catch(() => {})
}
}
const getCopyMethod = lazy(() => {
const os = platform()
if (os === "darwin" && which("osascript")) {
console.log("clipboard: using osascript")
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true })
// Windows/WSL: probe clipboard for images via PowerShell.
// Bracketed paste can't carry image data so we read it directly.
if (os === "win32" || release().includes("WSL")) {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
nothrow: true,
})
if (base64.text) {
const imageBuffer = Buffer.from(base64.text.trim(), "base64")
if (imageBuffer.length > 0) {
return { data: imageBuffer.toString("base64"), mime: "image/png" }
}
}
}
if (os === "linux") {
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
console.log("clipboard: using wl-copy")
return async (text: string) => {
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
if (which("xclip")) {
console.log("clipboard: using xclip")
return async (text: string) => {
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
if (which("xsel")) {
console.log("clipboard: using xsel")
return async (text: string) => {
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
if (os === "linux") {
const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true })
if (wayland.stdout.byteLength > 0) {
return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" }
}
const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], {
nothrow: true,
})
if (x11.stdout.byteLength > 0) {
return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" }
}
}
if (os === "win32") {
console.log("clipboard: using powershell")
const clipboardy = await getClipboardy()
const text = await clipboardy.read().catch(() => {})
if (text) {
return { data: text, mime: "text/plain" }
}
}
const getCopyMethod = lazy(async () => {
const os = platform()
const which = await getWhich()
if (os === "darwin" && which("osascript")) {
console.log("clipboard: using osascript")
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true })
}
}
if (os === "linux") {
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
console.log("clipboard: using wl-copy")
return async (text: string) => {
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
const proc = Process.spawn(
[
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
],
{
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
},
)
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
console.log("clipboard: no native support")
return async (text: string) => {
await clipboardy.write(text).catch(() => {})
if (which("xclip")) {
console.log("clipboard: using xclip")
return async (text: string) => {
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
if (which("xsel")) {
console.log("clipboard: using xsel")
return async (text: string) => {
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
})
export async function copy(text: string): Promise<void> {
writeOsc52(text)
await getCopyMethod()(text)
}
if (os === "win32") {
console.log("clipboard: using powershell")
return async (text: string) => {
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
const proc = Process.spawn(
[
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
],
{
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
},
)
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
console.log("clipboard: no native support")
return async (text: string) => {
const clipboardy = await getClipboardy()
await clipboardy.write(text).catch(() => {})
}
})
export async function copy(text: string): Promise<void> {
writeOsc52(text)
const method = await getCopyMethod()
await method(text)
}

View file

@ -3,35 +3,33 @@ import { rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { CliRenderer } from "@opentui/core"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { Filesystem } from "@/util"
import { Process } from "@/util"
export namespace Editor {
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
const editor = process.env["VISUAL"] || process.env["EDITOR"]
if (!editor) return
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
const editor = process.env["VISUAL"] || process.env["EDITOR"]
if (!editor) return
const filepath = join(tmpdir(), `${Date.now()}.md`)
await using _ = defer(async () => rm(filepath, { force: true }))
const filepath = join(tmpdir(), `${Date.now()}.md`)
await using _ = defer(async () => rm(filepath, { force: true }))
await Filesystem.write(filepath, opts.value)
opts.renderer.suspend()
await Filesystem.write(filepath, opts.value)
opts.renderer.suspend()
opts.renderer.currentRenderBuffer.clear()
try {
const parts = editor.split(" ")
const proc = Process.spawn([...parts, filepath], {
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
shell: process.platform === "win32",
})
await proc.exited
const content = await Filesystem.readText(filepath)
return content || undefined
} finally {
opts.renderer.currentRenderBuffer.clear()
try {
const parts = editor.split(" ")
const proc = Process.spawn([...parts, filepath], {
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
shell: process.platform === "win32",
})
await proc.exited
const content = await Filesystem.readText(filepath)
return content || undefined
} finally {
opts.renderer.currentRenderBuffer.clear()
opts.renderer.resume()
opts.renderer.requestRender()
}
opts.renderer.resume()
opts.renderer.requestRender()
}
}

View file

@ -0,0 +1,5 @@
export * as Editor from "./editor"
export * as Selection from "./selection"
export * as Sound from "./sound"
export * as Terminal from "./terminal"
export * as Clipboard from "./clipboard"

View file

@ -1,5 +1,5 @@
import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core"
import type { TuiConfig } from "@/config/tui"
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
export class CustomSpeedScroll implements ScrollAcceleration {
constructor(private speed: number) {}

View file

@ -1,4 +1,4 @@
import { Clipboard } from "./clipboard"
import * as Clipboard from "./clipboard"
type Toast = {
show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
@ -10,16 +10,14 @@ type Renderer = {
clearSelection: () => void
}
export namespace Selection {
export function copy(renderer: Renderer, toast: Toast): boolean {
const text = renderer.getSelection()?.getSelectedText()
if (!text) return false
export function copy(renderer: Renderer, toast: Toast): boolean {
const text = renderer.getSelection()?.getSelectedText()
if (!text) return false
Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
return true
}
renderer.clearSelection()
return true
}

View file

@ -2,7 +2,7 @@ import { Player } from "cli-sound"
import { mkdirSync } from "node:fs"
import { tmpdir } from "node:os"
import { basename, join } from "node:path"
import { Process } from "@/util/process"
import { Process } from "@/util"
import { which } from "@/util/which"
import pulseA from "../asset/pulse-a.wav" with { type: "file" }
import pulseB from "../asset/pulse-b.wav" with { type: "file" }
@ -43,114 +43,112 @@ function args(kind: Kind, file: string, volume: number) {
return [kind, "-c", `(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`]
}
export namespace Sound {
let item: Player | null | undefined
let kind: Kind | null | undefined
let proc: Process.Child | undefined
let tail: ReturnType<typeof setTimeout> | undefined
let cache: Promise<{ hum: string; pulse: string[] }> | undefined
let seq = 0
let shot = 0
let item: Player | null | undefined
let kind: Kind | null | undefined
let proc: Process.Child | undefined
let tail: ReturnType<typeof setTimeout> | undefined
let cache: Promise<{ hum: string; pulse: string[] }> | undefined
let seq = 0
let shot = 0
function load() {
if (item !== undefined) return item
try {
item = new Player({ volume: 0.35 })
} catch {
item = null
}
return item
}
async function file(path: string) {
mkdirSync(DIR, { recursive: true })
const next = join(DIR, basename(path))
const out = Bun.file(next)
if (await out.exists()) return next
await Bun.write(out, Bun.file(path))
return next
}
function asset() {
cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse }))
return cache
}
function pick() {
if (kind !== undefined) return kind
kind = LIST.find((item) => which(item)) ?? null
return kind
}
function run(file: string, volume: number) {
const kind = pick()
if (!kind) return
return Process.spawn(args(kind, file, volume), {
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
})
}
function clear() {
if (!tail) return
clearTimeout(tail)
tail = undefined
}
function play(file: string, volume: number) {
const item = load()
if (!item) return run(file, volume)?.exited
return item.play(file, { volume }).catch(() => run(file, volume)?.exited)
}
export function start() {
stop()
const id = ++seq
void asset().then(({ hum }) => {
if (id !== seq) return
const next = run(hum, 0.24)
if (!next) return
proc = next
void next.exited.then(
() => {
if (id !== seq) return
if (proc === next) proc = undefined
},
() => {
if (id !== seq) return
if (proc === next) proc = undefined
},
)
})
}
export function stop(delay = 0) {
seq++
clear()
if (!proc) return
const next = proc
if (delay <= 0) {
proc = undefined
void Process.stop(next).catch(() => undefined)
return
}
tail = setTimeout(() => {
tail = undefined
if (proc === next) proc = undefined
void Process.stop(next).catch(() => undefined)
}, delay)
}
export function pulse(scale = 1) {
stop(140)
const index = shot++ % FILE.length
void asset()
.then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale))
.catch(() => undefined)
}
export function dispose() {
stop()
function load() {
if (item !== undefined) return item
try {
item = new Player({ volume: 0.35 })
} catch {
item = null
}
return item
}
async function file(path: string) {
mkdirSync(DIR, { recursive: true })
const next = join(DIR, basename(path))
const out = Bun.file(next)
if (await out.exists()) return next
await Bun.write(out, Bun.file(path))
return next
}
function asset() {
cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse }))
return cache
}
function pick() {
if (kind !== undefined) return kind
kind = LIST.find((item) => which(item)) ?? null
return kind
}
function run(file: string, volume: number) {
const kind = pick()
if (!kind) return
return Process.spawn(args(kind, file, volume), {
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
})
}
function clear() {
if (!tail) return
clearTimeout(tail)
tail = undefined
}
function play(file: string, volume: number) {
const item = load()
if (!item) return run(file, volume)?.exited
return item.play(file, { volume }).catch(() => run(file, volume)?.exited)
}
export function start() {
stop()
const id = ++seq
void asset().then(({ hum }) => {
if (id !== seq) return
const next = run(hum, 0.24)
if (!next) return
proc = next
void next.exited.then(
() => {
if (id !== seq) return
if (proc === next) proc = undefined
},
() => {
if (id !== seq) return
if (proc === next) proc = undefined
},
)
})
}
export function stop(delay = 0) {
seq++
clear()
if (!proc) return
const next = proc
if (delay <= 0) {
proc = undefined
void Process.stop(next).catch(() => undefined)
return
}
tail = setTimeout(() => {
tail = undefined
if (proc === next) proc = undefined
void Process.stop(next).catch(() => undefined)
}, delay)
}
export function pulse(scale = 1) {
stop(140)
const index = shot++ % FILE.length
void asset()
.then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale))
.catch(() => undefined)
}
export function dispose() {
stop()
}

View file

@ -1,137 +1,135 @@
import { RGBA } from "@opentui/core"
export namespace Terminal {
export type Colors = Awaited<ReturnType<typeof colors>>
export type Colors = Awaited<ReturnType<typeof colors>>
function parse(color: string): RGBA | null {
if (color.startsWith("rgb:")) {
const parts = color.substring(4).split("/")
return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255)
}
if (color.startsWith("#")) {
return RGBA.fromHex(color)
}
if (color.startsWith("rgb(")) {
const parts = color.substring(4, color.length - 1).split(",")
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
}
return null
function parse(color: string): RGBA | null {
if (color.startsWith("rgb:")) {
const parts = color.substring(4).split("/")
return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255)
}
function mode(bg: RGBA | null): "dark" | "light" {
if (!bg) return "dark"
const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
return luminance > 0.5 ? "light" : "dark"
if (color.startsWith("#")) {
return RGBA.fromHex(color)
}
if (color.startsWith("rgb(")) {
const parts = color.substring(4, color.length - 1).split(",")
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
}
return null
}
/**
* Query terminal colors including background, foreground, and palette (0-15).
* Uses OSC escape sequences to retrieve actual terminal color values.
*
* Note: OSC 4 (palette) queries may not work through tmux as responses are filtered.
* OSC 10/11 (foreground/background) typically work in most environments.
*
* Returns an object with background, foreground, and colors array.
* Any query that fails will be null/empty.
*/
export async function colors(): Promise<{
background: RGBA | null
foreground: RGBA | null
colors: RGBA[]
}> {
if (!process.stdin.isTTY) return { background: null, foreground: null, colors: [] }
function mode(bg: RGBA | null): "dark" | "light" {
if (!bg) return "dark"
const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
return luminance > 0.5 ? "light" : "dark"
}
return new Promise((resolve) => {
let background: RGBA | null = null
let foreground: RGBA | null = null
const paletteColors: RGBA[] = []
let timeout: NodeJS.Timeout
/**
* Query terminal colors including background, foreground, and palette (0-15).
* Uses OSC escape sequences to retrieve actual terminal color values.
*
* Note: OSC 4 (palette) queries may not work through tmux as responses are filtered.
* OSC 10/11 (foreground/background) typically work in most environments.
*
* Returns an object with background, foreground, and colors array.
* Any query that fails will be null/empty.
*/
export async function colors(): Promise<{
background: RGBA | null
foreground: RGBA | null
colors: RGBA[]
}> {
if (!process.stdin.isTTY) return { background: null, foreground: null, colors: [] }
const cleanup = () => {
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
return new Promise((resolve) => {
let background: RGBA | null = null
let foreground: RGBA | null = null
const paletteColors: RGBA[] = []
let timeout: NodeJS.Timeout
const cleanup = () => {
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
}
const handler = (data: Buffer) => {
const str = data.toString()
// Match OSC 11 (background color)
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
if (bgMatch) {
background = parse(bgMatch[1])
}
const handler = (data: Buffer) => {
const str = data.toString()
// Match OSC 11 (background color)
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
if (bgMatch) {
background = parse(bgMatch[1])
}
// Match OSC 10 (foreground color)
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
if (fgMatch) {
foreground = parse(fgMatch[1])
}
// Match OSC 4 (palette colors)
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
for (const match of paletteMatches) {
const index = parseInt(match[1])
const color = parse(match[2])
if (color) paletteColors[index] = color
}
// Return immediately if we have all 16 palette colors
if (paletteColors.filter((c) => c !== undefined).length === 16) {
cleanup()
resolve({ background, foreground, colors: paletteColors })
}
// Match OSC 10 (foreground color)
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
if (fgMatch) {
foreground = parse(fgMatch[1])
}
process.stdin.setRawMode(true)
process.stdin.on("data", handler)
// Query background (OSC 11)
process.stdout.write("\x1b]11;?\x07")
// Query foreground (OSC 10)
process.stdout.write("\x1b]10;?\x07")
// Query palette colors 0-15 (OSC 4)
for (let i = 0; i < 16; i++) {
process.stdout.write(`\x1b]4;${i};?\x07`)
// Match OSC 4 (palette colors)
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
for (const match of paletteMatches) {
const index = parseInt(match[1])
const color = parse(match[2])
if (color) paletteColors[index] = color
}
timeout = setTimeout(() => {
// Return immediately if we have all 16 palette colors
if (paletteColors.filter((c) => c !== undefined).length === 16) {
cleanup()
resolve({ background, foreground, colors: paletteColors })
}, 1000)
})
}
// Keep startup mode detection separate from `colors()`: the TUI boot path only
// needs OSC 11 and should resolve on the first background response instead of
// waiting on the full palette query used by system theme generation.
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
if (!process.stdin.isTTY) return "dark"
return new Promise((resolve) => {
let timeout: NodeJS.Timeout
const cleanup = () => {
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
}
}
const handler = (data: Buffer) => {
const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/)
if (!match) return
cleanup()
resolve(mode(parse(match[1])))
}
process.stdin.setRawMode(true)
process.stdin.on("data", handler)
process.stdin.setRawMode(true)
process.stdin.on("data", handler)
process.stdout.write("\x1b]11;?\x07")
// Query background (OSC 11)
process.stdout.write("\x1b]11;?\x07")
// Query foreground (OSC 10)
process.stdout.write("\x1b]10;?\x07")
// Query palette colors 0-15 (OSC 4)
for (let i = 0; i < 16; i++) {
process.stdout.write(`\x1b]4;${i};?\x07`)
}
timeout = setTimeout(() => {
cleanup()
resolve("dark")
}, 1000)
})
}
timeout = setTimeout(() => {
cleanup()
resolve({ background, foreground, colors: paletteColors })
}, 1000)
})
}
// Keep startup mode detection separate from `colors()`: the TUI boot path only
// needs OSC 11 and should resolve on the first background response instead of
// waiting on the full palette query used by system theme generation.
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
if (!process.stdin.isTTY) return "dark"
return new Promise((resolve) => {
let timeout: NodeJS.Timeout
const cleanup = () => {
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
}
const handler = (data: Buffer) => {
const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/)
if (!match) return
cleanup()
resolve(mode(parse(match[1])))
}
process.stdin.setRawMode(true)
process.stdin.on("data", handler)
process.stdout.write("\x1b]11;?\x07")
timeout = setTimeout(() => {
cleanup()
resolve("dark")
}, 1000)
})
}

View file

@ -1,5 +1,5 @@
import type { AssistantMessage, Part, Provider, UserMessage } from "@opencode-ai/sdk/v2"
import { Locale } from "@/util/locale"
import { Locale } from "@/util"
import * as Model from "./model"
export type TranscriptOptions = {

View file

@ -1,11 +1,11 @@
import { Installation } from "@/installation"
import { Server } from "@/server/server"
import { Log } from "@/util/log"
import { Log } from "@/util"
import { Instance } from "@/project/instance"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Rpc } from "@/util/rpc"
import { Rpc } from "@/util"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { Config } from "@/config"
import { GlobalBus } from "@/bus/global"
import { Flag } from "@/flag/flag"
import { writeHeapSnapshot } from "node:v8"

View file

@ -7,8 +7,8 @@ import { Global } from "../../global"
import fs from "fs/promises"
import path from "path"
import os from "os"
import { Filesystem } from "../../util/filesystem"
import { Process } from "../../util/process"
import { Filesystem } from "../../util"
import { Process } from "../../util"
interface UninstallArgs {
keepConfig: boolean

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