feat(tui): add v2 terminal interface
This commit is contained in:
parent
0e2dd4ad15
commit
e6f660fecf
73 changed files with 3028 additions and 2035 deletions
|
|
@ -102,6 +102,7 @@
|
|||
"ai-gateway-provider": "3.1.2",
|
||||
"bun-pty": "0.4.8",
|
||||
"cross-spawn": "catalog:",
|
||||
"diff": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
export * as PublicEventManifest from "./public-event-manifest"
|
||||
|
||||
export { ServerDefinitions as Definitions } from "@opencode-ai/schema/event-manifest"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
|
||||
export const Definitions = EventManifest.ServerDefinitions
|
||||
export const Latest = Event.latest(Definitions)
|
||||
|
|
|
|||
|
|
@ -390,7 +390,13 @@ export const layer = Layer.unwrap(
|
|||
})
|
||||
}),
|
||||
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
const session = yield* result.get(input.sessionID)
|
||||
if (
|
||||
session.model?.providerID === input.model.providerID &&
|
||||
session.model.id === input.model.id &&
|
||||
(session.model.variant ?? "default") === (input.model.variant ?? "default")
|
||||
)
|
||||
return
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
|
|
|
|||
|
|
@ -349,6 +349,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
id: event.data.reasoningID,
|
||||
text: "",
|
||||
providerMetadata: event.data.providerMetadata,
|
||||
time: { created: event.data.timestamp },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -365,6 +366,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
const match = latestReasoning(draft, event.data.reasoningID)
|
||||
if (match) {
|
||||
match.text = event.data.text
|
||||
match.time = { created: match.time?.created ?? event.data.timestamp, completed: event.data.timestamp }
|
||||
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ export const fromCatalogModel = (
|
|||
credential?: Credential.Value,
|
||||
): Effect.Effect<Model, UnsupportedApiError> => {
|
||||
const resolved =
|
||||
credential?.metadata === undefined
|
||||
credential?.type !== "key" || credential.metadata === undefined
|
||||
? model
|
||||
: produce(model, (draft) => {
|
||||
Object.assign(draft.request.body, credential.metadata)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
export * as ApplyPatchTool from "./apply-patch"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { FSUtil } from "../fs-util"
|
||||
|
|
@ -24,7 +26,10 @@ export const Applied = Schema.Struct({
|
|||
target: Schema.String,
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({ applied: Schema.Array(Applied) })
|
||||
export const Output = Schema.Struct({
|
||||
applied: Schema.Array(Applied),
|
||||
files: Schema.Array(FileDiff.Info),
|
||||
})
|
||||
export type Output = typeof Output.Type
|
||||
|
||||
export const toModelOutput = (output: Output) =>
|
||||
|
|
@ -36,11 +41,17 @@ export const toModelOutput = (output: Output) =>
|
|||
].join("\n")
|
||||
|
||||
type Prepared =
|
||||
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & { readonly target: LocationMutation.Target })
|
||||
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & {
|
||||
readonly target: LocationMutation.Target
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
})
|
||||
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
|
||||
readonly target: LocationMutation.Target
|
||||
readonly source: Uint8Array
|
||||
readonly content: string
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
})
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
|
|
@ -113,29 +124,36 @@ export const layer = Layer.effectDiscard(
|
|||
for (const { hunk, target } of targets) {
|
||||
yield* Effect.gen(function* () {
|
||||
if (hunk.type === "add") {
|
||||
prepared.push({ ...hunk, target })
|
||||
prepared.push({
|
||||
...hunk,
|
||||
target,
|
||||
before: "",
|
||||
after:
|
||||
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
|
||||
})
|
||||
return
|
||||
}
|
||||
if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path)
|
||||
const source = yield* fs.readFile(target.canonical)
|
||||
const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(source)
|
||||
const before = original.replace(/^\uFEFF/, "")
|
||||
if (hunk.type === "delete") {
|
||||
prepared.push({ ...hunk, target })
|
||||
prepared.push({ ...hunk, target, before, after: "" })
|
||||
return
|
||||
}
|
||||
const source = yield* fs.readFile(target.canonical)
|
||||
const update = Patch.derive(
|
||||
hunk.path,
|
||||
hunk.chunks,
|
||||
new TextDecoder("utf-8", { ignoreBOM: true }).decode(source),
|
||||
)
|
||||
const update = Patch.derive(hunk.path, hunk.chunks, original)
|
||||
prepared.push({
|
||||
...hunk,
|
||||
target,
|
||||
source,
|
||||
content: Patch.joinBom(update.content, update.bom),
|
||||
before,
|
||||
after: update.content,
|
||||
})
|
||||
}).pipe(Effect.mapError(() => fail(hunk.path)))
|
||||
}
|
||||
|
||||
const patchFiles = prepared.map(patchFile)
|
||||
yield* Effect.forEach(
|
||||
prepared,
|
||||
(change) =>
|
||||
|
|
@ -165,7 +183,7 @@ export const layer = Layer.effectDiscard(
|
|||
}).pipe(Effect.mapError(() => fail(change.path))),
|
||||
{ discard: true },
|
||||
)
|
||||
return { applied }
|
||||
return { applied, files: patchFiles }
|
||||
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
|
||||
},
|
||||
}),
|
||||
|
|
@ -175,3 +193,19 @@ export const layer = Layer.effectDiscard(
|
|||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
|
||||
const counts = diffLines(change.before, change.after).reduce(
|
||||
(result, item) => ({
|
||||
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
|
||||
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
|
||||
}),
|
||||
{ additions: 0, deletions: 0 },
|
||||
)
|
||||
return {
|
||||
file: change.target.resource,
|
||||
patch: createTwoFilesPatch(change.target.resource, change.target.resource, change.before, change.after),
|
||||
status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified",
|
||||
...counts,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
export * as EditTool from "./edit"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { FSUtil } from "../fs-util"
|
||||
|
|
@ -30,10 +32,7 @@ export const Input = Schema.Struct({
|
|||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
operation: Schema.Literal("write"),
|
||||
target: Schema.String,
|
||||
resource: Schema.String,
|
||||
existed: Schema.Boolean,
|
||||
files: Schema.Array(FileDiff.Info),
|
||||
replacements: Schema.Number,
|
||||
})
|
||||
export type Output = typeof Output.Type
|
||||
|
|
@ -71,7 +70,7 @@ const previewLines = (value: string, prefix: "+" | "-") => {
|
|||
|
||||
export const toModelOutput = (output: Output, oldString: string, newString: string) =>
|
||||
[
|
||||
`Edited file successfully: ${output.resource}`,
|
||||
`Edited file successfully: ${output.files[0]?.file}`,
|
||||
`Replacements: ${output.replacements}`,
|
||||
"```diff",
|
||||
...previewLines(oldString, "-"),
|
||||
|
|
@ -179,6 +178,13 @@ export const layer = Layer.effectDiscard(
|
|||
input.replaceAll === true
|
||||
? source.text.replaceAll(oldString, newString)
|
||||
: source.text.replace(oldString, newString)
|
||||
const counts = diffLines(source.text, replaced).reduce(
|
||||
(result, item) => ({
|
||||
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
|
||||
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
|
||||
}),
|
||||
{ additions: 0, deletions: 0 },
|
||||
)
|
||||
const next = splitBom(replaced)
|
||||
const result = yield* unableToEdit(
|
||||
files.writeIfUnchanged({
|
||||
|
|
@ -187,7 +193,17 @@ export const layer = Layer.effectDiscard(
|
|||
content: joinBom(next.text, source.bom || next.bom),
|
||||
}),
|
||||
)
|
||||
return { ...result, replacements } satisfies Output
|
||||
return {
|
||||
files: [
|
||||
{
|
||||
file: result.resource,
|
||||
patch: createTwoFilesPatch(result.resource, result.resource, source.text, replaced),
|
||||
status: "modified" as const,
|
||||
...counts,
|
||||
},
|
||||
],
|
||||
replacements,
|
||||
} satisfies Output
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -42,6 +42,15 @@ const openai: Lowerer = {
|
|||
},
|
||||
request(options) {
|
||||
const result = snake(options)
|
||||
if (options.reasoningEffort !== undefined || options.reasoningSummary !== undefined) {
|
||||
result.reasoning = {
|
||||
...(isRecord(result.reasoning) ? result.reasoning : {}),
|
||||
...(options.reasoningEffort !== undefined ? { effort: options.reasoningEffort } : {}),
|
||||
...(options.reasoningSummary !== undefined ? { summary: options.reasoningSummary } : {}),
|
||||
}
|
||||
delete result.reasoning_effort
|
||||
delete result.reasoning_summary
|
||||
}
|
||||
if (options.textVerbosity !== undefined) {
|
||||
result.text = { ...(isRecord(result.text) ? result.text : {}), verbosity: options.textVerbosity }
|
||||
delete result.text_verbosity
|
||||
|
|
|
|||
|
|
@ -601,9 +601,9 @@ describe("Config", () => {
|
|||
models: {
|
||||
model: {
|
||||
request: {
|
||||
body: { temperature: 0.3, reasoning_effort: "high", service_tier: "priority" },
|
||||
body: { temperature: 0.3, reasoning: { effort: "high" }, service_tier: "priority" },
|
||||
},
|
||||
variants: [{ id: "high", body: { reasoning_effort: "high", reasoning_summary: "auto" } }],
|
||||
variants: [{ id: "high", body: { reasoning: { effort: "high", summary: "auto" } } }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -42,12 +42,14 @@ describe("ConfigProviderOptionsV1", () => {
|
|||
expect(
|
||||
lowerer.request({
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
reasoning: { encryptedContent: true },
|
||||
textVerbosity: "low",
|
||||
text: { outputFormat: "plain" },
|
||||
nestedValue: { camelCase: true },
|
||||
}),
|
||||
).toEqual({
|
||||
reasoning_effort: "high",
|
||||
reasoning: { encrypted_content: true, effort: "high", summary: "auto" },
|
||||
text: { output_format: "plain", verbosity: "low" },
|
||||
nested_value: { camel_case: true },
|
||||
})
|
||||
|
|
@ -138,8 +140,8 @@ describe("ConfigProviderOptionsV1", () => {
|
|||
body: { trace: true },
|
||||
settings: { resourceName: "resource" },
|
||||
})
|
||||
expect(lowerer.request({ reasoningEffort: "high", textVerbosity: "low" })).toEqual({
|
||||
reasoning_effort: "high",
|
||||
expect(lowerer.request({ reasoningEffort: "high", reasoningSummary: "auto", textVerbosity: "low" })).toEqual({
|
||||
reasoning: { effort: "high", summary: "auto" },
|
||||
text: { verbosity: "low" },
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -377,7 +377,7 @@ describe("SessionV2.create", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("persists repeated switches as distinct durable Session events", () =>
|
||||
it.effect("ignores a model switch when the selected model is unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
|
|
@ -389,11 +389,29 @@ describe("SessionV2.create", () => {
|
|||
const { db } = yield* Database.Service
|
||||
expect(
|
||||
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
|
||||
).toHaveLength(3)
|
||||
).toHaveLength(2)
|
||||
expect(yield* session.get(created.id)).toMatchObject({ model })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats an omitted variant as the default variant", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic })
|
||||
const created = yield* session.create({ location, model })
|
||||
|
||||
yield* session.switchModel({
|
||||
sessionID: created.id,
|
||||
model: ModelV2.Ref.make({ ...model, variant: ModelV2.VariantID.make("default") }),
|
||||
})
|
||||
|
||||
const { db } = yield* Database.Service
|
||||
expect(
|
||||
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
|
||||
).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a model switch for a missing Session", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { LLMClient } from "@opencode-ai/llm/route"
|
|||
import { DateTime, Effect } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
|
|
@ -291,6 +292,27 @@ describe("SessionRunnerModel", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("does not project OAuth account metadata into the request body", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
ModelV2.Info.make({
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { headers: {}, body: {} },
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
access: "secret",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
metadata: { server: "https://console.example", orgID: "org_123" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(resolved.route.defaults.http?.body).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects catalog APIs without a native route", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* SessionRunnerModel.fromCatalogModel(
|
||||
|
|
|
|||
|
|
@ -149,6 +149,29 @@ describe("ApplyPatchTool", () => {
|
|||
{ type: "update", resource: "update.txt" },
|
||||
{ type: "delete", resource: "remove.txt" },
|
||||
],
|
||||
files: [
|
||||
{
|
||||
file: "nested/new.txt",
|
||||
status: "added",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
patch: expect.stringContaining("+created"),
|
||||
},
|
||||
{
|
||||
file: "update.txt",
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: expect.stringContaining("-before\n+after"),
|
||||
},
|
||||
{
|
||||
file: "remove.txt",
|
||||
status: "deleted",
|
||||
additions: 0,
|
||||
deletions: 1,
|
||||
patch: expect.stringContaining("-remove"),
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(assertions).toMatchObject([
|
||||
{ sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] },
|
||||
|
|
|
|||
|
|
@ -125,11 +125,16 @@ describe("EditTool", () => {
|
|||
value: "Edited file successfully: hello.txt\nReplacements: 1\n```diff\n-before\n+after\n```",
|
||||
})
|
||||
expect(settled.output?.structured).toEqual({
|
||||
operation: "write",
|
||||
target: yield* Effect.promise(() => fs.realpath(target)),
|
||||
resource: "hello.txt",
|
||||
existed: true,
|
||||
replacements: 1,
|
||||
files: [
|
||||
{
|
||||
file: "hello.txt",
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: expect.stringContaining("-before\n+after"),
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue