mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 01:19:19 -04:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4778e6e031 | |||
| ac1b802820 | |||
| 5ee4c1082a | |||
| 8a70d70006 | |||
| 91b4634363 | |||
| 4f60bde502 | |||
| 0d3b6d430e | |||
| a7cf21e157 | |||
| e4a16830f1 | |||
| 103f764624 | |||
| 7a7075d86f | |||
| c764732aea | |||
| 72af084dc1 | |||
| 65a42fd549 |
@@ -54,9 +54,9 @@ ultimate source of truth.
|
|||||||
loop headers, so reads before initialization and self- or cross-referential initializers observe the JavaScript
|
loop headers, so reads before initialization and self- or cross-referential initializers observe the JavaScript
|
||||||
temporal dead zone.
|
temporal dead zone.
|
||||||
- [ ] Hoist function declarations accepted directly in switch cases.
|
- [ ] Hoist function declarations accepted directly in switch cases.
|
||||||
- [ ] Computed object destructuring keys such as `const { [field]: value } = record`.
|
- [x] Computed object destructuring keys such as `const { [field]: value } = record`.
|
||||||
- [ ] Object destructuring from arrays, such as `const { length } = values`.
|
- [x] Object destructuring from arrays, such as `const { length } = values`.
|
||||||
- [ ] Array destructuring from supported non-array iterables: strings, Maps, Sets, and URLSearchParams.
|
- [x] Array destructuring from supported non-array iterables: strings, Maps, Sets, and URLSearchParams.
|
||||||
|
|
||||||
## Statements and control flow
|
## Statements and control flow
|
||||||
|
|
||||||
|
|||||||
@@ -777,9 +777,9 @@ export class Interpreter<R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pattern.type === "ObjectPattern") {
|
if (pattern.type === "ObjectPattern") {
|
||||||
if (value === null || typeof value !== "object" || Array.isArray(value) || isRuntimeReference(value)) {
|
if (value === null || typeof value !== "object" || isRuntimeReference(value)) {
|
||||||
throw new InterpreterRuntimeError(
|
throw new InterpreterRuntimeError(
|
||||||
"Object destructuring requires a data object value.",
|
"Object destructuring requires a data object or array value.",
|
||||||
pattern,
|
pattern,
|
||||||
"InvalidDataValue",
|
"InvalidDataValue",
|
||||||
)
|
)
|
||||||
@@ -798,38 +798,35 @@ export class Interpreter<R> {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const key = yield* self.destructuringPropertyKey(property)
|
||||||
property.type !== "Property" ||
|
if (isBlockedMember(String(key))) {
|
||||||
getBoolean(property, "computed") ||
|
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, property)
|
||||||
getString(property, "kind") !== "init"
|
|
||||||
) {
|
|
||||||
throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property)
|
|
||||||
}
|
}
|
||||||
|
consumed.add(String(key))
|
||||||
const keyNode = getNode(property, "key")
|
yield* self.declarePattern(
|
||||||
const key = keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value)
|
getNode(property, "value"),
|
||||||
if (isBlockedMember(key)) {
|
self.destructuringPropertyValue(value as SafeObject | Array<unknown>, key),
|
||||||
throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, keyNode)
|
mutable,
|
||||||
}
|
property,
|
||||||
consumed.add(key)
|
)
|
||||||
yield* self.declarePattern(getNode(property, "value"), (value as SafeObject)[key], mutable, property)
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pattern.type === "ArrayPattern") {
|
if (pattern.type === "ArrayPattern") {
|
||||||
if (!Array.isArray(value)) {
|
const items = spreadItems(value)
|
||||||
throw new InterpreterRuntimeError("Array destructuring requires an array value.", pattern)
|
if (items === undefined) {
|
||||||
|
throw new InterpreterRuntimeError("Array destructuring requires a supported iterable value.", pattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [index, item] of getArray(pattern, "elements").entries()) {
|
for (const [index, item] of getArray(pattern, "elements").entries()) {
|
||||||
if (item === null) continue
|
if (item === null) continue
|
||||||
const element = asNode(item, `elements[${index}]`)
|
const element = asNode(item, `elements[${index}]`)
|
||||||
if (element.type === "RestElement") {
|
if (element.type === "RestElement") {
|
||||||
yield* self.declarePattern(getNode(element, "argument"), value.slice(index), mutable, element)
|
yield* self.declarePattern(getNode(element, "argument"), items.slice(index), mutable, element)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
yield* self.declarePattern(element, value[index], mutable, pattern)
|
yield* self.declarePattern(element, items[index], mutable, pattern)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -858,15 +855,15 @@ export class Interpreter<R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pattern.type === "ObjectPattern") {
|
if (pattern.type === "ObjectPattern") {
|
||||||
if (value === null || typeof value !== "object" || Array.isArray(value) || isRuntimeReference(value)) {
|
if (value === null || typeof value !== "object" || isRuntimeReference(value)) {
|
||||||
throw new InterpreterRuntimeError(
|
throw new InterpreterRuntimeError(
|
||||||
"Object destructuring requires a data object value.",
|
"Object destructuring requires a data object or array value.",
|
||||||
pattern,
|
pattern,
|
||||||
"InvalidDataValue",
|
"InvalidDataValue",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = value as SafeObject
|
const source = value as SafeObject | Array<unknown>
|
||||||
const consumed = new Set<string>()
|
const consumed = new Set<string>()
|
||||||
for (const propertyValue of getArray(pattern, "properties")) {
|
for (const propertyValue of getArray(pattern, "properties")) {
|
||||||
const property = asNode(propertyValue, "properties")
|
const property = asNode(propertyValue, "properties")
|
||||||
@@ -878,36 +875,29 @@ export class Interpreter<R> {
|
|||||||
yield* self.assignPattern(getNode(property, "argument"), rest, property)
|
yield* self.assignPattern(getNode(property, "argument"), rest, property)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (
|
const key = yield* self.destructuringPropertyKey(property)
|
||||||
property.type !== "Property" ||
|
if (isBlockedMember(String(key))) {
|
||||||
getBoolean(property, "computed") ||
|
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, property)
|
||||||
getString(property, "kind") !== "init"
|
|
||||||
) {
|
|
||||||
throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property)
|
|
||||||
}
|
}
|
||||||
const keyNode = getNode(property, "key")
|
consumed.add(String(key))
|
||||||
const key = keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value)
|
yield* self.assignPattern(getNode(property, "value"), self.destructuringPropertyValue(source, key), property)
|
||||||
if (isBlockedMember(key)) {
|
|
||||||
throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, keyNode)
|
|
||||||
}
|
|
||||||
consumed.add(key)
|
|
||||||
yield* self.assignPattern(getNode(property, "value"), source[key], property)
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pattern.type === "ArrayPattern") {
|
if (pattern.type === "ArrayPattern") {
|
||||||
if (!Array.isArray(value)) {
|
const items = spreadItems(value)
|
||||||
throw new InterpreterRuntimeError("Array destructuring requires an array value.", pattern)
|
if (items === undefined) {
|
||||||
|
throw new InterpreterRuntimeError("Array destructuring requires a supported iterable value.", pattern)
|
||||||
}
|
}
|
||||||
for (const [index, item] of getArray(pattern, "elements").entries()) {
|
for (const [index, item] of getArray(pattern, "elements").entries()) {
|
||||||
if (item === null) continue
|
if (item === null) continue
|
||||||
const element = asNode(item, `elements[${index}]`)
|
const element = asNode(item, `elements[${index}]`)
|
||||||
if (element.type === "RestElement") {
|
if (element.type === "RestElement") {
|
||||||
yield* self.assignPattern(getNode(element, "argument"), value.slice(index), element)
|
yield* self.assignPattern(getNode(element, "argument"), items.slice(index), element)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
yield* self.assignPattern(element, value[index], pattern)
|
yield* self.assignPattern(element, items[index], pattern)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -916,6 +906,26 @@ export class Interpreter<R> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private destructuringPropertyKey(property: AstNode): Effect.Effect<string | number, unknown, R> {
|
||||||
|
if (property.type !== "Property" || getString(property, "kind") !== "init") {
|
||||||
|
throw new InterpreterRuntimeError("Unsupported object destructuring property.", property)
|
||||||
|
}
|
||||||
|
const keyNode = getNode(property, "key")
|
||||||
|
if (getBoolean(property, "computed")) {
|
||||||
|
return Effect.map(this.evaluateExpression(keyNode), (value) => this.toPropertyKey(value, keyNode))
|
||||||
|
}
|
||||||
|
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)]
|
||||||
|
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)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
private evaluateExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
private evaluateExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
case "Literal": {
|
case "Literal": {
|
||||||
|
|||||||
@@ -548,4 +548,71 @@ describe("destructuring assignment", () => {
|
|||||||
test("returns the assigned value", async () => {
|
test("returns the assigned value", async () => {
|
||||||
expect(await value(`let a = 0; const result = ([a] = [7]); return [a, result]`)).toEqual([7, [7]])
|
expect(await value(`let a = 0; const result = ([a] = [7]); return [a, result]`)).toEqual([7, [7]])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("supports computed object keys and evaluates them once", async () => {
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
let calls = 0
|
||||||
|
const field = () => { calls++; return "name" }
|
||||||
|
const { [field()]: name, ...rest } = { name: "Ada", role: "engineer" }
|
||||||
|
return { calls, name, rest }
|
||||||
|
`),
|
||||||
|
).toEqual({ calls: 1, name: "Ada", rest: { role: "engineer" } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("supports object patterns over arrays", async () => {
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const { 0: first, length, slice, ...rest } = ["a", "b", "c"]
|
||||||
|
return { first, length, sliced: slice(1), rest }
|
||||||
|
`),
|
||||||
|
).toEqual({ first: "a", length: 3, sliced: ["b", "c"], rest: { 1: "b", 2: "c" } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves exact computed property names on arrays", async () => {
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const { ["01"]: item, ...rest } = [10, 20]
|
||||||
|
return { missing: item === undefined, rest }
|
||||||
|
`),
|
||||||
|
).toEqual({ missing: true, rest: { 0: 10, 1: 20 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("supports array patterns over strings, Maps, Sets, and URLSearchParams", async () => {
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const [letter, ...letters] = "A😀B"
|
||||||
|
const [[mapKey, mapValue]] = new Map([["key", 1]])
|
||||||
|
const [setFirst, setSecond] = new Set([2, 3])
|
||||||
|
const [[queryKey, queryValue]] = new URLSearchParams("q=test&page=2")
|
||||||
|
return { letter, letters, mapKey, mapValue, setFirst, setSecond, queryKey, queryValue }
|
||||||
|
`),
|
||||||
|
).toEqual({
|
||||||
|
letter: "A",
|
||||||
|
letters: ["😀", "B"],
|
||||||
|
mapKey: "key",
|
||||||
|
mapValue: 1,
|
||||||
|
setFirst: 2,
|
||||||
|
setSecond: 3,
|
||||||
|
queryKey: "q",
|
||||||
|
queryValue: "test",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("supports iterable patterns in assignment and parameters", async () => {
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
let first
|
||||||
|
let rest
|
||||||
|
;[first, ...rest] = new Set([1, 2, 3])
|
||||||
|
const read = ([[key, value]]) => key + value
|
||||||
|
return { first, rest, entry: read(new Map([["a", 4]])) }
|
||||||
|
`),
|
||||||
|
).toEqual({ first: 1, rest: [2, 3], entry: "a4" })
|
||||||
|
})
|
||||||
|
|
||||||
|
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")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ export * as Config from "./config"
|
|||||||
|
|
||||||
import { makeLocationNode } from "./effect/app-node"
|
import { makeLocationNode } from "./effect/app-node"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { isDeepStrictEqual } from "node:util"
|
||||||
import { type ParseError, parse } from "jsonc-parser"
|
import { type ParseError, parse } from "jsonc-parser"
|
||||||
import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
|
import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Semaphore, Stream } from "effect"
|
||||||
import { Permission } from "@opencode-ai/schema/permission"
|
import { Permission } from "@opencode-ai/schema/permission"
|
||||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||||
import { Integration } from "@opencode-ai/schema/integration"
|
import { Integration } from "@opencode-ai/schema/integration"
|
||||||
@@ -165,6 +166,7 @@ const layer = Layer.effect(
|
|||||||
const credentials = yield* Credential.Service
|
const credentials = yield* Credential.Service
|
||||||
const wellknown = yield* WellKnown.Service
|
const wellknown = yield* WellKnown.Service
|
||||||
const names = ["opencode.json", "opencode.jsonc"]
|
const names = ["opencode.json", "opencode.jsonc"]
|
||||||
|
const reloadLock = Semaphore.makeUnsafe(1)
|
||||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||||
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
||||||
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
|
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
|
||||||
@@ -326,12 +328,17 @@ const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const reload = Effect.fn("Config.reload")(function* () {
|
const reload = Effect.fn("Config.reload")(() =>
|
||||||
const next = yield* discover()
|
reloadLock.withPermit(
|
||||||
configs = next
|
Effect.gen(function* () {
|
||||||
yield* reconcile(next)
|
const next = yield* discover()
|
||||||
yield* events.publish(ConfigSchema.Event.Updated, {})
|
if (isDeepStrictEqual(configs, next)) return
|
||||||
})
|
configs = next
|
||||||
|
yield* reconcile(next)
|
||||||
|
yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
yield* Stream.fromPubSub(updates).pipe(
|
yield* Stream.fromPubSub(updates).pipe(
|
||||||
Stream.debounce("100 millis"),
|
Stream.debounce("100 millis"),
|
||||||
@@ -354,12 +361,29 @@ const layer = Layer.effect(
|
|||||||
),
|
),
|
||||||
Effect.forkScoped({ startImmediately: true }),
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
)
|
)
|
||||||
yield* wellknown.changes.pipe(
|
yield* events.subscribe(WellKnown.Event.Updated).pipe(
|
||||||
Stream.runForEach(() =>
|
Stream.runForEach(() =>
|
||||||
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown sources", { cause }))),
|
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown sources", { cause }))),
|
||||||
),
|
),
|
||||||
Effect.forkScoped({ startImmediately: true }),
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
)
|
)
|
||||||
|
yield* Effect.sleep("10 minutes").pipe(
|
||||||
|
Effect.andThen(
|
||||||
|
Effect.suspend(() => {
|
||||||
|
if (!wellknown.snapshot().length) return Effect.void
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const changed = yield* wellknown.refresh().pipe(
|
||||||
|
Effect.catch((error) =>
|
||||||
|
Effect.logWarning("failed to refresh wellknown manifests", { error }).pipe(Effect.as(false)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!changed) yield* reload()
|
||||||
|
}).pipe(Effect.catchCause((cause) => Effect.logWarning("failed to refresh wellknown config", { cause })))
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Effect.forever,
|
||||||
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
|
)
|
||||||
yield* reconcile(initial)
|
yield* reconcile(initial)
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ export * as ConfigExperimental from "./experimental"
|
|||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { NonNegativeInt } from "../schema"
|
import { NonNegativeInt } from "../schema"
|
||||||
|
import { ConfigPolicy } from "./policy"
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
|
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
|
||||||
subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({
|
subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({
|
||||||
description: "Maximum subagent nesting depth. Defaults to 1.",
|
description: "Maximum subagent nesting depth. Defaults to 1.",
|
||||||
}),
|
}),
|
||||||
|
policies: ConfigPolicy.Info.pipe(Schema.Array, Schema.optional).annotate({
|
||||||
|
description: "Ordered policies controlling access to configured resources",
|
||||||
|
}),
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export * as ConfigPolicyPlugin from "./policy"
|
||||||
|
|
||||||
|
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
|
import { Effect, Stream } from "effect"
|
||||||
|
import { Config } from "../../config"
|
||||||
|
import { Wildcard } from "../../util/wildcard"
|
||||||
|
|
||||||
|
export const Plugin = define({
|
||||||
|
id: "opencode.config.policy",
|
||||||
|
effect: Effect.fn(function* (ctx) {
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const loaded = { entries: yield* config.entries() }
|
||||||
|
yield* ctx.catalog.transform((catalog) => {
|
||||||
|
// User-global policy takes priority over policy authored by a repository.
|
||||||
|
const policies = loaded.entries
|
||||||
|
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||||
|
.toReversed()
|
||||||
|
.flatMap((entry) => entry.info.experimental?.policies ?? [])
|
||||||
|
for (const record of catalog.provider.list()) {
|
||||||
|
const policy = policies.findLast((policy) => Wildcard.match(record.provider.id, policy.resource))
|
||||||
|
if (policy?.effect === "deny") catalog.provider.remove(record.provider.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
yield* ctx.event.subscribe().pipe(
|
||||||
|
Stream.filter((event) => event.type === "config.updated"),
|
||||||
|
Stream.runForEach(() =>
|
||||||
|
config.entries().pipe(
|
||||||
|
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||||
|
Effect.andThen(ctx.catalog.reload()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export * as ConfigPolicy from "./policy"
|
||||||
|
|
||||||
|
import { Schema } from "effect"
|
||||||
|
|
||||||
|
export const Effect = Schema.Literals(["allow", "deny"])
|
||||||
|
export type Effect = typeof Effect.Type
|
||||||
|
|
||||||
|
export const Info = Schema.Struct({
|
||||||
|
action: Schema.Literal("provider.use"),
|
||||||
|
resource: Schema.String,
|
||||||
|
effect: Effect,
|
||||||
|
})
|
||||||
|
export type Info = typeof Info.Type
|
||||||
@@ -39,6 +39,7 @@ import { InstructionDiscovery } from "./instruction-discovery"
|
|||||||
import { InstructionBuiltIns } from "./instructions/builtins"
|
import { InstructionBuiltIns } from "./instructions/builtins"
|
||||||
import { InstructionEntry } from "./session/instruction-entry"
|
import { InstructionEntry } from "./session/instruction-entry"
|
||||||
import { SessionInstructions } from "./session/instructions"
|
import { SessionInstructions } from "./session/instructions"
|
||||||
|
import { SessionGenerateNode } from "./session/generate-node"
|
||||||
import { McpTool } from "./tool/mcp"
|
import { McpTool } from "./tool/mcp"
|
||||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||||
import { ToolRegistry } from "./tool/registry"
|
import { ToolRegistry } from "./tool/registry"
|
||||||
@@ -81,6 +82,7 @@ const locationServiceNodes = [
|
|||||||
Form.node,
|
Form.node,
|
||||||
QuestionV2.node,
|
QuestionV2.node,
|
||||||
Generate.node,
|
Generate.node,
|
||||||
|
SessionGenerateNode.node,
|
||||||
ReadToolFileSystem.node,
|
ReadToolFileSystem.node,
|
||||||
McpTool.node,
|
McpTool.node,
|
||||||
SessionInstructions.node,
|
SessionInstructions.node,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Config } from "../config"
|
|||||||
import { ConfigAgentPlugin } from "../config/plugin/agent"
|
import { ConfigAgentPlugin } from "../config/plugin/agent"
|
||||||
import { ConfigCommandPlugin } from "../config/plugin/command"
|
import { ConfigCommandPlugin } from "../config/plugin/command"
|
||||||
import { ConfigProviderPlugin } from "../config/plugin/provider"
|
import { ConfigProviderPlugin } from "../config/plugin/provider"
|
||||||
|
import { ConfigPolicyPlugin } from "../config/plugin/policy"
|
||||||
import { ConfigReferencePlugin } from "../config/plugin/reference"
|
import { ConfigReferencePlugin } from "../config/plugin/reference"
|
||||||
import { ConfigSkillPlugin } from "../config/plugin/skill"
|
import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
@@ -151,6 +152,7 @@ const post = [
|
|||||||
ConfigSkillPlugin.Plugin,
|
ConfigSkillPlugin.Plugin,
|
||||||
ConfigProviderPlugin.Plugin,
|
ConfigProviderPlugin.Plugin,
|
||||||
VariantPlugin.Plugin,
|
VariantPlugin.Plugin,
|
||||||
|
ConfigPolicyPlugin.Plugin,
|
||||||
] as const satisfies readonly InternalPlugin[]
|
] as const satisfies readonly InternalPlugin[]
|
||||||
|
|
||||||
export const list = Effect.fn("PluginInternal.list")(function* () {
|
export const list = Effect.fn("PluginInternal.list")(function* () {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { LocationServiceMap } from "./location-service-map"
|
|||||||
import { MessageDecodeError } from "./session/error"
|
import { MessageDecodeError } from "./session/error"
|
||||||
import { SessionEvent } from "./session/event"
|
import { SessionEvent } from "./session/event"
|
||||||
import { SessionPending } from "./session/pending"
|
import { SessionPending } from "./session/pending"
|
||||||
|
import { SessionGenerate } from "./session/generate"
|
||||||
import { Snapshot } from "./snapshot"
|
import { Snapshot } from "./snapshot"
|
||||||
import { SessionRevert } from "./session/revert"
|
import { SessionRevert } from "./session/revert"
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
@@ -176,6 +177,7 @@ export type Error =
|
|||||||
| CommandV2.NotFoundError
|
| CommandV2.NotFoundError
|
||||||
| CommandV2.EvaluationError
|
| CommandV2.EvaluationError
|
||||||
| MessageNotFoundError
|
| MessageNotFoundError
|
||||||
|
| SessionGenerate.Error
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly list: (input?: ListInput) => Effect.Effect<{
|
readonly list: (input?: ListInput) => Effect.Effect<{
|
||||||
@@ -244,6 +246,11 @@ export interface Interface {
|
|||||||
delivery?: SessionPending.Delivery
|
delivery?: SessionPending.Delivery
|
||||||
resume?: boolean
|
resume?: boolean
|
||||||
}) => Effect.Effect<SessionPending.User, NotFoundError | PromptConflictError | AttachmentError>
|
}) => Effect.Effect<SessionPending.User, NotFoundError | PromptConflictError | AttachmentError>
|
||||||
|
/** Generates text from current Session context without admitting input or mutating history. */
|
||||||
|
readonly generate: (input: {
|
||||||
|
sessionID: SessionSchema.ID
|
||||||
|
prompt: string
|
||||||
|
}) => Effect.Effect<string, NotFoundError | SessionGenerate.Error>
|
||||||
readonly command: (input: {
|
readonly command: (input: {
|
||||||
id?: SessionMessage.ID
|
id?: SessionMessage.ID
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
@@ -569,6 +576,11 @@ const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
generate: Effect.fn("V2Session.generate")(function* (input) {
|
||||||
|
const session = yield* result.get(input.sessionID)
|
||||||
|
const generate = yield* SessionGenerate.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||||
|
return yield* generate.generate(input)
|
||||||
|
}),
|
||||||
command: Effect.fn("V2Session.command")(function* (input) {
|
command: Effect.fn("V2Session.command")(function* (input) {
|
||||||
const session = yield* result.get(input.sessionID)
|
const session = yield* result.get(input.sessionID)
|
||||||
const commands = yield* CommandV2.Service.pipe(Effect.provide(locations.get(session.location)))
|
const commands = yield* CommandV2.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
export * as SessionGenerateNode from "./generate-node"
|
||||||
|
|
||||||
|
import { LLM, LLMClient, Message, SystemPart } from "@opencode-ai/ai"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { Database } from "../database/database"
|
||||||
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
|
import { llmClient } from "../effect/app-node-platform"
|
||||||
|
import { PluginHooks } from "../plugin/hooks"
|
||||||
|
import { SessionContext } from "./context"
|
||||||
|
import { SessionGenerate } from "./generate"
|
||||||
|
import { SessionHistory } from "./history"
|
||||||
|
import { SessionModelHeaders } from "./model-headers"
|
||||||
|
import { SessionRunnerModel } from "./runner/model"
|
||||||
|
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
|
||||||
|
import { toLLMMessages } from "./runner/to-llm-message"
|
||||||
|
|
||||||
|
const layer = Layer.effect(
|
||||||
|
SessionGenerate.Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const context = yield* SessionContext.Service
|
||||||
|
const database = yield* Database.Service
|
||||||
|
const hooks = yield* PluginHooks.Service
|
||||||
|
const llm = yield* LLMClient.Service
|
||||||
|
const models = yield* SessionRunnerModel.Service
|
||||||
|
|
||||||
|
return SessionGenerate.Service.of({
|
||||||
|
generate: Effect.fn("SessionGenerate.generate")(function* (input) {
|
||||||
|
const selection = yield* context.select(input.sessionID)
|
||||||
|
const model = yield* models.resolve(selection.session)
|
||||||
|
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions)
|
||||||
|
const providerMetadataKey = model.model.route.providerMetadataKey ?? model.model.provider
|
||||||
|
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(selection.session.id)
|
||||||
|
? selection.session.id.slice(4)
|
||||||
|
: selection.session.id
|
||||||
|
const contextEvent = yield* hooks.trigger("session", "context", {
|
||||||
|
sessionID: selection.session.id,
|
||||||
|
agent: selection.agent.id,
|
||||||
|
model: model.ref,
|
||||||
|
system: [selection.agent.info.system ? selection.agent.info.system : PROMPT_DEFAULT, history.initial]
|
||||||
|
.filter((part) => part.length > 0)
|
||||||
|
.map(SystemPart.make),
|
||||||
|
messages: [
|
||||||
|
...toLLMMessages(history.messages, model.ref, providerMetadataKey),
|
||||||
|
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
|
||||||
|
Message.user(input.prompt),
|
||||||
|
],
|
||||||
|
tools: {},
|
||||||
|
})
|
||||||
|
return (yield* llm.generate(
|
||||||
|
LLM.request({
|
||||||
|
model: model.model,
|
||||||
|
http: { headers: SessionModelHeaders.make(selection.session) },
|
||||||
|
providerOptions: { openai: { promptCacheKey } },
|
||||||
|
system: contextEvent.system,
|
||||||
|
messages: contextEvent.messages,
|
||||||
|
tools: [],
|
||||||
|
toolChoice: "none",
|
||||||
|
}),
|
||||||
|
)).text
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
service: SessionGenerate.Service,
|
||||||
|
layer,
|
||||||
|
deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, llmClient],
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export * as SessionGenerate from "./generate"
|
||||||
|
|
||||||
|
import type { LLMError } from "@opencode-ai/ai"
|
||||||
|
import { Context, type Effect } from "effect"
|
||||||
|
import type { Instructions } from "../instructions"
|
||||||
|
import type { AgentNotFoundError } from "./error"
|
||||||
|
import type { SessionRunnerModel } from "./runner/model"
|
||||||
|
import type { SessionSchema } from "./schema"
|
||||||
|
|
||||||
|
export type Error = AgentNotFoundError | Instructions.InitializationBlocked | SessionRunnerModel.Error | LLMError
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
/** Generates text from current Session context without mutating the Session. */
|
||||||
|
readonly generate: (input: {
|
||||||
|
readonly sessionID: SessionSchema.ID
|
||||||
|
readonly prompt: string
|
||||||
|
}) => Effect.Effect<string, Error>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Location-scoped transient generation from Session context. */
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionGenerate") {}
|
||||||
@@ -60,13 +60,17 @@ const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||||
return yield* Effect.forEach(
|
const rows = yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID))
|
||||||
yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID)),
|
return yield* Effect.forEach(rows, (row) =>
|
||||||
decodeMessageRow,
|
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||||
|
return (yield* messageEntries(db, sessionID)).map((entry) => entry.message)
|
||||||
|
})
|
||||||
|
|
||||||
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
|
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
@@ -75,10 +79,7 @@ export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(fun
|
|||||||
return yield* db
|
return yield* db
|
||||||
.transaction(() =>
|
.transaction(() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const rows = yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID))
|
const messages = yield* messageEntries(db, sessionID)
|
||||||
const messages = yield* Effect.forEach(rows, (row) =>
|
|
||||||
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
|
|
||||||
)
|
|
||||||
const assembled = yield* InstructionState.assemble(db, sessionID, instructions)
|
const assembled = yield* InstructionState.assemble(db, sessionID, instructions)
|
||||||
return {
|
return {
|
||||||
initial: assembled.initial,
|
initial: assembled.initial,
|
||||||
@@ -89,6 +90,33 @@ export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(fun
|
|||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const preview = Effect.fn("SessionHistory.preview")(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
instructions: Instructions.Instructions,
|
||||||
|
) {
|
||||||
|
const observed = yield* Instructions.read(instructions)
|
||||||
|
return yield* db
|
||||||
|
.transaction(() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const messages = yield* messageEntries(db, sessionID)
|
||||||
|
// An active assistant may contain an unresolved tool call, so only preview the settled prefix.
|
||||||
|
const unsettled = messages.findIndex(
|
||||||
|
(entry) => entry.message.type === "assistant" && entry.message.time.completed === undefined,
|
||||||
|
)
|
||||||
|
const settled = unsettled === -1 ? messages : messages.slice(0, unsettled)
|
||||||
|
const assembled = yield* InstructionState.preview(db, sessionID, instructions, observed)
|
||||||
|
const entries = [...settled, ...assembled.updates].toSorted((a, b) => a.seq - b.seq)
|
||||||
|
return {
|
||||||
|
initial: assembled.initial,
|
||||||
|
messages: entries.map((entry) => entry.message),
|
||||||
|
instructionUpdate: assembled.update,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.pipe(Effect.catch((error) => (error instanceof Instructions.InitializationBlocked ? error : Effect.die(error))))
|
||||||
|
})
|
||||||
|
|
||||||
/** Returns the session's sole user message, or `undefined` once a second one exists. */
|
/** Returns the session's sole user message, or `undefined` once a second one exists. */
|
||||||
export const firstUserMessageIfOnly = Effect.fn("SessionHistory.firstUserMessageIfOnly")(function* (
|
export const firstUserMessageIfOnly = Effect.fn("SessionHistory.firstUserMessageIfOnly")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
|
|||||||
@@ -29,12 +29,11 @@ export const observe = Effect.fn("InstructionState.observe")(function* (
|
|||||||
const [observed, stored] = yield* Effect.all([Instructions.read(instructions), ensure(db, sessionID)], {
|
const [observed, stored] = yield* Effect.all([Instructions.read(instructions), ensure(db, sessionID)], {
|
||||||
concurrency: "unbounded",
|
concurrency: "unbounded",
|
||||||
})
|
})
|
||||||
const admission = yield* Instructions.diff(observed, stored?.current_values)
|
const result = yield* observeAgainst(observed, stored?.current_values)
|
||||||
return {
|
return {
|
||||||
sessionID,
|
sessionID,
|
||||||
initial: !stored,
|
initial: !stored,
|
||||||
current: Instructions.applyHashDelta(stored?.current_values ?? {}, admission.delta),
|
...result,
|
||||||
...admission,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -123,28 +122,21 @@ export const rebuild = Effect.fn("InstructionState.rebuild")(function* (
|
|||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
) {
|
) {
|
||||||
const folded = fold(yield* instructionEvents(db, sessionID))
|
const state = yield* stateFromEvents(db, sessionID)
|
||||||
if (!folded) {
|
if (!state) {
|
||||||
yield* reset(db, sessionID)
|
yield* reset(db, sessionID)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
const state = {
|
|
||||||
session_id: sessionID,
|
|
||||||
epoch_start: folded.epochStart,
|
|
||||||
through_seq: folded.throughSeq,
|
|
||||||
initial_values: folded.initial,
|
|
||||||
current_values: folded.current,
|
|
||||||
}
|
|
||||||
yield* db
|
yield* db
|
||||||
.insert(InstructionStateTable)
|
.insert(InstructionStateTable)
|
||||||
.values(state)
|
.values(state)
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: InstructionStateTable.session_id,
|
target: InstructionStateTable.session_id,
|
||||||
set: {
|
set: {
|
||||||
epoch_start: folded.epochStart,
|
epoch_start: state.epoch_start,
|
||||||
through_seq: folded.throughSeq,
|
through_seq: state.through_seq,
|
||||||
initial_values: folded.initial,
|
initial_values: state.initial_values,
|
||||||
current_values: folded.current,
|
current_values: state.current_values,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.run()
|
.run()
|
||||||
@@ -152,13 +144,12 @@ export const rebuild = Effect.fn("InstructionState.rebuild")(function* (
|
|||||||
return state
|
return state
|
||||||
})
|
})
|
||||||
|
|
||||||
export const assemble = Effect.fn("InstructionState.assemble")(function* (
|
const assembleState = Effect.fnUntraced(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
instructions: Instructions.Instructions,
|
instructions: Instructions.Instructions,
|
||||||
|
state: typeof InstructionStateTable.$inferSelect,
|
||||||
) {
|
) {
|
||||||
const state = yield* find(db, sessionID)
|
|
||||||
if (!state) return yield* Effect.die(new Error(`Instruction state not found during assembly: ${sessionID}`))
|
|
||||||
const rows = yield* instructionUpdatesAfter(db, sessionID, state.epoch_start)
|
const rows = yield* instructionUpdatesAfter(db, sessionID, state.epoch_start)
|
||||||
const updates = rows.map((row) => ({
|
const updates = rows.map((row) => ({
|
||||||
row,
|
row,
|
||||||
@@ -188,7 +179,49 @@ export const assemble = Effect.fn("InstructionState.assemble")(function* (
|
|||||||
})
|
})
|
||||||
values = Instructions.applyDelta(values, delta)
|
values = Instructions.applyDelta(values, delta)
|
||||||
}
|
}
|
||||||
return { initial: Instructions.renderInitial(instructions, valuesAtStart), updates: result }
|
return { initial: Instructions.renderInitial(instructions, valuesAtStart), updates: result, current: values }
|
||||||
|
})
|
||||||
|
|
||||||
|
export const assemble = Effect.fn("InstructionState.assemble")(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
instructions: Instructions.Instructions,
|
||||||
|
) {
|
||||||
|
const state = yield* find(db, sessionID)
|
||||||
|
if (!state) return yield* Effect.die(new Error(`Instruction state not found during assembly: ${sessionID}`))
|
||||||
|
const assembled = yield* assembleState(db, sessionID, instructions, state)
|
||||||
|
return { initial: assembled.initial, updates: assembled.updates }
|
||||||
|
})
|
||||||
|
|
||||||
|
export const preview = Effect.fn("InstructionState.preview")(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
instructions: Instructions.Instructions,
|
||||||
|
observed: Instructions.ReadResult,
|
||||||
|
) {
|
||||||
|
const state = yield* readState(db, sessionID)
|
||||||
|
const result = yield* observeAgainst(observed, state?.current_values)
|
||||||
|
const blobs = new Map<Instructions.Hash, Schema.Json>(
|
||||||
|
Object.entries(result.blobs).map(([hash, value]) => [Instructions.Hash.make(hash), value]),
|
||||||
|
)
|
||||||
|
if (!state) {
|
||||||
|
const values = dereference(result.current, blobs)
|
||||||
|
return { initial: Instructions.renderInitial(instructions, values), updates: [], update: "" }
|
||||||
|
}
|
||||||
|
const assembled = yield* assembleState(db, sessionID, instructions, state)
|
||||||
|
return {
|
||||||
|
initial: assembled.initial,
|
||||||
|
updates: assembled.updates,
|
||||||
|
update: Instructions.renderUpdate(instructions, assembled.current, dereferenceDelta(result.delta, blobs)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const observeAgainst = Effect.fnUntraced(function* (observed: Instructions.ReadResult, previous?: Instructions.Values) {
|
||||||
|
const admission = yield* Instructions.diff(observed, previous)
|
||||||
|
return {
|
||||||
|
current: Instructions.applyHashDelta(previous ?? {}, admission.delta),
|
||||||
|
...admission,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||||
@@ -203,7 +236,26 @@ const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: Sessio
|
|||||||
const ensure = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
const ensure = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||||
const stored = yield* find(db, sessionID)
|
const stored = yield* find(db, sessionID)
|
||||||
if (!stored) return yield* rebuild(db, sessionID)
|
if (!stored) return yield* rebuild(db, sessionID)
|
||||||
const latest = yield* db
|
const latest = yield* latestRelevantSequence(db, sessionID)
|
||||||
|
if (!latest || latest.seq <= stored.through_seq) return stored
|
||||||
|
return yield* rebuild(db, sessionID)
|
||||||
|
})
|
||||||
|
|
||||||
|
const readState = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||||
|
const stored = yield* find(db, sessionID)
|
||||||
|
if (!stored) return yield* stateFromEvents(db, sessionID)
|
||||||
|
const latest = yield* latestRelevantSequence(db, sessionID)
|
||||||
|
if (!latest || latest.seq <= stored.through_seq) return stored
|
||||||
|
return yield* stateFromEvents(db, sessionID)
|
||||||
|
})
|
||||||
|
|
||||||
|
const stateFromEvents = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||||
|
const folded = fold(yield* instructionEvents(db, sessionID))
|
||||||
|
return folded ? foldedState(sessionID, folded) : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const latestRelevantSequence = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||||
|
return yield* db
|
||||||
.select({ seq: EventTable.seq })
|
.select({ seq: EventTable.seq })
|
||||||
.from(EventTable)
|
.from(EventTable)
|
||||||
.where(and(eq(EventTable.aggregate_id, sessionID), inArray(EventTable.type, relevantEventTypes)))
|
.where(and(eq(EventTable.aggregate_id, sessionID), inArray(EventTable.type, relevantEventTypes)))
|
||||||
@@ -211,8 +263,6 @@ const ensure = Effect.fnUntraced(function* (db: DatabaseService, sessionID: Sess
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (!latest || latest.seq <= stored.through_seq) return stored
|
|
||||||
return yield* rebuild(db, sessionID)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const insertBlobs = Effect.fnUntraced(function* (db: DatabaseService, blobs: Readonly<Record<string, Schema.Json>>) {
|
const insertBlobs = Effect.fnUntraced(function* (db: DatabaseService, blobs: Readonly<Record<string, Schema.Json>>) {
|
||||||
@@ -364,3 +414,13 @@ function fold(rows: ReadonlyArray<InstructionEventRow>) {
|
|||||||
: { epochStart: row.seq, throughSeq: row.seq, initial: current, current }
|
: { epochStart: row.seq, throughSeq: row.seq, initial: current, current }
|
||||||
}, undefined)
|
}, undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function foldedState(sessionID: SessionSchema.ID, folded: NonNullable<ReturnType<typeof fold>>) {
|
||||||
|
return {
|
||||||
|
session_id: sessionID,
|
||||||
|
epoch_start: folded.epochStart,
|
||||||
|
through_seq: folded.throughSeq,
|
||||||
|
initial_values: folded.initial,
|
||||||
|
current_values: folded.current,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+46
-10
@@ -1,14 +1,14 @@
|
|||||||
export * as State from "./state"
|
export * as State from "./state"
|
||||||
|
|
||||||
import { Context, Effect, Scope, Semaphore } from "effect"
|
import { Clock, Context, Deferred, Effect, Scope, Semaphore } from "effect"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A replayable transform applied to a draft during reload.
|
* A replayable transform applied to a draft during reload.
|
||||||
*
|
*
|
||||||
* Domain drafts expose readable and writable state while preserving concise
|
* Domain drafts expose readable and writable state while preserving concise
|
||||||
* plugin/config code. Transforms may perform Effects before returning.
|
* plugin/config code. Transforms synchronously rebuild derived state.
|
||||||
*/
|
*/
|
||||||
type TransformCallback<DraftApi> = (draft: DraftApi) => Effect.Effect<void> | void
|
type TransformCallback<DraftApi> = (draft: DraftApi) => void
|
||||||
export type MakeDraft<State, DraftApi> = (state: State) => DraftApi
|
export type MakeDraft<State, DraftApi> = (state: State) => DraftApi
|
||||||
|
|
||||||
export interface Registration {
|
export interface Registration {
|
||||||
@@ -34,6 +34,7 @@ type Batch = {
|
|||||||
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
|
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
|
||||||
defaultValue: () => undefined,
|
defaultValue: () => undefined,
|
||||||
})
|
})
|
||||||
|
const reloadDebounce = 500
|
||||||
|
|
||||||
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
@@ -73,6 +74,10 @@ export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
|
|||||||
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
|
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
|
||||||
let state = options.initial()
|
let state = options.initial()
|
||||||
let transforms: { run: TransformCallback<DraftApi> }[] = []
|
let transforms: { run: TransformCallback<DraftApi> }[] = []
|
||||||
|
let generation = 0
|
||||||
|
let requestedAt = 0
|
||||||
|
let running = false
|
||||||
|
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
|
||||||
const semaphore = Semaphore.makeUnsafe(1)
|
const semaphore = Semaphore.makeUnsafe(1)
|
||||||
|
|
||||||
const commit = Effect.fn("State.commit")(function* (next: State) {
|
const commit = Effect.fn("State.commit")(function* (next: State) {
|
||||||
@@ -82,9 +87,8 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
|||||||
})
|
})
|
||||||
|
|
||||||
const apply = (transform: TransformCallback<DraftApi>, draft: DraftApi) =>
|
const apply = (transform: TransformCallback<DraftApi>, draft: DraftApi) =>
|
||||||
Effect.suspend(() => {
|
Effect.sync(() => {
|
||||||
const result = transform(draft)
|
transform(draft)
|
||||||
return Effect.isEffect(result) ? Effect.asVoid(result).pipe(Effect.orDie) : Effect.void
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const materialize = Effect.fnUntraced(function* () {
|
const materialize = Effect.fnUntraced(function* () {
|
||||||
@@ -97,7 +101,39 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
|||||||
yield* commit(next)
|
yield* commit(next)
|
||||||
})
|
})
|
||||||
|
|
||||||
const reload = () => semaphore.withPermit(materialize())
|
const materializeReload = () => semaphore.withPermit(materialize())
|
||||||
|
|
||||||
|
const rebuild = (): Effect.Effect<void> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const clock = yield* Clock.Clock
|
||||||
|
const remaining = requestedAt + reloadDebounce - clock.currentTimeMillisUnsafe()
|
||||||
|
if (remaining > 0) yield* Effect.sleep(remaining)
|
||||||
|
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* rebuild()
|
||||||
|
|
||||||
|
const target = generation
|
||||||
|
const exit = yield* materializeReload().pipe(Effect.exit)
|
||||||
|
const completed = waiters.filter((waiter) => waiter.generation <= target)
|
||||||
|
waiters = waiters.filter((waiter) => waiter.generation > target)
|
||||||
|
yield* Effect.forEach(completed, (waiter) => Deferred.done(waiter.done, exit), {
|
||||||
|
concurrency: "unbounded",
|
||||||
|
discard: true,
|
||||||
|
})
|
||||||
|
if (generation > target) return yield* rebuild()
|
||||||
|
running = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const reload = Effect.fnUntraced(function* () {
|
||||||
|
const done = Deferred.makeUnsafe<void>()
|
||||||
|
const clock = yield* Clock.Clock
|
||||||
|
generation++
|
||||||
|
requestedAt = clock.currentTimeMillisUnsafe()
|
||||||
|
waiters.push({ generation, done })
|
||||||
|
if (!running) {
|
||||||
|
running = true
|
||||||
|
yield* rebuild().pipe(Effect.forkDetach)
|
||||||
|
}
|
||||||
|
return yield* Deferred.await(done)
|
||||||
|
})
|
||||||
|
|
||||||
const result: Interface<State, DraftApi> = {
|
const result: Interface<State, DraftApi> = {
|
||||||
get: () => state,
|
get: () => state,
|
||||||
@@ -117,7 +153,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
|||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const batch = yield* CurrentBatch
|
const batch = yield* CurrentBatch
|
||||||
if (batch?.active) {
|
if (batch?.active) {
|
||||||
batch.reloads.add(reload)
|
batch.reloads.add(materializeReload)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
yield* materialize()
|
yield* materialize()
|
||||||
@@ -132,8 +168,8 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
|||||||
)
|
)
|
||||||
yield* Scope.addFinalizer(scope, dispose)
|
yield* Scope.addFinalizer(scope, dispose)
|
||||||
const batch = yield* CurrentBatch
|
const batch = yield* CurrentBatch
|
||||||
if (batch?.active) batch.reloads.add(reload)
|
if (batch?.active) batch.reloads.add(materializeReload)
|
||||||
else yield* reload()
|
else yield* materializeReload()
|
||||||
return { dispose }
|
return { dispose }
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const name = "shell"
|
|||||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||||
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
|
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
|
||||||
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
||||||
|
export const PROGRESS_LINES = 25
|
||||||
|
|
||||||
const BACKGROUND_STARTED = "The command was moved to the background."
|
const BACKGROUND_STARTED = "The command was moved to the background."
|
||||||
const BACKGROUND_INSTRUCTION =
|
const BACKGROUND_INSTRUCTION =
|
||||||
@@ -210,6 +211,23 @@ export const Plugin = {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const captureProgress = Effect.fn("ShellTool.captureProgress")(function* () {
|
||||||
|
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
|
||||||
|
const start = Math.max(0, latest.size - MAX_CAPTURE_BYTES)
|
||||||
|
const page = yield* shell.output(info.id, { cursor: start, limit: MAX_CAPTURE_BYTES })
|
||||||
|
const trailingNewline = page.output.endsWith("\n")
|
||||||
|
const lines = trailingNewline ? page.output.split("\n").slice(0, -1) : page.output.split("\n")
|
||||||
|
const truncated = start > 0 || lines.length > PROGRESS_LINES
|
||||||
|
const output = lines.slice(-PROGRESS_LINES).join("\n") + (trailingNewline ? "\n" : "")
|
||||||
|
const notice = truncated
|
||||||
|
? `[output truncated; showing last ${PROGRESS_LINES} lines. Full output saved to: ${info.file}]\n\n`
|
||||||
|
: ""
|
||||||
|
return {
|
||||||
|
output: `${notice}${output || "(no output)"}`,
|
||||||
|
truncated,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
|
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
|
||||||
const final = yield* shell.wait(info.id)
|
const final = yield* shell.wait(info.id)
|
||||||
|
|
||||||
@@ -258,7 +276,7 @@ export const Plugin = {
|
|||||||
|
|
||||||
const progress = yield* Effect.sleep("1 second").pipe(
|
const progress = yield* Effect.sleep("1 second").pipe(
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
captureShell().pipe(
|
captureProgress().pipe(
|
||||||
Effect.flatMap((capture) =>
|
Effect.flatMap((capture) =>
|
||||||
context.progress({
|
context.progress({
|
||||||
structured: { truncated: capture.truncated },
|
structured: { truncated: capture.truncated },
|
||||||
|
|||||||
@@ -77,10 +77,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
|
|||||||
commands: commands(info.command),
|
commands: commands(info.command),
|
||||||
instructions: info.instructions,
|
instructions: info.instructions,
|
||||||
references: info.references ?? info.reference,
|
references: info.references ?? info.reference,
|
||||||
experimental:
|
experimental: experimental(info),
|
||||||
info.experimental?.subagent_depth === undefined
|
|
||||||
? undefined
|
|
||||||
: { subagent_depth: info.experimental.subagent_depth },
|
|
||||||
plugins: info.plugin?.map((plugin) =>
|
plugins: info.plugin?.map((plugin) =>
|
||||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||||
),
|
),
|
||||||
@@ -88,6 +85,31 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function experimental(info: typeof ConfigV1.Info.Type) {
|
||||||
|
const policies = [
|
||||||
|
...(info.enabled_providers === undefined
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{ action: "provider.use" as const, resource: "*", effect: "deny" as const },
|
||||||
|
...info.enabled_providers.map((resource) => ({
|
||||||
|
action: "provider.use" as const,
|
||||||
|
resource,
|
||||||
|
effect: "allow" as const,
|
||||||
|
})),
|
||||||
|
]),
|
||||||
|
...(info.disabled_providers ?? []).map((resource) => ({
|
||||||
|
action: "provider.use" as const,
|
||||||
|
resource,
|
||||||
|
effect: "deny" as const,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
if (info.experimental?.subagent_depth === undefined && !policies.length) return
|
||||||
|
return {
|
||||||
|
subagent_depth: info.experimental?.subagent_depth,
|
||||||
|
policies: policies.length ? policies : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<string, boolean>>) {
|
function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<string, boolean>>) {
|
||||||
const rules: Array<{ action: string; resource: string; effect: ConfigPermissionV1.Action }> = Object.entries(
|
const rules: Array<{ action: string; resource: string; effect: ConfigPermissionV1.Action }> = Object.entries(
|
||||||
tools ?? {},
|
tools ?? {},
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
export * as WellKnown from "./wellknown"
|
export * as WellKnown from "./wellknown"
|
||||||
|
|
||||||
import { Integration } from "@opencode-ai/schema/integration"
|
import { Integration } from "@opencode-ai/schema/integration"
|
||||||
import { Context, Effect, Layer, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
|
import { Context, Effect, Layer, Ref, Schema, Semaphore } from "effect"
|
||||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
|
import { isDeepStrictEqual } from "node:util"
|
||||||
import { makeGlobalNode } from "./effect/app-node"
|
import { makeGlobalNode } from "./effect/app-node"
|
||||||
import { httpClient } from "./effect/app-node-platform"
|
import { httpClient } from "./effect/app-node-platform"
|
||||||
|
import { EventV2 } from "./event"
|
||||||
import { KV } from "./kv"
|
import { KV } from "./kv"
|
||||||
|
|
||||||
export interface Auth extends Schema.Schema.Type<typeof Auth> {}
|
export interface Auth extends Schema.Schema.Type<typeof Auth> {}
|
||||||
@@ -43,14 +45,18 @@ export interface Entry {
|
|||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly entries: () => Effect.Effect<readonly Entry[], Error>
|
readonly entries: () => Effect.Effect<readonly Entry[], Error>
|
||||||
readonly snapshot: () => readonly Entry[]
|
readonly snapshot: () => readonly Entry[]
|
||||||
|
readonly refresh: () => Effect.Effect<boolean, Error>
|
||||||
readonly add: (origin: string) => Effect.Effect<Entry, Error>
|
readonly add: (origin: string) => Effect.Effect<Entry, Error>
|
||||||
readonly remove: (origin: string) => Effect.Effect<void>
|
readonly remove: (origin: string) => Effect.Effect<void>
|
||||||
readonly changes: Stream.Stream<void>
|
|
||||||
readonly resolve: (entry: Entry, variables: Readonly<Record<string, string>>) => Effect.Effect<Config[], Error>
|
readonly resolve: (entry: Entry, variables: Readonly<Record<string, string>>) => Effect.Effect<Config[], Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/WellKnown") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/WellKnown") {}
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Updated: EventV2.ephemeral({ type: "wellknown.updated", schema: {} }),
|
||||||
|
}
|
||||||
|
|
||||||
export const inspect = Effect.fn("WellKnown.inspect")(function* (origin: string) {
|
export const inspect = Effect.fn("WellKnown.inspect")(function* (origin: string) {
|
||||||
const url = `${origin.replace(/\/+$/, "")}/.well-known/opencode`
|
const url = `${origin.replace(/\/+$/, "")}/.well-known/opencode`
|
||||||
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
||||||
@@ -97,8 +103,8 @@ const layer = Layer.effect(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const http = yield* HttpClient.HttpClient
|
const http = yield* HttpClient.HttpClient
|
||||||
const kv = yield* KV.Service
|
const kv = yield* KV.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
const cache = yield* Ref.make(new Map<string, Entry>())
|
const cache = yield* Ref.make(new Map<string, Entry>())
|
||||||
const changes = yield* PubSub.unbounded<void>()
|
|
||||||
const lock = Semaphore.makeUnsafe(1)
|
const lock = Semaphore.makeUnsafe(1)
|
||||||
|
|
||||||
const load = Effect.fn("WellKnown.load")(function* () {
|
const load = Effect.fn("WellKnown.load")(function* () {
|
||||||
@@ -117,9 +123,32 @@ const layer = Layer.effect(
|
|||||||
return entries
|
return entries
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const refresh = Effect.fn("WellKnown.refresh")(function* () {
|
||||||
|
return yield* lock.withPermit(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const value = yield* kv.get(sourcesKey)
|
||||||
|
const origins = Schema.is(Sources)(value) ? value : []
|
||||||
|
if (!origins.length) return false
|
||||||
|
const entries = yield* Effect.forEach(origins, (origin) =>
|
||||||
|
inspect(origin).pipe(
|
||||||
|
Effect.provideService(HttpClient.HttpClient, http),
|
||||||
|
Effect.map((manifest) => ({ origin, integrationID: Integration.ID.make(origin), manifest })),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const next = new Map(entries.map((entry) => [entry.origin, entry]))
|
||||||
|
const changed = !isDeepStrictEqual(Ref.getUnsafe(cache), next)
|
||||||
|
if (!changed) return false
|
||||||
|
yield* Ref.set(cache, next)
|
||||||
|
yield* events.publish(Event.Updated, {})
|
||||||
|
return true
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
entries: load,
|
entries: load,
|
||||||
snapshot: () => Array.from(Ref.getUnsafe(cache).values()),
|
snapshot: () => Array.from(Ref.getUnsafe(cache).values()),
|
||||||
|
refresh,
|
||||||
add: Effect.fn("WellKnown.add")(function* (value) {
|
add: Effect.fn("WellKnown.add")(function* (value) {
|
||||||
return yield* lock.withPermit(
|
return yield* lock.withPermit(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -131,7 +160,7 @@ const layer = Layer.effect(
|
|||||||
const origins = Schema.is(Sources)(sources) ? sources : []
|
const origins = Schema.is(Sources)(sources) ? sources : []
|
||||||
yield* kv.set(sourcesKey, Array.from(new Set([...origins, origin])))
|
yield* kv.set(sourcesKey, Array.from(new Set([...origins, origin])))
|
||||||
yield* Ref.update(cache, (current) => new Map(current).set(origin, entry))
|
yield* Ref.update(cache, (current) => new Map(current).set(origin, entry))
|
||||||
yield* PubSub.publish(changes, undefined)
|
yield* events.publish(Event.Updated, {})
|
||||||
return entry
|
return entry
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -151,11 +180,10 @@ const layer = Layer.effect(
|
|||||||
next.delete(origin)
|
next.delete(origin)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
yield* PubSub.publish(changes, undefined)
|
yield* events.publish(Event.Updated, {})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
changes: Stream.fromPubSub(changes),
|
|
||||||
resolve: Effect.fn("WellKnown.resolveEntry")(function* (entry, variables) {
|
resolve: Effect.fn("WellKnown.resolveEntry")(function* (entry, variables) {
|
||||||
return yield* resolveEntry(entry, variables).pipe(Effect.provideService(HttpClient.HttpClient, http))
|
return yield* resolveEntry(entry, variables).pipe(Effect.provideService(HttpClient.HttpClient, http))
|
||||||
}),
|
}),
|
||||||
@@ -163,4 +191,4 @@ const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const node = makeGlobalNode({ service: Service, layer, deps: [httpClient, KV.node] })
|
export const node = makeGlobalNode({ service: Service, layer, deps: [httpClient, KV.node, EventV2.node] })
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ export * as WellKnownPlugin from "./plugin"
|
|||||||
|
|
||||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
import { Effect, Stream } from "effect"
|
import { Effect, Stream } from "effect"
|
||||||
|
import { EventV2 } from "../event"
|
||||||
import { WellKnown } from "../wellknown"
|
import { WellKnown } from "../wellknown"
|
||||||
|
|
||||||
export const Plugin = define({
|
export const Plugin = define({
|
||||||
id: "opencode.wellknown",
|
id: "opencode.wellknown",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
const wellknown = yield* WellKnown.Service
|
const wellknown = yield* WellKnown.Service
|
||||||
yield* wellknown.entries().pipe(Effect.orDie)
|
yield* wellknown.entries().pipe(Effect.orDie)
|
||||||
yield* ctx.integration.transform((draft) => {
|
yield* ctx.integration.transform((draft) => {
|
||||||
@@ -26,7 +28,7 @@ export const Plugin = define({
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
yield* wellknown.changes.pipe(
|
yield* events.subscribe(WellKnown.Event.Updated).pipe(
|
||||||
Stream.runForEach(() => ctx.integration.reload()),
|
Stream.runForEach(() => ctx.integration.reload()),
|
||||||
Effect.forkScoped({ startImmediately: true }),
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
|
import { Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
|
||||||
|
import { TestClock } from "effect/testing"
|
||||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
@@ -76,7 +77,9 @@ describe("AgentV2", () => {
|
|||||||
)
|
)
|
||||||
description = "New description"
|
description = "New description"
|
||||||
hidden = false
|
hidden = false
|
||||||
yield* agent.reload()
|
const reload = yield* agent.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||||
|
yield* TestClock.adjust("500 millis")
|
||||||
|
yield* Fiber.join(reload)
|
||||||
|
|
||||||
expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false })
|
expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false })
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { Effect, Fiber, Layer, Stream } from "effect"
|
import { Effect, Fiber, Layer, Stream } from "effect"
|
||||||
|
import { TestClock } from "effect/testing"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Integration } from "@opencode-ai/core/integration"
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { Credential } from "@opencode-ai/core/credential"
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
@@ -261,7 +262,9 @@ describe("CatalogV2", () => {
|
|||||||
expect((yield* catalog.model.default())?.id).toBe(old)
|
expect((yield* catalog.model.default())?.id).toBe(old)
|
||||||
|
|
||||||
configured = false
|
configured = false
|
||||||
yield* catalog.reload()
|
const reload = yield* catalog.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||||
|
yield* TestClock.adjust("500 millis")
|
||||||
|
yield* Fiber.join(reload)
|
||||||
expect((yield* catalog.model.default())?.id).toBe(newest)
|
expect((yield* catalog.model.default())?.id).toBe(newest)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -53,9 +53,9 @@ const emptyWellknownNode = makeGlobalNode({
|
|||||||
WellKnown.Service.of({
|
WellKnown.Service.of({
|
||||||
entries: () => Effect.succeed([]),
|
entries: () => Effect.succeed([]),
|
||||||
snapshot: () => [],
|
snapshot: () => [],
|
||||||
|
refresh: () => Effect.succeed(false),
|
||||||
add: () => Effect.die("unused Wellknown.add"),
|
add: () => Effect.die("unused Wellknown.add"),
|
||||||
remove: () => Effect.die("unused Wellknown.remove"),
|
remove: () => Effect.die("unused Wellknown.remove"),
|
||||||
changes: Stream.empty,
|
|
||||||
resolve: () => Effect.die("unused Wellknown.resolve"),
|
resolve: () => Effect.die("unused Wellknown.resolve"),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -216,9 +216,9 @@ describe("Config", () => {
|
|||||||
WellKnown.Service.of({
|
WellKnown.Service.of({
|
||||||
entries: () => Effect.succeed([entry]),
|
entries: () => Effect.succeed([entry]),
|
||||||
snapshot: () => [entry],
|
snapshot: () => [entry],
|
||||||
|
refresh: () => Effect.succeed(false),
|
||||||
add: () => Effect.die("unused Wellknown.add"),
|
add: () => Effect.die("unused Wellknown.add"),
|
||||||
remove: () => Effect.die("unused Wellknown.remove"),
|
remove: () => Effect.die("unused Wellknown.remove"),
|
||||||
changes: Stream.empty,
|
|
||||||
resolve: (_entry, variables) => Effect.succeed([{ shell: variables.TOKEN }]),
|
resolve: (_entry, variables) => Effect.succeed([{ shell: variables.TOKEN }]),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -289,6 +289,25 @@ describe("Config", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("migrates v1 provider lists to policies", () =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
expect(
|
||||||
|
ConfigMigrateV1.migrate({
|
||||||
|
enabled_providers: ["anthropic", "openai"],
|
||||||
|
disabled_providers: ["openai"],
|
||||||
|
}).experimental?.policies,
|
||||||
|
).toEqual([
|
||||||
|
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||||
|
{ action: "provider.use", resource: "anthropic", effect: "allow" },
|
||||||
|
{ action: "provider.use", resource: "openai", effect: "allow" },
|
||||||
|
{ action: "provider.use", resource: "openai", effect: "deny" },
|
||||||
|
])
|
||||||
|
expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
|
||||||
|
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("migrates v1 provider setup options into AISDK settings", () =>
|
it.effect("migrates v1 provider setup options into AISDK settings", () =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const migrated = ConfigMigrateV1.migrate({
|
const migrated = ConfigMigrateV1.migrate({
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||||
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
|
import { Config } from "@opencode-ai/core/config"
|
||||||
|
import { ConfigPolicyPlugin } from "@opencode-ai/core/config/plugin/policy"
|
||||||
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
|
import { Effect, Schema } from "effect"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "../plugin/fixture"
|
||||||
|
|
||||||
|
const it = testEffect(PluginTestLayer)
|
||||||
|
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||||
|
|
||||||
|
const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) =>
|
||||||
|
new Config.Document({
|
||||||
|
type: "document",
|
||||||
|
info: decode({
|
||||||
|
experimental: {
|
||||||
|
policies: items.map((item) => ({ action: "provider.use", ...item })),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const addPlugin = Effect.fn(function* (entries: () => Config.Entry[]) {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const host = yield* PluginHost.make(plugin)
|
||||||
|
yield* ConfigPolicyPlugin.Plugin.effect(host).pipe(
|
||||||
|
Effect.provideService(Config.Service, Config.Service.of({ entries: () => Effect.sync(entries) })),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("ConfigPolicyPlugin.Plugin", () => {
|
||||||
|
it.effect("filters plugin-provided providers with ordered wildcard policies", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
yield* catalog.transform((catalog) => {
|
||||||
|
catalog.provider.update(ProviderV2.ID.openai, () => {})
|
||||||
|
catalog.provider.update(ProviderV2.ID.anthropic, () => {})
|
||||||
|
catalog.provider.update(ProviderV2.ID.make("company-internal"), () => {})
|
||||||
|
})
|
||||||
|
yield* addPlugin(() => [
|
||||||
|
policies(
|
||||||
|
{ effect: "deny", resource: "*" },
|
||||||
|
{ effect: "allow", resource: "anthropic" },
|
||||||
|
{ effect: "allow", resource: "company-*" },
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(yield* catalog.provider.get(ProviderV2.ID.openai)).toBeUndefined()
|
||||||
|
expect(yield* catalog.provider.get(ProviderV2.ID.anthropic)).toBeDefined()
|
||||||
|
expect(yield* catalog.provider.get(ProviderV2.ID.make("company-internal"))).toBeDefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("prevents project policy from overriding user-global policy", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {}))
|
||||||
|
yield* addPlugin(() => [
|
||||||
|
policies({ effect: "deny", resource: "openai" }),
|
||||||
|
policies({ effect: "allow", resource: "openai" }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(yield* catalog.provider.get(ProviderV2.ID.openai)).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("reloads changed policies", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
let entries: Config.Entry[] = [policies({ effect: "deny", resource: "openai" })]
|
||||||
|
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {}))
|
||||||
|
yield* addPlugin(() => entries)
|
||||||
|
expect(yield* catalog.provider.get(ProviderV2.ID.openai)).toBeUndefined()
|
||||||
|
|
||||||
|
entries = [policies({ effect: "allow", resource: "openai" })]
|
||||||
|
yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||||
|
yield* waitUntil(
|
||||||
|
catalog.provider.get(ProviderV2.ID.openai).pipe(Effect.map((provider) => provider !== undefined)),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
|
||||||
|
for (let attempt = 0; attempt < 200; attempt++) {
|
||||||
|
if (yield* condition) return
|
||||||
|
yield* Effect.sleep("10 millis")
|
||||||
|
}
|
||||||
|
return yield* Effect.die("Timed out waiting for policy reload")
|
||||||
|
})
|
||||||
@@ -63,6 +63,11 @@ const instructionEvents = (db: Database.Interface["db"], sessionID: SessionSchem
|
|||||||
.all()
|
.all()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
|
const preview = (db: Database.Interface["db"], sessionID: SessionSchema.ID, instructions: Instructions.Instructions) =>
|
||||||
|
Instructions.read(instructions).pipe(
|
||||||
|
Effect.flatMap((observed) => InstructionState.preview(db, sessionID, instructions, observed)),
|
||||||
|
)
|
||||||
|
|
||||||
describe("InstructionState", () => {
|
describe("InstructionState", () => {
|
||||||
it.effect("observes each source once without publishing events or inserting blobs", () =>
|
it.effect("observes each source once without publishing events or inserting blobs", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -227,6 +232,134 @@ describe("InstructionState", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("assembles a fresh private update without repairing a missing cache", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const sessionID = SessionSchema.ID.make("ses_instruction_generate")
|
||||||
|
const { db, events } = yield* setup(sessionID)
|
||||||
|
let value = "Initial context"
|
||||||
|
const instructions = source(
|
||||||
|
"test/context",
|
||||||
|
Effect.sync(() => value),
|
||||||
|
)
|
||||||
|
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
||||||
|
yield* db
|
||||||
|
.delete(InstructionStateTable)
|
||||||
|
.where(eq(InstructionStateTable.session_id, sessionID))
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
value = "Changed context"
|
||||||
|
const beforeEvents = yield* instructionEvents(db, sessionID)
|
||||||
|
const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)
|
||||||
|
|
||||||
|
const assembled = yield* preview(db, sessionID, instructions)
|
||||||
|
|
||||||
|
expect(assembled).toEqual({ initial: "Initial context", updates: [], update: "Changed context" })
|
||||||
|
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
|
||||||
|
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
|
||||||
|
expect(
|
||||||
|
yield* db
|
||||||
|
.select()
|
||||||
|
.from(InstructionStateTable)
|
||||||
|
.where(eq(InstructionStateTable.session_id, sessionID))
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie),
|
||||||
|
).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("reads through a stale cache without repairing it", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const sessionID = SessionSchema.ID.make("ses_instruction_generate_stale")
|
||||||
|
const { db, events } = yield* setup(sessionID)
|
||||||
|
let value = "Initial context"
|
||||||
|
const instructions = source(
|
||||||
|
"test/context",
|
||||||
|
Effect.sync(() => value),
|
||||||
|
)
|
||||||
|
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
||||||
|
value = "Committed update"
|
||||||
|
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
||||||
|
yield* db
|
||||||
|
.update(InstructionStateTable)
|
||||||
|
.set({ through_seq: 0, current_values: { "test/context": Instructions.hash("Initial context") } })
|
||||||
|
.where(eq(InstructionStateTable.session_id, sessionID))
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
value = "Private update"
|
||||||
|
const beforeEvents = yield* instructionEvents(db, sessionID)
|
||||||
|
const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)
|
||||||
|
const beforeState = yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)
|
||||||
|
|
||||||
|
const assembled = yield* preview(db, sessionID, instructions)
|
||||||
|
|
||||||
|
expect(assembled.initial).toBe("Initial context")
|
||||||
|
expect(assembled.updates.map((entry) => entry.message.text)).toEqual(["Committed update"])
|
||||||
|
expect(assembled.update).toBe("Private update")
|
||||||
|
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
|
||||||
|
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
|
||||||
|
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toEqual(beforeState)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("assembles initial instructions without persisting a baseline", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const sessionID = SessionSchema.ID.make("ses_instruction_generate_initial")
|
||||||
|
const { db } = yield* setup(sessionID)
|
||||||
|
const instructions = source("test/context", Effect.succeed("Initial context"))
|
||||||
|
|
||||||
|
expect(yield* preview(db, sessionID, instructions)).toEqual({
|
||||||
|
initial: "Initial context",
|
||||||
|
updates: [],
|
||||||
|
update: "",
|
||||||
|
})
|
||||||
|
expect(yield* instructionEvents(db, sessionID)).toEqual([])
|
||||||
|
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual([])
|
||||||
|
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("retains a committed value when fresh instructions are unavailable", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const sessionID = SessionSchema.ID.make("ses_instruction_generate_unavailable")
|
||||||
|
const { db, events } = yield* setup(sessionID)
|
||||||
|
let value: string | Instructions.Unavailable = "Committed context"
|
||||||
|
const instructions = source(
|
||||||
|
"test/context",
|
||||||
|
Effect.sync(() => value),
|
||||||
|
)
|
||||||
|
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
||||||
|
value = Instructions.unavailable
|
||||||
|
const beforeEvents = yield* instructionEvents(db, sessionID)
|
||||||
|
const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)
|
||||||
|
const beforeState = yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)
|
||||||
|
|
||||||
|
expect(yield* preview(db, sessionID, instructions)).toEqual({
|
||||||
|
initial: "Committed context",
|
||||||
|
updates: [],
|
||||||
|
update: "",
|
||||||
|
})
|
||||||
|
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
|
||||||
|
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
|
||||||
|
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toEqual(beforeState)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("blocks an unavailable initial instruction without persisting a baseline", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const sessionID = SessionSchema.ID.make("ses_instruction_generate_blocked")
|
||||||
|
const { db } = yield* setup(sessionID)
|
||||||
|
const instructions = source("test/context", Effect.succeed(Instructions.unavailable))
|
||||||
|
|
||||||
|
const error = yield* preview(db, sessionID, instructions).pipe(Effect.flip)
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(Instructions.InitializationBlocked)
|
||||||
|
expect(error.keys).toEqual([Instructions.Key.make("test/context")])
|
||||||
|
expect(yield* instructionEvents(db, sessionID)).toEqual([])
|
||||||
|
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual([])
|
||||||
|
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("keeps prepare equivalent to observe followed by commit", () =>
|
it.effect("keeps prepare equivalent to observe followed by commit", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const observedSessionID = SessionSchema.ID.make("ses_instruction_composed")
|
const observedSessionID = SessionSchema.ID.make("ses_instruction_composed")
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { expect } from "bun:test"
|
||||||
|
import { LLMClient, LLMEvent, LLMResponse, Model, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||||
|
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||||
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
|
||||||
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
|
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||||
|
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
|
||||||
|
import { Instructions } from "@opencode-ai/core/instructions"
|
||||||
|
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
|
||||||
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
|
import { Project } from "@opencode-ai/core/project"
|
||||||
|
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||||
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
|
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
|
||||||
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||||
|
import { SessionGenerate } from "@opencode-ai/core/session/generate"
|
||||||
|
import { SessionGenerateNode } from "@opencode-ai/core/session/generate-node"
|
||||||
|
import { InstructionState } from "@opencode-ai/core/session/instruction-state"
|
||||||
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
|
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||||
|
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||||
|
import {
|
||||||
|
InstructionBlobTable,
|
||||||
|
InstructionStateTable,
|
||||||
|
SessionMessageTable,
|
||||||
|
SessionPendingTable,
|
||||||
|
SessionTable,
|
||||||
|
} from "@opencode-ai/core/session/sql"
|
||||||
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||||
|
import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
|
||||||
|
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||||
|
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||||
|
import { asc, eq } from "drizzle-orm"
|
||||||
|
import { Effect, Layer, Schema, Stream } from "effect"
|
||||||
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
|
const requests: LLMRequest[] = []
|
||||||
|
let instruction: string | Instructions.Unavailable = "Initial context"
|
||||||
|
const sessionID = SessionSchema.ID.make("ses_generate_test")
|
||||||
|
|
||||||
|
const model = Model.make({ id: "generate-model", provider: "test", route: OpenAIChat.route })
|
||||||
|
const client = Layer.mock(LLMClient.Service)({
|
||||||
|
prepare: () => Effect.die(new Error("unused")),
|
||||||
|
stream: () => Stream.die(new Error("unused")),
|
||||||
|
generate: (request) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
requests.push(request)
|
||||||
|
const response = LLMResponse.fromEvents([
|
||||||
|
LLMEvent.stepStart({ index: 0 }),
|
||||||
|
LLMEvent.textStart({ id: "generate" }),
|
||||||
|
LLMEvent.textDelta({ id: "generate", text: "Transient answer" }),
|
||||||
|
LLMEvent.textEnd({ id: "generate" }),
|
||||||
|
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 100, outputTokens: 10 } }),
|
||||||
|
LLMEvent.finish({ reason: "stop" }),
|
||||||
|
])
|
||||||
|
if (!response) throw new Error("Incomplete generate response")
|
||||||
|
return response
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
|
||||||
|
const builtins = Layer.mock(InstructionBuiltIns.Service, {
|
||||||
|
load: () =>
|
||||||
|
Effect.succeed(
|
||||||
|
Instructions.make({
|
||||||
|
key: Instructions.Key.make("test/context"),
|
||||||
|
codec: Schema.toCodecJson(Schema.String),
|
||||||
|
read: Effect.sync(() => instruction),
|
||||||
|
render: {
|
||||||
|
initial: String,
|
||||||
|
changed: (_previous, current) => current,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
const discovery = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||||
|
const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||||
|
const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||||
|
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||||
|
const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
|
||||||
|
|
||||||
|
const it = testEffect(
|
||||||
|
AppNodeBuilder.build(
|
||||||
|
LayerNode.group([
|
||||||
|
Database.node,
|
||||||
|
EventV2.node,
|
||||||
|
SessionProjector.node,
|
||||||
|
SessionStore.node,
|
||||||
|
AgentV2.node,
|
||||||
|
InstructionBuiltIns.node,
|
||||||
|
PluginHooks.node,
|
||||||
|
SessionGenerateNode.node,
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
[llmClient, client],
|
||||||
|
[SessionRunnerModel.node, models],
|
||||||
|
[InstructionBuiltIns.node, builtins],
|
||||||
|
[InstructionDiscovery.node, discovery],
|
||||||
|
[SkillInstructions.node, skills],
|
||||||
|
[ReferenceInstructions.node, references],
|
||||||
|
[McpInstructions.node, mcp],
|
||||||
|
[PluginSupervisor.node, plugins],
|
||||||
|
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const durableState = (db: Database.Interface["db"], sessionID: SessionSchema.ID) =>
|
||||||
|
Effect.all({
|
||||||
|
sequence: EventV2.latestSequence(db, sessionID),
|
||||||
|
events: db
|
||||||
|
.select()
|
||||||
|
.from(EventTable)
|
||||||
|
.where(eq(EventTable.aggregate_id, sessionID))
|
||||||
|
.orderBy(asc(EventTable.seq))
|
||||||
|
.all()
|
||||||
|
.pipe(Effect.orDie),
|
||||||
|
messages: db
|
||||||
|
.select()
|
||||||
|
.from(SessionMessageTable)
|
||||||
|
.where(eq(SessionMessageTable.session_id, sessionID))
|
||||||
|
.orderBy(asc(SessionMessageTable.seq))
|
||||||
|
.all()
|
||||||
|
.pipe(Effect.orDie),
|
||||||
|
pending: db
|
||||||
|
.select()
|
||||||
|
.from(SessionPendingTable)
|
||||||
|
.where(eq(SessionPendingTable.session_id, sessionID))
|
||||||
|
.orderBy(asc(SessionPendingTable.admitted_seq))
|
||||||
|
.all()
|
||||||
|
.pipe(Effect.orDie),
|
||||||
|
instructions: db
|
||||||
|
.select()
|
||||||
|
.from(InstructionStateTable)
|
||||||
|
.where(eq(InstructionStateTable.session_id, sessionID))
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie),
|
||||||
|
blobs: db.select().from(InstructionBlobTable).orderBy(asc(InstructionBlobTable.hash)).all().pipe(Effect.orDie),
|
||||||
|
session: db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie),
|
||||||
|
})
|
||||||
|
|
||||||
|
const userTexts = (request: LLMRequest) =>
|
||||||
|
request.messages.flatMap((message) =>
|
||||||
|
message.role === "user"
|
||||||
|
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
|
||||||
|
const setup = Effect.gen(function* () {
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const agents = yield* AgentV2.Service
|
||||||
|
const instructionBuiltIns = yield* InstructionBuiltIns.Service
|
||||||
|
yield* agents.transform((draft) =>
|
||||||
|
draft.update(AgentV2.ID.make("build"), (agent) => {
|
||||||
|
agent.mode = "primary"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
yield* db
|
||||||
|
.insert(ProjectTable)
|
||||||
|
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
yield* db
|
||||||
|
.insert(SessionTable)
|
||||||
|
.values({
|
||||||
|
id: sessionID,
|
||||||
|
project_id: Project.ID.global,
|
||||||
|
slug: "generate-test",
|
||||||
|
directory: "/project",
|
||||||
|
title: "Generate test",
|
||||||
|
version: "test",
|
||||||
|
agent: AgentV2.ID.make("build"),
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
return { db, events, instructions: yield* instructionBuiltIns.load(sessionID) }
|
||||||
|
})
|
||||||
|
|
||||||
|
it.effect("generates from fresh settled Session context without durable mutation", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
requests.length = 0
|
||||||
|
instruction = "Initial context"
|
||||||
|
const { db, events, instructions } = yield* setup
|
||||||
|
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
||||||
|
const existing = SessionMessage.ID.create()
|
||||||
|
yield* events.publish(SessionEvent.InputAdmitted, {
|
||||||
|
sessionID,
|
||||||
|
inputID: existing,
|
||||||
|
input: { type: "user", data: { text: "Existing durable context" }, delivery: "steer" },
|
||||||
|
})
|
||||||
|
yield* events.publish(SessionEvent.InputPromoted, { sessionID, inputID: existing })
|
||||||
|
const settledAssistant = SessionMessage.ID.create()
|
||||||
|
yield* events.publish(SessionEvent.Step.Started, {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: settledAssistant,
|
||||||
|
agent: AgentV2.ID.make("build"),
|
||||||
|
model: { id: ModelV2.ID.make("generate-model"), providerID: ProviderV2.ID.make("test") },
|
||||||
|
})
|
||||||
|
yield* events.publish(SessionEvent.Text.Started, {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: settledAssistant,
|
||||||
|
ordinal: 0,
|
||||||
|
})
|
||||||
|
yield* events.publish(SessionEvent.Text.Ended, {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: settledAssistant,
|
||||||
|
ordinal: 0,
|
||||||
|
text: "Settled partial answer",
|
||||||
|
})
|
||||||
|
const activeAssistant = SessionMessage.ID.create()
|
||||||
|
yield* events.publish(SessionEvent.Step.Started, {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: activeAssistant,
|
||||||
|
agent: AgentV2.ID.make("build"),
|
||||||
|
model: { id: ModelV2.ID.make("generate-model"), providerID: ProviderV2.ID.make("test") },
|
||||||
|
})
|
||||||
|
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: activeAssistant,
|
||||||
|
callID: "active-call",
|
||||||
|
name: "echo",
|
||||||
|
})
|
||||||
|
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: activeAssistant,
|
||||||
|
callID: "active-call",
|
||||||
|
text: "{}",
|
||||||
|
})
|
||||||
|
yield* events.publish(SessionEvent.Tool.Called, {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: activeAssistant,
|
||||||
|
callID: "active-call",
|
||||||
|
input: {},
|
||||||
|
executed: false,
|
||||||
|
})
|
||||||
|
yield* events.publish(SessionEvent.InputAdmitted, {
|
||||||
|
sessionID,
|
||||||
|
inputID: SessionMessage.ID.create(),
|
||||||
|
input: { type: "user", data: { text: "Queued input must remain invisible" }, delivery: "queue" },
|
||||||
|
})
|
||||||
|
instruction = "Changed context"
|
||||||
|
const before = yield* durableState(db, sessionID)
|
||||||
|
const hooks = yield* PluginHooks.Service
|
||||||
|
yield* hooks.register("session", "context", (event) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
event.system = [SystemPart.make("Hooked system"), ...event.system]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const generate = yield* SessionGenerate.Service
|
||||||
|
const result = yield* generate.generate({ sessionID, prompt: "Summarize privately" })
|
||||||
|
|
||||||
|
expect(result).toBe("Transient answer")
|
||||||
|
expect(requests).toHaveLength(1)
|
||||||
|
expect(requests[0]?.model).toBe(model)
|
||||||
|
expect(requests[0]?.system[0]?.text).toBe("Hooked system")
|
||||||
|
expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context")
|
||||||
|
expect(requests[0]?.http?.headers).toMatchObject({ "X-Session-Id": sessionID })
|
||||||
|
expect(requests[0]?.providerOptions).toMatchObject({ openai: { promptCacheKey: sessionID } })
|
||||||
|
expect(
|
||||||
|
requests[0]?.messages.flatMap((message) =>
|
||||||
|
message.role === "system"
|
||||||
|
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
).toEqual(["Changed context"])
|
||||||
|
expect(userTexts(requests[0])).toEqual(["Existing durable context", "Summarize privately"])
|
||||||
|
expect(
|
||||||
|
requests[0]?.messages.flatMap((message) =>
|
||||||
|
message.role === "assistant"
|
||||||
|
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
).toEqual(["Settled partial answer"])
|
||||||
|
expect(requests[0]?.tools).toEqual([])
|
||||||
|
expect(requests[0]?.toolChoice).toMatchObject({ type: "none" })
|
||||||
|
expect(yield* durableState(db, sessionID)).toEqual(before)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("blocks unavailable initial instructions before generation", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
requests.length = 0
|
||||||
|
instruction = Instructions.unavailable
|
||||||
|
const { db } = yield* setup
|
||||||
|
const before = yield* durableState(db, sessionID)
|
||||||
|
const generate = yield* SessionGenerate.Service
|
||||||
|
|
||||||
|
const error = yield* generate.generate({ sessionID, prompt: "Summarize privately" }).pipe(Effect.flip)
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(Instructions.InitializationBlocked)
|
||||||
|
expect(requests).toEqual([])
|
||||||
|
expect(yield* durableState(db, sessionID)).toEqual(before)
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { State } from "@opencode-ai/core/state"
|
import { State } from "@opencode-ai/core/state"
|
||||||
import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
|
import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
|
||||||
|
import { TestClock } from "effect/testing"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const it = testEffect(Layer.empty)
|
const it = testEffect(Layer.empty)
|
||||||
@@ -35,7 +36,7 @@ describe("State", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("runs effectful transforms during every reload", () =>
|
it.effect("runs transforms during every reload", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
let value = "first"
|
let value = "first"
|
||||||
const state = State.create({
|
const state = State.create({
|
||||||
@@ -43,15 +44,15 @@ describe("State", () => {
|
|||||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* state.transform((editor) =>
|
yield* state.transform((editor) => {
|
||||||
Effect.sync(() => {
|
editor.add(value)
|
||||||
editor.add(value)
|
})
|
||||||
}),
|
|
||||||
)
|
|
||||||
expect(state.get().values).toEqual(["first"])
|
expect(state.get().values).toEqual(["first"])
|
||||||
|
|
||||||
value = "second"
|
value = "second"
|
||||||
yield* state.reload()
|
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||||
|
yield* TestClock.adjust("500 millis")
|
||||||
|
yield* Fiber.join(reload)
|
||||||
expect(state.get().values).toEqual(["second"])
|
expect(state.get().values).toEqual(["second"])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -112,4 +113,30 @@ describe("State", () => {
|
|||||||
expect(finalized).toBe(2)
|
expect(finalized).toBe(2)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("debounces reload bursts", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
let finalized = 0
|
||||||
|
const state = State.create({
|
||||||
|
initial: () => ({ values: [] as string[] }),
|
||||||
|
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||||
|
finalize: () => Effect.sync(() => finalized++),
|
||||||
|
})
|
||||||
|
yield* state.transform((draft) => {
|
||||||
|
draft.add("value")
|
||||||
|
})
|
||||||
|
finalized = 0
|
||||||
|
|
||||||
|
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||||
|
yield* TestClock.adjust("250 millis")
|
||||||
|
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||||
|
yield* TestClock.adjust("499 millis")
|
||||||
|
expect(finalized).toBe(0)
|
||||||
|
yield* TestClock.adjust("1 millis")
|
||||||
|
yield* Fiber.join(first)
|
||||||
|
yield* Fiber.join(second)
|
||||||
|
|
||||||
|
expect(finalized).toBe(1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -166,10 +166,10 @@ const overflowCommand = (bytes: number) =>
|
|||||||
isWindows
|
isWindows
|
||||||
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
|
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
|
||||||
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
|
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
|
||||||
const progressOverflowCommand = (bytes: number) =>
|
const progressLinesCommand = (lines: number) =>
|
||||||
isWindows
|
isWindows
|
||||||
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 1500`
|
? `1..${lines} | ForEach-Object { [Console]::Out.WriteLine(('line {0:d2}' -f $_)) }; Start-Sleep -Milliseconds 1500`
|
||||||
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'; sleep 1.5`
|
: `i=1; while [ $i -le ${lines} ]; do printf 'line %02d\\n' "$i"; i=$((i+1)); done; sleep 1.5`
|
||||||
|
|
||||||
const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
|
const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -417,17 +417,17 @@ describe("ShellTool", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("reports bounded output progress for a running command", () =>
|
it.live("reports the latest output lines for a running command", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
(tmp) => {
|
(tmp) => {
|
||||||
reset()
|
reset()
|
||||||
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
|
const lines = ShellTool.PROGRESS_LINES + 10
|
||||||
return withSession(tmp.path, (registry) =>
|
return withSession(tmp.path, (registry) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const progress: ToolRegistry.Progress[] = []
|
const progress: ToolRegistry.Progress[] = []
|
||||||
yield* settleTool(registry, {
|
yield* settleTool(registry, {
|
||||||
...call({ command: progressOverflowCommand(bytes) }, "call-progress"),
|
...call({ command: progressLinesCommand(lines) }, "call-progress"),
|
||||||
progress: (update) => Effect.sync(() => progress.push(update)),
|
progress: (update) => Effect.sync(() => progress.push(update)),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -436,9 +436,12 @@ describe("ShellTool", () => {
|
|||||||
const content = progress[0]?.content[0]
|
const content = progress[0]?.content[0]
|
||||||
expect(content?.type).toBe("text")
|
expect(content?.type).toBe("text")
|
||||||
if (content?.type !== "text") return
|
if (content?.type !== "text") return
|
||||||
expect(content.text.indexOf("\n\n[output truncated; full output saved to:")).toBe(
|
expect(content.text).toStartWith(
|
||||||
ShellTool.MAX_CAPTURE_BYTES,
|
`[output truncated; showing last ${ShellTool.PROGRESS_LINES} lines. Full output saved to:`,
|
||||||
)
|
)
|
||||||
|
expect(content.text).not.toContain("line 10\n")
|
||||||
|
expect(content.text).toContain("line 11\n")
|
||||||
|
expect(content.text).toContain(`line ${lines}\n`)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { Effect, Fiber, Stream } from "effect"
|
|||||||
import { FetchHttpClient } from "effect/unstable/http"
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
import { KV } from "@opencode-ai/core/kv"
|
import { KV } from "@opencode-ai/core/kv"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { WellKnown } from "@opencode-ai/core/wellknown"
|
import { WellKnown } from "@opencode-ai/core/wellknown"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const it = testEffect(FetchHttpClient.layer)
|
const it = testEffect(FetchHttpClient.layer)
|
||||||
const serviceIt = testEffect(LayerNode.compile(LayerNode.group([WellKnown.node, KV.node])))
|
const serviceIt = testEffect(LayerNode.compile(LayerNode.group([WellKnown.node, KV.node, EventV2.node])))
|
||||||
|
|
||||||
it.live("loads embedded and remote configuration", () =>
|
it.live("loads embedded and remote configuration", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
@@ -65,7 +66,10 @@ serviceIt.live("persists sources in one KV value", () =>
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const wellknown = yield* WellKnown.Service
|
const wellknown = yield* WellKnown.Service
|
||||||
const kv = yield* KV.Service
|
const kv = yield* KV.Service
|
||||||
const changed = yield* wellknown.changes.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
const events = yield* EventV2.Service
|
||||||
|
const changed = yield* events
|
||||||
|
.subscribe(WellKnown.Event.Updated)
|
||||||
|
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||||
const entry = yield* wellknown.add(`${server.url.origin}/`)
|
const entry = yield* wellknown.add(`${server.url.origin}/`)
|
||||||
|
|
||||||
expect(entry.origin).toBe(server.url.origin)
|
expect(entry.origin).toBe(server.url.origin)
|
||||||
@@ -80,3 +84,36 @@ serviceIt.live("persists sources in one KV value", () =>
|
|||||||
(server) => Effect.promise(() => server.stop(true)),
|
(server) => Effect.promise(() => server.stop(true)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
serviceIt.live("refreshes changed manifests", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
let command = "first"
|
||||||
|
return {
|
||||||
|
server: Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch: () => Response.json({ auth: { command: [command], env: "TOKEN" } }),
|
||||||
|
}),
|
||||||
|
update: () => {
|
||||||
|
command = "second"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
({ server, update }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const wellknown = yield* WellKnown.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
yield* wellknown.add(server.url.origin)
|
||||||
|
expect(yield* wellknown.refresh()).toBe(false)
|
||||||
|
|
||||||
|
const changed = yield* events
|
||||||
|
.subscribe(WellKnown.Event.Updated)
|
||||||
|
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||||
|
update()
|
||||||
|
expect(yield* wellknown.refresh()).toBe(true)
|
||||||
|
expect(yield* Fiber.join(changed)).toHaveLength(1)
|
||||||
|
expect(wellknown.snapshot()[0]?.manifest.auth?.command).toEqual(["second"])
|
||||||
|
}),
|
||||||
|
({ server }) => Effect.promise(() => server.stop(true)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -134,11 +134,13 @@ export const Definitions = {
|
|||||||
messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"),
|
messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"),
|
||||||
messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"),
|
messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"),
|
||||||
messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"),
|
messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"),
|
||||||
messages_first: keybind("ctrl+g,home", "Navigate to first message"),
|
messages_first: keybind("ctrl+g,home,alt+home", "Navigate to first message"),
|
||||||
messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"),
|
messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"),
|
||||||
messages_next: keybind("none", "Navigate to next message"),
|
messages_next: keybind("alt+down", "Navigate to next message"),
|
||||||
messages_previous: keybind("none", "Navigate to previous message"),
|
messages_previous: keybind("alt+up", "Navigate to previous message"),
|
||||||
messages_last_user: keybind("none", "Navigate to last user message"),
|
messages_next_user: keybind("alt+shift+down", "Navigate to next user message"),
|
||||||
|
messages_previous_user: keybind("alt+shift+up", "Navigate to previous user message"),
|
||||||
|
messages_last_user: keybind("alt+end", "Navigate to last user message"),
|
||||||
messages_copy: keybind("<leader>y", "Copy message"),
|
messages_copy: keybind("<leader>y", "Copy message"),
|
||||||
messages_undo: keybind("<leader>u", "Undo message"),
|
messages_undo: keybind("<leader>u", "Undo message"),
|
||||||
messages_redo: keybind("<leader>r", "Redo message"),
|
messages_redo: keybind("<leader>r", "Redo message"),
|
||||||
@@ -335,6 +337,8 @@ export const CommandMap = {
|
|||||||
messages_last: "session.last",
|
messages_last: "session.last",
|
||||||
messages_next: "session.message.next",
|
messages_next: "session.message.next",
|
||||||
messages_previous: "session.message.previous",
|
messages_previous: "session.message.previous",
|
||||||
|
messages_next_user: "session.message.user.next",
|
||||||
|
messages_previous_user: "session.message.user.previous",
|
||||||
messages_last_user: "session.messages_last_user",
|
messages_last_user: "session.messages_last_user",
|
||||||
messages_copy: "messages.copy",
|
messages_copy: "messages.copy",
|
||||||
messages_undo: "session.undo",
|
messages_undo: "session.undo",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import { dedupeWith } from "effect/Array"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { batch, createEffect, createMemo } from "solid-js"
|
import { batch, createEffect, createMemo } from "solid-js"
|
||||||
import { useEvent } from "./event"
|
import { useEvent } from "./event"
|
||||||
@@ -54,7 +55,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const data = useData()
|
const data = useData()
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { theme, themeV2 } = useTheme()
|
const { theme, themeV2, mode } = useTheme()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const paths = useTuiPaths()
|
const paths = useTuiPaths()
|
||||||
const args = useArgs()
|
const args = useArgs()
|
||||||
@@ -83,15 +84,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const [agentStore, setAgentStore] = createStore({
|
const [agentStore, setAgentStore] = createStore({
|
||||||
current: undefined as string | undefined,
|
current: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
const colors = createMemo(() => [
|
const colors = createMemo(() => {
|
||||||
themeV2.hue.accent(500),
|
const step = mode() === "light" ? 800 : 200
|
||||||
theme.accent,
|
return dedupeWith(
|
||||||
theme.success,
|
[
|
||||||
theme.warning,
|
themeV2.hue.blue(step),
|
||||||
theme.primary,
|
themeV2.hue.purple(step),
|
||||||
theme.error,
|
themeV2.hue.green(step),
|
||||||
theme.info,
|
themeV2.hue.orange(step),
|
||||||
])
|
themeV2.hue.red(step),
|
||||||
|
themeV2.hue.cyan(step),
|
||||||
|
],
|
||||||
|
(first, second) => first.equals(second),
|
||||||
|
)
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
list() {
|
list() {
|
||||||
return agents()
|
return agents()
|
||||||
@@ -441,7 +447,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const slots = createMemo(() => {
|
const slots = createMemo(() => {
|
||||||
const existing = new Set(data.session.list().filter((x) => x.parentID === undefined).map((x) => x.id))
|
const existing = new Set(
|
||||||
|
data.session
|
||||||
|
.list()
|
||||||
|
.filter((x) => x.parentID === undefined)
|
||||||
|
.map((x) => x.id),
|
||||||
|
)
|
||||||
return sessionStore.pinned.filter((id) => existing.has(id)).slice(0, 9)
|
return sessionStore.pinned.filter((id) => existing.has(id)).slice(0, 9)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export function ShellTab(props: { sessionID: string }) {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
const { themeV2 } = useTheme()
|
const { themeV2 } = useTheme()
|
||||||
const fg = themeV2.text.action.primary("focused")
|
|
||||||
const composer = useComposerTab()
|
const composer = useComposerTab()
|
||||||
const shortcuts = Keymap.useShortcuts()
|
const shortcuts = Keymap.useShortcuts()
|
||||||
|
|
||||||
@@ -96,11 +95,7 @@ export function ShellTab(props: { sessionID: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={composer.active("shell")}>
|
<Show when={composer.active("shell")}>
|
||||||
<scrollbox
|
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
|
||||||
scrollbarOptions={{ visible: false }}
|
|
||||||
maxHeight={5}
|
|
||||||
ref={(r: ScrollBoxRenderable) => (scroll = r)}
|
|
||||||
>
|
|
||||||
<Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued()}> No shell commands</text>}>
|
<Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued()}> No shell commands</text>}>
|
||||||
<For each={entries()}>
|
<For each={entries()}>
|
||||||
{(shell, index) => {
|
{(shell, index) => {
|
||||||
@@ -110,11 +105,11 @@ export function ShellTab(props: { sessionID: string }) {
|
|||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={active() ? themeV2.background.action.primary() : RGBA.fromInts(0, 0, 0, 0)}
|
backgroundColor={active() ? themeV2.background.action.primary("selected") : RGBA.fromInts(0, 0, 0, 0)}
|
||||||
onMouseOver={() => setStore("selected", index())}
|
onMouseOver={() => setStore("selected", index())}
|
||||||
>
|
>
|
||||||
<text
|
<text
|
||||||
fg={active() ? fg : themeV2.text()}
|
fg={themeV2.text.action.primary(active() ? "focused" : "default")}
|
||||||
attributes={active() ? TextAttributes.BOLD : undefined}
|
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||||
wrapMode="none"
|
wrapMode="none"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
const data = useData()
|
const data = useData()
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
const { themeV2 } = useTheme()
|
const { themeV2 } = useTheme()
|
||||||
const fg = themeV2.text.action.primary("focused")
|
|
||||||
const navigate = useRoute().navigate
|
const navigate = useRoute().navigate
|
||||||
const composer = useComposerTab()
|
const composer = useComposerTab()
|
||||||
const shortcuts = Keymap.useShortcuts()
|
const shortcuts = Keymap.useShortcuts()
|
||||||
@@ -39,7 +38,11 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
const siblings = data.session.list().filter((s) => s.parentID === current.parentID)
|
const siblings = data.session.list().filter((s) => s.parentID === current.parentID)
|
||||||
for (const sibling of siblings) {
|
for (const sibling of siblings) {
|
||||||
const agentMatch = sibling.title.match(/@(\w+) subagent/)
|
const agentMatch = sibling.title.match(/@(\w+) subagent/)
|
||||||
const agent = sibling.agent ? Locale.titlecase(sibling.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
|
const agent = sibling.agent
|
||||||
|
? Locale.titlecase(sibling.agent)
|
||||||
|
: agentMatch
|
||||||
|
? Locale.titlecase(agentMatch[1])
|
||||||
|
: "Subagent"
|
||||||
const name = agentMatch ? sibling.title.replace(agentMatch[0], "").trim() || sibling.title : sibling.title
|
const name = agentMatch ? sibling.title.replace(agentMatch[0], "").trim() || sibling.title : sibling.title
|
||||||
result.push({
|
result.push({
|
||||||
sessionID: sibling.id,
|
sessionID: sibling.id,
|
||||||
@@ -53,7 +56,11 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
const children = data.session.list().filter((s) => s.parentID === props.sessionID)
|
const children = data.session.list().filter((s) => s.parentID === props.sessionID)
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
const agentMatch = child.title.match(/@(\w+) subagent/)
|
const agentMatch = child.title.match(/@(\w+) subagent/)
|
||||||
const agent = child.agent ? Locale.titlecase(child.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
|
const agent = child.agent
|
||||||
|
? Locale.titlecase(child.agent)
|
||||||
|
: agentMatch
|
||||||
|
? Locale.titlecase(agentMatch[1])
|
||||||
|
: "Subagent"
|
||||||
const name = agentMatch ? child.title.replace(agentMatch[0], "").trim() || child.title : child.title
|
const name = agentMatch ? child.title.replace(agentMatch[0], "").trim() || child.title : child.title
|
||||||
result.push({
|
result.push({
|
||||||
sessionID: child.id,
|
sessionID: child.id,
|
||||||
@@ -195,11 +202,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={composer.active("subagents")}>
|
<Show when={composer.active("subagents")}>
|
||||||
<scrollbox
|
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
|
||||||
scrollbarOptions={{ visible: false }}
|
|
||||||
maxHeight={5}
|
|
||||||
ref={(r: ScrollBoxRenderable) => (scroll = r)}
|
|
||||||
>
|
|
||||||
<Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued()}> No subagents</text>}>
|
<Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued()}> No subagents</text>}>
|
||||||
<For each={entries()}>
|
<For each={entries()}>
|
||||||
{(entry, index) => {
|
{(entry, index) => {
|
||||||
@@ -213,7 +216,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={active() ? themeV2.background.action.primary() : RGBA.fromInts(0, 0, 0, 0)}
|
backgroundColor={themeV2.background.action.primary(active() ? "focused" : "default")}
|
||||||
onMouseOver={() => setStore("selected", index())}
|
onMouseOver={() => setStore("selected", index())}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
setStore("selected", index())
|
setStore("selected", index())
|
||||||
@@ -222,7 +225,9 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
>
|
>
|
||||||
<box flexGrow={1} minWidth={0} flexDirection="row">
|
<box flexGrow={1} minWidth={0} flexDirection="row">
|
||||||
<text
|
<text
|
||||||
fg={active() ? fg : entry.current ? themeV2.background.action.primary() : themeV2.text()}
|
fg={themeV2.text.action.primary(
|
||||||
|
active() ? "focused" : entry.current ? "selected" : "default",
|
||||||
|
)}
|
||||||
attributes={active() ? TextAttributes.BOLD : undefined}
|
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||||
wrapMode="none"
|
wrapMode="none"
|
||||||
>
|
>
|
||||||
@@ -230,7 +235,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<Show when={status()}>
|
<Show when={status()}>
|
||||||
<text fg={active() ? fg : themeV2.text.subdued()} wrapMode="none">
|
<text fg={active() ? themeV2.text.action.primary() : themeV2.text.subdued()} wrapMode="none">
|
||||||
{status()}
|
{status()}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -71,11 +71,15 @@ import { usePluginRuntime } from "../../plugin/runtime"
|
|||||||
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
|
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
|
||||||
import { usePathFormatter } from "../../context/path-format"
|
import { usePathFormatter } from "../../context/path-format"
|
||||||
import { useLocation } from "../../context/location"
|
import { useLocation } from "../../context/location"
|
||||||
import { createSessionRows, resolvePart, type PartRef, type SessionRow } from "./rows"
|
import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./rows"
|
||||||
import { switchLabel } from "../../util/model"
|
import { switchLabel } from "../../util/model"
|
||||||
|
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
|
||||||
|
|
||||||
addDefaultParsers(parsers.parsers)
|
addDefaultParsers(parsers.parsers)
|
||||||
|
|
||||||
|
// Exclude temporary bottom space when measuring the real transcript height.
|
||||||
|
const NAVIGATION_SLACK_ID = "session-navigation-slack"
|
||||||
|
|
||||||
const context = createContext<{
|
const context = createContext<{
|
||||||
width: number
|
width: number
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -180,6 +184,23 @@ export function Session() {
|
|||||||
const client = useClient()
|
const client = useClient()
|
||||||
const editor = useEditorContext()
|
const editor = useEditorContext()
|
||||||
const rows = createSessionRows(() => route.sessionID)
|
const rows = createSessionRows(() => route.sessionID)
|
||||||
|
const boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
|
||||||
|
const [navigationMessage, setNavigationMessage] = createSignal<string>()
|
||||||
|
const [navigationSlack, setNavigationSlack] = createSignal(0)
|
||||||
|
|
||||||
|
const clearMessageNavigation = () => {
|
||||||
|
setNavigationSlack(0)
|
||||||
|
setNavigationMessage(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => [dimensions().width, dimensions().height] as const,
|
||||||
|
(_, previous) => {
|
||||||
|
if (previous) clearMessageNavigation()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on([descendantSessionIDs, () => client.connection.status()], ([sessionIDs, status]) => {
|
on([descendantSessionIDs, () => client.connection.status()], ([sessionIDs, status]) => {
|
||||||
@@ -239,53 +260,55 @@ export function Session() {
|
|||||||
dialog.clear()
|
dialog.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Find next visible message boundary in direction
|
const alignMessage = (messageID: string, top: number) => {
|
||||||
const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
|
scroll.stickyScroll = false
|
||||||
const children = scroll.getChildren()
|
setNavigationMessage(messageID)
|
||||||
const messagesList = messages()
|
setNavigationSlack(
|
||||||
const scrollTop = scroll.y
|
messageNavigationSlack({
|
||||||
|
top,
|
||||||
// Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content
|
viewportHeight: scroll.viewport.height,
|
||||||
const visibleMessages = children
|
scrollHeight: scroll.scrollHeight,
|
||||||
.filter((c) => {
|
currentSlack: scroll.getRenderable(NAVIGATION_SLACK_ID)?.height ?? 0,
|
||||||
if (!c.id) return false
|
}),
|
||||||
const message = messagesList.find((m) => m.id === c.id)
|
)
|
||||||
if (!message) return false
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
if (message.type === "user") return Boolean(message.text.trim())
|
if (scroll.isDestroyed || navigationMessage() !== messageID) return
|
||||||
return (
|
scroll.scrollTo(top)
|
||||||
message.type === "assistant" &&
|
|
||||||
message.content.some((content) => content.type === "text" && content.text.trim())
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.y - b.y)
|
})
|
||||||
|
|
||||||
if (visibleMessages.length === 0) return null
|
|
||||||
|
|
||||||
if (direction === "next") {
|
|
||||||
// Find first message below current position
|
|
||||||
return visibleMessages.find((c) => c.y > scrollTop + 10)?.id ?? null
|
|
||||||
}
|
|
||||||
// Find last message above current position
|
|
||||||
return [...visibleMessages].reverse().find((c) => c.y < scrollTop - 10)?.id ?? null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Scroll to message in direction or fallback to page scroll
|
const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>, userOnly = false) => {
|
||||||
const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>) => {
|
const target = findMessageBoundary({
|
||||||
const targetID = findNextVisibleMessage(direction)
|
direction,
|
||||||
|
children: scroll.getChildren(),
|
||||||
|
messages: messages(),
|
||||||
|
scrollTop: scroll.scrollTop,
|
||||||
|
viewportY: scroll.viewport.y,
|
||||||
|
currentID: navigationMessage(),
|
||||||
|
userOnly,
|
||||||
|
})
|
||||||
|
|
||||||
if (!targetID) {
|
if (!target) {
|
||||||
scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height)
|
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const child = scroll.getChildren().find((c) => c.id === targetID)
|
alignMessage(target.id, target.top)
|
||||||
if (child) scroll.scrollBy(child.y - scroll.y - 1)
|
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const jumpToMessage = (messageID: string) => {
|
||||||
|
const child = scroll.getRenderable(messageID)
|
||||||
|
if (!child) return
|
||||||
|
const y = scroll.scrollTop + child.y - scroll.viewport.y
|
||||||
|
const message = data.session.message.get(route.sessionID, messageID)
|
||||||
|
alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0)))
|
||||||
|
}
|
||||||
|
|
||||||
function toBottom() {
|
function toBottom() {
|
||||||
|
clearMessageNavigation()
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!scroll || scroll.isDestroyed) return
|
if (!scroll || scroll.isDestroyed) return
|
||||||
scroll.scrollTo(scroll.scrollHeight)
|
scroll.scrollTo(scroll.scrollHeight)
|
||||||
@@ -299,6 +322,7 @@ export function Session() {
|
|||||||
category: "Session",
|
category: "Session",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
run: () => {
|
run: () => {
|
||||||
|
clearMessageNavigation()
|
||||||
scroll.scrollBy(-scroll.height / 2)
|
scroll.scrollBy(-scroll.height / 2)
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
@@ -309,6 +333,7 @@ export function Session() {
|
|||||||
category: "Session",
|
category: "Session",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
run: () => {
|
run: () => {
|
||||||
|
clearMessageNavigation()
|
||||||
scroll.scrollBy(scroll.height / 2)
|
scroll.scrollBy(scroll.height / 2)
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
@@ -319,6 +344,7 @@ export function Session() {
|
|||||||
category: "Session",
|
category: "Session",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
run: () => {
|
run: () => {
|
||||||
|
clearMessageNavigation()
|
||||||
scroll.scrollBy(-1)
|
scroll.scrollBy(-1)
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
@@ -329,6 +355,7 @@ export function Session() {
|
|||||||
category: "Session",
|
category: "Session",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
run: () => {
|
run: () => {
|
||||||
|
clearMessageNavigation()
|
||||||
scroll.scrollBy(1)
|
scroll.scrollBy(1)
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
@@ -339,6 +366,7 @@ export function Session() {
|
|||||||
category: "Session",
|
category: "Session",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
run: () => {
|
run: () => {
|
||||||
|
clearMessageNavigation()
|
||||||
scroll.scrollBy(-scroll.height / 4)
|
scroll.scrollBy(-scroll.height / 4)
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
@@ -349,6 +377,7 @@ export function Session() {
|
|||||||
category: "Session",
|
category: "Session",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
run: () => {
|
run: () => {
|
||||||
|
clearMessageNavigation()
|
||||||
scroll.scrollBy(scroll.height / 4)
|
scroll.scrollBy(scroll.height / 4)
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
@@ -362,6 +391,7 @@ export function Session() {
|
|||||||
category: "Session",
|
category: "Session",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
run: () => {
|
run: () => {
|
||||||
|
clearMessageNavigation()
|
||||||
scroll.scrollTo(0)
|
scroll.scrollTo(0)
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
@@ -372,6 +402,7 @@ export function Session() {
|
|||||||
category: "Session",
|
category: "Session",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
run: () => {
|
run: () => {
|
||||||
|
clearMessageNavigation()
|
||||||
scroll.scrollTo(scroll.scrollHeight)
|
scroll.scrollTo(scroll.scrollHeight)
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
@@ -412,8 +443,7 @@ export function Session() {
|
|||||||
sessionID={route.sessionID}
|
sessionID={route.sessionID}
|
||||||
onMove={(messageID) => {
|
onMove={(messageID) => {
|
||||||
if (!messageID) return
|
if (!messageID) return
|
||||||
const child = scroll.getChildren().find((child) => child.id === messageID)
|
jumpToMessage(messageID)
|
||||||
if (child) scroll.scrollBy(child.y - scroll.y - 1)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
@@ -574,10 +604,7 @@ export function Session() {
|
|||||||
const message = messages[i]
|
const message = messages[i]
|
||||||
if (!message || message.type !== "user" || !message.text.trim()) continue
|
if (!message || message.type !== "user" || !message.text.trim()) continue
|
||||||
{
|
{
|
||||||
const child = scroll.getChildren().find((child) => {
|
jumpToMessage(message.id)
|
||||||
return child.id === message.id
|
|
||||||
})
|
|
||||||
if (child) scroll.scrollBy(child.y - scroll.y - 1)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -597,6 +624,20 @@ export function Session() {
|
|||||||
hidden: true,
|
hidden: true,
|
||||||
run: () => scrollToMessage("prev", dialog),
|
run: () => scrollToMessage("prev", dialog),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Next user message",
|
||||||
|
name: "session.message.user.next",
|
||||||
|
category: "Session",
|
||||||
|
hidden: true,
|
||||||
|
run: () => scrollToMessage("next", dialog, true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Previous user message",
|
||||||
|
name: "session.message.user.previous",
|
||||||
|
category: "Session",
|
||||||
|
hidden: true,
|
||||||
|
run: () => scrollToMessage("prev", dialog, true),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Copy last assistant message",
|
title: "Copy last assistant message",
|
||||||
name: "messages.copy",
|
name: "messages.copy",
|
||||||
@@ -810,7 +851,10 @@ export function Session() {
|
|||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => route.sessionID,
|
() => route.sessionID,
|
||||||
() => setComposer("open", false),
|
() => {
|
||||||
|
setComposer("open", false)
|
||||||
|
clearMessageNavigation()
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -846,16 +890,17 @@ export function Session() {
|
|||||||
foregroundColor: themeV2.border(),
|
foregroundColor: themeV2.border(),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
stickyScroll={true}
|
stickyScroll={!navigationMessage()}
|
||||||
stickyStart="bottom"
|
stickyStart="bottom"
|
||||||
flexGrow={1}
|
flexGrow={1}
|
||||||
scrollAcceleration={scrollAcceleration()}
|
scrollAcceleration={scrollAcceleration()}
|
||||||
>
|
>
|
||||||
<For each={rows}>
|
<For each={rows}>
|
||||||
{(row) => (
|
{(row, index) => (
|
||||||
<SessionRowView
|
<SessionRowView
|
||||||
row={row}
|
row={row}
|
||||||
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
|
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
|
||||||
|
boundaryID={boundaries()[index()]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -870,6 +915,9 @@ export function Session() {
|
|||||||
files={session()!.revert!.files ?? []}
|
files={session()!.revert!.files ?? []}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={navigationSlack()}>
|
||||||
|
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
|
||||||
|
</Show>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
<box flexShrink={0}>
|
<box flexShrink={0}>
|
||||||
<Composer
|
<Composer
|
||||||
@@ -942,9 +990,13 @@ export function Session() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionRowView(props: { row: SessionRow; message: (messageID: string) => SessionMessageInfo | undefined }) {
|
function SessionRowView(props: {
|
||||||
|
row: SessionRow
|
||||||
|
message: (messageID: string) => SessionMessageInfo | undefined
|
||||||
|
boundaryID?: string
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<box marginTop={1} flexShrink={0}>
|
<box id={props.boundaryID} marginTop={1} flexShrink={0}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.row.type === "message" ? props.row : undefined}>
|
<Match when={props.row.type === "message" ? props.row : undefined}>
|
||||||
{(row) => (
|
{(row) => (
|
||||||
@@ -1563,7 +1615,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
|||||||
return (
|
return (
|
||||||
<Show when={props.message.text.trim() || files().length}>
|
<Show when={props.message.text.trim() || files().length}>
|
||||||
<box
|
<box
|
||||||
id={props.message.id}
|
|
||||||
border={["left"]}
|
border={["left"]}
|
||||||
borderColor={queued() ? themeV2.border() : color()}
|
borderColor={queued() ? themeV2.border() : color()}
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
@@ -2279,7 +2330,7 @@ function BlockTool(props: {
|
|||||||
paddingLeft={2}
|
paddingLeft={2}
|
||||||
gap={1}
|
gap={1}
|
||||||
backgroundColor={
|
backgroundColor={
|
||||||
hover() ? themeV2.background.action.secondary() : themeV2.background()
|
hover() ? themeV2.background.action.secondary("focused") : themeV2.background()
|
||||||
}
|
}
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
borderColor={themeV2.background()}
|
borderColor={themeV2.background()}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { SessionMessageInfo } from "@opencode-ai/client"
|
||||||
|
|
||||||
|
type MessageChild = {
|
||||||
|
readonly id?: string
|
||||||
|
readonly y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function messageNavigationSlack(input: {
|
||||||
|
top: number
|
||||||
|
viewportHeight: number
|
||||||
|
scrollHeight: number
|
||||||
|
currentSlack: number
|
||||||
|
}) {
|
||||||
|
const contentHeight = input.scrollHeight - input.currentSlack
|
||||||
|
return Math.max(0, Math.ceil(input.top + input.viewportHeight - contentHeight))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findMessageBoundary(input: {
|
||||||
|
direction: "next" | "prev"
|
||||||
|
children: readonly MessageChild[]
|
||||||
|
messages: readonly SessionMessageInfo[]
|
||||||
|
scrollTop: number
|
||||||
|
viewportY: number
|
||||||
|
currentID?: string
|
||||||
|
userOnly?: boolean
|
||||||
|
}) {
|
||||||
|
const messages = new Map(input.messages.map((message) => [message.id, message]))
|
||||||
|
const visible = input.children
|
||||||
|
.flatMap((child) => {
|
||||||
|
if (!child.id) return []
|
||||||
|
const message = messages.get(child.id)
|
||||||
|
if (!message) return []
|
||||||
|
if (message.type === "user" && message.text.trim()) {
|
||||||
|
const y = input.scrollTop + child.y - input.viewportY
|
||||||
|
return [{ id: child.id, y, top: y }]
|
||||||
|
}
|
||||||
|
if (input.userOnly || message.type !== "assistant") return []
|
||||||
|
if (!message.content.some((content) => content.type === "text" && content.text.trim())) return []
|
||||||
|
const y = input.scrollTop + child.y - input.viewportY
|
||||||
|
return [{ id: child.id, y, top: Math.max(0, y - 1) }]
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.y - b.y)
|
||||||
|
|
||||||
|
const current = visible.findIndex((child) => child.id === input.currentID)
|
||||||
|
if (current !== -1) return visible[current + (input.direction === "next" ? 1 : -1)] ?? null
|
||||||
|
if (input.direction === "next") return visible.find((child) => child.y > input.scrollTop) ?? null
|
||||||
|
return visible.findLast((child) => child.y < input.scrollTop) ?? null
|
||||||
|
}
|
||||||
@@ -656,7 +656,7 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
<box
|
<box
|
||||||
backgroundColor={themeV2.background()}
|
backgroundColor={themeV2.background()}
|
||||||
border={["left"]}
|
border={["left"]}
|
||||||
borderColor={themeV2.text.feedback.warning()}
|
borderColor={themeV2.background.action.primary("focused")}
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
{...(store.expanded
|
{...(store.expanded
|
||||||
? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
|
? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
|
||||||
|
|||||||
@@ -285,6 +285,36 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
|
|||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function messageBoundaryIDs(rows: SessionRow[], messages: SessionMessageInfo[]) {
|
||||||
|
const byID = new Map(messages.map((message) => [message.id, message]))
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return rows.map((row) => {
|
||||||
|
const id = rowBoundaryMessageID(row, byID)
|
||||||
|
if (!id || seen.has(id)) return undefined
|
||||||
|
seen.add(id)
|
||||||
|
return id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMessageInfo>) {
|
||||||
|
if (row.type === "message") {
|
||||||
|
const message = messages.get(row.messageID)
|
||||||
|
if (message?.type === "user" && message.text.trim()) return message.id
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const messageID =
|
||||||
|
row.type === "part"
|
||||||
|
? row.ref.messageID
|
||||||
|
: row.type === "group"
|
||||||
|
? row.refs[0]?.messageID
|
||||||
|
: row.type === "assistant-footer"
|
||||||
|
? row.messageID
|
||||||
|
: undefined
|
||||||
|
if (!messageID) return undefined
|
||||||
|
const message = messages.get(messageID)
|
||||||
|
if (message?.type === "assistant") return message.id
|
||||||
|
}
|
||||||
|
|
||||||
export function resolvePart(message: SessionMessageAssistant, partID: string) {
|
export function resolvePart(message: SessionMessageAssistant, partID: string) {
|
||||||
const tool = message.content.find((part) => part.type === "tool" && part.id === partID)
|
const tool = message.content.find((part) => part.type === "tool" && part.id === partID)
|
||||||
if (tool) return tool
|
if (tool) return tool
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function createComponentTheme(current: Accessor<ResolvedThemeView>) {
|
|||||||
blue: (step: HueStep) => current().hue.blue[step],
|
blue: (step: HueStep) => current().hue.blue[step],
|
||||||
purple: (step: HueStep) => current().hue.purple[step],
|
purple: (step: HueStep) => current().hue.purple[step],
|
||||||
accent: (step: HueStep) => current().hue.accent[step],
|
accent: (step: HueStep) => current().hue.accent[step],
|
||||||
|
interactive: (step: HueStep) => current().hue.interactive[step],
|
||||||
neutral: (step: HueStep) => current().hue.neutral[step],
|
neutral: (step: HueStep) => current().hue.neutral[step],
|
||||||
}
|
}
|
||||||
const text = Object.assign(() => current().text.default, {
|
const text = Object.assign(() => current().text.default, {
|
||||||
|
|||||||
@@ -93,79 +93,91 @@ export const DEFAULT_THEME = {
|
|||||||
900: "#581c87",
|
900: "#581c87",
|
||||||
},
|
},
|
||||||
accent: "$hue.blue",
|
accent: "$hue.blue",
|
||||||
|
interactive: "$hue.blue",
|
||||||
neutral: "$hue.gray",
|
neutral: "$hue.gray",
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
|
default: "$hue.neutral.900",
|
||||||
|
subdued: "$hue.neutral.600",
|
||||||
|
action: {
|
||||||
|
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
|
||||||
|
secondary: { default: "$hue.neutral.900", $disabled: "$hue.neutral.500" },
|
||||||
|
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
|
||||||
|
},
|
||||||
|
formfield: {
|
||||||
default: "$hue.neutral.900",
|
default: "$hue.neutral.900",
|
||||||
subdued: "$hue.neutral.600",
|
$focused: "$text.action.primary.default",
|
||||||
action: {
|
$pressed: "$hue.neutral.100",
|
||||||
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
|
$disabled: "$hue.neutral.500",
|
||||||
secondary: { default: "$hue.neutral.900", $disabled: "$hue.neutral.500" },
|
$selected: "$hue.interactive.600",
|
||||||
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
|
},
|
||||||
|
feedback: {
|
||||||
|
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
|
||||||
|
warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
|
||||||
|
success: { default: "$hue.green.700", subdued: "$hue.green.600" },
|
||||||
|
info: { default: "$hue.cyan.700", subdued: "$hue.cyan.600" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: "$hue.neutral.100",
|
||||||
|
surface: {
|
||||||
|
offset: "$hue.neutral.200",
|
||||||
|
overlay: "$hue.neutral.300",
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
primary: {
|
||||||
|
default: "$hue.interactive.600",
|
||||||
|
$focused: "$hue.interactive.700",
|
||||||
|
$pressed: "$hue.interactive.800",
|
||||||
|
$selected: "$hue.interactive.700",
|
||||||
|
$disabled: "$hue.neutral.300",
|
||||||
},
|
},
|
||||||
formfield: {
|
secondary: {
|
||||||
default: "$hue.neutral.900",
|
default: "$hue.neutral.200",
|
||||||
$focused: "$text.action.primary.default",
|
$focused: "$hue.neutral.300",
|
||||||
$pressed: "$hue.neutral.100",
|
$pressed: "$hue.neutral.400",
|
||||||
$disabled: "$hue.neutral.500",
|
$selected: "$hue.neutral.300",
|
||||||
$selected: "$hue.accent.600",
|
$disabled: "$hue.neutral.200",
|
||||||
},
|
},
|
||||||
feedback: {
|
destructive: {
|
||||||
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
|
default: "$hue.red.600",
|
||||||
warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
|
$focused: "$hue.red.700",
|
||||||
success: { default: "$hue.green.700", subdued: "$hue.green.600" },
|
$pressed: "$hue.red.800",
|
||||||
info: { default: "$hue.cyan.700", subdued: "$hue.cyan.600" },
|
$selected: "$hue.red.700",
|
||||||
|
$disabled: "$hue.neutral.300",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
background: {
|
formfield: {
|
||||||
default: "$hue.neutral.100",
|
default: "$background.default",
|
||||||
surface: {
|
$focused: "$background.action.primary.default",
|
||||||
offset: "$hue.neutral.200",
|
$pressed: "$hue.interactive.800",
|
||||||
overlay: "$hue.neutral.300",
|
$disabled: "$background.default",
|
||||||
},
|
$selected: "$background.formfield.default",
|
||||||
action: {
|
|
||||||
primary: {
|
|
||||||
default: "$hue.accent.600", $focused: "$hue.accent.700", $pressed: "$hue.accent.800",
|
|
||||||
$disabled: "$hue.neutral.300",
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
default: "$hue.neutral.200", $focused: "$hue.neutral.300", $pressed: "$hue.neutral.400",
|
|
||||||
$disabled: "$hue.neutral.200",
|
|
||||||
},
|
|
||||||
destructive: {
|
|
||||||
default: "$hue.red.600", $focused: "$hue.red.700", $pressed: "$hue.red.800",
|
|
||||||
$disabled: "$hue.neutral.300",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
formfield: {
|
|
||||||
default: "$background.default",
|
|
||||||
$focused: "$background.action.primary.default",
|
|
||||||
$pressed: "$hue.accent.800",
|
|
||||||
$disabled: "$background.default",
|
|
||||||
$selected: "$background.formfield.default",
|
|
||||||
},
|
|
||||||
feedback: {
|
|
||||||
error: { default: "$background.default" },
|
|
||||||
warning: { default: "$background.default" },
|
|
||||||
success: { default: "$background.default" },
|
|
||||||
info: { default: "$background.default" },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
border: { default: "$hue.neutral.300" },
|
feedback: {
|
||||||
scrollbar: { default: "$hue.neutral.400" },
|
error: { default: "$background.default" },
|
||||||
diff: {
|
warning: { default: "$background.default" },
|
||||||
text: {
|
success: { default: "$background.default" },
|
||||||
added: "$hue.green.700", removed: "$hue.red.700", context: "$hue.neutral.900",
|
info: { default: "$background.default" },
|
||||||
hunkHeader: "$hue.purple.600",
|
|
||||||
},
|
|
||||||
background: { added: "$hue.green.100", removed: "$hue.red.100", context: "$hue.neutral.100" },
|
|
||||||
highlight: { added: "$hue.green.600", removed: "$hue.red.600" },
|
|
||||||
lineNumber: {
|
|
||||||
text: "$hue.neutral.600",
|
|
||||||
background: { added: "$hue.green.200", removed: "$hue.red.200" },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
syntax: {
|
},
|
||||||
|
border: { default: "$hue.neutral.300" },
|
||||||
|
scrollbar: { default: "$hue.neutral.400" },
|
||||||
|
diff: {
|
||||||
|
text: {
|
||||||
|
added: "$hue.green.700",
|
||||||
|
removed: "$hue.red.700",
|
||||||
|
context: "$hue.neutral.900",
|
||||||
|
hunkHeader: "$hue.purple.600",
|
||||||
|
},
|
||||||
|
background: { added: "$hue.green.100", removed: "$hue.red.100", context: "$hue.neutral.100" },
|
||||||
|
highlight: { added: "$hue.green.600", removed: "$hue.red.600" },
|
||||||
|
lineNumber: {
|
||||||
|
text: "$hue.neutral.600",
|
||||||
|
background: { added: "$hue.green.200", removed: "$hue.red.200" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
syntax: {
|
||||||
comment: "$hue.neutral.600",
|
comment: "$hue.neutral.600",
|
||||||
keyword: "$hue.purple.600",
|
keyword: "$hue.purple.600",
|
||||||
function: "$hue.accent.600",
|
function: "$hue.accent.600",
|
||||||
@@ -176,7 +188,7 @@ export const DEFAULT_THEME = {
|
|||||||
operator: "$hue.cyan.600",
|
operator: "$hue.cyan.600",
|
||||||
punctuation: "$hue.neutral.900",
|
punctuation: "$hue.neutral.900",
|
||||||
},
|
},
|
||||||
markdown: {
|
markdown: {
|
||||||
text: "$hue.neutral.900",
|
text: "$hue.neutral.900",
|
||||||
heading: "$hue.purple.600",
|
heading: "$hue.purple.600",
|
||||||
link: "$hue.accent.600",
|
link: "$hue.accent.600",
|
||||||
@@ -196,14 +208,14 @@ export const DEFAULT_THEME = {
|
|||||||
text: { action: { primary: { default: "$hue.neutral.100" } } },
|
text: { action: { primary: { default: "$hue.neutral.100" } } },
|
||||||
background: {
|
background: {
|
||||||
default: "$background.surface.offset",
|
default: "$background.surface.offset",
|
||||||
action: { primary: { default: "$hue.accent.500" } },
|
action: { primary: { default: "$hue.interactive.500" } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"@context:overlay": {
|
"@context:overlay": {
|
||||||
text: { action: { primary: { default: "$hue.neutral.100" } } },
|
text: { action: { primary: { default: "$hue.neutral.100" } } },
|
||||||
background: {
|
background: {
|
||||||
default: "$background.surface.overlay",
|
default: "$background.surface.overlay",
|
||||||
action: { primary: { default: "$hue.accent.500" } },
|
action: { primary: { default: "$hue.interactive.500" } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -298,79 +310,91 @@ export const DEFAULT_THEME = {
|
|||||||
900: "#581c87",
|
900: "#581c87",
|
||||||
},
|
},
|
||||||
accent: "$hue.blue",
|
accent: "$hue.blue",
|
||||||
|
interactive: "$hue.blue",
|
||||||
neutral: "$hue.gray",
|
neutral: "$hue.gray",
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
|
default: "$hue.neutral.100",
|
||||||
|
subdued: "$hue.neutral.400",
|
||||||
|
action: {
|
||||||
|
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
|
||||||
|
secondary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
|
||||||
|
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
|
||||||
|
},
|
||||||
|
formfield: {
|
||||||
default: "$hue.neutral.100",
|
default: "$hue.neutral.100",
|
||||||
subdued: "$hue.neutral.400",
|
$focused: "$text.action.primary.default",
|
||||||
action: {
|
$pressed: "$hue.neutral.100",
|
||||||
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
|
$disabled: "$hue.neutral.500",
|
||||||
secondary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
|
$selected: "$hue.interactive.500",
|
||||||
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
|
},
|
||||||
|
feedback: {
|
||||||
|
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
|
||||||
|
warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },
|
||||||
|
success: { default: "$hue.green.300", subdued: "$hue.green.400" },
|
||||||
|
info: { default: "$hue.cyan.300", subdued: "$hue.cyan.400" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: "$hue.neutral.900",
|
||||||
|
surface: {
|
||||||
|
offset: "$hue.neutral.800",
|
||||||
|
overlay: "$hue.neutral.700",
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
primary: {
|
||||||
|
default: "$hue.interactive.500",
|
||||||
|
$focused: "$hue.interactive.600",
|
||||||
|
$pressed: "$hue.interactive.800",
|
||||||
|
$selected: "$hue.interactive.600",
|
||||||
|
$disabled: "$hue.neutral.800",
|
||||||
},
|
},
|
||||||
formfield: {
|
secondary: {
|
||||||
default: "$hue.neutral.100",
|
default: "$hue.neutral.800",
|
||||||
$focused: "$text.action.primary.default",
|
$focused: "$hue.neutral.700",
|
||||||
$pressed: "$hue.neutral.100",
|
$pressed: "$hue.neutral.900",
|
||||||
$disabled: "$hue.neutral.500",
|
$selected: "$hue.neutral.700",
|
||||||
$selected: "$hue.accent.500",
|
$disabled: "$hue.neutral.900",
|
||||||
},
|
},
|
||||||
feedback: {
|
destructive: {
|
||||||
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
|
default: "$hue.red.600",
|
||||||
warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },
|
$focused: "$hue.red.700",
|
||||||
success: { default: "$hue.green.300", subdued: "$hue.green.400" },
|
$pressed: "$hue.red.800",
|
||||||
info: { default: "$hue.cyan.300", subdued: "$hue.cyan.400" },
|
$selected: "$hue.red.700",
|
||||||
|
$disabled: "$hue.neutral.800",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
background: {
|
formfield: {
|
||||||
default: "$hue.neutral.900",
|
default: "$background.default",
|
||||||
surface: {
|
$focused: "$background.action.primary.default",
|
||||||
offset: "$hue.neutral.800",
|
$pressed: "$hue.interactive.800",
|
||||||
overlay: "$hue.neutral.700",
|
$disabled: "$background.default",
|
||||||
},
|
$selected: "$background.formfield.default",
|
||||||
action: {
|
|
||||||
primary: {
|
|
||||||
default: "$hue.accent.500", $focused: "$hue.accent.600", $pressed: "$hue.accent.800",
|
|
||||||
$disabled: "$hue.neutral.800",
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
default: "$hue.neutral.800", $focused: "$hue.neutral.700", $pressed: "$hue.neutral.900",
|
|
||||||
$disabled: "$hue.neutral.900",
|
|
||||||
},
|
|
||||||
destructive: {
|
|
||||||
default: "$hue.red.600", $focused: "$hue.red.700", $pressed: "$hue.red.800",
|
|
||||||
$disabled: "$hue.neutral.800",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
formfield: {
|
|
||||||
default: "$background.default",
|
|
||||||
$focused: "$background.action.primary.default",
|
|
||||||
$pressed: "$hue.accent.800",
|
|
||||||
$disabled: "$background.default",
|
|
||||||
$selected: "$background.formfield.default",
|
|
||||||
},
|
|
||||||
feedback: {
|
|
||||||
error: { default: "$background.default" },
|
|
||||||
warning: { default: "$background.default" },
|
|
||||||
success: { default: "$background.default" },
|
|
||||||
info: { default: "$background.default" },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
border: { default: "$hue.neutral.700" },
|
feedback: {
|
||||||
scrollbar: { default: "$hue.neutral.600" },
|
error: { default: "$background.default" },
|
||||||
diff: {
|
warning: { default: "$background.default" },
|
||||||
text: {
|
success: { default: "$background.default" },
|
||||||
added: "$hue.green.300", removed: "$hue.red.300", context: "$hue.neutral.100",
|
info: { default: "$background.default" },
|
||||||
hunkHeader: "$hue.purple.400",
|
|
||||||
},
|
|
||||||
background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" },
|
|
||||||
highlight: { added: "$hue.green.400", removed: "$hue.red.400" },
|
|
||||||
lineNumber: {
|
|
||||||
text: "$hue.neutral.400",
|
|
||||||
background: { added: "$hue.green.800", removed: "$hue.red.800" },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
syntax: {
|
},
|
||||||
|
border: { default: "$hue.neutral.700" },
|
||||||
|
scrollbar: { default: "$hue.neutral.600" },
|
||||||
|
diff: {
|
||||||
|
text: {
|
||||||
|
added: "$hue.green.300",
|
||||||
|
removed: "$hue.red.300",
|
||||||
|
context: "$hue.neutral.100",
|
||||||
|
hunkHeader: "$hue.purple.400",
|
||||||
|
},
|
||||||
|
background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" },
|
||||||
|
highlight: { added: "$hue.green.400", removed: "$hue.red.400" },
|
||||||
|
lineNumber: {
|
||||||
|
text: "$hue.neutral.400",
|
||||||
|
background: { added: "$hue.green.800", removed: "$hue.red.800" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
syntax: {
|
||||||
comment: "$hue.neutral.400",
|
comment: "$hue.neutral.400",
|
||||||
keyword: "$hue.purple.400",
|
keyword: "$hue.purple.400",
|
||||||
function: "$hue.accent.400",
|
function: "$hue.accent.400",
|
||||||
@@ -381,7 +405,7 @@ export const DEFAULT_THEME = {
|
|||||||
operator: "$hue.cyan.400",
|
operator: "$hue.cyan.400",
|
||||||
punctuation: "$hue.neutral.100",
|
punctuation: "$hue.neutral.100",
|
||||||
},
|
},
|
||||||
markdown: {
|
markdown: {
|
||||||
text: "$hue.neutral.100",
|
text: "$hue.neutral.100",
|
||||||
heading: "$hue.purple.400",
|
heading: "$hue.purple.400",
|
||||||
link: "$hue.accent.400",
|
link: "$hue.accent.400",
|
||||||
@@ -401,14 +425,14 @@ export const DEFAULT_THEME = {
|
|||||||
text: { action: { primary: { default: "$hue.neutral.100" } } },
|
text: { action: { primary: { default: "$hue.neutral.100" } } },
|
||||||
background: {
|
background: {
|
||||||
default: "$background.surface.offset",
|
default: "$background.surface.offset",
|
||||||
action: { primary: { default: "$hue.accent.400" } },
|
action: { primary: { default: "$hue.interactive.400" } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"@context:overlay": {
|
"@context:overlay": {
|
||||||
text: { action: { primary: { default: "$hue.neutral.900" } } },
|
text: { action: { primary: { default: "$hue.neutral.900" } } },
|
||||||
background: {
|
background: {
|
||||||
default: "$background.surface.overlay",
|
default: "$background.surface.overlay",
|
||||||
action: { primary: { default: "$hue.accent.400" } },
|
action: { primary: { default: "$hue.interactive.400" } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -157,7 +157,6 @@ function resolveHue(definition: HueDefinition) {
|
|||||||
if (stack.includes(name)) throw new Error(`Circular hue reference: ${[...stack, name].join(" -> ")}`)
|
if (stack.includes(name)) throw new Error(`Circular hue reference: ${[...stack, name].join(" -> ")}`)
|
||||||
const value = source[name]
|
const value = source[name]
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
if ((BaseHue.literals as readonly string[]).includes(name)) throw new Error(`Base hue "${name}" must be a scale`)
|
|
||||||
const match = /^\$hue\.([^.]+)$/.exec(value)
|
const match = /^\$hue\.([^.]+)$/.exec(value)
|
||||||
if (!match?.[1]) throw new Error(`Hue alias "${value}" must reference a hue scale`)
|
if (!match?.[1]) throw new Error(`Hue alias "${value}" must reference a hue scale`)
|
||||||
const result = resolve(match[1], [...stack, name])
|
const result = resolve(match[1], [...stack, name])
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ export type HueStep = Schema.Schema.Type<typeof HueStep>
|
|||||||
export const BaseHue = Schema.Literals(["gray", "red", "orange", "yellow", "green", "cyan", "blue", "purple"])
|
export const BaseHue = Schema.Literals(["gray", "red", "orange", "yellow", "green", "cyan", "blue", "purple"])
|
||||||
export type BaseHue = Schema.Schema.Type<typeof BaseHue>
|
export type BaseHue = Schema.Schema.Type<typeof BaseHue>
|
||||||
|
|
||||||
export const HueAlias = Schema.Literals(["accent", "neutral"])
|
export const HueAlias = Schema.Literals(["accent", "interactive", "neutral"])
|
||||||
export type HueAlias = Schema.Schema.Type<typeof HueAlias>
|
export type HueAlias = Schema.Schema.Type<typeof HueAlias>
|
||||||
|
|
||||||
export const ActionVariant = Schema.Literals(["primary", "secondary", "destructive"])
|
export const ActionVariant = Schema.Literals(["primary", "secondary", "destructive"])
|
||||||
export type ActionVariant = Schema.Schema.Type<typeof ActionVariant>
|
export type ActionVariant = Schema.Schema.Type<typeof ActionVariant>
|
||||||
|
|
||||||
export const ActionState = Schema.Literals(["focused", "pressed", "disabled"])
|
export const ActionState = Schema.Literals(["focused", "pressed", "selected", "disabled"])
|
||||||
export type ActionState = Schema.Schema.Type<typeof ActionState>
|
export type ActionState = Schema.Schema.Type<typeof ActionState>
|
||||||
export type ActionStateKey = `$${ActionState}`
|
export type ActionStateKey = `$${ActionState}`
|
||||||
|
|
||||||
@@ -41,33 +41,35 @@ const ContextKey = Schema.Literals(["@context:elevated", "@context:overlay"])
|
|||||||
export type ContextKey = Schema.Schema.Type<typeof ContextKey>
|
export type ContextKey = Schema.Schema.Type<typeof ContextKey>
|
||||||
|
|
||||||
const HueScaleDefinition = Schema.Record(HueStep, HexColor)
|
const HueScaleDefinition = Schema.Record(HueStep, HexColor)
|
||||||
const HueAliasDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition])
|
const HueValueDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition])
|
||||||
|
|
||||||
const HueDefinition = Schema.Struct({
|
const HueDefinition = Schema.Struct({
|
||||||
gray: HueScaleDefinition,
|
gray: HueValueDefinition,
|
||||||
red: HueScaleDefinition,
|
red: HueValueDefinition,
|
||||||
orange: HueScaleDefinition,
|
orange: HueValueDefinition,
|
||||||
yellow: HueScaleDefinition,
|
yellow: HueValueDefinition,
|
||||||
green: HueScaleDefinition,
|
green: HueValueDefinition,
|
||||||
cyan: HueScaleDefinition,
|
cyan: HueValueDefinition,
|
||||||
blue: HueScaleDefinition,
|
blue: HueValueDefinition,
|
||||||
purple: HueScaleDefinition,
|
purple: HueValueDefinition,
|
||||||
accent: HueAliasDefinition,
|
accent: HueValueDefinition,
|
||||||
neutral: HueAliasDefinition,
|
interactive: HueValueDefinition,
|
||||||
|
neutral: HueValueDefinition,
|
||||||
})
|
})
|
||||||
export type HueDefinition = Schema.Schema.Type<typeof HueDefinition>
|
export type HueDefinition = Schema.Schema.Type<typeof HueDefinition>
|
||||||
|
|
||||||
const HueOverrideDefinition = Schema.Struct({
|
const HueOverrideDefinition = Schema.Struct({
|
||||||
gray: Schema.optional(HueScaleDefinition),
|
gray: Schema.optional(HueValueDefinition),
|
||||||
red: Schema.optional(HueScaleDefinition),
|
red: Schema.optional(HueValueDefinition),
|
||||||
orange: Schema.optional(HueScaleDefinition),
|
orange: Schema.optional(HueValueDefinition),
|
||||||
yellow: Schema.optional(HueScaleDefinition),
|
yellow: Schema.optional(HueValueDefinition),
|
||||||
green: Schema.optional(HueScaleDefinition),
|
green: Schema.optional(HueValueDefinition),
|
||||||
cyan: Schema.optional(HueScaleDefinition),
|
cyan: Schema.optional(HueValueDefinition),
|
||||||
blue: Schema.optional(HueScaleDefinition),
|
blue: Schema.optional(HueValueDefinition),
|
||||||
purple: Schema.optional(HueScaleDefinition),
|
purple: Schema.optional(HueValueDefinition),
|
||||||
accent: Schema.optional(HueAliasDefinition),
|
accent: Schema.optional(HueValueDefinition),
|
||||||
neutral: Schema.optional(HueAliasDefinition),
|
interactive: Schema.optional(HueValueDefinition),
|
||||||
|
neutral: Schema.optional(HueValueDefinition),
|
||||||
})
|
})
|
||||||
export type HueOverrideDefinition = Schema.Schema.Type<typeof HueOverrideDefinition>
|
export type HueOverrideDefinition = Schema.Schema.Type<typeof HueOverrideDefinition>
|
||||||
|
|
||||||
@@ -75,6 +77,7 @@ const StatefulColorDefinition = Schema.Struct({
|
|||||||
default: Schema.optional(ColorValue),
|
default: Schema.optional(ColorValue),
|
||||||
$focused: Schema.optional(ColorValue),
|
$focused: Schema.optional(ColorValue),
|
||||||
$pressed: Schema.optional(ColorValue),
|
$pressed: Schema.optional(ColorValue),
|
||||||
|
$selected: Schema.optional(ColorValue),
|
||||||
$disabled: Schema.optional(ColorValue),
|
$disabled: Schema.optional(ColorValue),
|
||||||
})
|
})
|
||||||
export type StatefulColorDefinition = Schema.Schema.Type<typeof StatefulColorDefinition>
|
export type StatefulColorDefinition = Schema.Schema.Type<typeof StatefulColorDefinition>
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { RGBA } from "@opentui/core"
|
import { RGBA } from "@opentui/core"
|
||||||
|
import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color"
|
||||||
import type { Theme, ThemeJson } from "../index"
|
import type { Theme, ThemeJson } from "../index"
|
||||||
import { DEFAULT_THEME } from "./defaults"
|
import { DEFAULT_THEME } from "./defaults"
|
||||||
import type { ThemeFile } from "./index"
|
import type { ThemeFile } from "./index"
|
||||||
|
import { HueStep } from "./schema"
|
||||||
|
|
||||||
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
|
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
|
||||||
|
type ChromaticHue = "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple"
|
||||||
|
|
||||||
|
const chromaticHues: readonly ChromaticHue[] = ["red", "orange", "yellow", "green", "cyan", "blue", "purple"]
|
||||||
|
const minimumChroma = 0.03
|
||||||
|
const lightThreshold = 0.6
|
||||||
|
|
||||||
export function migrateV1(theme: ThemeJson): ThemeFile {
|
export function migrateV1(theme: ThemeJson): ThemeFile {
|
||||||
return {
|
return {
|
||||||
@@ -18,33 +25,49 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
|
|||||||
const color = (key: ThemeColor) => hex(theme[key])
|
const color = (key: ThemeColor) => hex(theme[key])
|
||||||
const selected = hex(selectedForeground(theme, theme.primary))
|
const selected = hex(selectedForeground(theme, theme.primary))
|
||||||
const destructive = hex(selectedForeground(theme, theme.error))
|
const destructive = hex(selectedForeground(theme, theme.error))
|
||||||
|
const hues = inferHues(theme, mode)
|
||||||
|
const text = mode === "light" ? "$hue.neutral.900" : "$hue.neutral.100"
|
||||||
|
const textMuted = mode === "light" ? "$hue.neutral.700" : "$hue.neutral.300"
|
||||||
|
const primary = mode === "light" ? "$hue.interactive.900" : "$hue.interactive.100"
|
||||||
|
const background = mode === "light" ? "$hue.neutral.100" : "$hue.neutral.900"
|
||||||
|
const backgroundPanel = mode === "light" ? "$hue.neutral.200" : "$hue.neutral.800"
|
||||||
|
const backgroundMenu = mode === "light" ? "$hue.neutral.300" : "$hue.neutral.700"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hue: {
|
hue: {
|
||||||
...DEFAULT_THEME[mode].hue,
|
gray: neutralScale(theme, mode),
|
||||||
accent: hueScale(theme.secondary),
|
...Object.fromEntries(
|
||||||
|
chromaticHues.map((name) => {
|
||||||
|
const match = hues[name]
|
||||||
|
return [name, match ? hueScale(match.color, mode) : "$hue.gray"]
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
accent: ambiguous(theme.accent) ? "$hue.gray" : hueScale(theme.accent, mode),
|
||||||
|
interactive: ambiguous(theme.primary) ? "$hue.gray" : hueScale(theme.primary, mode),
|
||||||
|
neutral: "$hue.gray",
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
default: color("text"),
|
default: text,
|
||||||
subdued: color("textMuted"),
|
subdued: textMuted,
|
||||||
action: {
|
action: {
|
||||||
primary: {
|
primary: {
|
||||||
default: selected,
|
default: "$text.default",
|
||||||
$disabled: color("textMuted"),
|
$disabled: textMuted,
|
||||||
$focused: selected,
|
$focused: selected,
|
||||||
|
$selected: primary,
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
default: "$text.default",
|
default: "$text.default",
|
||||||
$disabled: color("textMuted"),
|
$disabled: textMuted,
|
||||||
},
|
},
|
||||||
destructive: { default: destructive, $disabled: color("textMuted") },
|
destructive: { default: destructive, $disabled: textMuted },
|
||||||
},
|
},
|
||||||
formfield: {
|
formfield: {
|
||||||
default: color("text"),
|
default: text,
|
||||||
$focused: color("primary"),
|
$focused: primary,
|
||||||
$pressed: color("primary"),
|
$pressed: primary,
|
||||||
$disabled: color("textMuted"),
|
$disabled: textMuted,
|
||||||
$selected: color("primary"),
|
$selected: primary,
|
||||||
},
|
},
|
||||||
feedback: {
|
feedback: {
|
||||||
error: { default: color("error") },
|
error: { default: color("error") },
|
||||||
@@ -54,13 +77,13 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
background: {
|
background: {
|
||||||
default: color("background"),
|
default: background,
|
||||||
surface: {
|
surface: {
|
||||||
offset: color("backgroundPanel"),
|
offset: backgroundPanel,
|
||||||
overlay: color("backgroundMenu"),
|
overlay: backgroundMenu,
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
primary: { default: color("primary"), $focused: color("primary") },
|
primary: { default: "transparent", $focused: primary, $selected: primary },
|
||||||
secondary: {
|
secondary: {
|
||||||
default: "$background.default",
|
default: "$background.default",
|
||||||
$focused: color("backgroundElement"),
|
$focused: color("backgroundElement"),
|
||||||
@@ -128,24 +151,43 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
|
|||||||
imageText: color("markdownImageText"),
|
imageText: color("markdownImageText"),
|
||||||
codeBlock: color("markdownCodeBlock"),
|
codeBlock: color("markdownCodeBlock"),
|
||||||
},
|
},
|
||||||
"@context:elevated": {
|
"@context:elevated": { background: { default: "$background.surface.offset" } },
|
||||||
background: {
|
|
||||||
default: "$background.surface.offset",
|
|
||||||
action: {
|
|
||||||
primary: {
|
|
||||||
default: color("primary"),
|
|
||||||
$focused: color("primary"),
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
default: "$background.surface.offset",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"@context:overlay": { background: { default: "$background.surface.overlay" } },
|
"@context:overlay": { background: { default: "$background.surface.overlay" } },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inferHues(theme: Theme, mode: "light" | "dark") {
|
||||||
|
return [theme.accent, theme.success, theme.warning, theme.primary, theme.error, theme.info, theme.secondary].reduce<
|
||||||
|
Partial<Record<ChromaticHue, { color: RGBA; distance: number }>>
|
||||||
|
>((result, color) => {
|
||||||
|
const value = toOklch(color)
|
||||||
|
if (ambiguous(color, value.c)) return result
|
||||||
|
const anchor = inferenceAnchor(value.l)
|
||||||
|
const nearest = chromaticHues
|
||||||
|
.map((name) => ({
|
||||||
|
name,
|
||||||
|
distance: hueDistance(value.h, toOklch(RGBA.fromHex(DEFAULT_THEME[mode].hue[name][anchor])).h),
|
||||||
|
}))
|
||||||
|
.sort((first, second) => first.distance - second.distance)[0]
|
||||||
|
const current = result[nearest.name]
|
||||||
|
if (current && current.distance <= nearest.distance) return result
|
||||||
|
return { ...result, [nearest.name]: { color, distance: nearest.distance } }
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferenceAnchor(lightness: number): HueStep {
|
||||||
|
return lightness >= lightThreshold ? 300 : 700
|
||||||
|
}
|
||||||
|
|
||||||
|
function hueDistance(first: number, second: number) {
|
||||||
|
const difference = Math.abs(first - second)
|
||||||
|
return Math.min(difference, 360 - difference)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ambiguous(color: RGBA, chroma = toOklch(color).c) {
|
||||||
|
return color.toInts()[3] === 0 || chroma < minimumChroma
|
||||||
|
}
|
||||||
|
|
||||||
function resolveV1(theme: ThemeJson, mode: "dark" | "light"): Theme {
|
function resolveV1(theme: ThemeJson, mode: "dark" | "light"): Theme {
|
||||||
const defs = theme.defs ?? {}
|
const defs = theme.defs ?? {}
|
||||||
|
|
||||||
@@ -192,28 +234,66 @@ function selectedForeground(theme: Theme, background: RGBA) {
|
|||||||
: RGBA.fromInts(255, 255, 255)
|
: RGBA.fromInts(255, 255, 255)
|
||||||
}
|
}
|
||||||
|
|
||||||
function hueScale(color: RGBA) {
|
function hueScale(color: RGBA, mode: "light" | "dark") {
|
||||||
return {
|
const value = toOklch(color)
|
||||||
100: mix(color, 255, 0.8),
|
const anchor = mode === "light" ? 900 : 100
|
||||||
200: mix(color, 255, 0.6),
|
const endpoint = mode === "light" ? Math.max(0.97, value.l) : Math.min(0.18, value.l)
|
||||||
300: mix(color, 255, 0.4),
|
const alpha = color.toInts()[3]
|
||||||
400: mix(color, 255, 0.2),
|
return Object.fromEntries(
|
||||||
500: hex(color),
|
HueStep.literals.map((step) => {
|
||||||
600: mix(color, 0, 0.15),
|
if (step === anchor) return [step, hex(color)]
|
||||||
700: mix(color, 0, 0.3),
|
const progress = mode === "light" ? (900 - step) / 800 : (step - 100) / 800
|
||||||
800: mix(color, 0, 0.45),
|
const generated = oklchToHex({
|
||||||
900: mix(color, 0, 0.6),
|
l: value.l + (endpoint - value.l) * progress,
|
||||||
}
|
c: value.c * (1 - progress * 0.5),
|
||||||
|
h: value.h,
|
||||||
|
})
|
||||||
|
return [step, alpha === 255 ? generated : `${generated}${byte(alpha)}`]
|
||||||
|
}),
|
||||||
|
) as Record<HueStep, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
function mix(color: RGBA, target: number, amount: number) {
|
function neutralScale(theme: Theme, mode: "light" | "dark") {
|
||||||
const [r, g, b, a] = color.toInts()
|
const anchors = neutralAnchors(theme, mode)
|
||||||
return hexInts(
|
return Object.fromEntries(
|
||||||
Math.round(r + (target - r) * amount),
|
HueStep.literals.map((step) => {
|
||||||
Math.round(g + (target - g) * amount),
|
const exact = anchors.find((anchor) => anchor.step === step)
|
||||||
Math.round(b + (target - b) * amount),
|
if (exact) return [step, hex(exact.color)]
|
||||||
a,
|
const lower = anchors.filter((anchor) => anchor.step < step).at(-1)!
|
||||||
)
|
const upper = anchors.find((anchor) => anchor.step > step)!
|
||||||
|
return [step, interpolate(lower.color, upper.color, (step - lower.step) / (upper.step - lower.step))]
|
||||||
|
}),
|
||||||
|
) as Record<HueStep, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function neutralAnchors(theme: Theme, mode: "light" | "dark") {
|
||||||
|
const light: { step: HueStep; color: RGBA }[] = [
|
||||||
|
{ step: 100, color: theme.background },
|
||||||
|
{ step: 200, color: theme.backgroundPanel },
|
||||||
|
{ step: 300, color: theme.backgroundMenu },
|
||||||
|
{ step: 700, color: theme.textMuted },
|
||||||
|
{ step: 900, color: theme.text },
|
||||||
|
]
|
||||||
|
if (mode === "light") return light
|
||||||
|
return light.toReversed().map((source) => ({ ...source, step: (1000 - source.step) as HueStep }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(first: RGBA, second: RGBA, amount: number) {
|
||||||
|
const start = toOklch(first)
|
||||||
|
const end = toOklch(second)
|
||||||
|
const hue = ((((end.h - start.h) % 360) + 540) % 360) - 180
|
||||||
|
const generated = oklchToHex({
|
||||||
|
l: start.l + (end.l - start.l) * amount,
|
||||||
|
c: start.c + (end.c - start.c) * amount,
|
||||||
|
h: start.h + hue * amount,
|
||||||
|
})
|
||||||
|
const alpha = Math.round(first.toInts()[3] + (second.toInts()[3] - first.toInts()[3]) * amount)
|
||||||
|
return alpha === 255 ? generated : `${generated}${byte(alpha)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOklch(color: RGBA) {
|
||||||
|
const [red, green, blue] = color.toInts()
|
||||||
|
return rgbToOklch(red / 255, green / 255, blue / 255)
|
||||||
}
|
}
|
||||||
|
|
||||||
function hex(color: RGBA) {
|
function hex(color: RGBA) {
|
||||||
@@ -221,10 +301,13 @@ function hex(color: RGBA) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hexInts(r: number, g: number, b: number, a: number) {
|
function hexInts(r: number, g: number, b: number, a: number) {
|
||||||
const byte = (value: number) => value.toString(16).padStart(2, "0")
|
|
||||||
return `#${byte(r)}${byte(g)}${byte(b)}${a === 255 ? "" : byte(a)}`
|
return `#${byte(r)}${byte(g)}${byte(b)}${a === 255 ? "" : byte(a)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function byte(value: number) {
|
||||||
|
return value.toString(16).padStart(2, "0")
|
||||||
|
}
|
||||||
|
|
||||||
function ansi(code: number) {
|
function ansi(code: number) {
|
||||||
if (code < 16) {
|
if (code < 16) {
|
||||||
const colors = [
|
const colors = [
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||||
|
import { findMessageBoundary, messageNavigationSlack } from "../../../src/routes/session/message-navigation"
|
||||||
|
|
||||||
|
const messages: SessionMessageInfo[] = [
|
||||||
|
{ type: "user", id: "user-1", text: "First", time: { created: 0 } },
|
||||||
|
assistant("assistant-1", "Response"),
|
||||||
|
{ type: "user", id: "user-2", text: "Second", time: { created: 2 } },
|
||||||
|
]
|
||||||
|
const children = [
|
||||||
|
{ id: "user-1", y: 0 },
|
||||||
|
{ id: "assistant-1", y: 20 },
|
||||||
|
{ id: "user-2", y: 40 },
|
||||||
|
]
|
||||||
|
|
||||||
|
test("adds only enough slack to align the selected message", () => {
|
||||||
|
expect(messageNavigationSlack({ top: 80, viewportHeight: 50, scrollHeight: 100, currentSlack: 0 })).toBe(30)
|
||||||
|
expect(messageNavigationSlack({ top: 20, viewportHeight: 50, scrollHeight: 130, currentSlack: 30 })).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("finds the next user message without stopping at an assistant message", () => {
|
||||||
|
expect(
|
||||||
|
findMessageBoundary({
|
||||||
|
direction: "next",
|
||||||
|
children,
|
||||||
|
messages,
|
||||||
|
scrollTop: 0,
|
||||||
|
viewportY: 0,
|
||||||
|
userOnly: true,
|
||||||
|
}),
|
||||||
|
).toEqual({ id: "user-2", y: 40, top: 40 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("finds the previous user message without stopping at an assistant message", () => {
|
||||||
|
expect(
|
||||||
|
findMessageBoundary({
|
||||||
|
direction: "prev",
|
||||||
|
children: children.map((child) => ({ ...child, y: child.y - 35 })),
|
||||||
|
messages,
|
||||||
|
scrollTop: 35,
|
||||||
|
viewportY: 0,
|
||||||
|
userOnly: true,
|
||||||
|
}),
|
||||||
|
).toEqual({ id: "user-1", y: 0, top: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves navigation across both user and assistant messages", () => {
|
||||||
|
expect(
|
||||||
|
findMessageBoundary({
|
||||||
|
direction: "next",
|
||||||
|
children,
|
||||||
|
messages,
|
||||||
|
scrollTop: 0,
|
||||||
|
viewportY: 0,
|
||||||
|
}),
|
||||||
|
).toEqual({ id: "assistant-1", y: 20, top: 19 })
|
||||||
|
expect(
|
||||||
|
findMessageBoundary({
|
||||||
|
direction: "prev",
|
||||||
|
children: children.map((child) => ({ ...child, y: child.y - 35 })),
|
||||||
|
messages,
|
||||||
|
scrollTop: 35,
|
||||||
|
viewportY: 0,
|
||||||
|
}),
|
||||||
|
).toEqual({ id: "assistant-1", y: 20, top: 19 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses the selected message when the viewport is too tall to scroll", () => {
|
||||||
|
expect(
|
||||||
|
findMessageBoundary({
|
||||||
|
direction: "next",
|
||||||
|
children,
|
||||||
|
messages,
|
||||||
|
scrollTop: 0,
|
||||||
|
viewportY: 0,
|
||||||
|
currentID: "user-1",
|
||||||
|
userOnly: true,
|
||||||
|
}),
|
||||||
|
).toEqual({ id: "user-2", y: 40, top: 40 })
|
||||||
|
expect(
|
||||||
|
findMessageBoundary({
|
||||||
|
direction: "prev",
|
||||||
|
children,
|
||||||
|
messages,
|
||||||
|
scrollTop: 0,
|
||||||
|
viewportY: 0,
|
||||||
|
currentID: "user-2",
|
||||||
|
userOnly: true,
|
||||||
|
}),
|
||||||
|
).toEqual({ id: "user-1", y: 0, top: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("stops at the first and last selected user message", () => {
|
||||||
|
expect(
|
||||||
|
findMessageBoundary({
|
||||||
|
direction: "next",
|
||||||
|
children,
|
||||||
|
messages,
|
||||||
|
scrollTop: 0,
|
||||||
|
viewportY: 0,
|
||||||
|
currentID: "user-2",
|
||||||
|
userOnly: true,
|
||||||
|
}),
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
findMessageBoundary({
|
||||||
|
direction: "prev",
|
||||||
|
children,
|
||||||
|
messages,
|
||||||
|
scrollTop: 0,
|
||||||
|
viewportY: 0,
|
||||||
|
currentID: "user-1",
|
||||||
|
userOnly: true,
|
||||||
|
}),
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps the logical boundary when layout temporarily moves it outside the viewport", () => {
|
||||||
|
expect(
|
||||||
|
findMessageBoundary({
|
||||||
|
direction: "next",
|
||||||
|
children,
|
||||||
|
messages,
|
||||||
|
scrollTop: 0,
|
||||||
|
viewportY: 0,
|
||||||
|
currentID: "user-2",
|
||||||
|
userOnly: true,
|
||||||
|
}),
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("stops at the first and last message", () => {
|
||||||
|
expect(
|
||||||
|
findMessageBoundary({
|
||||||
|
direction: "next",
|
||||||
|
children,
|
||||||
|
messages,
|
||||||
|
scrollTop: 0,
|
||||||
|
viewportY: 0,
|
||||||
|
currentID: "user-2",
|
||||||
|
}),
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
findMessageBoundary({
|
||||||
|
direction: "prev",
|
||||||
|
children,
|
||||||
|
messages,
|
||||||
|
scrollTop: 0,
|
||||||
|
viewportY: 0,
|
||||||
|
currentID: "user-1",
|
||||||
|
}),
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
function assistant(id: string, text: string): SessionMessageAssistant {
|
||||||
|
return {
|
||||||
|
type: "assistant",
|
||||||
|
id,
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "test", id: "test" },
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
time: { created: 1, completed: 1 },
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,20 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||||
import { reduceSessionRows } from "../../../src/routes/session/rows"
|
import { messageBoundaryIDs, reduceSessionRows } from "../../../src/routes/session/rows"
|
||||||
|
|
||||||
|
test("assigns assistant boundaries to the first rendered row instead of the first text row", () => {
|
||||||
|
const messages: SessionMessageInfo[] = [
|
||||||
|
{ type: "user", id: "user-1", text: "Question", time: { created: 0 } },
|
||||||
|
assistant("assistant-1", [
|
||||||
|
{ type: "reasoning", text: "Thinking" },
|
||||||
|
{ type: "text", text: "First" },
|
||||||
|
{ type: "text", text: "Second" },
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
const rows = reduceSessionRows(messages)
|
||||||
|
|
||||||
|
expect(messageBoundaryIDs(rows, messages)).toEqual(["user-1", "assistant-1", undefined, undefined])
|
||||||
|
})
|
||||||
|
|
||||||
test("groups exploration parts across assistant messages until a delimiter", () => {
|
test("groups exploration parts across assistant messages until a delimiter", () => {
|
||||||
const messages: SessionMessageInfo[] = [
|
const messages: SessionMessageInfo[] = [
|
||||||
|
|||||||
@@ -86,6 +86,17 @@ test("resolves a session move keybind", () => {
|
|||||||
expect(config.keybinds.get("session.move")).toMatchObject([{ key: "ctrl+o" }])
|
expect(config.keybinds.get("session.move")).toMatchObject([{ key: "ctrl+o" }])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("resolves message navigation defaults", () => {
|
||||||
|
const config = resolve({}, { terminalSuspend: true })
|
||||||
|
|
||||||
|
expect(config.keybinds.get("session.first")).toMatchObject([{ key: "ctrl+g,home,alt+home" }])
|
||||||
|
expect(config.keybinds.get("session.message.previous")).toMatchObject([{ key: "alt+up" }])
|
||||||
|
expect(config.keybinds.get("session.message.next")).toMatchObject([{ key: "alt+down" }])
|
||||||
|
expect(config.keybinds.get("session.message.user.previous")).toMatchObject([{ key: "alt+shift+up" }])
|
||||||
|
expect(config.keybinds.get("session.message.user.next")).toMatchObject([{ key: "alt+shift+down" }])
|
||||||
|
expect(config.keybinds.get("session.messages_last_user")).toMatchObject([{ key: "alt+end" }])
|
||||||
|
})
|
||||||
|
|
||||||
test("opens the subagent picker with down", () => {
|
test("opens the subagent picker with down", () => {
|
||||||
const config = resolve({}, { terminalSuspend: true })
|
const config = resolve({}, { terminalSuspend: true })
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||||
import { createBindingLookup } from "@opentui/keymap/extras"
|
import { createBindingLookup } from "@opentui/keymap/extras"
|
||||||
|
import { type TextareaRenderable } from "@opentui/core"
|
||||||
import { testRender, useRenderer } from "@opentui/solid"
|
import { testRender, useRenderer } from "@opentui/solid"
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { onCleanup } from "solid-js"
|
import { onCleanup, onMount } from "solid-js"
|
||||||
import { TuiKeybind } from "../src/config/keybind"
|
import { TuiKeybind } from "../src/config/keybind"
|
||||||
import {
|
import {
|
||||||
formatKeySequence,
|
formatKeySequence,
|
||||||
@@ -110,6 +111,64 @@ test("formats navigation keys as arrows", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("dispatches message navigation while the composer is focused", async () => {
|
||||||
|
for (const kittyKeyboard of [false, true]) {
|
||||||
|
const counts = {
|
||||||
|
"session.first": 0,
|
||||||
|
"session.message.previous": 0,
|
||||||
|
"session.message.next": 0,
|
||||||
|
"session.messages_last_user": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
const renderer = useRenderer()
|
||||||
|
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||||
|
const config = createResolvedKeymapConfig()
|
||||||
|
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
|
||||||
|
const commands = Object.keys(counts) as (keyof typeof counts)[]
|
||||||
|
const offLayer = keymap.registerLayer({
|
||||||
|
commands: commands.map((name) => ({
|
||||||
|
name,
|
||||||
|
run() {
|
||||||
|
counts[name]++
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
bindings: commands.flatMap((command) => config.keybinds.get(command)),
|
||||||
|
})
|
||||||
|
let textarea: TextareaRenderable
|
||||||
|
onMount(() => textarea.focus())
|
||||||
|
onCleanup(() => {
|
||||||
|
offLayer()
|
||||||
|
offKeymap()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OpencodeKeymapProvider keymap={keymap}>
|
||||||
|
<textarea ref={(value) => (textarea = value)} />
|
||||||
|
</OpencodeKeymapProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => <Harness />, { kittyKeyboard })
|
||||||
|
try {
|
||||||
|
await app.renderOnce()
|
||||||
|
app.mockInput.pressArrow("up", { meta: true })
|
||||||
|
app.mockInput.pressArrow("down", { meta: true })
|
||||||
|
app.mockInput.pressKey("HOME", { meta: true })
|
||||||
|
app.mockInput.pressKey("END", { meta: true })
|
||||||
|
expect(counts).toEqual({
|
||||||
|
"session.first": 1,
|
||||||
|
"session.message.previous": 1,
|
||||||
|
"session.message.next": 1,
|
||||||
|
"session.messages_last_user": 1,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
app.renderer.currentFocusedEditor?.blur()
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("mode-less bindings stay active when opencode mode changes", async () => {
|
test("mode-less bindings stay active when opencode mode changes", async () => {
|
||||||
const counts: Record<string, Record<string, number>> = {}
|
const counts: Record<string, Record<string, number>> = {}
|
||||||
|
|
||||||
@@ -169,13 +228,13 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
|
|||||||
const app = await testRender(() => <Harness />)
|
const app = await testRender(() => <Harness />)
|
||||||
try {
|
try {
|
||||||
expect(counts).toEqual({
|
expect(counts).toEqual({
|
||||||
base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 1 },
|
base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 3, "model.list": 1 },
|
||||||
question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 0 },
|
question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 3, "model.list": 0 },
|
||||||
autocomplete: {
|
autocomplete: {
|
||||||
"session.list": 1,
|
"session.list": 1,
|
||||||
"session.new": 1,
|
"session.new": 1,
|
||||||
"session.page.up": 2,
|
"session.page.up": 2,
|
||||||
"session.first": 2,
|
"session.first": 3,
|
||||||
"model.list": 0,
|
"model.list": 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ test("provides reactive property, variant, state, and context accessors", () =>
|
|||||||
|
|
||||||
expect(theme.text()).toBe(resolved().text.default)
|
expect(theme.text()).toBe(resolved().text.default)
|
||||||
expect(theme.hue.accent(500)).toBe(resolved().hue.accent[500])
|
expect(theme.hue.accent(500)).toBe(resolved().hue.accent[500])
|
||||||
|
expect(theme.hue.interactive(500)).toBe(resolved().hue.interactive[500])
|
||||||
expect(theme.hue.gray(200)).toBe(resolved().hue.gray[200])
|
expect(theme.hue.gray(200)).toBe(resolved().hue.gray[200])
|
||||||
expect(theme.text.subdued()).toBe(resolved().text.subdued)
|
expect(theme.text.subdued()).toBe(resolved().text.subdued)
|
||||||
expect(theme.text.action()).toBe(resolved().text.action.primary.default)
|
expect(theme.text.action()).toBe(resolved().text.action.primary.default)
|
||||||
expect(theme.text.action.primary("pressed")).toBe(resolved().text.action.primary.pressed)
|
expect(theme.text.action.primary("pressed")).toBe(resolved().text.action.primary.pressed)
|
||||||
|
expect(theme.text.action.primary("selected")).toBe(resolved().text.action.primary.selected)
|
||||||
|
expect(theme.background.action.primary("selected")).toBe(resolved().background.action.primary.selected)
|
||||||
expect(theme.background.action.secondary("disabled")).toBe(
|
expect(theme.background.action.secondary("disabled")).toBe(
|
||||||
resolved().background.action.secondary.disabled,
|
resolved().background.action.secondary.disabled,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ test("resolves independent definitions and hue aliases", () => {
|
|||||||
const darkTheme = resolveTheme(dark)
|
const darkTheme = resolveTheme(dark)
|
||||||
|
|
||||||
expect(lightTheme.hue.accent).toBe(lightTheme.hue.blue)
|
expect(lightTheme.hue.accent).toBe(lightTheme.hue.blue)
|
||||||
|
expect(lightTheme.hue.interactive).toBe(lightTheme.hue.blue)
|
||||||
expect(lightTheme.hue.neutral).toBe(lightTheme.hue.gray)
|
expect(lightTheme.hue.neutral).toBe(lightTheme.hue.gray)
|
||||||
expect(lightTheme.text.default).toBeInstanceOf(RGBA)
|
expect(lightTheme.text.default).toBeInstanceOf(RGBA)
|
||||||
expect(darkTheme.background.default).toBeInstanceOf(RGBA)
|
expect(darkTheme.background.default).toBeInstanceOf(RGBA)
|
||||||
@@ -21,33 +22,54 @@ test("resolves independent definitions and hue aliases", () => {
|
|||||||
expect(lightTheme.syntax.keyword).toBeInstanceOf(RGBA)
|
expect(lightTheme.syntax.keyword).toBeInstanceOf(RGBA)
|
||||||
expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
|
expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
|
||||||
expect(lightTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe(
|
expect(lightTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe(
|
||||||
lightTheme.hue.accent[500],
|
lightTheme.hue.interactive[500],
|
||||||
)
|
)
|
||||||
expect(lightTheme.contexts["@context:elevated"]?.background.default).toBe(lightTheme.background.surface.offset)
|
expect(lightTheme.contexts["@context:elevated"]?.background.default).toBe(lightTheme.background.surface.offset)
|
||||||
expect(lightTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(
|
expect(lightTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(
|
||||||
lightTheme.hue.neutral[100],
|
lightTheme.hue.neutral[100],
|
||||||
)
|
)
|
||||||
expect(lightTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(
|
expect(lightTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(
|
||||||
lightTheme.hue.accent[500],
|
lightTheme.hue.interactive[500],
|
||||||
)
|
)
|
||||||
expect(lightTheme.contexts["@context:overlay"]?.background.default).toBe(lightTheme.background.surface.overlay)
|
expect(lightTheme.contexts["@context:overlay"]?.background.default).toBe(lightTheme.background.surface.overlay)
|
||||||
expect(lightTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(
|
expect(lightTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(
|
||||||
lightTheme.hue.neutral[100],
|
lightTheme.hue.neutral[100],
|
||||||
)
|
)
|
||||||
expect(darkTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe(
|
expect(darkTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe(
|
||||||
darkTheme.hue.accent[400],
|
darkTheme.hue.interactive[400],
|
||||||
)
|
)
|
||||||
expect(darkTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(
|
expect(darkTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(
|
||||||
darkTheme.hue.neutral[100],
|
darkTheme.hue.neutral[100],
|
||||||
)
|
)
|
||||||
expect(darkTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(
|
expect(darkTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(
|
||||||
darkTheme.hue.accent[400],
|
darkTheme.hue.interactive[400],
|
||||||
)
|
)
|
||||||
expect(darkTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(
|
expect(darkTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(
|
||||||
darkTheme.hue.neutral[900],
|
darkTheme.hue.neutral[900],
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("resolves base hue aliases and rejects circular hue aliases", () => {
|
||||||
|
const aliased = resolveTheme({
|
||||||
|
...light,
|
||||||
|
hue: { ...light.hue, blue: "$hue.red", purple: "$hue.blue" },
|
||||||
|
})
|
||||||
|
const overridden = resolveThemeFile(
|
||||||
|
{ version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} },
|
||||||
|
"light",
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(aliased.hue.blue).toBe(aliased.hue.red)
|
||||||
|
expect(aliased.hue.purple).toBe(aliased.hue.red)
|
||||||
|
expect(overridden.hue.blue).toBe(overridden.hue.red)
|
||||||
|
expect(() =>
|
||||||
|
resolveTheme({
|
||||||
|
...light,
|
||||||
|
hue: { ...light.hue, red: "$hue.blue", blue: "$hue.red" },
|
||||||
|
}),
|
||||||
|
).toThrow("Circular hue reference: red -> blue -> red")
|
||||||
|
})
|
||||||
|
|
||||||
test("merges partial files with the selected OpenCode defaults", () => {
|
test("merges partial files with the selected OpenCode defaults", () => {
|
||||||
const theme = resolveThemeFile(
|
const theme = resolveThemeFile(
|
||||||
{
|
{
|
||||||
@@ -117,7 +139,9 @@ test("resolves matched action variants and states", () => {
|
|||||||
const theme = resolveTheme(light)
|
const theme = resolveTheme(light)
|
||||||
|
|
||||||
expect(theme.text.action.primary.pressed).toBeInstanceOf(RGBA)
|
expect(theme.text.action.primary.pressed).toBeInstanceOf(RGBA)
|
||||||
|
expect(theme.text.action.primary.selected).toBeInstanceOf(RGBA)
|
||||||
expect(theme.background.action.primary.pressed).toBeInstanceOf(RGBA)
|
expect(theme.background.action.primary.pressed).toBeInstanceOf(RGBA)
|
||||||
|
expect(theme.background.action.primary.selected).toBeInstanceOf(RGBA)
|
||||||
expect(theme.text.action.secondary.default).toBeInstanceOf(RGBA)
|
expect(theme.text.action.secondary.default).toBeInstanceOf(RGBA)
|
||||||
expect(theme.background.action.destructive.disabled).toBeInstanceOf(RGBA)
|
expect(theme.background.action.destructive.disabled).toBeInstanceOf(RGBA)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,11 +19,15 @@ const background = {
|
|||||||
default: "$hue.neutral.100",
|
default: "$hue.neutral.100",
|
||||||
surface: { offset: "$hue.neutral.200", overlay: "$hue.neutral.300" },
|
surface: { offset: "$hue.neutral.200", overlay: "$hue.neutral.300" },
|
||||||
action: {
|
action: {
|
||||||
primary: { default: "$hue.accent.600", $pressed: "$hue.accent.800" },
|
primary: {
|
||||||
|
default: "$hue.interactive.600",
|
||||||
|
$pressed: "$hue.interactive.800",
|
||||||
|
$selected: "$hue.interactive.700",
|
||||||
|
},
|
||||||
secondary: { default: "$hue.neutral.200" },
|
secondary: { default: "$hue.neutral.200" },
|
||||||
destructive: { default: "$hue.red.600" },
|
destructive: { default: "$hue.red.600" },
|
||||||
},
|
},
|
||||||
formfield: { default: "$hue.neutral.100", $selected: "$hue.accent.600" },
|
formfield: { default: "$hue.neutral.100", $selected: "$hue.interactive.600" },
|
||||||
feedback: { error: { default: "$hue.red.100" } },
|
feedback: { error: { default: "$hue.red.100" } },
|
||||||
} satisfies BackgroundDefinition
|
} satisfies BackgroundDefinition
|
||||||
|
|
||||||
@@ -45,6 +49,7 @@ test("supports property-first definitions, variants, states, and contexts", () =
|
|||||||
expect(text.action.primary.$pressed).toBe("$hue.neutral.200")
|
expect(text.action.primary.$pressed).toBe("$hue.neutral.200")
|
||||||
expect(text.formfield.$selected).toBe("$hue.neutral.100")
|
expect(text.formfield.$selected).toBe("$hue.neutral.100")
|
||||||
expect(background.action.destructive.default).toBe("$hue.red.600")
|
expect(background.action.destructive.default).toBe("$hue.red.600")
|
||||||
|
expect(background.action.primary.$selected).toBe("$hue.interactive.700")
|
||||||
expect(background.surface.offset).toBe("$hue.neutral.200")
|
expect(background.surface.offset).toBe("$hue.neutral.200")
|
||||||
expect(definition["@context:elevated"].text?.default).toBe("$hue.neutral.800")
|
expect(definition["@context:elevated"].text?.default).toBe("$hue.neutral.800")
|
||||||
expect(definition["@context:overlay"].background?.default).toBe("$hue.neutral.300")
|
expect(definition["@context:overlay"].background?.default).toBe("$hue.neutral.300")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { DEFAULT_THEMES, resolveTheme as resolveV1, selectedForeground } from "../../../src/theme"
|
import { DEFAULT_THEMES, resolveTheme as resolveV1 } from "../../../src/theme"
|
||||||
import { resolveThemeFile } from "../../../src/theme/v2/resolve"
|
import { resolveThemeFile } from "../../../src/theme/v2/resolve"
|
||||||
import { migrateV1 } from "../../../src/theme/v2/v1-migrate"
|
import { migrateV1 } from "../../../src/theme/v2/v1-migrate"
|
||||||
|
|
||||||
@@ -10,11 +10,23 @@ test("migrates resolved V1 modes into literal V2 tokens", () => {
|
|||||||
|
|
||||||
expect(migrated.standalone).toBeTrue()
|
expect(migrated.standalone).toBeTrue()
|
||||||
expect(migrated.light.hue?.accent).toBeObject()
|
expect(migrated.light.hue?.accent).toBeObject()
|
||||||
if (typeof migrated.light.hue?.accent !== "object") throw new Error("Expected a concrete accent scale")
|
expect(migrated.light.hue?.interactive).toBeObject()
|
||||||
expect(migrated.light.hue.accent[500]).toBe(hex(legacy.secondary))
|
if (typeof migrated.light.hue?.accent !== "object" || typeof migrated.light.hue.interactive !== "object") {
|
||||||
expect(migrated.light.background?.default).toBe(hex(legacy.background))
|
throw new Error("Expected concrete accent and interactive scales")
|
||||||
expect(migrated.light.background?.action?.primary?.default).toBe(hex(legacy.primary))
|
}
|
||||||
expect(migrated.light.text?.action?.primary?.default).toBe(hex(selectedForeground(legacy, legacy.primary)))
|
expect(migrated.light.hue.accent[900]).toBe(hex(legacy.accent))
|
||||||
|
expect(migrated.light.hue.interactive[900]).toBe(hex(legacy.primary))
|
||||||
|
expect(migrated.light.text?.default).toBe("$hue.neutral.900")
|
||||||
|
expect(migrated.light.text?.subdued).toBe("$hue.neutral.700")
|
||||||
|
expect(migrated.light.background?.action?.primary?.default).toBe("transparent")
|
||||||
|
expect(migrated.light.background?.default).toBe("$hue.neutral.100")
|
||||||
|
expect(migrated.light.background?.surface?.offset).toBe("$hue.neutral.200")
|
||||||
|
expect(migrated.light.background?.surface?.overlay).toBe("$hue.neutral.300")
|
||||||
|
expect(migrated.dark.background?.default).toBe("$hue.neutral.900")
|
||||||
|
expect(migrated.dark.background?.surface?.offset).toBe("$hue.neutral.800")
|
||||||
|
expect(migrated.dark.background?.surface?.overlay).toBe("$hue.neutral.700")
|
||||||
|
expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
|
||||||
|
expect(migrated.light.background?.action?.primary?.$selected).toBe("$hue.interactive.900")
|
||||||
expect(migrated.light.scrollbar?.default).toBe(hex(legacy.borderActive))
|
expect(migrated.light.scrollbar?.default).toBe(hex(legacy.borderActive))
|
||||||
expect(migrated.light.diff?.lineNumber?.background?.removed).toBe(hex(legacy.diffRemovedLineNumberBg))
|
expect(migrated.light.diff?.lineNumber?.background?.removed).toBe(hex(legacy.diffRemovedLineNumberBg))
|
||||||
expect(migrated.light.markdown?.emphasis).toBe(hex(legacy.markdownEmph))
|
expect(migrated.light.markdown?.emphasis).toBe(hex(legacy.markdownEmph))
|
||||||
@@ -26,10 +38,10 @@ test("migrates resolved V1 modes into literal V2 tokens", () => {
|
|||||||
expect(resolved.text.formfield.default.toInts()).toEqual(legacy.text.toInts())
|
expect(resolved.text.formfield.default.toInts()).toEqual(legacy.text.toInts())
|
||||||
expect(resolved.text.formfield.selected.toInts()).toEqual(legacy.primary.toInts())
|
expect(resolved.text.formfield.selected.toInts()).toEqual(legacy.primary.toInts())
|
||||||
expect(resolved.text.formfield.focused.toInts()).toEqual(legacy.primary.toInts())
|
expect(resolved.text.formfield.focused.toInts()).toEqual(legacy.primary.toInts())
|
||||||
expect(resolved.hue.accent[500].toInts()).toEqual(legacy.secondary.toInts())
|
expect(resolved.hue.accent[900].toInts()).toEqual(legacy.accent.toInts())
|
||||||
expect(resolved.hue.accent[300].r + resolved.hue.accent[300].g + resolved.hue.accent[300].b).toBeGreaterThan(
|
expect(resolved.hue.interactive[900].toInts()).toEqual(legacy.primary.toInts())
|
||||||
resolved.hue.accent[500].r + resolved.hue.accent[500].g + resolved.hue.accent[500].b,
|
expect(resolved.background.action.primary.selected.toInts()).toEqual(legacy.primary.toInts())
|
||||||
)
|
expect(resolved.text.action.primary.selected.toInts()).toEqual(legacy.primary.toInts())
|
||||||
expect(resolved.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts())
|
expect(resolved.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts())
|
||||||
expect(resolved.contexts["@context:elevated"]?.background.default.toInts()).toEqual(
|
expect(resolved.contexts["@context:elevated"]?.background.default.toInts()).toEqual(
|
||||||
legacy.backgroundPanel.toInts(),
|
legacy.backgroundPanel.toInts(),
|
||||||
@@ -37,29 +49,83 @@ test("migrates resolved V1 modes into literal V2 tokens", () => {
|
|||||||
expect(resolved.contexts["@context:elevated"]?.background.action.secondary.default.toInts()).toEqual(
|
expect(resolved.contexts["@context:elevated"]?.background.action.secondary.default.toInts()).toEqual(
|
||||||
legacy.backgroundPanel.toInts(),
|
legacy.backgroundPanel.toInts(),
|
||||||
)
|
)
|
||||||
expect(resolved.contexts["@context:elevated"]?.background.action.primary.default.toInts()).toEqual(
|
expect(resolved.contexts["@context:elevated"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
|
||||||
legacy.primary.toInts(),
|
|
||||||
)
|
|
||||||
expect(resolved.contexts["@context:elevated"]?.text.action.primary.default.toInts()).toEqual(
|
expect(resolved.contexts["@context:elevated"]?.text.action.primary.default.toInts()).toEqual(
|
||||||
selectedForeground(legacy, legacy.primary).toInts(),
|
legacy.text.toInts(),
|
||||||
)
|
)
|
||||||
expect(resolved.contexts["@context:overlay"]?.background.default.toInts()).toEqual(
|
expect(resolved.contexts["@context:overlay"]?.background.default.toInts()).toEqual(
|
||||||
legacy.backgroundMenu.toInts(),
|
legacy.backgroundMenu.toInts(),
|
||||||
)
|
)
|
||||||
expect(resolved.contexts["@context:overlay"]?.background.action.primary.default.toInts()).toEqual(
|
expect(resolved.contexts["@context:overlay"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
|
||||||
legacy.primary.toInts(),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves V1 selected foreground behavior on transparent backgrounds", () => {
|
test("infers chromatic hues, anchors light and dark colors, and aliases ambiguous hues to gray", () => {
|
||||||
|
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||||
|
const ambiguous = { light: "#808080", dark: "#808080" }
|
||||||
|
source.theme.accent = ambiguous
|
||||||
|
source.theme.warning = ambiguous
|
||||||
|
source.theme.primary = ambiguous
|
||||||
|
source.theme.error = ambiguous
|
||||||
|
source.theme.info = ambiguous
|
||||||
|
source.theme.secondary = "transparent"
|
||||||
|
source.theme.success = { light: "#ff6666", dark: "#450000" }
|
||||||
|
|
||||||
|
const migrated = migrateV1(source)
|
||||||
|
const lightRed = migrated.light.hue?.red
|
||||||
|
const darkRed = migrated.dark.hue?.red
|
||||||
|
if (typeof lightRed !== "object" || typeof darkRed !== "object") throw new Error("Expected generated red scales")
|
||||||
|
|
||||||
|
expect(lightRed[900]).toBe("#ff6666")
|
||||||
|
expect(darkRed[100]).toBe("#450000")
|
||||||
|
expect(migrated.light.hue?.orange).toBe("$hue.gray")
|
||||||
|
expect(migrated.light.hue?.yellow).toBe("$hue.gray")
|
||||||
|
expect(migrated.light.hue?.green).toBe("$hue.gray")
|
||||||
|
expect(migrated.light.hue?.cyan).toBe("$hue.gray")
|
||||||
|
expect(migrated.light.hue?.blue).toBe("$hue.gray")
|
||||||
|
expect(migrated.light.hue?.purple).toBe("$hue.gray")
|
||||||
|
expect(migrated.light.hue?.accent).toBe("$hue.gray")
|
||||||
|
expect(migrated.light.hue?.interactive).toBe("$hue.gray")
|
||||||
|
expect(() => resolveThemeFile(migrated, "light")).not.toThrow()
|
||||||
|
expect(() => resolveThemeFile(migrated, "dark")).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("builds gray from V1 surfaces and text without using borders", () => {
|
||||||
|
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||||
|
source.theme.backgroundMenu = { light: "#ededed", dark: "#252525" }
|
||||||
|
const light = resolveV1(source, "light")
|
||||||
|
const dark = resolveV1(source, "dark")
|
||||||
|
const migrated = migrateV1(source)
|
||||||
|
const lightGray = migrated.light.hue?.gray
|
||||||
|
const darkGray = migrated.dark.hue?.gray
|
||||||
|
if (typeof lightGray !== "object" || typeof darkGray !== "object") throw new Error("Expected concrete gray scales")
|
||||||
|
|
||||||
|
expect(lightGray[100]).toBe(hex(light.background))
|
||||||
|
expect(lightGray[200]).toBe(hex(light.backgroundPanel))
|
||||||
|
expect(lightGray[300]).toBe(hex(light.backgroundMenu))
|
||||||
|
expect(lightGray[700]).toBe(hex(light.textMuted))
|
||||||
|
expect(lightGray[900]).toBe(hex(light.text))
|
||||||
|
expect(darkGray[100]).toBe(hex(dark.text))
|
||||||
|
expect(darkGray[300]).toBe(hex(dark.textMuted))
|
||||||
|
expect(darkGray[700]).toBe(hex(dark.backgroundMenu))
|
||||||
|
expect(darkGray[800]).toBe(hex(dark.backgroundPanel))
|
||||||
|
expect(darkGray[900]).toBe(hex(dark.background))
|
||||||
|
|
||||||
|
source.theme.borderSubtle = "#ff00ff"
|
||||||
|
source.theme.border = "#00ff00"
|
||||||
|
source.theme.borderActive = "#00ffff"
|
||||||
|
expect(migrateV1(source).light.hue?.gray).toEqual(lightGray)
|
||||||
|
expect(migrateV1(source).dark.hue?.gray).toEqual(darkGray)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses the default text reference for primary actions on transparent backgrounds", () => {
|
||||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||||
source.theme.background = "transparent"
|
source.theme.background = "transparent"
|
||||||
source.theme.primary = { light: "#ffffff", dark: "#000000" }
|
source.theme.primary = { light: "#ffffff", dark: "#000000" }
|
||||||
delete source.theme.selectedListItemText
|
delete source.theme.selectedListItemText
|
||||||
const migrated = migrateV1(source)
|
const migrated = migrateV1(source)
|
||||||
|
|
||||||
expect(migrated.light.text?.action?.primary?.default).toBe("#000000")
|
expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
|
||||||
expect(migrated.dark.text?.action?.primary?.default).toBe("#ffffff")
|
expect(migrated.dark.text?.action?.primary?.default).toBe("$text.default")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("retains V1 circular reference errors", () => {
|
test("retains V1 circular reference errors", () => {
|
||||||
|
|||||||
+28
-48
@@ -1,19 +1,16 @@
|
|||||||
# Session-Aware One-Shot Generation Plan
|
# Session-Aware One-Shot Generation Plan
|
||||||
|
|
||||||
Status: **Proposed**
|
Status: **In progress**
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
Add a Session operation that prepares one request from the Session's active model context, appends a transient prompt, executes exactly one Physical Attempt, returns the assistant text, and leaves the Session unchanged:
|
Add a Session operation that prepares one request from the Session's active model context, appends a transient prompt, executes exactly one Physical Attempt, returns the assistant text, and leaves the Session unchanged:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const { text } = await client.session.generate({
|
const text = yield * session.generate({ sessionID, prompt: "Summarize where we left off." })
|
||||||
sessionID,
|
|
||||||
text: "Summarize where we left off.",
|
|
||||||
})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The operation belongs to Session because its meaning depends on Session History, selected agent and model, instructions, tools, provider transforms, hooks, and prompt-cache identity. The existing stateless `Generate.text` module remains unaware of Session.
|
The operation belongs to Session because its meaning depends on Session History, the selected agent and model, instructions, provider transforms, hooks, and prompt-cache identity. The existing stateless `Generate.text` module remains unaware of Session.
|
||||||
|
|
||||||
This feature should deepen the Session request-preparation module rather than add a second approximation of the runner. The durable runner and `session.generate` must share preparation, then diverge before provider output acquires durable consequences.
|
This feature should deepen the Session request-preparation module rather than add a second approximation of the runner. The durable runner and `session.generate` must share preparation, then diverge before provider output acquires durable consequences.
|
||||||
|
|
||||||
@@ -38,14 +35,15 @@ The desired architecture makes request preparation independently callable while
|
|||||||
session.prompt
|
session.prompt
|
||||||
-> SessionAdmission
|
-> SessionAdmission
|
||||||
-> SessionExecution
|
-> SessionExecution
|
||||||
-> SessionContext.snapshot
|
-> SessionContext.select/load
|
||||||
-> SessionModelRequest.prepare
|
-> SessionModelRequest.prepare
|
||||||
-> LLMClient.stream
|
-> LLMClient.stream
|
||||||
-> SessionSettlement
|
-> SessionSettlement
|
||||||
|
|
||||||
session.generate
|
session.generate
|
||||||
-> SessionContext.snapshot
|
-> SessionContext.select
|
||||||
-> SessionModelRequest.prepare
|
-> SessionHistory.preview
|
||||||
|
-> SessionGenerate request construction
|
||||||
-> LLMClient.generate
|
-> LLMClient.generate
|
||||||
-> return text
|
-> return text
|
||||||
```
|
```
|
||||||
@@ -54,7 +52,8 @@ The modules have distinct jobs:
|
|||||||
|
|
||||||
- `SessionAdmission` records and promotes durable input.
|
- `SessionAdmission` records and promotes durable input.
|
||||||
- `SessionContext` resolves a read-only, internally consistent view of what the selected agent would see.
|
- `SessionContext` resolves a read-only, internally consistent view of what the selected agent would see.
|
||||||
- `SessionModelRequest` converts that view into the provider request used by a Physical Attempt.
|
- `SessionModelRequest` converts durable Step context into a provider request paired with tool capability.
|
||||||
|
- `SessionGenerate` owns the one tool-free transient request shape rather than threading generation through the durable modules.
|
||||||
- `LLMClient` executes one provider request without deciding what becomes durable.
|
- `LLMClient` executes one provider request without deciding what becomes durable.
|
||||||
- `SessionSettlement` gives streamed provider events their durable Session meaning, executes local tools, records usage, and decides continuation.
|
- `SessionSettlement` gives streamed provider events their durable Session meaning, executes local tools, records usage, and decides continuation.
|
||||||
- `SessionCompaction` replaces oversized active history and remains a durable Session operation.
|
- `SessionCompaction` replaces oversized active history and remains a durable Session operation.
|
||||||
@@ -63,22 +62,18 @@ Durability becomes a property of admission and settlement, not request preparati
|
|||||||
|
|
||||||
## The Core Seam
|
## The Core Seam
|
||||||
|
|
||||||
The first extraction should be one internal Location-scoped module with a small interface. Names are provisional; behavior is not.
|
The Location-scoped request module keeps its durable Step interface:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
interface SessionModelRequest {
|
interface SessionModelRequest {
|
||||||
readonly prepare: (input: {
|
readonly prepare: (input: { context: SessionContext.Loaded; step: number }) => Effect<PreparedSessionModelRequest>
|
||||||
snapshot: SessionContext.Snapshot
|
|
||||||
operation: { type: "step"; current: number; maximum?: number } | { type: "generate"; prompt: Message.User }
|
|
||||||
}) => Effect<PreparedSessionModelRequest, SessionModelRequestError>
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type PreparedSessionModelRequest = {
|
type PreparedSessionModelRequest = {
|
||||||
request: LLM.Request
|
request: LLM.Request
|
||||||
snapshot: SessionContext.Snapshot
|
resolveToolCall: (name: string) => ToolCallResolution
|
||||||
executableTools?: MaterializedTools
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -95,14 +90,7 @@ type PreparedSessionModelRequest = {
|
|||||||
- provider transforms and Session context hooks;
|
- provider transforms and Session context hooks;
|
||||||
- Session-based prompt-cache identity.
|
- Session-based prompt-cache identity.
|
||||||
|
|
||||||
The operation determines tool capability and request shape:
|
Durable `prepare` advertises permitted tools and returns the capability used by settlement, except when the agent's Step limit disables tools. `SessionGenerate` constructs its request with no tool definitions and tool choice `none`, independently of agent permissions. Avoid a generic `step | generate | compaction` operation union or independently selectable behavior flags; the two operations own their concrete request shapes.
|
||||||
|
|
||||||
- `step` advertises tools and returns the execution capability used by durable settlement, except when the agent's Step limit disables tools.
|
|
||||||
- `generate` advertises the same definitions but returns no execution capability.
|
|
||||||
|
|
||||||
`session.generate` keeps normal tool choice so providers retain the normal request and cache shape. Several protocols omit tool definitions entirely when `toolChoice` is `none`, so that setting cannot satisfy cache-shape parity. A generated tool call is collected but never executed or continued.
|
|
||||||
|
|
||||||
Avoid a broad bag of booleans or independently selectable tool modes. The operation discriminant derives valid tool materialization and request behavior. Admission, compaction, attempts, and settlement remain separate modules rather than modes hidden inside preparation.
|
|
||||||
|
|
||||||
## Read-Only Session Context
|
## Read-Only Session Context
|
||||||
|
|
||||||
@@ -117,17 +105,19 @@ commit the canonical model's instruction state durable
|
|||||||
|
|
||||||
Both a durable Step and `session.generate` resolve and assemble instructions. Only durable execution commits instruction-state changes. A transient request must not make the false durable claim that the canonical Session model saw an instruction update.
|
Both a durable Step and `session.generate` resolve and assemble instructions. Only durable execution commits instruction-state changes. A transient request must not make the false durable claim that the canonical Session model saw an instruction update.
|
||||||
|
|
||||||
The context snapshot should be loaded from one consistent database view and carry a revision, likely the latest aggregate or projected sequence used to assemble it. A concurrent durable Step may advance the Session after that point; the transient request continues against its immutable snapshot.
|
The active history and committed instruction state are loaded from one consistent database view. A concurrent durable Step may advance the Session afterward; the already prepared request remains immutable. No reusable snapshot or revision protocol is needed for this operation.
|
||||||
|
|
||||||
Pending inputs remain excluded. They are not Session History until promotion, and `session.generate` must not alter admission order or expose queued work early.
|
Pending inputs remain excluded. They are not Session History until promotion, and `session.generate` must not alter admission order or expose queued work early.
|
||||||
|
|
||||||
|
If the Session currently has an unsettled assistant message, generation stops history at that boundary. In particular, it never appends the transient user prompt after an unresolved tool call.
|
||||||
|
|
||||||
## One Physical Attempt Without Settlement
|
## One Physical Attempt Without Settlement
|
||||||
|
|
||||||
Use the existing `LLMClient` interface directly. The durable runner consumes `llm.stream(prepared.request)`, while `session.generate` calls `llm.generate(prepared.request)` to collect the same event stream into its existing `LLMResponse` model. No additional provider-attempt module is needed.
|
Use the existing `LLMClient` interface directly. The durable runner consumes `llm.stream(prepared.request)`, while `session.generate` calls `llm.generate(prepared.request)` to collect the same event stream into its existing `LLMResponse` model. No additional provider-attempt module is needed.
|
||||||
|
|
||||||
`LLMClient.generate` collects exactly one provider stream. It does not retry as a new logical Step, execute tools, continue after tool calls, publish Session events, capture filesystem snapshots, or update Session usage.
|
`LLMClient.generate` collects exactly one provider stream. It does not retry as a new logical Step, execute tools, continue after tool calls, publish Session events, capture filesystem snapshots, or update Session usage.
|
||||||
|
|
||||||
The initial public result exposes only `{ text }`. Keeping richer evidence internal avoids prematurely committing the public contract while allowing tests to verify tool-call and finish behavior.
|
The internal result is the collected text string. Keeping richer evidence internal avoids prematurely committing a public transport contract.
|
||||||
|
|
||||||
If a provider returns tool calls, collection records and ignores them. No tool hook or execution path runs. Assistant text, including an empty string, remains a successful result. Empty text is required for cache-warming calls.
|
If a provider returns tool calls, collection records and ignores them. No tool hook or execution path runs. Assistant text, including an empty string, remains a successful result. Empty text is required for cache-warming calls.
|
||||||
|
|
||||||
@@ -147,32 +137,22 @@ The first contract should state:
|
|||||||
|
|
||||||
The operation does not acquire ownership of the durable Session Drain and does not fail merely because the Session is running. This keeps transient generation independent from durable scheduling.
|
The operation does not acquire ownership of the durable Session Drain and does not fail merely because the Session is running. This keeps transient generation independent from durable scheduling.
|
||||||
|
|
||||||
The prepared result carries the captured revision internally. A later recap integration can suppress stale output when the Session advances while generation runs. Returning the revision publicly can follow if more consumers need compare-and-display behavior.
|
A later recap integration can suppress stale output by comparing the Session aggregate sequence captured by that caller. Core does not expose a generic revision protocol before a concrete consumer needs one.
|
||||||
|
|
||||||
## Hooks Follow The Stage They Affect
|
## Hooks Follow The Stage They Affect
|
||||||
|
|
||||||
The existing Session context hook should run because it participates in normal request preparation. Its event should eventually identify the operation:
|
The existing Session context hook runs because it participates in normal request preparation. Admission, projection, Session settlement, and tool-execution hooks do not run because those stages do not occur. Add operation metadata only when a concrete hook consumer needs it; do not introduce a speculative operation union.
|
||||||
|
|
||||||
```ts
|
|
||||||
type SessionModelRequestOperation = { type: "step"; step: number } | { type: "generate" } | { type: "compaction" }
|
|
||||||
```
|
|
||||||
|
|
||||||
Request preparation and observation hooks run for `session.generate`. Admission, projection, Session settlement, and tool-execution hooks do not run because those stages do not occur.
|
|
||||||
|
|
||||||
The no-mutation guarantee covers OpenCode's durable Session state. Arbitrary plugin hooks may still perform external side effects.
|
The no-mutation guarantee covers OpenCode's durable Session state. Arbitrary plugin hooks may still perform external side effects.
|
||||||
|
|
||||||
## Public Contract
|
## Internal Contract
|
||||||
|
|
||||||
Start with the smallest useful interface:
|
Start with the smallest useful interface:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type SessionGenerateInput = {
|
type SessionGenerateInput = {
|
||||||
sessionID: SessionID
|
sessionID: SessionID
|
||||||
text: string
|
prompt: string
|
||||||
}
|
|
||||||
|
|
||||||
type SessionGenerateOutput = {
|
|
||||||
text: string
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -180,9 +160,9 @@ The operation:
|
|||||||
|
|
||||||
1. Resolves the Session or returns the normal Session-not-found error.
|
1. Resolves the Session or returns the normal Session-not-found error.
|
||||||
2. Captures its latest committed active model context.
|
2. Captures its latest committed active model context.
|
||||||
3. Uses the selected agent, model, instructions, provider configuration, tools, transforms, hooks, and Session cache key.
|
3. Uses the selected agent, model, instructions, provider configuration, transforms, hooks, and Session cache key.
|
||||||
4. Appends `text` only to the in-memory provider request.
|
4. Appends `prompt` only to the in-memory provider request.
|
||||||
5. Executes exactly one Physical Attempt with normal tool definitions and no executable tool capability.
|
5. Executes exactly one Physical Attempt with tools disabled.
|
||||||
6. Returns collected assistant text, including an empty string.
|
6. Returns collected assistant text, including an empty string.
|
||||||
7. Does not admit input, publish Session events, execute tools, initiate compaction, update usage, or mutate Session projections.
|
7. Does not admit input, publish Session events, execute tools, initiate compaction, update usage, or mutate Session projections.
|
||||||
|
|
||||||
@@ -229,7 +209,7 @@ promote -> snapshot -> compact if required -> prepare -> attempt -> settle
|
|||||||
|
|
||||||
### 5. Add The Core `Session.generate` Operation
|
### 5. Add The Core `Session.generate` Operation
|
||||||
|
|
||||||
Use read-only snapshot and request preparation, append one transient user message, select advertise-only tools, collect one attempt, and return text. Add tests proving that messages, pending inputs, instruction state, Session events, snapshots, and usage remain unchanged.
|
Use read-only context and request preparation, append one transient user message, disable tools, collect one attempt, and return text. Add tests proving that messages, pending inputs, instruction state, Session events, and usage remain unchanged.
|
||||||
|
|
||||||
Test concurrent Session advancement with deterministic synchronization around request dispatch. The generated request should retain its captured context while the source Session advances independently.
|
Test concurrent Session advancement with deterministic synchronization around request dispatch. The generated request should retain its captured context while the source Session advances independently.
|
||||||
|
|
||||||
@@ -248,11 +228,11 @@ The implementation is complete when tests establish these laws:
|
|||||||
1. **Request equivalence:** the durable runner's prepared request remains equivalent before and after extraction.
|
1. **Request equivalence:** the durable runner's prepared request remains equivalent before and after extraction.
|
||||||
2. **Transcript immutability:** `session.generate` leaves Session messages and pending inputs unchanged.
|
2. **Transcript immutability:** `session.generate` leaves Session messages and pending inputs unchanged.
|
||||||
3. **Instruction immutability:** transient generation does not advance instruction state or publish instruction events.
|
3. **Instruction immutability:** transient generation does not advance instruction state or publish instruction events.
|
||||||
4. **Single attempt:** one call produces exactly one `llm.stream` invocation and no continuation.
|
4. **Single attempt:** one call produces exactly one `llm.generate` invocation and no continuation.
|
||||||
5. **No tool execution:** advertised tool calls never reach tool settlement or tool hooks.
|
5. **No tools:** transient requests advertise no tools and never reach tool settlement or tool hooks.
|
||||||
6. **Cache identity:** normal Steps and transient generation use the same Session-derived prompt-cache key.
|
6. **Cache identity:** normal Steps and transient generation use the same Session-derived prompt-cache key.
|
||||||
7. **Hook parity:** Session request hooks see and may transform transient requests through the same preparation seam.
|
7. **Hook parity:** Session request hooks see and may transform transient requests through the same preparation seam.
|
||||||
8. **Empty success:** a provider response with no assistant text returns `{ text: "" }`.
|
8. **Empty success:** a provider response with no assistant text returns `""`.
|
||||||
9. **Snapshot isolation:** concurrent Session advancement does not change an already prepared transient request.
|
9. **Snapshot isolation:** concurrent Session advancement does not change an already prepared transient request.
|
||||||
10. **No accounting mutation:** transient usage does not alter durable Session cost or token totals.
|
10. **No accounting mutation:** transient usage does not alter durable Session cost or token totals.
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Provider Policy Proposal
|
# Provider Policy
|
||||||
|
|
||||||
Status: **Proposed and unimplemented.** This document is design input, not current configuration or runtime behavior.
|
Status: **Implemented.**
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
This proposal would let policies control whether an operation on a named resource is allowed. Statements could be authored in configuration files while policy evaluation remained its own runtime concern.
|
Policies control whether an operation on a named resource is allowed. Statements are authored in configuration files and applied by a terminal catalog plugin.
|
||||||
|
|
||||||
The first policy consumer is provider availability:
|
The first policy consumer is provider availability:
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ interface PolicyInfo {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The proposed `Policy` module would own the shared `Policy.Info` interface, `Policy.Effect` type, and evaluator. Domains would define their supported typed statement schemas; for example, `Catalog.ProviderPolicy` would fix `action` to `"provider.use"`. The config schema could gather those domain-defined statement schemas into an `experimental.policies` union.
|
`ConfigPolicy` owns the statement schema. The policy plugin interprets the supported `provider.use` action after all other catalog transforms have run.
|
||||||
|
|
||||||
## Matching
|
## Matching
|
||||||
|
|
||||||
@@ -236,16 +236,16 @@ Provider policy applies regardless of how a provider becomes known or usable, in
|
|||||||
|
|
||||||
## Applying Provider Policy
|
## Applying Provider Policy
|
||||||
|
|
||||||
Provider records and model overrides should be assembled before checking provider policy. Otherwise later provider loading could recreate a provider that was already filtered.
|
Provider records and model overrides are assembled before checking provider policy. Otherwise later provider loading could recreate a provider that was already filtered.
|
||||||
|
|
||||||
Intended flow:
|
Flow:
|
||||||
|
|
||||||
1. Build provider/model catalog entries.
|
1. Build provider/model catalog entries.
|
||||||
2. Apply configured provider and model overrides.
|
2. Apply configured provider and model overrides.
|
||||||
3. Ask `Policy.Service` to evaluate `provider.use` for each provider ID.
|
3. Run the terminal config policy transform.
|
||||||
4. Prevent denied providers from being selectable or used.
|
4. Remove providers denied by the final matching `provider.use` statement.
|
||||||
|
|
||||||
Whether denied providers are removed entirely or retained as disabled records for diagnostics remains an implementation decision.
|
Config reload refreshes the plugin's policy snapshot and rebuilds the catalog.
|
||||||
|
|
||||||
## Legacy Migration
|
## Legacy Migration
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user