Compare commits

...

9 Commits

Author SHA1 Message Date
Kit Langton ffa0645572 perf(tui): batch event propagation 2026-07-21 15:50:12 -04:00
Simon Klee c4fa5e6619 mini: add turn summary visibility setting (#38153) 2026-07-21 21:01:47 +02:00
Aiden Cline 6ec17e5d55 fix(core): align patch Unicode matching (#38160) 2026-07-21 13:47:52 -05:00
Dax 0405670cab refactor(core): move database path policy (#38159) 2026-07-21 18:44:37 +00:00
opencode-agent[bot] caf727ecb7 fix(core): filter unsupported media inputs (#38145)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-21 13:44:10 -05:00
Dax 9c38358197 refactor: centralize client identity (#38148) 2026-07-21 14:01:04 -04:00
Simon Klee dd6c95fdc7 mini: add quiet transcript settings (#38152) 2026-07-21 19:36:34 +02:00
Aiden Cline 6f4b9504e5 feat(codemode): support custom async iterators (#38141) 2026-07-21 12:18:46 -05:00
Aiden Cline dbfbb13ccc fix(core): authorize patch move destinations (#38133) 2026-07-21 12:18:23 -05:00
60 changed files with 1670 additions and 238 deletions
@@ -32,6 +32,9 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
replayLimit: Option.getOrUndefined(input.replayLimit),
demo: input.demo,
tuiConfig: resolved,
config: {
update: (update) => runServicePromise(config.update(update)),
},
}),
)
}),
+3 -3
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env bun
import { NodeRuntime, NodeServices } from "@effect/platform-node"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
import { Observability } from "@opencode-ai/util/observability"
import { Client } from "@opencode-ai/util/client"
import { Updater } from "./services/updater"
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/util/installation/version"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -73,8 +74,7 @@ Effect.logInfo("cli starting", {
Observability.layer({
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
headers: process.env.OTEL_EXPORTER_OTLP_HEADERS,
client: process.env.OPENCODE_CLIENT ?? "cli",
}),
}).pipe(Layer.provide(Client.layer(process.env.OPENCODE_CLIENT))),
),
Effect.provide(NodeServices.layer),
Effect.scoped,
+2
View File
@@ -22,6 +22,7 @@ export type MiniCommandInput = {
replayLimit?: number
demo?: boolean
tuiConfig?: MiniFrontendInput["tuiConfig"]
config?: MiniFrontendInput["config"]
}
type Model = MiniFrontendInput["model"]
@@ -119,6 +120,7 @@ export async function runMini(input: MiniCommandInput) {
replayLimit: input.replayLimit,
demo: input.demo,
tuiConfig: input.tuiConfig,
config: input.config,
})
})
if (result.exitCode !== 0) process.exit(result.exitCode)
+8 -2
View File
@@ -4,7 +4,7 @@ import { NodeServices } from "@effect/platform-node"
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import path from "node:path"
@@ -75,7 +75,13 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
password,
simulation: truthy(process.env.OPENCODE_SIMULATE),
database: {
path: process.env.OPENCODE_DB,
path:
process.env.OPENCODE_DB ??
(["latest", "beta", "prod"].includes(InstallationChannel) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
? "opencode.db"
: `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
},
models: {
url: process.env.OPENCODE_MODELS_URL,
+6 -1
View File
@@ -131,11 +131,16 @@ test("updates a config draft while preserving JSONC comments", async () => {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.prompt = { paste: "compact" }
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide" }
})
}),
)
expect(config).toEqual({ animations: true, prompt: { paste: "compact" } })
expect(config).toEqual({
animations: true,
prompt: { paste: "compact" },
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide" },
})
expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
} finally {
await Bun.$`rm -rf ${directory}`
+7 -4
View File
@@ -36,7 +36,8 @@ ultimate source of truth.
- [x] Regular-expression literals.
- [x] `NaN` and `Infinity` globals.
- [ ] BigInt literals and in-interpreter BigInt arithmetic; BigInt remains invalid at JSON-like host boundaries.
- [ ] Symbol primitive values and symbol-keyed properties.
- [ ] Arbitrary Symbol primitive values and symbol-keyed properties. The confined `Symbol.iterator` and
`Symbol.asyncIterator` keys are available only for custom iterator protocols.
- [ ] Tagged-template calls.
- [ ] Getter and setter definitions in object literals.
@@ -70,9 +71,11 @@ ultimate source of truth.
- [x] `try`, `catch`, optional catch bindings, and `finally`.
- [x] `throw` with arbitrary values.
- [x] Labeled statements, labeled `break`, and labeled `continue`.
- [x] `for await...of` over the supported synchronous collections, awaiting each yielded CodeMode promise or plain
value before binding it. Custom sync/async iterator objects, `Symbol.asyncIterator`, and async generators remain
outside the supported subset.
- [x] `for await...of` over the supported synchronous collections and custom iterator objects using
`Symbol.asyncIterator` or the `Symbol.iterator` fallback. Each iterator step is sequential, yielded promises and
plain values from synchronous collections and sync iterators are awaited before binding, and abrupt loop
completion invokes the iterator's optional `return()`. Custom async iterators control their yielded values, as in
JavaScript; only their `next()` results are awaited. Async generators remain outside the supported subset.
## Functions and callbacks
+7 -1
View File
@@ -36,7 +36,7 @@ export type StatementResult =
export type MemberReference = {
target: SafeObject | Array<unknown> | CodeModeRegExp | CodeModeURL
key: string | number
key: PropertyKey
}
export class CodeModeFunction {
@@ -61,6 +61,12 @@ export class ComputedValue {
export class PromiseNamespace {}
export class SymbolNamespace {}
export const AsyncIteratorSymbol: unique symbol = Symbol("codemode.async-iterator")
export const IteratorSymbol: unique symbol = Symbol("codemode.iterator")
export const IteratorSymbols = [AsyncIteratorSymbol, IteratorSymbol] as const
export type PromiseMethodName = "all" | "allSettled" | "race" | "any" | "resolve" | "reject"
export class PromiseMethodReference {
@@ -13,6 +13,7 @@ import {
PromiseMethodReference,
PromiseNamespace,
SearchFunction,
SymbolNamespace,
UriFunction,
} from "./model.js"
import { ToolReference } from "../tool-runtime.js"
@@ -34,6 +35,7 @@ export const isRuntimeReference = (value: unknown): boolean =>
value instanceof SearchFunction ||
value instanceof PromiseCapabilityFunction ||
value instanceof ErrorConstructorReference ||
value instanceof SymbolNamespace ||
isCodeModeValue(value)
function* childValues(value: object): Generator<unknown> {
@@ -113,7 +115,8 @@ export const typeofValue = (value: unknown): string => {
value instanceof PromiseInstanceMethodReference ||
value instanceof PromiseNamespace ||
value instanceof PromiseCapabilityFunction ||
value instanceof ErrorConstructorReference
value instanceof ErrorConstructorReference ||
value instanceof SymbolNamespace
)
return "function"
if (value instanceof UriFunction || value instanceof SearchFunction) return "function"
+195 -41
View File
@@ -1,7 +1,8 @@
import { Cause, Effect } from "effect"
import { Cause, Effect, Exit } from "effect"
import { isBlockedMember, ToolReference, ToolRuntimeError, type SafeObject } from "../tool-runtime.js"
import {
type AstNode,
AsyncIteratorSymbol,
asNode,
type Binding,
CodeModeFunction,
@@ -19,6 +20,8 @@ import {
IntrinsicReference,
InterpreterRuntimeError,
isRecord,
IteratorSymbol,
IteratorSymbols,
JsonMethodReference,
type MemberReference,
OptionalShortCircuit,
@@ -30,6 +33,7 @@ import {
ProgramThrow,
type ProgramNode,
SearchFunction,
SymbolNamespace,
type StatementResult,
supportedSyntaxMessage,
unsupportedSyntax,
@@ -211,6 +215,12 @@ const loopDeclaration = (left: AstNode, statement: "for...of" | "for...in") => {
}
}
type CustomIterator = {
iterator: SafeObject
next: unknown
asynchronous: boolean
}
export class Interpreter<R> {
private scopes: ScopeStack
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
@@ -241,6 +251,7 @@ export class Interpreter<R> {
globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
globalScope.set("search", { mutable: false, value: new SearchFunction() })
globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
globalScope.set("Symbol", { mutable: false, value: new SymbolNamespace() })
globalScope.set("undefined", { mutable: false, value: undefined })
globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") })
globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") })
@@ -636,9 +647,10 @@ export class Interpreter<R> {
const body = getNode(node, "body")
const iterable = spreadItems(right)
if (iterable === undefined) {
const iterator = iterable === undefined && awaiting ? yield* self.customIterator(right, node) : undefined
if (iterable === undefined && iterator === undefined) {
throw new InterpreterRuntimeError(
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams value.`,
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams${awaiting ? ", or custom iterator" : ""} value.`,
node,
)
}
@@ -657,19 +669,14 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
}
for (const value of iterable) {
const resolved = awaiting
? value instanceof CodeModePromise
? yield* self.settlePromise(value)
: yield* Effect.as(Effect.yieldNow, value)
: value
const result = yield* Effect.gen(function* () {
const evaluateBody = (value: unknown) =>
Effect.gen(function* () {
if (declared) {
self.scopes.push()
if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
yield* self.declarePattern(declared.pattern, resolved, declared.mutable, left, declared.lexical)
yield* self.declarePattern(declared.pattern, value, declared.mutable, left, declared.lexical)
} else if (assignment) {
yield* self.assignPattern(assignment, resolved, left)
yield* self.assignPattern(assignment, value, left)
}
return yield* self.evaluateStatement(body)
}).pipe(
@@ -680,22 +687,48 @@ export class Interpreter<R> {
),
)
if (iterable !== undefined) {
for (const value of iterable) {
const result = yield* evaluateBody(awaiting ? yield* self.awaitValue(value) : value)
if (result.kind === "return") return result
if (result.kind === "break") {
if (result.label !== undefined && !labels?.has(result.label)) return result
return { kind: "none" } satisfies StatementResult
}
if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) return result
}
return { kind: "none" } satisfies StatementResult
}
if (iterator === undefined) throw new InterpreterRuntimeError("Custom iterator is unavailable.", node)
while (true) {
const step = yield* self.nextIteratorResult(iterator, node)
if (step.done) return { kind: "none" } satisfies StatementResult
const bodyExit = yield* Effect.exit(evaluateBody(step.value))
if (!Exit.isSuccess(bodyExit)) {
// Process interruption must remain prompt; user cleanup cannot extend a timeout.
if (!Cause.hasInterruptsOnly(bodyExit.cause)) yield* Effect.exit(self.closeIterator(iterator, node))
return yield* Effect.failCause(bodyExit.cause)
}
const result = bodyExit.value
if (result.kind === "return") {
yield* self.closeIterator(iterator, node)
return result
}
if (result.kind === "break") {
yield* self.closeIterator(iterator, node)
if (result.label !== undefined && !labels?.has(result.label)) return result
return { kind: "none" } satisfies StatementResult
}
if (result.kind === "continue") {
if (result.label !== undefined && !labels?.has(result.label)) return result
continue
if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) {
yield* self.closeIterator(iterator, node)
return result
}
}
return { kind: "none" } satisfies StatementResult
}).pipe(
Effect.ensuring(
Effect.sync(() => {
@@ -705,6 +738,101 @@ export class Interpreter<R> {
)
}
private awaitValue(value: unknown): Effect.Effect<unknown, unknown, R> {
return value instanceof CodeModePromise ? this.settlePromise(value) : Effect.as(Effect.yieldNow, value)
}
private customIterator(value: unknown, node: AstNode) {
if (!isRecord(value) || isRuntimeReference(value)) return Effect.succeed(undefined)
const asyncMethod = Reflect.get(value, AsyncIteratorSymbol)
const method = asyncMethod ?? Reflect.get(value, IteratorSymbol)
if (method === undefined || method === null) return Effect.succeed(undefined)
const self = this
return Effect.map(
this.invokeCallable(this.requireIteratorMethod(method, "Iterator method", node), [], node),
(iterator) => {
const object = self.requireIteratorObject(iterator, "Iterator method result", node)
return {
iterator: object,
next: self.requireIteratorMethod(object.next, "Iterator next", node),
asynchronous: asyncMethod !== undefined && asyncMethod !== null,
}
},
)
}
private nextIteratorResult(iterator: CustomIterator, node: AstNode) {
const self = this
return Effect.gen(function* () {
if (iterator.asynchronous) {
const object = self.requireIteratorObject(
yield* self.awaitValue(yield* self.invokeCallable(iterator.next, [], node)),
"Iterator next() result",
node,
)
return { done: Boolean(object.done), value: object.value }
}
const called = yield* Effect.exit(self.invokeCallable(iterator.next, [], node))
if (!Exit.isSuccess(called)) {
yield* Effect.yieldNow
return yield* Effect.failCause(called.cause)
}
const captured = yield* Effect.exit(
Effect.sync(() => {
const object = self.requireIteratorObject(called.value, "Iterator next() result", node)
return { done: Boolean(object.done), value: object.value }
}),
)
if (!Exit.isSuccess(captured)) {
yield* Effect.yieldNow
return yield* Effect.failCause(captured.cause)
}
return { done: captured.value.done, value: yield* self.awaitValue(captured.value.value) }
})
}
private closeIterator(iterator: CustomIterator, node: AstNode): Effect.Effect<void, unknown, R> {
const close = iterator.iterator.return
if (close === undefined || close === null) return iterator.asynchronous ? Effect.void : Effect.yieldNow
const self = this
return Effect.gen(function* () {
const method = self.requireIteratorMethod(close, "Iterator return", node)
if (iterator.asynchronous) {
self.requireIteratorObject(
yield* self.awaitValue(yield* self.invokeCallable(method, [], node)),
"Iterator return() result",
node,
)
return
}
const called = yield* Effect.exit(self.invokeCallable(method, [], node))
if (!Exit.isSuccess(called)) {
yield* Effect.yieldNow
return yield* Effect.failCause(called.cause)
}
const captured = yield* Effect.exit(
Effect.sync(() => self.requireIteratorObject(called.value, "Iterator return() result", node).value),
)
if (!Exit.isSuccess(captured)) {
yield* Effect.yieldNow
return yield* Effect.failCause(captured.cause)
}
yield* self.awaitValue(captured.value)
})
}
private requireIteratorObject(value: unknown, context: string, node: AstNode): SafeObject {
if (isRecord(value) && !isRuntimeReference(value)) return value
throw new InterpreterRuntimeError(`${context} must be an object.`, node).as("TypeError")
}
private requireIteratorMethod(value: unknown, context: string, node: AstNode): unknown {
if (typeofValue(value) === "function") return value
throw new InterpreterRuntimeError(`${context} must be a function.`, node).as("TypeError")
}
private enumerableKeys(value: unknown): Array<string> | undefined {
if (value instanceof ToolReference) {
return [...this.toolKeys(value.path)]
@@ -922,7 +1050,7 @@ export class Interpreter<R> {
)
}
const consumed = new Set<string>()
const consumed = new Set<PropertyKey>()
for (const propertyValue of getArray(pattern, "properties")) {
const property = asNode(propertyValue, "properties")
@@ -931,6 +1059,10 @@ export class Interpreter<R> {
for (const [key, item] of Object.entries(value as SafeObject)) {
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
}
for (const symbol of IteratorSymbols) {
if (!consumed.has(symbol) && Object.hasOwn(value, symbol))
Reflect.set(rest, symbol, Reflect.get(value, symbol))
}
yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property, initialize)
continue
}
@@ -939,7 +1071,7 @@ export class Interpreter<R> {
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property)
}
consumed.add(String(key))
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.declarePattern(
getNode(property, "value"),
self.destructuringPropertyValue(value as SafeObject | Array<unknown>, key),
@@ -1002,7 +1134,7 @@ export class Interpreter<R> {
}
const source = value as SafeObject | Array<unknown>
const consumed = new Set<string>()
const consumed = new Set<PropertyKey>()
for (const propertyValue of getArray(pattern, "properties")) {
const property = asNode(propertyValue, "properties")
if (property.type === "RestElement") {
@@ -1010,6 +1142,10 @@ export class Interpreter<R> {
for (const [key, item] of Object.entries(source)) {
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
}
for (const symbol of IteratorSymbols) {
if (!consumed.has(symbol) && Object.hasOwn(source, symbol))
Reflect.set(rest, symbol, Reflect.get(source, symbol))
}
yield* self.assignPattern(getNode(property, "argument"), rest, property)
continue
}
@@ -1017,7 +1153,7 @@ export class Interpreter<R> {
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property)
}
consumed.add(String(key))
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.assignPattern(getNode(property, "value"), self.destructuringPropertyValue(source, key), property)
}
return
@@ -1044,7 +1180,7 @@ export class Interpreter<R> {
})
}
private destructuringPropertyKey(property: AstNode): Effect.Effect<string | number, unknown, R> {
private destructuringPropertyKey(property: AstNode): Effect.Effect<PropertyKey, unknown, R> {
if (property.type !== "Property" || getString(property, "kind") !== "init") {
throw new InterpreterRuntimeError("Unsupported object destructuring property.", property)
}
@@ -1055,12 +1191,12 @@ export class Interpreter<R> {
return Effect.succeed(keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value))
}
private destructuringPropertyValue(source: SafeObject | Array<unknown>, key: string | number): unknown {
if (!Array.isArray(source)) return source[String(key)]
private destructuringPropertyValue(source: SafeObject | Array<unknown>, key: PropertyKey): unknown {
if (!Array.isArray(source)) return Reflect.get(source, key)
if (key === "length") return source.length
if (typeof key === "number") return source[key]
if (Object.hasOwn(source, key)) return (source as Record<string, unknown> & Array<unknown>)[key]
if (arrayMethods.has(key)) return new IntrinsicReference(source, key)
if (Object.hasOwn(source, key)) return Reflect.get(source, key)
if (typeof key === "string" && arrayMethods.has(key)) return new IntrinsicReference(source, key)
return undefined
}
@@ -1690,6 +1826,12 @@ export class Interpreter<R> {
if (callable instanceof PromiseNamespace) {
throw new InterpreterRuntimeError("Constructor Promise requires 'new'.", node).as("TypeError")
}
if (callable instanceof SymbolNamespace) {
throw new InterpreterRuntimeError(
"Symbol is not callable; only Symbol.asyncIterator and Symbol.iterator are available.",
node,
).as("TypeError")
}
if (callable instanceof PromiseCapabilityFunction) {
callable.settle(args[0])
return undefined
@@ -1800,6 +1942,9 @@ export class Interpreter<R> {
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, property)
objectValue[key] = value
}
for (const symbol of IteratorSymbols) {
if (Object.hasOwn(spread, symbol)) Reflect.set(objectValue, symbol, Reflect.get(spread, symbol))
}
continue
}
@@ -1830,7 +1975,7 @@ export class Interpreter<R> {
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, keyNode)
}
objectValue[String(key)] = yield* self.evaluateExpression(valueNode)
Reflect.set(objectValue, key, yield* self.evaluateExpression(valueNode))
}
return objectValue
@@ -1955,6 +2100,12 @@ export class Interpreter<R> {
)
}
if (objectValue instanceof SymbolNamespace) {
if (key === "asyncIterator") return new ComputedValue(AsyncIteratorSymbol)
if (key === "iterator") return new ComputedValue(IteratorSymbol)
return new ComputedValue(undefined)
}
if (objectValue instanceof GlobalNamespace) {
if (typeof key === "string" && isBlockedMember(key)) {
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available.`, propertyNode)
@@ -1976,7 +2127,7 @@ export class Interpreter<R> {
if (typeof objectValue === "string") {
if (key === "length") return new ComputedValue(objectValue.length)
const index = parseArrayIndex(key)
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
if (index !== undefined) return new ComputedValue(objectValue[index])
if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
@@ -2072,7 +2223,7 @@ export class Interpreter<R> {
if (Array.isArray(objectValue)) {
if (operation === "delete") return { target: objectValue, key }
const index = parseArrayIndex(key)
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
if (key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && index === undefined) {
if (typeof key === "string" && Object.hasOwn(objectValue, key)) {
return new ComputedValue((objectValue as Record<string, unknown> & Array<unknown>)[key])
@@ -2103,13 +2254,13 @@ export class Interpreter<R> {
if (Array.isArray(reference.target)) {
if (reference.key === "length") return reference.target.length
if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key)
return reference.target[reference.key]
return Reflect.get(reference.target, reference.key)
}
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
if (reference.target instanceof CodeModeURL) {
return (reference.target.url as unknown as Record<string, unknown>)[String(reference.key)]
return Reflect.get(reference.target.url, reference.key)
}
return reference.target[String(reference.key)]
return Reflect.get(reference.target, reference.key)
})
}
@@ -2171,22 +2322,22 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError("Array methods cannot be assigned.", node)
}
}
const key = Array.isArray(reference.target) ? reference.key : String(reference.key)
const key = reference.key
const { write, next, result } = yield* compute(self.readReferenceValue(reference, key))
if (write) self.assignToReference(reference, key, next, node)
return result
})
}
private readReferenceValue(reference: MemberReference, key: number | string): unknown {
private readReferenceValue(reference: MemberReference, key: PropertyKey): unknown {
if (reference.target instanceof CodeModeURL) {
return (reference.target.url as unknown as Record<string, unknown>)[key]
return Reflect.get(reference.target.url, key)
}
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
return (reference.target as Record<PropertyKey, unknown>)[key]
return Reflect.get(reference.target, key)
}
private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void {
private assignToReference(reference: MemberReference, key: PropertyKey, next: unknown, node: AstNode): void {
if (Array.isArray(reference.target)) {
const target = reference.target
if (typeof key !== "number" || parseArrayIndex(key) === undefined) {
@@ -2219,16 +2370,19 @@ export class Interpreter<R> {
return
}
const target = reference.target as SafeObject
const objectKey = key as string
rejectCircularInsertion(target, next, "Object assignment result", node)
target[objectKey] = next
Reflect.set(target, key, next)
}
private toPropertyKey(value: unknown, node: AstNode): string | number {
private toPropertyKey(value: unknown, node: AstNode): PropertyKey {
if (typeof value === "string" || typeof value === "number") {
return value
}
if (value === AsyncIteratorSymbol || value === IteratorSymbol) return value
throw new InterpreterRuntimeError("Property key must be a string or number.", node)
throw new InterpreterRuntimeError(
"Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator.",
node,
)
}
}
+14 -2
View File
@@ -1,4 +1,10 @@
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import {
type AstNode,
AsyncIteratorSymbol,
InterpreterRuntimeError,
IteratorSymbol,
IteratorSymbols,
} from "../interpreter/model.js"
import { containsOpaqueReference } from "../interpreter/references.js"
import { isBlockedMember } from "../tool-runtime.js"
import { isCodeModeValue, CodeModeMap, CodeModePromise, CodeModeSet, CodeModeURLSearchParams } from "../values.js"
@@ -46,7 +52,10 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
case "entries":
return Object.entries(requireObject()).map(([key, item]) => [key, item])
case "hasOwn":
return Object.hasOwn(requireObject(), String(args[1]))
return Object.hasOwn(
requireObject(),
args[1] === AsyncIteratorSymbol || args[1] === IteratorSymbol ? args[1] : String(args[1]),
)
case "is":
if (containsOpaqueReference(args[0]) || containsOpaqueReference(args[1])) {
throw new InterpreterRuntimeError("Object.is requires data values.", node, "InvalidDataValue")
@@ -64,6 +73,9 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
}
for (const [key, item] of Object.entries(source)) guardedSet(out, key, item)
for (const symbol of IteratorSymbols) {
if (Object.hasOwn(source, symbol)) Reflect.set(out, symbol, Reflect.get(source, symbol))
}
}
return out
}
@@ -1,10 +1,15 @@
/*
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
* - test/language/statements/for-await-of/ticks-with-sync-iter-resolved-promise-and-constructor-lookup.js
* - test/language/statements/for-await-of/ticks-with-async-iter-resolved-promise-and-constructor-lookup.js
* - test/language/statements/for-await-of/async-func-dstr-let-ary-ptrn-elem-id-iter-val.js
* - test/language/statements/for-await-of/async-func-decl-dstr-array-rest-after-element.js
* - test/language/statements/for-await-of/iterator-close-non-throw-get-method-is-null.js
* - test/language/statements/for-await-of/iterator-close-non-throw-get-method-non-callable.js
* - test/language/statements/for-await-of/iterator-close-throw-get-method-non-callable.js
*
* Copyright (C) 2019 André Bargull. All rights reserved.
* Copyright (C) 2020 Alexey Shvayka. All rights reserved.
* Test262 portions are governed by the BSD license in LICENSE.test262.
*/
import { describe, expect, test } from "bun:test"
@@ -130,10 +135,400 @@ describe("Test262 for-await-of adaptations", () => {
).toEqual([1, 3])
})
test("keeps custom iterator objects outside the supported subset", async () => {
test("drives a custom async iterator sequentially", async () => {
expect(
await value(`
let index = 0
const iterator = {
[Symbol.asyncIterator]: () => iterator,
async next() {
index += 1
if (index > 3) return { done: true }
return { done: false, value: index }
},
}
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1, 2, 3])
})
test("leaves async iterator values under the iterator's control", async () => {
expect(
await value(`
let done = false
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => done ? { done: true } : (done = true, { done: false, value: Promise.resolve(1) }),
}
for await (const item of iterator) return [item instanceof Promise, await item]
`),
).toEqual([true, 1])
})
test("awaits synchronous results from an async iterator before the body", async () => {
expect(
await value(`
const events = ["pre"]
const ticks = Promise.resolve()
.then(() => events.push("tick 1"))
.then(() => events.push("tick 2"))
let done = false
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: () => done ? { done: true } : (done = true, { done: false, value: Promise.resolve(1) }),
}
for await (const item of iterator) events.push(item instanceof Promise ? "loop" : "adopted")
events.push("post")
await ticks
return events
`),
).toEqual(["pre", "tick 1", "loop", "tick 2", "post"])
})
test("falls back to a custom synchronous iterator", async () => {
expect(
await value(`
let index = 0
const iterator = {
[Symbol.iterator]: () => iterator,
next() {
index += 1
return index > 2 ? { done: true } : { done: false, value: Promise.resolve(index) }
},
}
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1, 2])
})
test("adopts terminal and close values from synchronous iterators", async () => {
expect(
await value(`
const terminal = {
[Symbol.iterator]: () => terminal,
next: () => ({ done: true, value: Promise.reject("terminal") }),
}
let terminalError
try {
for await (const item of terminal) {}
} catch (error) {
terminalError = error
}
const closing = {
[Symbol.iterator]: () => closing,
next: () => ({ done: false, value: 1 }),
return: () => ({ done: true, value: Promise.reject("close") }),
}
let closeError
try {
for await (const item of closing) break
} catch (error) {
closeError = error
}
return [terminalError, closeError]
`),
).toEqual(["terminal", "close"])
})
test("captures the next method when acquiring the iterator", async () => {
expect(
await value(`
let count = 0
const next = () => {
count += 1
if (count === 1) iterator.next = () => ({ done: true })
return count > 2 ? { done: true } : { done: false, value: count }
}
const iterator = { [Symbol.iterator]: () => iterator, next }
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1, 2])
})
test("captures synchronous iterator result fields before suspending", async () => {
expect(
await value(`
let count = 0
const result = { done: false, value: 1 }
const iterator = {
[Symbol.iterator]: () => iterator,
next() {
count += 1
if (count > 1) return { done: true }
Promise.resolve().then(() => {
result.done = true
result.value = 2
})
return result
},
}
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1])
})
test("preserves the async close turn for a sync iterator without return", async () => {
expect(
await value(`
const events = []
const iterator = {
[Symbol.iterator]: () => iterator,
next: () => ({ done: false, value: 1 }),
}
for await (const item of iterator) {
Promise.resolve().then(() => events.push("reaction"))
break
}
events.push("after")
return events
`),
).toEqual(["reaction", "after"])
})
test("defers synchronous iterator protocol errors", async () => {
expect(
await value(`
const events = []
const throwing = {
[Symbol.iterator]: () => throwing,
next() {
Promise.resolve().then(() => events.push("next reaction"))
throw "next"
},
}
try {
for await (const item of throwing) {}
} catch (error) {
events.push("next catch")
}
const malformed = {
[Symbol.iterator]: () => malformed,
next() {
Promise.resolve().then(() => events.push("result reaction"))
return 1
},
}
try {
for await (const item of malformed) {}
} catch (error) {
events.push("result catch")
}
const closing = {
[Symbol.iterator]: () => closing,
next: () => ({ done: false, value: 1 }),
return() {
Promise.resolve().then(() => events.push("return reaction"))
throw "return"
},
}
try {
for await (const item of closing) break
} catch (error) {
events.push("return catch")
}
return events
`),
).toEqual(["next reaction", "next catch", "result reaction", "result catch", "return reaction", "return catch"])
})
test("prefers Symbol.asyncIterator over Symbol.iterator", async () => {
expect(
await value(`
let done = false
const asyncIterator = {
next: async () => done ? { done: true } : (done = true, { done: false, value: "async" }),
}
const syncIterator = { next: () => ({ done: true }) }
const iterable = {
[Symbol.asyncIterator]: () => asyncIterator,
[Symbol.iterator]: () => syncIterator,
}
const values = []
for await (const item of iterable) values.push(item)
return values
`),
).toEqual(["async"])
})
test("closes a custom iterator on abrupt loop completion", async () => {
expect(
await value(`
let closed = 0
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: async () => (closed += 1, { done: true }),
}
for await (const item of iterator) break
try {
for await (const item of iterator) throw "stop"
} catch (error) {}
return closed
`),
).toBe(2)
})
test("treats a null iterator return method as absent", async () => {
expect(
await value(`
let count = 0
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: null,
}
for await (const item of iterator) {
count += 1
break
}
return count
`),
).toBe(1)
})
test("a non-callable return method replaces break with TypeError", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: true,
}
try {
for await (const item of iterator) break
} catch (error) {
return error.name
}
return "missed"
`),
).toBe("TypeError")
})
test("a body throw wins over a non-callable return method", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: true,
}
try {
for await (const item of iterator) throw "body"
} catch (error) {
return error
}
return "missed"
`),
).toBe("body")
})
test("rejects a primitive iterator return result", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: async () => null,
}
try {
for await (const item of iterator) break
} catch (error) {
return error.name
}
return "missed"
`),
).toBe("TypeError")
})
test("propagates iterator acquisition failures", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => { throw "acquire" },
}
try {
for await (const item of iterator) {}
} catch (error) {
return error
}
return "missed"
`),
).toBe("acquire")
})
test("rejects malformed iterator acquisition methods and results", async () => {
expect(
await value(`
const names = []
const invalid = [
{ [Symbol.asyncIterator]: true },
{ [Symbol.asyncIterator]: () => 1 },
{ [Symbol.asyncIterator]: () => ({ next: true }) },
]
for (const iterator of invalid) {
try {
for await (const item of iterator) {}
} catch (error) {
names.push(error.name)
}
}
return names
`),
).toEqual(["TypeError", "TypeError", "TypeError"])
})
test("preserves iterator protocol keys through object copies", async () => {
expect(
await value(`
let done = false
const iterable = {
plain: true,
[Symbol.asyncIterator]: () => iterable,
next: async () => done ? { done: true } : (done = true, { done: false, value: 1 }),
}
const spread = { ...iterable }
const { plain, ...rest } = iterable
const assigned = Object.assign({}, iterable)
const values = []
for await (const item of spread) values.push(item)
return [
values,
Object.hasOwn(spread, Symbol.asyncIterator),
Object.hasOwn(rest, Symbol.asyncIterator),
Object.hasOwn(assigned, Symbol.asyncIterator),
]
`),
).toEqual([[1], true, true, true])
})
test("rejects malformed iterator protocol results", async () => {
const result = await execute(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => 1,
}
for await (const item of iterator) {}
`)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.message).toContain("Iterator next() result must be an object")
})
test("rejects objects without an iterator protocol method", async () => {
const result = await execute(`for await (const item of { values: [1, 2] }) {}`)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.message).toContain("for await...of requires an array, string, Map, Set, or URLSearchParams")
expect(result.error.message).toContain("or custom iterator value")
})
})
+12
View File
@@ -717,6 +717,18 @@ describe("destructuring assignment", () => {
).toEqual({ first: 1, rest: [2, 3], entry: "a4" })
})
test("excludes computed numeric keys from object rest", async () => {
expect(
await value(`
const { [0]: declared, ...declarationRest } = { 0: "a", 1: "b" }
let assigned
let assignmentRest
;({ [0]: assigned, ...assignmentRest } = { 0: "c", 1: "d" })
return { declared, declarationRest, assigned, assignmentRest }
`),
).toEqual({ declared: "a", declarationRest: { 1: "b" }, assigned: "c", assignmentRest: { 1: "d" } })
})
test("rejects computed keys that are not confined property keys", async () => {
const err = await error(`const key = {}; const { [key]: value } = {}`)
expect(err.message).toContain("Property key must be a string or number")
+4 -13
View File
@@ -6,7 +6,6 @@ import { Context, Effect, Layer, Schema } from "effect"
import { Global } from "@opencode-ai/util/global"
import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration"
import { InstallationChannel } from "@opencode-ai/util/installation/version"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
@@ -40,20 +39,12 @@ const databaseLayer = Layer.effect(
}).pipe(Effect.orDie),
)
export function layer(options?: Options) {
export function layer(options: Options = { path: ":memory:" }) {
return Layer.suspend(() => {
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
if (options?.path === ":memory:" || (options?.path && isAbsolute(options.path))) return provide(options.path)
if (options?.path) return provide(join(Global.Path.data, options.path))
if (
["latest", "beta", "prod"].includes(InstallationChannel) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
)
return provide(join(Global.Path.data, "opencode.db"))
return provide(
join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
)
const filename = options.path ?? ":memory:"
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
return provide(join(Global.Path.data, filename))
})
}
+11 -9
View File
@@ -3,6 +3,7 @@ import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effe
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ModelsDev } from "@opencode-ai/schema/models-dev"
import { Money } from "@opencode-ai/schema/money"
import { Client } from "@opencode-ai/util/client"
import { Global } from "@opencode-ai/util/global"
import { Flock } from "@opencode-ai/util/flock"
import { Hash } from "@opencode-ai/util/hash"
@@ -531,17 +532,18 @@ export const Options = Schema.Struct({
url: Schema.optional(Schema.String),
file: Schema.optional(Schema.String),
fetch: Schema.optional(Schema.Boolean),
client: Schema.optional(Schema.String),
})
export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
export const layer = (options?: Options) => Layer.effect(
export const layer = (options?: Options) =>
Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const events = yield* EventV2.Service
const client = yield* Client.Name
const http = HttpClient.filterStatusOk(
(yield* HttpClient.HttpClient).pipe(
HttpClient.retryTransient({
@@ -554,7 +556,7 @@ export const layer = (options?: Options) => Layer.effect(
const source = options?.url || "https://models.dev"
const fetch = options?.fetch ?? true
const userAgent = `opencode/${InstallationChannel}/${InstallationVersion}/${options?.client ?? "cli"}`
const userAgent = `opencode/${InstallationChannel}/${InstallationVersion}/${client}`
const filepath = path.join(
Global.Path.cache,
source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`,
@@ -581,11 +583,7 @@ export const layer = (options?: Options) => Layer.effect(
const loadFromDisk = fs.readJson(options?.file ?? filepath).pipe(
Effect.map((input) => input as Record<string, SourceProvider>),
Effect.catch((error) => {
if (
options?.file === undefined &&
error._tag === "FileSystemError" &&
error.method === "readJson"
) {
if (options?.file === undefined && error._tag === "FileSystemError" && error.method === "readJson") {
return fs.remove(filepath, { force: true }).pipe(Effect.ignore, Effect.as(undefined))
}
return Effect.succeed(undefined)
@@ -659,7 +657,11 @@ export const layer = (options?: Options) => Layer.effect(
)
export function configured(options?: Options) {
return makeGlobalNode({ service: Service, layer: layer(options), deps: [FSUtil.node, EventV2.node, httpClient] })
return makeGlobalNode({
service: Service,
layer: layer(options),
deps: [FSUtil.node, EventV2.node, Client.node, httpClient],
})
}
export const node = configured()
+11 -13
View File
@@ -10,6 +10,7 @@ import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event"
import type { SessionMessage } from "./message"
import { SessionModelHeaders } from "./model-headers"
import { Client } from "@opencode-ai/util/client"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
import { toSessionError } from "./to-session-error"
@@ -60,7 +61,7 @@ type Settings = {
}
type Dependencies = {
readonly headers?: SessionModelHeaders.Options
readonly client: string
readonly events: EventV2.Interface
readonly llm: {
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
@@ -259,7 +260,7 @@ const make = (dependencies: Dependencies) => {
.stream(
LLM.request({
model: plan.model,
http: { headers: SessionModelHeaders.make(plan.session, dependencies.headers) },
http: { headers: SessionModelHeaders.make(plan.session, dependencies.client) },
messages: [Message.user(plan.prompt)],
tools: [],
}),
@@ -391,23 +392,20 @@ const make = (dependencies: Dependencies) => {
})
}
export const layer = (options?: SessionModelHeaders.Options) => Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const llm = yield* LLMClient.Service
const config = yield* Config.Service
const models = yield* SessionRunnerModel.Service
return make({ events, llm, models, config: settings(yield* config.entries()), headers: options })
const client = yield* Client.Name
return make({ events, llm, models, config: settings(yield* config.entries()), client })
}),
)
export function configured(options?: SessionModelHeaders.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node],
})
}
export const node = configured()
export const node = makeLocationNode({
service: Service,
layer,
deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node, Client.node],
})
+9 -11
View File
@@ -4,6 +4,7 @@ import { LLM, LLMClient, Message, SystemPart } from "@opencode-ai/ai"
import { Effect, Layer } from "effect"
import { Database } from "../database/database"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Client } from "@opencode-ai/util/client"
import { llmClient } from "../effect/app-node-platform"
import { PluginHooks } from "../plugin/hooks"
import { SessionContext } from "./context"
@@ -14,7 +15,7 @@ import { SessionRunnerModel } from "./runner/model"
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { toLLMMessages } from "./runner/to-llm-message"
export const layer = (options?: SessionModelHeaders.Options) => Layer.effect(
export const layer = Layer.effect(
SessionGenerate.Service,
Effect.gen(function* () {
const context = yield* SessionContext.Service
@@ -22,6 +23,7 @@ export const layer = (options?: SessionModelHeaders.Options) => Layer.effect(
const hooks = yield* PluginHooks.Service
const llm = yield* LLMClient.Service
const models = yield* SessionRunnerModel.Service
const client = yield* Client.Name
return SessionGenerate.Service.of({
generate: Effect.fn("SessionGenerate.generate")(function* (input) {
@@ -49,7 +51,7 @@ export const layer = (options?: SessionModelHeaders.Options) => Layer.effect(
return (yield* llm.generate(
LLM.request({
model: model.model,
http: { headers: SessionModelHeaders.make(selection.session, options) },
http: { headers: SessionModelHeaders.make(selection.session, client) },
providerOptions: { openai: { promptCacheKey } },
system: contextEvent.system,
messages: contextEvent.messages,
@@ -62,12 +64,8 @@ export const layer = (options?: SessionModelHeaders.Options) => Layer.effect(
}),
)
export function configured(options?: SessionModelHeaders.Options) {
return makeLocationNode({
service: SessionGenerate.Service,
layer: layer(options),
deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, llmClient],
})
}
export const node = configured()
export const node = makeLocationNode({
service: SessionGenerate.Service,
layer,
deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, Client.node, llmClient],
})
+2 -11
View File
@@ -2,22 +2,13 @@ export * as SessionModelHeaders from "./model-headers"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { SessionSchema } from "./schema"
import { Schema } from "effect"
export const Options = Schema.Struct({
client: Schema.optional(Schema.String),
})
export type Options = typeof Options.Type
export const make = (
session: Pick<SessionSchema.Info, "id" | "parentID" | "projectID">,
options?: Options,
) => ({
export const make = (session: Pick<SessionSchema.Info, "id" | "parentID" | "projectID">, client: string) => ({
"x-session-affinity": session.id,
"X-Session-Id": session.id,
...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
"User-Agent": `opencode/${InstallationVersion}`,
"x-opencode-project": session.projectID,
"x-opencode-session": session.id,
"x-opencode-client": options?.client ?? "cli",
"x-opencode-client": client,
})
+51 -9
View File
@@ -1,9 +1,11 @@
export * as SessionModelRequest from "./model-request"
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import { LLM, Message, SystemPart, type LLMRequest, type ToolContent } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Client } from "@opencode-ai/util/client"
import { ModelV2 } from "../model"
import { PluginHooks } from "../plugin/hooks"
import { ToolRegistry } from "../tool/registry"
import { SessionContext } from "./context"
@@ -26,6 +28,45 @@ interface PrepareInput {
readonly step: number
}
const mimeToModality = (mime: string) => {
if (mime.startsWith("image/")) return "image"
if (mime.startsWith("audio/")) return "audio"
if (mime.startsWith("video/")) return "video"
if (mime === "application/pdf") return "pdf"
}
const unsupportedMedia = (mime: string, name: string | undefined, capabilities: ModelV2.Capabilities) => {
const modality = mimeToModality(mime)
if (!modality || capabilities.input.some((item) => item.startsWith(modality))) return
return {
type: "text" as const,
text: `ERROR: Cannot read ${name ? `"${name}"` : modality} (this model does not support ${modality} input). Inform the user.`,
}
}
export const unsupportedParts = (messages: LLMRequest["messages"], capabilities: ModelV2.Capabilities) =>
messages.map((message) =>
Message.make({
...message,
content: message.content.map((part) => {
if (part.type === "media") {
return unsupportedMedia(part.mediaType, part.filename, capabilities) ?? part
}
if (part.type !== "tool-result" || part.result.type !== "content") return part
return {
...part,
result: {
...part.result,
value: part.result.value.map((item: ToolContent) => {
if (item.type !== "file") return item
return unsupportedMedia(item.mime, item.name, capabilities) ?? item
}),
},
}
}),
}),
)
/**
* Builds an outbound model request and captures the tool-call capability that
* must remain paired with it. It does not execute the request or mutate
@@ -39,11 +80,12 @@ export interface Interface {
/** Location-scoped outbound model-request preparation. */
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionModelRequest") {}
export const layer = (options?: SessionModelHeaders.Options) => Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const registry = yield* ToolRegistry.Service
const client = yield* Client.Name
const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) {
const session = input.context.session
@@ -81,11 +123,11 @@ export const layer = (options?: SessionModelHeaders.Options) => Layer.effect(
const request = LLM.request({
model,
http: {
headers: SessionModelHeaders.make(session, options),
headers: SessionModelHeaders.make(session, client),
},
providerOptions: { openai: { promptCacheKey } },
system: contextEvent.system,
messages: contextEvent.messages,
messages: unsupportedParts(contextEvent.messages, resolved.capabilities),
tools: hookedTools,
toolChoice: stepLimitReached ? "none" : undefined,
})
@@ -112,8 +154,8 @@ export const layer = (options?: SessionModelHeaders.Options) => Layer.effect(
}),
)
export function configured(options?: SessionModelHeaders.Options) {
return makeLocationNode({ service: Service, layer: layer(options), deps: [PluginHooks.node, ToolRegistry.node] })
}
export const node = configured()
export const node = makeLocationNode({
service: Service,
layer,
deps: [PluginHooks.node, ToolRegistry.node, Client.node],
})
+14 -3
View File
@@ -82,6 +82,8 @@ export interface Resolved {
readonly model: Model
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */
readonly ref: ModelV2.Ref
/** Catalog capabilities used to shape requests before provider lowering. */
readonly capabilities: ModelV2.Capabilities
/** Catalog pricing in dollars per million tokens. */
readonly cost: ModelV2.Info["cost"]
}
@@ -96,14 +98,22 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
export const resolved = (model: Model, variant?: ModelV2.VariantID, cost: ModelV2.Info["cost"] = []): Resolved => ({
export const resolved = (
model: Model,
options: {
readonly capabilities: ModelV2.Capabilities
readonly variant?: ModelV2.VariantID
readonly cost: ModelV2.Info["cost"]
},
): Resolved => ({
model,
ref: ModelV2.Ref.make({
id: ModelV2.ID.make(model.id),
providerID: ProviderV2.ID.make(model.provider),
...(variant === undefined ? {} : { variant }),
...(options.variant === undefined ? {} : { variant: options.variant }),
}),
cost,
capabilities: options.capabilities,
cost: options.cost,
})
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
@@ -359,6 +369,7 @@ const layer = Layer.effect(
providerID: selected.providerID,
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
}),
capabilities: selected.capabilities,
cost: selected.cost,
}
}),
+11 -13
View File
@@ -6,6 +6,7 @@ import { AgentV2 } from "../agent"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Client } from "@opencode-ai/util/client"
import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event"
import { SessionHistory } from "./history"
@@ -17,7 +18,7 @@ import { SessionUsage } from "./usage"
const MAX_LENGTH = 100
type Dependencies = {
readonly headers?: SessionModelHeaders.Options
readonly client: string
readonly events: EventV2.Interface
readonly llm: {
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
@@ -67,7 +68,7 @@ const make = (dependencies: Dependencies) => {
.stream(
LLM.request({
model: resolved.model,
http: { headers: SessionModelHeaders.make(session, dependencies.headers) },
http: { headers: SessionModelHeaders.make(session, dependencies.client) },
system: agent.system,
messages: [Message.user(firstUser.text)],
tools: [],
@@ -103,7 +104,7 @@ const make = (dependencies: Dependencies) => {
return { generateForFirstPrompt }
}
export const layer = (options?: SessionModelHeaders.Options) => Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
@@ -111,19 +112,16 @@ export const layer = (options?: SessionModelHeaders.Options) => Layer.effect(
const agents = yield* AgentV2.Service
const models = yield* SessionRunnerModel.Service
const database = yield* Database.Service
const title = make({ events, llm, agents, models, headers: options })
const client = yield* Client.Name
const title = make({ events, llm, agents, models, client })
return Service.of({
generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session),
})
}),
)
export function configured(options?: SessionModelHeaders.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node],
})
}
export const node = configured()
export const node = makeLocationNode({
service: Service,
layer,
deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node, Client.node],
})
+1
View File
@@ -189,6 +189,7 @@ export const Plugin = {
catch: (error) => new ToolFailure({ message: `patch verification failed: ${String(error)}` }),
})
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
if (moveTarget) targets.push(moveTarget)
if (moveTarget?.externalDirectory) {
yield* permission.assert({
action: "external_directory",
+47
View File
@@ -40,6 +40,18 @@ describe("Patch", () => {
expect(() => parse("*** Begin Patch\n*** Add File: add.txt\n+added")).toThrow(
"The last line of the patch must be '*** End Patch'",
)
expect(() => parse("extra\n*** Begin Patch\n*** End Patch")).toThrow(
"The first line of the patch must be '*** Begin Patch'",
)
expect(() => parse("*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\nextra")).toThrow(
"The last line of the patch must be '*** End Patch'",
)
})
test("allows whitespace after the end marker", () => {
expect(parse("*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\n \t\n")).toEqual([
{ type: "add", path: "add.txt", contents: "added" },
])
})
test("strips a heredoc wrapper", () => {
@@ -169,6 +181,41 @@ describe("Patch", () => {
).toBe('He said "hi"\n')
})
test("matches Unicode minus signs and spaces", () => {
expect(
Patch.derive("minus.txt", [{ oldLines: ["value - 1"], newLines: ["value - 2"] }], "value 1\n")
.content,
).toBe("value - 2\n")
const spaces = ["\u00A0", "\u2002", "\u2003", "\u2004", "\u2005", "\u2006", "\u2007", "\u2008", "\u2009", "\u200A", "\u202F", "\u205F", "\u3000"]
spaces.forEach(
(space) => {
expect(
Patch.derive(
"spaces.txt",
[{ oldLines: ["hello world"], newLines: ["hello there"] }],
`hello${space}world\n`,
).content,
).toBe("hello there\n")
},
)
})
test("does not normalize ellipses", () => {
expect(() =>
Patch.derive("ellipsis.txt", [{ oldLines: ["wait..."], newLines: ["done"] }], "wait…\n"),
).toThrow("Failed to find expected lines")
})
test("prefers a later exact match over an earlier normalized match", () => {
expect(
Patch.derive(
"quotes.txt",
[{ oldLines: ['He said "hello"'], newLines: ['He said "goodbye"'] }],
'He said “hello”\nmiddle\nHe said "hello"\n',
).content,
).toBe('He said “hello”\nmiddle\nHe said "goodbye"\n')
})
test("matches EOF-anchored chunks from the end", () => {
expect(
Patch.derive(
+9 -2
View File
@@ -49,14 +49,21 @@ const client = Layer.mock(LLMClient.Service)({
generate: () => Effect.die("unused"),
})
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
const models = SessionRunnerModel.layerWith(() =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
)
const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
// The test only needs the compaction location service used by SessionV2.compact.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
SessionCompaction.layer().pipe(
SessionCompaction.layer.pipe(
Layer.provide(client),
Layer.provide(config),
Layer.provide(models),
@@ -68,7 +68,13 @@ const client = Layer.mock(LLMClient.Service)({
})
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)),
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost,
}),
),
})
const it = testEffect(
AppNodeBuilder.build(
+8 -1
View File
@@ -65,7 +65,14 @@ const client = Layer.mock(LLMClient.Service)({
return response
}),
})
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
const models = SessionRunnerModel.layerWith(() =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
)
const builtins = Layer.mock(InstructionBuiltIns.Service, {
load: () =>
Effect.succeed(
@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test"
import { Message, ToolResultPart } from "@opencode-ai/ai"
import { unsupportedParts } from "@opencode-ai/core/session/model-request"
const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] })
describe("SessionModelRequest.unsupportedParts", () => {
test("replaces unsupported user media with a visible error", () => {
const messages = unsupportedParts(
[
Message.user([
Message.text("Describe this image"),
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "logo.png" },
]),
],
capabilities(["text"]),
)
expect(messages[0]?.content).toEqual([
Message.text("Describe this image"),
Message.text('ERROR: Cannot read "logo.png" (this model does not support image input). Inform the user.'),
])
})
test("replaces unsupported media nested in tool results", () => {
const messages = unsupportedParts(
[
Message.tool(
ToolResultPart.make({
id: "call_1",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "logo.png" },
],
},
}),
),
],
capabilities(["text"]),
)
expect(messages[0]?.content[0]).toMatchObject({
type: "tool-result",
result: {
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{
type: "text",
text: 'ERROR: Cannot read "logo.png" (this model does not support image input). Inform the user.',
},
],
},
})
})
test("preserves supported media", () => {
const message = Message.user({ type: "media", mediaType: "image/png", data: "aGVsbG8=" })
expect(unsupportedParts([message], capabilities(["text", "image"]))[0]?.content).toEqual(message.content)
})
})
@@ -73,7 +73,14 @@ const model = OpenAIChat.route
generation: { maxTokens: 20, temperature: 0 },
})
.model({ id: "gpt-4o-mini" })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
const models = SessionRunnerModel.layerWith(() =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
)
const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) })
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
const skillInstructions = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
+5 -4
View File
@@ -283,10 +283,11 @@ let currentModel = model
const models = SessionRunnerModel.layerWith((session) =>
modelResolveHook.pipe(
Effect.as(
SessionRunnerModel.resolved(
session.model?.id === "replacement" ? replacementModel : currentModel,
session.model?.variant,
),
SessionRunnerModel.resolved(session.model?.id === "replacement" ? replacementModel : currentModel, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
variant: session.model?.variant,
}),
),
),
)
+7 -1
View File
@@ -65,7 +65,13 @@ const client = Layer.mock(LLMClient.Service)({
generate: () => Effect.die("unused"),
})
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)),
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost,
}),
),
})
const it = testEffect(
AppNodeBuilder.build(
+22
View File
@@ -351,6 +351,28 @@ describe("PatchTool", () => {
),
)
it.live("includes the move destination in edit permission resources", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const source = path.join(directory, "old", "name.txt")
yield* Effect.promise(() => fs.mkdir(path.dirname(source), { recursive: true }))
yield* Effect.promise(() => fs.writeFile(source, "old content\n"))
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch",
),
)
expect(assertions).toMatchObject([
{
action: "edit",
resources: ["old/name.txt", "renamed/dir/name.txt"],
},
])
}),
),
)
it.live("inserts lines with an insert-only hunk", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
+5 -3
View File
@@ -9,9 +9,11 @@ export const create = Effect.fn("OpenCode.create")(function* (options: ServerOpt
const runtime = yield* Effect.acquireRelease(
Effect.sync(() =>
ManagedRuntime.make(
createEmbeddedRoutes({ ...options, database: { path: ":memory:", ...options.database } }).pipe(
Layer.provide(HttpServer.layerServices),
),
createEmbeddedRoutes({
...options,
client: options.client ?? "sdk",
database: { path: ":memory:", ...options.database },
}).pipe(Layer.provide(HttpServer.layerServices)),
),
),
(runtime) => runtime.disposeEffect,
+4 -6
View File
@@ -6,6 +6,7 @@ import { EventV2 } from "@opencode-ai/core/event"
import { EventLogger } from "@opencode-ai/core/event-logger"
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
import { Observability } from "@opencode-ai/util/observability"
import { Client } from "@opencode-ai/util/client"
import { Credential } from "@opencode-ai/core/credential"
import { Config } from "@opencode-ai/core/config"
import { CommandV2 } from "@opencode-ai/core/command"
@@ -87,7 +88,8 @@ function makeRoutes<AuthError, AuthServices>(
const pluginRuntimeCell = PluginRuntime.makeCell()
const replacements: LayerNode.Replacements = [
[Database.node, Database.configured(options.database)],
[ModelsDev.node, ModelsDev.configured({ ...options.models, client: options.client })],
[Client.node, Client.configured(options.client)],
[ModelsDev.node, ModelsDev.configured(options.models)],
[Watcher.node, Watcher.configured({ enabled: options.fs?.filewatcher })],
[FileSystemSearch.node, FileSystemSearch.configured({ fff: options.fs?.fff })],
[Global.node, Global.layerWith(options.config?.directory ? { config: options.config.directory } : {})],
@@ -103,10 +105,6 @@ function makeRoutes<AuthError, AuthServices>(
[CommandV2.node, CommandV2.configured({ gitbash: options.windows?.gitbash })],
[Pty.node, Pty.configured({ gitbash: options.windows?.gitbash })],
[Shell.node, Shell.configured({ gitbash: options.windows?.gitbash })],
[SessionCompaction.node, SessionCompaction.configured({ client: options.client })],
[SessionGenerateNode.node, SessionGenerateNode.configured({ client: options.client })],
[SessionModelRequest.node, SessionModelRequest.configured({ client: options.client })],
[SessionTitle.node, SessionTitle.configured({ client: options.client })],
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
]
@@ -135,7 +133,7 @@ function makeRoutes<AuthError, AuthServices>(
Layer.provide(authorizationLayer),
Layer.provide(schemaErrorLayer),
Layer.provide(auth),
Layer.provide(Observability.layer({ ...options.observability, client: options.client })),
Layer.provide(Observability.layer(options.observability).pipe(Layer.provide(Client.layer(options.client)))),
HttpRouter.provideRequest(requestServices),
Layer.provideMerge(services),
Layer.provideMerge(HttpRouter.layer),
+13
View File
@@ -122,6 +122,19 @@ export const Info = Schema.Struct({
}),
}),
).annotate({ description: "Session transcript presentation settings" }),
mini: Schema.optional(
Schema.Struct({
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
description: "Show or hide model reasoning in Mini",
}),
shell_output: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
description: "Show or hide raw shell tool output in Mini",
}),
turn_summary: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
description: "Show or hide the agent, model, and duration summary in Mini scrollback",
}),
}),
).annotate({ description: "Mini transcript presentation settings" }),
hints: Schema.optional(
Schema.Struct({
onboarding: Schema.optional(Schema.Boolean).annotate({ description: "Show getting-started guidance" }),
+10 -2
View File
@@ -1,8 +1,9 @@
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup, onMount } from "solid-js"
import { batch, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { errorMessage } from "../util/error"
import { createEventBatcher } from "./event-batcher"
import { createSimpleContext } from "./helper"
import { useLog } from "./log"
@@ -61,6 +62,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
const cancel = () => request.abort(controller.signal.reason)
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
controller.signal.addEventListener("abort", cancel, { once: true })
let queued: ReturnType<typeof createEventBatcher<OpenCodeEvent>> | undefined
const error = await (async () => {
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
log.info("event stream connecting", { attempt })
@@ -79,6 +81,11 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
log.info("event stream connected")
events.emit(first.value.type, first.value)
setConnection({ status: "connected", attempt: 0, error: undefined })
queued = createEventBatcher((pending) => {
batch(() => {
for (const event of pending) events.emit(event.type, event)
})
})
while (!abort.signal.aborted && !controller.signal.aborted) {
const event = await iterator.next()
if (abort.signal.aborted || controller.signal.aborted) return undefined
@@ -89,12 +96,13 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
aggregateID: event.value.durable.aggregateID,
seq: event.value.durable.seq,
})
events.emit(event.value.type, event.value)
queued.add(event.value)
}
return undefined
})()
.catch((error) => error)
.finally(() => {
queued?.end(abort.signal.aborted || controller.signal.aborted)
request.abort()
clearTimeout(timeout)
controller.signal.removeEventListener("abort", cancel)
+57
View File
@@ -0,0 +1,57 @@
const defaultInterval = 16
const defaultLimit = 1_024
type Options = {
interval?: number
limit?: number
now?: () => number
schedule?: (callback: () => void, delay: number) => ReturnType<typeof setTimeout>
cancel?: (timer: ReturnType<typeof setTimeout>) => void
}
export function createEventBatcher<T>(onFlush: (events: T[]) => void, options: Options = {}) {
const interval = options.interval ?? defaultInterval
const limit = options.limit ?? defaultLimit
const now = options.now ?? Date.now
const schedule = options.schedule ?? setTimeout
const cancel = options.cancel ?? clearTimeout
let queue: T[] = []
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
let ended = false
function flush() {
if (queue.length === 0) return
const pending = queue
queue = []
timer = undefined
last = now()
onFlush(pending)
}
return {
add(event: T) {
if (ended) return
queue.push(event)
if (queue.length >= limit) {
if (timer !== undefined) cancel(timer)
flush()
return
}
if (timer !== undefined) return
if (now() - last >= interval) {
flush()
return
}
timer = schedule(flush, interval)
},
end(discard: boolean) {
if (ended) return
ended = true
if (timer !== undefined) cancel(timer)
timer = undefined
if (!discard) flush()
queue = []
},
}
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { toolEntryBody } from "./tool"
import type { RunEntryBody, StreamCommit } from "./types"
import type { RunEntryBody, ScrollbackOptions, StreamCommit } from "./types"
export type EntryFlags = {
startOnNewLine: boolean
@@ -162,7 +162,7 @@ export function entryCanStream(commit: StreamCommit, body: RunEntryBody): boolea
return commit.kind === "assistant" || commit.kind === "reasoning"
}
export function entryBody(commit: StreamCommit): RunEntryBody {
export function entryBody(commit: StreamCommit, options?: ScrollbackOptions): RunEntryBody {
if (commit.summary) {
return RUN_ENTRY_NONE
}
@@ -174,7 +174,7 @@ export function entryBody(commit: StreamCommit): RunEntryBody {
}
if (commit.kind === "tool") {
return toolEntryBody(commit, raw) ?? RUN_ENTRY_NONE
return toolEntryBody(commit, raw, options) ?? RUN_ENTRY_NONE
}
if (commit.kind === "assistant") {
+119 -3
View File
@@ -5,7 +5,15 @@ import fuzzysort from "fuzzysort"
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme"
import type { FooterQueuedPrompt, FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types"
import type {
FooterQueuedPrompt,
FooterSubagentTab,
MiniSettingChange,
MiniSettings,
RunCommand,
RunInput,
RunProvider,
} from "./types"
type PanelEntry = RunFooterMenuItem & {
category: string
@@ -20,6 +28,7 @@ type CommandEntry =
| (PanelEntry & { action: "subagent" })
| (PanelEntry & { action: "variant.cycle" })
| (PanelEntry & { action: "variant.list" })
| (PanelEntry & { action: "settings" })
| (PanelEntry & { action: "slash"; name: string })
| (PanelEntry & { action: "exit" })
@@ -48,6 +57,10 @@ type QueuedEntry = PanelEntry & {
prompt: FooterQueuedPrompt
}
type SettingEntry = PanelEntry & {
key: keyof MiniSettings
}
const PANEL_PAD = 2
const PANEL_LIST_ROWS = 10
const PANEL_FRAME_ROWS = 6
@@ -266,6 +279,7 @@ function PanelShell(props: {
inputRef: (input: InputRenderable) => void
onQuery: (query: string) => void
children: JSX.Element
hint?: string
dark?: boolean
chrome?: "default" | "minimal"
}) {
@@ -294,7 +308,7 @@ function PanelShell(props: {
) : null}
<box flexGrow={1} flexShrink={1} backgroundColor="transparent" />
<text fg={props.theme().muted} wrapMode="none" truncate flexShrink={0}>
esc
{props.hint ? `${props.hint} · ` : ""}esc
</text>
</box>
<box height={1} flexShrink={0} backgroundColor={background()} />
@@ -400,6 +414,7 @@ export function RunCommandMenuBody(props: {
onQueued: () => void
onVariant: () => void
onVariantCycle: () => void
onSettings: () => void
onCommand: (name: string) => void
onNew: () => void
onExit: () => void
@@ -407,7 +422,7 @@ export function RunCommandMenuBody(props: {
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length)
const entries = createMemo<CommandEntry[]>(() => {
const builtins = ["editor", "new"]
const builtins = ["editor", "new", "settings"]
const session: CommandEntry[] = [
{
action: "editor",
@@ -515,6 +530,13 @@ export function RunCommandMenuBody(props: {
...prompt,
...agent,
...commands,
{
action: "settings",
category: "System",
display: "Open settings",
footer: "/settings",
keywords: "/settings settings preferences configuration",
},
{ action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" },
]
})
@@ -554,6 +576,11 @@ export function RunCommandMenuBody(props: {
return
}
if (item.action === "settings") {
props.onSettings()
return
}
if (item.action === "exit") {
props.onExit()
return
@@ -606,6 +633,95 @@ export function RunCommandMenuBody(props: {
)
}
export function RunSettingsBody(props: {
theme: Accessor<RunFooterTheme>
settings: Accessor<MiniSettings>
onClose: () => void
onChange: (change: MiniSettingChange) => void | Promise<void>
}) {
const [saving, setSaving] = createSignal<keyof MiniSettings>()
const entries = createMemo<SettingEntry[]>(() => [
{
category: "Transcript",
display: "Thinking",
description: "future sessions",
footer: saving() === "thinking" ? "saving" : props.settings().thinking,
keywords: `thinking reasoning ${props.settings().thinking}`,
key: "thinking",
},
{
category: "Transcript",
display: "Shell tool output",
description: "model-issued commands",
footer: saving() === "shell_output" ? "saving" : props.settings().shell_output,
keywords: `shell tool command output ${props.settings().shell_output}`,
key: "shell_output",
},
{
category: "Transcript",
display: "Turn summary",
description: "agent, model, and duration",
footer: saving() === "turn_summary" ? "saving" : props.settings().turn_summary,
keywords: `turn summary agent model duration ${props.settings().turn_summary}`,
key: "turn_summary",
},
])
const change = (item: SettingEntry) => {
if (saving()) return
const current = props.settings()[item.key]
setSaving(item.key)
void Promise.resolve(props.onChange({ key: item.key, value: current === "show" ? "hide" : "show" }))
.catch(() => {})
.finally(() => setSaving())
}
const controller = createSearchablePanelController({
entries,
limit: PANEL_LIST_ROWS,
onClose: props.onClose,
onSelect: change,
onKey(event, item) {
const name = event.name.toLowerCase()
if (name !== "left" && name !== "right") return false
event.preventDefault()
if (item) change(item)
return true
},
})
return (
<PanelShell
title="Settings"
countVisible={false}
query={controller.query()}
count={controller.items().length}
total={entries().length}
placeholder="Search"
theme={props.theme}
inputRef={controller.inputRef}
onQuery={controller.setQuery}
hint="left/right change"
dark
chrome="minimal"
>
<RunFooterMenu
theme={props.theme}
items={controller.items}
selected={controller.menu.selected}
offset={controller.menu.offset}
rows={() => PANEL_LIST_ROWS}
limit={PANEL_LIST_ROWS}
empty="No settings found"
border={false}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={!controller.query().trim()}
background
headerColor={props.theme().muted}
/>
</PanelShell>
)
}
export function RunSubagentSelectBody(props: {
theme: Accessor<RunFooterTheme>
tabs: Accessor<FooterSubagentTab[]>
+22 -1
View File
@@ -51,7 +51,7 @@ type Auto = RunFooterMenuItem & {
type SlashOption = RunFooterMenuItem & {
kind: "slash"
name: string
action?: "skill-menu" | "editor"
action?: "skill-menu" | "editor" | "settings"
}
type PromptOption = Auto | SlashOption
@@ -79,6 +79,7 @@ type PromptInput = {
onExitRequest?: () => boolean
onExit: () => void
onSkillMenu: () => void
onSettings: () => void
onRows: (rows: number) => void
onStatus: (text: string) => void
}
@@ -380,6 +381,13 @@ export function createPromptState(input: PromptInput): PromptState {
display: "/editor",
description: "compose in your external editor",
} satisfies SlashOption,
{
kind: "slash",
action: "settings" as const,
name: "settings",
display: "/settings",
description: "configure Mini transcript output",
} satisfies SlashOption,
{ kind: "slash", name: "new", display: "/new", description: "start a new session" } satisfies SlashOption,
{ kind: "slash", name: "exit", display: "/exit", description: "close OpenCode" } satisfies SlashOption,
]
@@ -815,6 +823,12 @@ export function createPromptState(input: PromptInput): PromptState {
return
}
if (next.action === "settings" && !shell()) {
cancelAutocomplete()
input.onSettings()
return
}
const cursor = area.cursorOffset
const head = parseSlashHead(area.plainText)
const local = !shell() && (next.name === "new" || next.name === "exit")
@@ -922,6 +936,7 @@ export function createPromptState(input: PromptInput): PromptState {
if (current === "skill") return false
if (current === "model") return false
if (current === "variant") return false
if (current === "settings") return false
if (current === "queued-menu") return false
if (current === "subagent-menu") return false
return true
@@ -1119,6 +1134,12 @@ export function createPromptState(input: PromptInput): PromptState {
return
}
if (!command && next.mode !== "shell" && next.text.trim().toLowerCase() === "/settings") {
resetDraft()
input.onSettings()
return
}
const parsed =
command || next.mode === "shell" || isNewCommand(next.text)
? undefined
+2 -1
View File
@@ -56,6 +56,7 @@ export function RunFooterSubagentBody(props: {
// Formatted interrupt shortcut from the registered keymap binding; the
// command itself is dispatched through the keymap in footer.view.
interrupt?: () => string | undefined
shellOutput?: () => boolean
}) {
const theme = createMemo(() => props.theme())
const footer = createMemo(() => theme().footer)
@@ -86,7 +87,7 @@ export function RunFooterSubagentBody(props: {
const rows = indexArray(commits, (commit, index) => (
<box flexDirection="column" gap={0} flexShrink={0}>
{index > 0 && separatorRows(commits()[index - 1], commit()) > 0 ? <box height={1} flexShrink={0} /> : null}
<RunEntryContent commit={commit()} theme={theme()} />
<RunEntryContent commit={commit()} theme={theme()} opts={{ shellOutput: props.shellOutput?.() ?? true }} />
</box>
))
let scroll: ScrollBoxRenderable | undefined
+38 -19
View File
@@ -48,6 +48,8 @@ import type {
FooterView,
FormCancel,
FormReply,
MiniSettingChange,
MiniSettings,
PermissionReply,
RunAgent,
RunCommand,
@@ -81,6 +83,10 @@ type RunFooterOptions = {
history?: RunPrompt[]
theme: RunTheme
tuiConfig: RunTuiConfig
miniSettings: {
current: MiniSettings
update?: (change: MiniSettingChange) => Promise<MiniSettings>
}
onPermissionReply: (input: PermissionReply) => void | Promise<void>
onFormReply: (input: FormReply) => void | Promise<void>
onFormCancel: (input: FormCancel) => void | Promise<void>
@@ -97,11 +103,7 @@ type RunFooterOptions = {
const PERMISSION_ROWS = 12
const FORM_ROWS = 14
const COMMAND_ROWS = RUN_COMMAND_PANEL_ROWS
const SKILL_ROWS = RUN_COMMAND_PANEL_ROWS
const SUBAGENT_ROWS = RUN_SUBAGENT_PANEL_ROWS
const MODEL_ROWS = RUN_COMMAND_PANEL_ROWS
const VARIANT_ROWS = RUN_COMMAND_PANEL_ROWS
const NOTICE_DURATION = 3000
const THEME_REFRESH_DELAYS = [1000, 1000] as const
@@ -185,6 +187,8 @@ export class RunFooter implements FooterApi {
private setQueuedPrompts: Setter<FooterQueuedPrompt[]>
private history: Accessor<RunPrompt[]>
private setHistory: Setter<RunPrompt[]>
private miniSettings: Accessor<MiniSettings>
private setMiniSettings: Setter<MiniSettings>
private promptRoute: FooterPromptRoute = { type: "composer" }
private subagentMenuRows = SUBAGENT_ROWS
private interruptTimeout: NodeJS.Timeout | undefined
@@ -209,6 +213,7 @@ export class RunFooter implements FooterApi {
.catch(() => {})
.finally(() => this.destroyTheme(theme))
},
shellOutput: () => this.miniSettings().shell_output === "show",
})
}
@@ -269,6 +274,9 @@ export class RunFooter implements FooterApi {
const [history, setHistory] = createSignal(options.history ?? [])
this.history = history
this.setHistory = setHistory
const [miniSettings, setMiniSettings] = createSignal(options.miniSettings.current)
this.miniSettings = miniSettings
this.setMiniSettings = setMiniSettings
this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
this.scrollback = this.createScrollback(options.wrote ?? false)
@@ -300,6 +308,7 @@ export class RunFooter implements FooterApi {
currentVariant: footer.currentVariant,
theme: footer.theme,
tuiConfig: options.tuiConfig,
miniSettings: footer.miniSettings,
history: footer.history,
onSubmit: footer.handlePrompt,
onPermissionReply: footer.handlePermissionReply,
@@ -318,6 +327,7 @@ export class RunFooter implements FooterApi {
onRows: footer.syncRows,
onLayout: footer.syncLayout,
onStatus: footer.setStatus,
onMiniSettingChange: footer.handleMiniSettingChange,
onSubagentSelect: options.onSubagentSelect,
onSubagentInterrupt: options.onSubagentInterrupt,
})
@@ -374,6 +384,7 @@ export class RunFooter implements FooterApi {
}
if (next.type === "turn.duration") {
if (this.miniSettings().turn_summary === "hide") return
const current = this.currentModel()
this.flush()
this.flushing = this.flushing
@@ -662,26 +673,19 @@ export class RunFooter implements FooterApi {
// get fixed extra rows; the prompt view scales with textarea line count.
private applyHeight(): void {
const type = this.view().type
const route = this.promptRoute.type
const height =
type === "permission"
? this.base + PERMISSION_ROWS
: type === "form"
? this.base + FORM_ROWS
: this.promptRoute.type === "command"
? 1 + COMMAND_ROWS
: this.promptRoute.type === "skill"
? 1 + SKILL_ROWS
: this.promptRoute.type === "model"
? 1 + MODEL_ROWS
: this.promptRoute.type === "variant"
? 1 + VARIANT_ROWS
: this.promptRoute.type === "queued-menu"
? 1 + this.subagentMenuRows
: this.promptRoute.type === "subagent-menu"
? 1 + this.subagentMenuRows
: this.promptRoute.type === "subagent"
? this.base + SUBAGENT_INSPECTOR_ROWS
: this.base + Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, this.rows))
: ["command", "skill", "model", "variant", "settings"].includes(route)
? 1 + RUN_COMMAND_PANEL_ROWS
: route === "queued-menu" || route === "subagent-menu"
? 1 + this.subagentMenuRows
: route === "subagent"
? this.base + SUBAGENT_INSPECTOR_ROWS
: this.base + Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, this.rows))
if (height !== this.renderer.footerHeight) {
this.renderer.footerHeight = height
@@ -864,6 +868,21 @@ export class RunFooter implements FooterApi {
.catch(() => {})
}
private handleMiniSettingChange = async (change: MiniSettingChange): Promise<void> => {
if (!this.options.miniSettings.update) {
this.setNotice("settings are unavailable")
return
}
try {
this.setMiniSettings(await this.options.miniSettings.update(change))
this.setNotice("settings updated")
} catch (error) {
this.setNotice("failed to save settings")
throw error
}
}
private clearInterruptTimer(): void {
if (!this.interruptTimeout) {
return
+25 -1
View File
@@ -17,6 +17,7 @@ import {
RunCommandMenuBody,
RunModelSelectBody,
RunQueuedPromptSelectBody,
RunSettingsBody,
RunSkillSelectBody,
RunSubagentSelectBody,
RunVariantSelectBody,
@@ -39,6 +40,8 @@ import type {
FooterView,
FormCancel,
FormReply,
MiniSettingChange,
MiniSettings,
PermissionReply,
RunAgent,
RunCommand,
@@ -82,6 +85,7 @@ type RunFooterViewProps = {
queuedPrompts?: () => FooterQueuedPrompt[]
theme: () => RunTheme
tuiConfig: RunTuiConfig
miniSettings: () => MiniSettings
history?: () => RunPrompt[]
onSubmit: (input: RunPrompt) => boolean
onPermissionReply: (input: PermissionReply) => void | Promise<void>
@@ -100,6 +104,7 @@ type RunFooterViewProps = {
onRows: (rows: number) => void
onLayout: (input: { route: FooterPromptRoute; subagentRows: number }) => void
onStatus: (text: string) => void
onMiniSettingChange: (change: MiniSettingChange) => void | Promise<void>
onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void
}
@@ -131,6 +136,7 @@ export function RunFooterView(props: RunFooterViewProps) {
const skilling = createMemo(() => active().type === "prompt" && route().type === "skill")
const modeling = createMemo(() => active().type === "prompt" && route().type === "model")
const varianting = createMemo(() => active().type === "prompt" && route().type === "variant")
const setting = createMemo(() => active().type === "prompt" && route().type === "settings")
const panel = createMemo(
() =>
active().type === "permission" ||
@@ -140,7 +146,8 @@ export function RunFooterView(props: RunFooterViewProps) {
commanding() ||
skilling() ||
modeling() ||
varianting(),
varianting() ||
setting(),
)
const selected = createMemo(() => {
const current = route()
@@ -257,6 +264,11 @@ export function RunFooterView(props: RunFooterViewProps) {
props.onSubagentSelect?.(undefined)
}
const openSettings = () => {
setRoute({ type: "settings" })
props.onSubagentSelect?.(undefined)
}
const openSubagentMenu = () => {
if (tabs().length === 0) {
return
@@ -323,6 +335,7 @@ export function RunFooterView(props: RunFooterViewProps) {
onExitRequest: props.onExitRequest,
onExit: props.onExit,
onSkillMenu: openSkillMenu,
onSettings: openSettings,
onRows: props.onRows,
onStatus: props.onStatus,
})
@@ -559,6 +572,7 @@ export function RunFooterView(props: RunFooterViewProps) {
current.type !== "skill" &&
current.type !== "model" &&
current.type !== "variant" &&
current.type !== "settings" &&
current.type !== "queued-menu" &&
current.type !== "subagent-menu"
) {
@@ -668,6 +682,7 @@ export function RunFooterView(props: RunFooterViewProps) {
onSubagent={openSubagentMenu}
onQueued={openQueuedMenu}
onVariant={openVariant}
onSettings={openSettings}
onVariantCycle={() => {
props.onCycle()
closePanel()
@@ -726,6 +741,14 @@ export function RunFooterView(props: RunFooterViewProps) {
}}
/>
</Match>
<Match when={setting()}>
<RunSettingsBody
theme={theme}
settings={props.miniSettings}
onClose={closePanel}
onChange={props.onMiniSettingChange}
/>
</Match>
<Match when={active().type === "permission"}>
<RunPermissionBody
request={permission()!.request}
@@ -904,6 +927,7 @@ export function RunFooterView(props: RunFooterViewProps) {
onCycle={cycleTab}
onClose={closeTab}
interrupt={() => subagentInterruptShortcut() || undefined}
shellOutput={() => props.miniSettings().shell_output === "show"}
/>
</box>
</Show>
+9 -1
View File
@@ -8,7 +8,7 @@ import type { LocationRef } from "@opencode-ai/client/promise"
import { resolve } from "../config"
import { loadRunProviders } from "./catalog.shared"
import { resolveCurrentSession, sessionHistory } from "./session.shared"
import type { RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
import type { MiniSettings, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
import { pickVariant } from "./variant.shared"
export type ModelInfo = {
@@ -83,3 +83,11 @@ export async function resolveRunTuiConfig(
.then((value) => value ?? defaultRunTuiConfig(platform))
.catch(() => defaultRunTuiConfig(platform))
}
export function resolveMiniSettings(config?: { mini?: Partial<MiniSettings> }): MiniSettings {
return {
thinking: config?.mini?.thinking ?? "hide",
shell_output: config?.mini?.shell_output ?? "hide",
turn_summary: config?.mini?.turn_summary ?? "show",
}
}
@@ -18,6 +18,8 @@ import type {
FooterApi,
FormCancel,
FormReply,
MiniSettingChange,
MiniSettings,
MiniHost,
PermissionReply,
RunAgent,
@@ -26,6 +28,7 @@ import type {
RunReference,
RunTuiConfig,
} from "./types"
import { resolveMiniSettings } from "./runtime.boot"
import { formatModelLabel } from "./variant.shared"
const FOOTER_HEIGHT = 4
@@ -62,6 +65,7 @@ export type LifecycleInput = {
model: RunInput["model"]
variant: string | undefined
tuiConfig: RunTuiConfig | Promise<RunTuiConfig>
onMiniSettingChange?: (change: MiniSettingChange) => Promise<MiniSettings>
onPermissionReply: (input: PermissionReply) => void | Promise<void>
onFormReply: (input: FormReply) => void | Promise<void>
onFormCancel: (input: FormCancel) => void | Promise<void>
@@ -224,6 +228,10 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
theme,
wrote,
tuiConfig,
miniSettings: {
current: resolveMiniSettings(tuiConfig),
update: input.onMiniSettingChange,
},
onPermissionReply: input.onPermissionReply,
onFormReply: input.onFormReply,
onFormCancel: input.onFormCancel,
+41 -7
View File
@@ -10,11 +10,27 @@
// 4. runs the prompt queue until the footer closes.
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { LocationRef } from "@opencode-ai/client/promise"
import type { Config } from "../config"
import { loadRunAgents, loadRunCommands, loadRunReferences, waitForDefaultModel } from "./catalog.shared"
import { resolveModelInfo, resolveModelInfoStrict, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
import {
resolveMiniSettings,
resolveModelInfo,
resolveModelInfoStrict,
resolveRunTuiConfig,
resolveSessionInfo,
} from "./runtime.boot"
import { createRuntimeLifecycle } from "./runtime.lifecycle"
import { cycleVariant, formatModelLabel, resolveVariant } from "./variant.shared"
import type { LocalReplayRow, MiniHost, RunInput, RunPrompt, RunProvider, RunTuiConfig, StreamCommit } from "./types"
import type {
LocalReplayRow,
MiniHost,
MiniSettings,
RunInput,
RunPrompt,
RunProvider,
RunTuiConfig,
StreamCommit,
} from "./types"
type BootContext = Pick<RunInput, "sdk" | "agent" | "model" | "variant"> & {
location: LocationRef
@@ -43,6 +59,7 @@ type RunRuntimeInput = {
replayLimit?: number
demo?: RunInput["demo"]
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
config?: Pick<Config.Interface, "update">
}
export type RunDeferredInput = {
@@ -62,6 +79,7 @@ export type RunDeferredInput = {
replayLimit?: number
demo?: RunInput["demo"]
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
config?: Pick<Config.Interface, "update">
}
type StreamTransportModule = Pick<
@@ -174,7 +192,12 @@ function abortable<A>(task: Promise<A>, signal: AbortSignal): Promise<A | undefi
async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDeps = {}): Promise<void> {
const start = input.host.startup.now()
const log = input.host.diagnostics.trace
const tuiConfigTask = resolveRunTuiConfig(input.tuiConfig, input.host.platform)
const config = input.config
const configState: { current: MiniSettings } = { current: resolveMiniSettings() }
const tuiConfigTask = resolveRunTuiConfig(input.tuiConfig, input.host.platform).then((tuiConfig) => {
configState.current = resolveMiniSettings(tuiConfig)
return tuiConfig
})
const ctx = await input.boot()
const runtimeController = new AbortController()
const session = {
@@ -226,6 +249,16 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
model: state.model,
variant: state.activeVariant,
tuiConfig: tuiConfigTask,
onMiniSettingChange: config
? async (change) => {
const info = await config.update((draft) => {
if (!draft.mini || typeof draft.mini !== "object") draft.mini = {}
draft.mini[change.key] = change.value
})
configState.current = resolveMiniSettings(info)
return configState.current
}
: undefined,
onPermissionReply: async (next) => {
if (state.demo?.permission(next)) {
return
@@ -359,8 +392,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
})
},
})
const tuiConfig = await tuiConfigTask
const thinking = input.thinking ?? tuiConfig.session?.thinking !== "hide"
await tuiConfigTask
const thinking = () => input.thinking ?? configState.current.thinking === "show"
const footer = shell.footer
const firstPaint = footer.idle().catch(() => {})
const offRuntimeClose = footer.onClose(() => runtimeController.abort())
@@ -679,7 +712,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
return createRunDemo({
footer,
sessionID: state.sessionID,
thinking,
thinking: thinking(),
})
}
@@ -722,7 +755,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
readTextFile: input.host.files.readText,
location: state.location,
sessionID: state.sessionID,
thinking,
thinking: thinking(),
replay: input.replay,
replayLimit: input.replayLimit,
footer,
@@ -1018,6 +1051,7 @@ export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?:
replayLimit: input.replayLimit,
demo: input.demo,
tuiConfig: input.tuiConfig,
config: input.config,
reconnect: input.reconnect,
resolveSession: input.target,
createSession: input.createSession,
+4 -1
View File
@@ -88,6 +88,7 @@ export class RunScrollbackStream {
private active: ActiveEntry | undefined
private treeSitterClient: TreeSitterClient | undefined
private wrote: boolean
private shellOutput: () => boolean
private pendingThemes: RunTheme[] = []
constructor(
@@ -97,10 +98,12 @@ export class RunScrollbackStream {
wrote?: boolean
treeSitterClient?: TreeSitterClient
onThemeRelease?: (theme: RunTheme) => void
shellOutput?: () => boolean
} = {},
) {
this.treeSitterClient = options.treeSitterClient
this.wrote = options.wrote ?? false
this.shellOutput = options.shellOutput ?? (() => true)
this.onThemeRelease = options.onThemeRelease
}
@@ -354,7 +357,7 @@ export class RunScrollbackStream {
return
}
const body = entryBody(commit)
const body = entryBody(commit, { shellOutput: this.shellOutput() })
if (body.type === "none") {
if (entryDone(commit)) {
this.markRendered(await this.finishActive(false))
+1 -1
View File
@@ -74,7 +74,7 @@ export function RunEntryContent(props: {
opts?: ScrollbackOptions
}) {
const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
const body = createMemo(() => props.body ?? entryBody(props.commit))
const body = createMemo(() => props.body ?? entryBody(props.commit, props.opts))
const style = createMemo(() => entryLook(props.commit, theme().entry))
const syntax = createMemo(() => entrySyntax(theme()))
const color = createMemo(() => entryColor(props.commit, theme()))
+6 -4
View File
@@ -758,10 +758,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
client.session.active(options),
])
if (!current(attempt)) return
state.pending = new Map(pending.flatMap((item) => {
const prompt = pendingPrompt(item)
return prompt ? [[prompt.messageID, prompt] as const] : []
}))
state.pending = new Map(
pending.flatMap((item) => {
const prompt = pendingPrompt(item)
return prompt ? [[prompt.messageID, prompt] as const] : []
}),
)
syncPending()
state.permissions = permissions
pruneToolSources()
+7 -1
View File
@@ -1273,7 +1273,11 @@ function shellOutput(command: string, raw: string): string | undefined {
return `\n${body}`
}
export function toolEntryBody(commit: StreamCommit, raw: string): RunEntryBody | undefined {
export function toolEntryBody(
commit: StreamCommit,
raw: string,
options?: { shellOutput?: boolean },
): RunEntryBody | undefined {
if (commit.shell) {
if (commit.phase === "start") {
return textBody(`$ ${commit.shell.command}`)
@@ -1294,6 +1298,8 @@ export function toolEntryBody(commit: StreamCommit, raw: string): RunEntryBody |
const ctx = toolFrame(commit, raw)
const view = toolView(ctx.name)
if (ctx.name === "shell" && commit.phase === "progress" && options?.shellOutput === false) return undefined
if (ctx.name === "subagent") {
if (commit.phase === "start") {
return undefined
+14 -1
View File
@@ -184,6 +184,7 @@ export type TurnSummary = {
export type ScrollbackOptions = {
suppressBackgrounds?: boolean
shellOutput?: boolean
}
export type ToolCodeSnapshot = {
@@ -293,6 +294,7 @@ export type FooterPromptRoute =
| { type: "skill" }
| { type: "model" }
| { type: "variant" }
| { type: "settings" }
export type FooterSubagentTab = {
sessionID: string
@@ -389,7 +391,18 @@ export type FormCancel = {
location?: LocationRef
}
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "session">
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "session" | "mini">
export type MiniSettings = {
thinking: "show" | "hide"
shell_output: "show" | "hide"
turn_summary: "show" | "hide"
}
export type MiniSettingChange = {
key: keyof MiniSettings
value: "show" | "hide"
}
// Lifecycle phase of a scrollback entry. "start" opens the entry, "progress"
// appends content (coalesced in the footer queue), "final" closes it.
+57
View File
@@ -792,6 +792,63 @@ test("completes exploration when a queued prompt is promoted", async () => {
}
})
test("batches burst event projections into fewer reactive executions", async () => {
const events = createEventStream()
const calls = createFetch(undefined, events)
const sessionID = "session-event-burst"
let client!: ReturnType<typeof useClient>
let received = 0
let executions = 0
function Probe() {
const data = useData()
client = useClient()
client.event.on("session.input.admitted", () => received++)
createEffect(() => {
data.session.message.list(sessionID).length
executions++
})
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
await wait(() => client.connection.status() === "connected")
const baseline = executions
for (let index = 0; index < 10; index++) {
emitEvent(events, {
id: `evt_input_${index}`,
created: index,
type: "session.input.admitted",
durable: durable(sessionID, index),
data: {
sessionID,
inputID: `message-${index}`,
input: { type: "user", data: { text: `${index}` }, delivery: "steer" },
},
})
}
await wait(() => received === 10)
await Bun.sleep(20)
expect(received).toBe(10)
expect(executions - baseline).toBe(2)
} finally {
app.renderer.destroy()
}
})
test("classifies live tool rows independently of their call ID", async () => {
const events = createEventStream()
const sessionID = "session-tool-call-id"
+43 -5
View File
@@ -51,14 +51,12 @@ function update(version: string): OpenCodeEvent {
}
}
async function mount(
reconnect?: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>,
log?: LogSink,
) {
async function mount(reconnect?: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>, log?: LogSink) {
const events = createEventStream()
const calls = createFetch(undefined, events)
const seen: OpenCodeEvent[] = []
const workspaces: Array<string | undefined> = []
const handshakes: string[] = []
let client!: ReturnType<typeof useClient>
let done!: () => void
const ready = new Promise<void>((resolve) => {
@@ -76,24 +74,27 @@ async function mount(
}}
seen={seen}
workspaces={workspaces}
handshakes={handshakes}
/>
</ClientProvider>
</TestTuiContexts>
))
await ready
return { app, events, emit: events.emit, client, seen, workspaces }
return { app, events, emit: (event: OpenCodeEvent) => events.emit(event), client, seen, workspaces, handshakes }
}
function Probe(props: {
seen: OpenCodeEvent[]
workspaces: Array<string | undefined>
handshakes: string[]
onReady: (ctx: { client: ReturnType<typeof useClient> }) => void
}) {
const client = useClient()
const event = useEvent()
onMount(() => {
client.event.on("server.connected", () => props.handshakes.push(client.connection.status()))
event.subscribe((evt, { workspace }) => {
props.seen.push(evt)
props.workspaces.push(workspace)
@@ -105,6 +106,35 @@ function Probe(props: {
}
describe("useEvent", () => {
test("dispatches server.connected immediately", async () => {
const { app, client, handshakes } = await mount()
try {
await wait(() => client.connection.status() === "connected")
expect(handshakes).toEqual(["connecting"])
} finally {
app.renderer.destroy()
}
})
test("delivers a burst exactly once and in order", async () => {
const { app, client, emit, seen } = await mount()
try {
await wait(() => client.connection.status() === "connected")
for (const branch of ["one", "two", "three"]) emit(vcs(branch))
await wait(() => seen.length === 3)
expect(seen.map((item) => (item.type === "vcs.branch.updated" ? item.data.branch : item.type))).toEqual([
"one",
"two",
"three",
])
} finally {
app.renderer.destroy()
}
})
test("logs only durable events", async () => {
const logs: Array<{ level: LogLevel; message: string; tags: Readonly<Record<string, unknown>> }> = []
const { app, emit, seen } = await mount(undefined, (level, message, tags) => {
@@ -195,6 +225,9 @@ describe("useEvent", () => {
await wait(() => client.connection.status() === "connected")
// Reconnection only runs when the stream is down, never while connected.
expect(attempts).toEqual([])
events.emit(event(vcs("before-drop"), { directory: "/tmp/original" }))
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "before-drop"))
events.emit(event(vcs("at-drop"), { directory: "/tmp/original" }))
events.disconnect()
await wait(() => client.connection.status() === "connected" && attempts.length > 0)
replacementEvents.emit(event(vcs("rediscovered"), { directory: "/tmp/rediscovered" }))
@@ -202,6 +235,11 @@ describe("useEvent", () => {
expect(client.api).toBe(replacement.api)
expect(attempts).toEqual([1])
expect(seen.map((item) => (item.type === "vcs.branch.updated" ? item.data.branch : item.type))).toEqual([
"before-drop",
"at-drop",
"rediscovered",
])
const history = client.connection.internal.history()
expect(history.map((event) => [event.data.status, event.data.attempt])).toEqual([
["connecting", 0],
@@ -0,0 +1,89 @@
import { describe, expect, test } from "bun:test"
import { createEventBatcher } from "../../src/context/event-batcher"
function clock() {
let time = 100
const scheduled = new Map<ReturnType<typeof setTimeout>, { callback: () => void; at: number }>()
return {
now: () => time,
schedule(callback: () => void, delay: number) {
const timer = setTimeout(() => {}, 60_000)
scheduled.set(timer, { callback, at: time + delay })
return timer
},
cancel(timer: ReturnType<typeof setTimeout>) {
clearTimeout(timer)
scheduled.delete(timer)
},
advance(delay: number) {
time += delay
for (const [timer, task] of scheduled) {
if (task.at > time) continue
clearTimeout(timer)
scheduled.delete(timer)
task.callback()
}
},
pending() {
return scheduled.size
},
}
}
describe("createEventBatcher", () => {
test("preserves events in frame-bounded flushes", () => {
const time = clock()
const flushes: number[][] = []
const batcher = createEventBatcher<number>((events) => flushes.push(events), time)
batcher.add(1)
time.advance(1)
batcher.add(2)
batcher.add(3)
expect(flushes).toEqual([[1]])
expect(time.pending()).toBe(1)
time.advance(15)
expect(flushes).toEqual([[1]])
time.advance(1)
expect(flushes).toEqual([[1], [2, 3]])
expect(flushes.flat()).toEqual([1, 2, 3])
})
test("flushes a live generation and discards an obsolete generation", () => {
const time = clock()
const live: number[][] = []
const active = createEventBatcher<number>((events) => live.push(events), time)
active.add(1)
time.advance(1)
active.add(2)
active.end(false)
const obsolete: number[][] = []
const stale = createEventBatcher<number>((events) => obsolete.push(events), time)
stale.add(3)
time.advance(1)
stale.add(4)
stale.end(true)
time.advance(16)
expect(live).toEqual([[1], [2]])
expect(obsolete).toEqual([[3]])
expect(time.pending()).toBe(0)
})
test("caps a batch when timers cannot run", () => {
const time = clock()
const flushes: number[][] = []
const batcher = createEventBatcher<number>((events) => flushes.push(events), { ...time, limit: 3 })
batcher.add(1)
time.advance(1)
batcher.add(2)
batcher.add(3)
batcher.add(4)
expect(flushes).toEqual([[1], [2, 3, 4]])
expect(time.pending()).toBe(0)
})
})
+9 -1
View File
@@ -389,7 +389,6 @@ describe("run entry body", () => {
type: "text",
content: "$ pwd",
})
expect(
entryBody(
commit({
@@ -411,6 +410,15 @@ describe("run entry body", () => {
})
})
test("hides shell tool output but not direct shell output", () => {
const output = commit({ kind: "tool", text: "output", phase: "progress", source: "tool", tool: "shell" })
expect(entryBody(output, { shellOutput: false })).toEqual({ type: "none" })
expect(entryBody({ ...output, shell: { command: "pwd" } }, { shellOutput: false })).toEqual({
type: "text",
content: "\noutput",
})
})
test("falls back to patch summary when patch has no visible diff items", () => {
expect(
entryBody(
@@ -55,6 +55,7 @@ test("down opens subagents from an empty prompt", async () => {
subagent={subagents}
theme={() => RUN_THEME_FALLBACK}
tuiConfig={config}
miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show" })}
onSubmit={() => true}
onPermissionReply={() => {}}
onFormReply={() => {}}
@@ -69,6 +70,7 @@ test("down opens subagents from an empty prompt", async () => {
onRows={() => {}}
onLayout={() => {}}
onStatus={() => {}}
onMiniSettingChange={() => {}}
/>
</Keymap.Provider>
)
+83 -2
View File
@@ -11,6 +11,7 @@ import {
RunCommandMenuBody,
RunModelSelectBody,
RunQueuedPromptSelectBody,
RunSettingsBody,
RunSkillSelectBody,
RunSubagentSelectBody,
RunVariantSelectBody,
@@ -23,6 +24,8 @@ import type {
FooterSubagentState,
FooterSubagentTab,
FooterView,
MiniSettingChange,
MiniSettings,
RunCommand,
RunInput,
RunPrompt,
@@ -117,6 +120,8 @@ async function renderFooter(
onSubmit?: (prompt: RunPrompt) => boolean
view?: FooterView
onFormReply?: (input: unknown) => void
miniSettings?: MiniSettings
onMiniSettingChange?: (change: MiniSettingChange) => void
} = {},
) {
const [view, setView] = createSignal<FooterView>(input.view ?? { type: "prompt" })
@@ -125,6 +130,9 @@ async function renderFooter(
)
const state = footerState(input.state)
const config = input.tuiConfig ?? tuiConfig
const [miniSettings] = createSignal<MiniSettings>(
input.miniSettings ?? { thinking: "hide", shell_output: "hide", turn_summary: "show" },
)
function Harness() {
return (
<Keymap.Provider config={config}>
@@ -143,6 +151,7 @@ async function renderFooter(
subagent={subagents}
theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
tuiConfig={config}
miniSettings={miniSettings}
onSubmit={input.onSubmit ?? (() => true)}
onPermissionReply={() => {}}
onFormReply={(value) => input.onFormReply?.(value)}
@@ -157,6 +166,7 @@ async function renderFooter(
onRows={() => {}}
onLayout={() => {}}
onStatus={() => {}}
onMiniSettingChange={(change) => input.onMiniSettingChange?.(change)}
/>
</Keymap.Provider>
)
@@ -369,6 +379,7 @@ test("direct command panel renders grouped command palette", async () => {
onQueued={() => {}}
onVariant={() => {}}
onVariantCycle={() => {}}
onSettings={() => {}}
onCommand={() => {}}
onNew={() => {}}
onExit={() => {}}
@@ -408,6 +419,52 @@ test("direct command panel renders grouped command palette", async () => {
}
})
test("direct settings panel changes Mini transcript preferences", async () => {
const [settings, setSettings] = createSignal<MiniSettings>({
thinking: "hide",
shell_output: "hide",
turn_summary: "show",
})
const app = await testRender(
() => (
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
<RunSettingsBody
theme={() => RUN_THEME_FALLBACK.footer}
settings={settings}
onClose={() => {}}
onChange={(change) => {
setSettings((current) => ({ ...current, [change.key]: change.value }))
}}
/>
</box>
),
{ width: 100, height: RUN_COMMAND_PANEL_ROWS },
)
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Settings")
expect(app.captureCharFrame()).toContain("Thinking")
expect(app.captureCharFrame()).toContain("Shell tool output")
expect(app.captureCharFrame()).toContain("Turn summary")
expect(app.captureCharFrame()).toContain("left/right change")
app.mockInput.pressKey("ARROW_RIGHT")
await app.renderOnce()
expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "show" })
app.mockInput.pressKey("ARROW_DOWN")
app.mockInput.pressKey("ARROW_DOWN")
app.mockInput.pressKey("ARROW_RIGHT")
await app.renderOnce()
expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "hide" })
} finally {
app.renderer.destroy()
}
})
test("direct skill panel renders searchable skill list", async () => {
const [commands] = createSignal<RunCommand[] | undefined>([
command({ name: "review", description: "Review code" }),
@@ -518,6 +575,7 @@ test("direct command panel shows subagent entry when available", async () => {
onQueued={() => {}}
onVariant={() => {}}
onVariantCycle={() => {}}
onSettings={() => {}}
onCommand={() => {}}
onNew={() => {}}
onExit={() => {}}
@@ -566,6 +624,7 @@ test("direct command panel keeps completed subagents available", async () => {
onQueued={() => {}}
onVariant={() => {}}
onVariantCycle={() => {}}
onSettings={() => {}}
onCommand={() => {}}
onNew={() => {}}
onExit={() => {}}
@@ -822,7 +881,7 @@ test("direct footer submits slash autocomplete selections without dispatching sh
await app.renderOnce()
app.mockInput.pressKey("!")
"/rev".split("").forEach((key) => app.mockInput.pressKey(key))
"/settings".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
@@ -834,7 +893,7 @@ test("direct footer submits slash autocomplete selections without dispatching sh
{ text: "/new ", parts: [] },
{ text: "/new ", parts: [] },
])
expect(app.captureCharFrame()).toContain("/review")
expect(app.renderer.currentFocusedEditor?.plainText).toBe("/settings ")
} finally {
app.cleanup()
}
@@ -867,6 +926,26 @@ test("direct footer slash autocomplete keeps a real skills command", async () =>
}
})
test("direct footer closes settings with ctrl-c instead of arming exit", async () => {
const app = await renderFooter({ height: 20 })
try {
await app.renderOnce()
"/settings".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Shell tool output")
app.mockInput.pressKey("c", { ctrl: true })
await app.renderOnce()
expect(app.captureCharFrame()).not.toContain("Shell tool output")
expect(app.renderer.currentFocusedEditor?.plainText).toBe("")
} finally {
app.cleanup()
}
})
test("selectedCommand backfills the catalog source for bound drafts", () => {
const catalog = [command({ name: "opencode-ts", description: "TS skill", source: "skill" })]
@@ -1034,6 +1113,7 @@ test("direct footer shows authoritative pending work while running", async () =>
]}
theme={() => RUN_THEME_FALLBACK}
tuiConfig={tuiConfig}
miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show" })}
onSubmit={() => true}
onPermissionReply={() => {}}
onFormReply={() => {}}
@@ -1048,6 +1128,7 @@ test("direct footer shows authoritative pending work while running", async () =>
onRows={() => {}}
onLayout={() => {}}
onStatus={() => {}}
onMiniSettingChange={() => {}}
/>
</Keymap.Provider>
)
+10 -4
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client/promise"
import type { Resolved } from "../../src/config"
import { resolveModelInfo, resolveRunTuiConfig } from "../../src/mini/runtime.boot"
import { resolveMiniSettings, resolveModelInfo, resolveRunTuiConfig } from "../../src/mini/runtime.boot"
import { catalogModel, catalogProvider } from "./fixture/catalog"
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
@@ -91,18 +91,24 @@ describe("run runtime boot", () => {
expect(result.keybinds.get("leader")).toEqual([])
})
test("preserves current theme mode, leader, and thinking config", async () => {
test("preserves shared config while resolving independent Mini defaults", async () => {
const result = await resolveRunTuiConfig(
createTuiResolvedConfig({
theme: { mode: "light" },
leader_timeout: 450,
session: { thinking: "hide" },
session: { thinking: "show" },
}),
)
expect(result.theme).toEqual({ mode: "light" })
expect(result.leader.timeout).toBe(450)
expect(result.session?.thinking).toBe("hide")
expect(result.session?.thinking).toBe("show")
expect(resolveMiniSettings(result)).toEqual({ thinking: "hide", shell_output: "hide", turn_summary: "show" })
expect(resolveMiniSettings({ mini: { thinking: "show", shell_output: "show", turn_summary: "hide" } })).toEqual({
thinking: "show",
shell_output: "show",
turn_summary: "hide",
})
})
test("loads v2 providers and models for model selector data", async () => {
+14
View File
@@ -0,0 +1,14 @@
export * as Client from "./client.js"
import { Context, Layer } from "effect"
import { makeGlobalNode } from "./effect/app-node.js"
export const Name = Context.Reference<string>("@opencode/Client/Name", {
defaultValue: () => "cli",
})
export const layer = (name = "cli") => Layer.succeed(Name, name)
export const configured = (name?: string) => makeGlobalNode({ service: Name, layer: layer(name), deps: [] })
export const node = configured()
+6 -4
View File
@@ -7,11 +7,11 @@ import { FetchHttpClient } from "effect/unstable/http"
import { OtlpSerialization } from "effect/unstable/observability"
import { Logging } from "./observability/logging.js"
import { Otlp } from "./observability/otlp.js"
import { Client } from "./client.js"
export const Options = Schema.Struct({
endpoint: Schema.optional(Schema.String),
headers: Schema.optional(Schema.String),
client: Schema.optional(Schema.String),
})
export type Options = typeof Options.Type
@@ -19,7 +19,6 @@ export function layer(
options: Options = {
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
headers: process.env.OTEL_EXPORTER_OTLP_HEADERS,
client: process.env.OPENCODE_CLIENT ?? "cli",
},
) {
const local = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe(
@@ -29,14 +28,17 @@ export function layer(
)
return Layer.unwrap(
Effect.gen(function* () {
const logs = Logger.layer([...Logging.loggers(), ...Otlp.loggers(options)], { mergeWithExisting: false }).pipe(
const client = yield* Client.Name
const logs = Logger.layer([...Logging.loggers(), ...Otlp.loggers(options, client)], {
mergeWithExisting: false,
}).pipe(
Layer.provide(NodeFileSystem.layer),
Layer.provide(OtlpSerialization.layerJson),
Layer.provide(FetchHttpClient.layer),
Layer.orDie,
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
)
return Layer.merge(logs, yield* Effect.promise(() => Otlp.tracingLayer(options)))
return Layer.merge(logs, yield* Effect.promise(() => Otlp.tracingLayer(options, client)))
}),
).pipe(Layer.catchCause(() => local))
}
+16 -13
View File
@@ -6,19 +6,18 @@ import { runID } from "./shared.js"
export interface Options {
readonly endpoint?: string
readonly headers?: string
readonly client?: string
}
function parseHeaders(value?: string) {
return value
? value.split(",").reduce(
(acc, entry) => {
const [key, ...value] = entry.split("=")
acc[key] = value.join("=")
return acc
},
{} as Record<string, string>,
)
(acc, entry) => {
const [key, ...value] = entry.split("=")
acc[key] = value.join("=")
return acc
},
{} as Record<string, string>,
)
: undefined
}
@@ -38,7 +37,11 @@ function resourceAttributes() {
}
}
export function resource(client = "cli"): { serviceName: string; serviceVersion: string; attributes: Record<string, string> } {
export function resource(client = "cli"): {
serviceName: string
serviceVersion: string
attributes: Record<string, string>
} {
return {
serviceName: "opencode",
serviceVersion: InstallationVersion,
@@ -52,18 +55,18 @@ export function resource(client = "cli"): { serviceName: string; serviceVersion:
}
}
export function loggers(options?: Options) {
export function loggers(options: Options | undefined, client: string) {
if (!options?.endpoint) return []
return [
OtlpLogger.make({
url: `${options.endpoint}/v1/logs`,
resource: resource(options.client),
resource: resource(client),
headers: parseHeaders(options.headers),
}),
]
}
export async function tracingLayer(options?: Options) {
export async function tracingLayer(options: Options | undefined, client: string) {
if (!options?.endpoint) return Layer.empty
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
@@ -77,7 +80,7 @@ export async function tracingLayer(options?: Options) {
context.setGlobalContextManager(manager)
return NodeSdk.layer(() => ({
resource: resource(options.client),
resource: resource(client),
spanProcessor: new SdkBase.BatchSpanProcessor(
new OTLP.OTLPTraceExporter({
url: `${options.endpoint}/v1/traces`,
+4 -5
View File
@@ -47,8 +47,8 @@ export function parse(patchText: string): Result.Result<ReadonlyArray<Hunk>, Par
const lines = stripHeredoc(patchText.trim())
.split("\n")
.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line))
const begin = lines.findIndex((line) => line.trim() === "*** Begin Patch")
const end = lines.findIndex((line) => line.trim() === "*** End Patch")
const begin = lines[0]?.trim() === "*** Begin Patch" ? 0 : -1
const end = lines.at(-1)?.trim() === "*** End Patch" ? lines.length - 1 : -1
if (begin === -1) return Result.fail(new BoundaryError({ boundary: "first" }))
if (end === -1 || begin >= end) return Result.fail(new BoundaryError({ boundary: "last" }))
@@ -227,9 +227,8 @@ const normalize = (value: string) =>
value
.replace(/[‘’‚‛]/g, "'")
.replace(/[“”„‟]/g, '"')
.replace(/[‐‑‒–—―]/g, "-")
.replace(//g, "...")
.replace(/ /g, " ")
.replace(/[‐‑‒–—―]/g, "-")
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ")
const splitBom = (text: string) =>
text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text }
const stripHeredoc = (input: string) =>