chore: merge dev into websocket support
This commit is contained in:
@@ -4,10 +4,14 @@ import {
|
||||
type AgentSideConnection,
|
||||
type AuthenticateRequest,
|
||||
type CancelNotification,
|
||||
type CloseSessionRequest,
|
||||
type ForkSessionRequest,
|
||||
type InitializeRequest,
|
||||
type ListSessionsRequest,
|
||||
type LoadSessionRequest,
|
||||
type NewSessionRequest,
|
||||
type PromptRequest,
|
||||
type ResumeSessionRequest,
|
||||
type SetSessionConfigOptionRequest,
|
||||
type SetSessionModelRequest,
|
||||
type SetSessionModeRequest,
|
||||
@@ -44,6 +48,22 @@ export class Agent implements ACPAgent {
|
||||
return run(this.service.loadSession(params))
|
||||
}
|
||||
|
||||
listSessions(params: ListSessionsRequest) {
|
||||
return run(this.service.listSessions(params))
|
||||
}
|
||||
|
||||
resumeSession(params: ResumeSessionRequest) {
|
||||
return run(this.service.resumeSession(params))
|
||||
}
|
||||
|
||||
closeSession(params: CloseSessionRequest) {
|
||||
return run(this.service.closeSession(params))
|
||||
}
|
||||
|
||||
unstable_forkSession(params: ForkSessionRequest) {
|
||||
return run(this.service.forkSession(params))
|
||||
}
|
||||
|
||||
setSessionConfigOption(params: SetSessionConfigOptionRequest) {
|
||||
return run(this.service.setSessionConfigOption(params))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import type {
|
||||
Event,
|
||||
EventMessagePartDelta,
|
||||
EventMessagePartUpdated,
|
||||
OpencodeClient,
|
||||
Part,
|
||||
SessionMessageResponse,
|
||||
ToolPart,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { Effect } from "effect"
|
||||
import { ACPNextSession } from "./session"
|
||||
import {
|
||||
duplicateRunningToolUpdate,
|
||||
errorToolUpdate,
|
||||
pendingToolCall,
|
||||
runningToolUpdate,
|
||||
shellOutputSnapshot,
|
||||
completedToolUpdate,
|
||||
} from "./tool"
|
||||
|
||||
const log = Log.create({ service: "acp-next-event" })
|
||||
|
||||
type Connection = Pick<AgentSideConnection, "sessionUpdate">
|
||||
type GlobalEventEnvelope = {
|
||||
payload?: Event
|
||||
}
|
||||
type GlobalEventStream = {
|
||||
stream: AsyncIterable<GlobalEventEnvelope>
|
||||
}
|
||||
|
||||
export function start(input: { sdk: OpencodeClient; connection: Connection; session: ACPNextSession.Interface }) {
|
||||
const subscription = new Subscription(input)
|
||||
subscription.start()
|
||||
return subscription
|
||||
}
|
||||
|
||||
export class Subscription {
|
||||
private readonly abort = new AbortController()
|
||||
private readonly shellSnapshots = new Map<string, string>()
|
||||
private readonly toolStarts = new Set<string>()
|
||||
private started = false
|
||||
|
||||
constructor(
|
||||
private readonly input: {
|
||||
sdk: OpencodeClient
|
||||
connection: Connection
|
||||
session: ACPNextSession.Interface
|
||||
},
|
||||
) {}
|
||||
|
||||
start() {
|
||||
if (this.started) return
|
||||
this.started = true
|
||||
this.run().catch((error: unknown) => {
|
||||
if (this.abort.signal.aborted) return
|
||||
log.error("event subscription failed", { error })
|
||||
})
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.abort.abort()
|
||||
}
|
||||
|
||||
async handle(event: Event) {
|
||||
switch (event.type) {
|
||||
case "message.part.updated":
|
||||
return this.handlePartUpdated(event)
|
||||
case "message.part.delta":
|
||||
return this.handlePartDelta(event)
|
||||
}
|
||||
}
|
||||
|
||||
async replayMessage(message: SessionMessageResponse) {
|
||||
if (message.info.role !== "assistant" && message.info.role !== "user") return
|
||||
|
||||
for (const part of message.parts) {
|
||||
await this.recordFetchedPart(message.info.sessionID, message, part)
|
||||
if (part.type === "tool") {
|
||||
await this.handleToolPart(message.info.sessionID, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async run() {
|
||||
while (!this.abort.signal.aborted) {
|
||||
const events = (await this.input.sdk.global.event({
|
||||
signal: this.abort.signal,
|
||||
})) as GlobalEventStream
|
||||
|
||||
for await (const event of events.stream) {
|
||||
if (this.abort.signal.aborted) return
|
||||
if (!event.payload) continue
|
||||
await this.handle(event.payload).catch((error: unknown) => {
|
||||
log.error("failed to handle event", { error, type: event.payload?.type })
|
||||
})
|
||||
}
|
||||
if (!this.abort.signal.aborted) await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePartUpdated(event: EventMessagePartUpdated) {
|
||||
const part = event.properties.part
|
||||
const sessionId = part.sessionID || event.properties.sessionID
|
||||
const session = await Effect.runPromise(this.input.session.tryGet(sessionId))
|
||||
if (!session) return
|
||||
|
||||
await Effect.runPromise(
|
||||
this.input.session.recordPartMetadata({
|
||||
sessionId: session.id,
|
||||
messageId: part.messageID,
|
||||
partId: part.id,
|
||||
partType: part.type,
|
||||
role: part.type === "reasoning" ? "assistant" : undefined,
|
||||
ignored: part.type === "text" ? part.ignored : undefined,
|
||||
toolCallId: part.type === "tool" ? part.callID : undefined,
|
||||
metadata: "metadata" in part ? part.metadata : undefined,
|
||||
}),
|
||||
)
|
||||
if (part.type === "tool") {
|
||||
await this.handleToolPart(session.id, part)
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePartDelta(event: EventMessagePartDelta) {
|
||||
const props = event.properties
|
||||
const session = await Effect.runPromise(this.input.session.tryGet(props.sessionID))
|
||||
if (!session) return
|
||||
|
||||
const known = await Effect.runPromise(
|
||||
this.input.session.tryGetPartMetadata({
|
||||
sessionId: session.id,
|
||||
messageId: props.messageID,
|
||||
partId: props.partID,
|
||||
}),
|
||||
)
|
||||
const metadata =
|
||||
known?.role && known.partType
|
||||
? known
|
||||
: await this.fetchPartMetadata(session.id, session.cwd, props.messageID, props.partID)
|
||||
if (metadata?.role !== "assistant") return
|
||||
if (metadata.partType === "text" && props.field === "text" && metadata.ignored !== true) {
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId: session.id,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: props.messageID,
|
||||
content: {
|
||||
type: "text",
|
||||
text: props.delta,
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (metadata.partType === "reasoning" && props.field === "text") {
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId: session.id,
|
||||
update: {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: props.messageID,
|
||||
content: {
|
||||
type: "text",
|
||||
text: props.delta,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchPartMetadata(sessionId: string, cwd: string, messageId: string, partId: string) {
|
||||
const message = await this.input.sdk.session
|
||||
.message(
|
||||
{
|
||||
sessionID: sessionId,
|
||||
messageID: messageId,
|
||||
directory: cwd,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then((response) => response.data)
|
||||
.catch((error: unknown) => {
|
||||
log.error("unexpected error when fetching message for delta metadata", { error, messageId, partId })
|
||||
return undefined
|
||||
})
|
||||
if (!message) return
|
||||
|
||||
const part = message.parts.find((item) => item.id === partId)
|
||||
if (!part) return
|
||||
return await this.recordFetchedPart(sessionId, message, part)
|
||||
}
|
||||
|
||||
private async recordFetchedPart(sessionId: string, message: SessionMessageResponse, part: Part) {
|
||||
return await Effect.runPromise(
|
||||
this.input.session.recordPartMetadata({
|
||||
sessionId,
|
||||
messageId: part.messageID,
|
||||
partId: part.id,
|
||||
partType: part.type,
|
||||
role: message.info.role,
|
||||
ignored: part.type === "text" ? part.ignored : undefined,
|
||||
toolCallId: part.type === "tool" ? part.callID : undefined,
|
||||
metadata: "metadata" in part ? part.metadata : undefined,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
private async handleToolPart(sessionId: string, part: ToolPart) {
|
||||
await this.toolStart(sessionId, part)
|
||||
|
||||
switch (part.state.status) {
|
||||
case "pending":
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
return
|
||||
|
||||
case "running":
|
||||
await this.runningTool(sessionId, part)
|
||||
return
|
||||
|
||||
case "completed":
|
||||
this.clearTool(part.callID)
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...completedToolUpdate({
|
||||
toolCallId: part.callID,
|
||||
toolName: part.tool,
|
||||
state: part.state,
|
||||
}),
|
||||
},
|
||||
})
|
||||
return
|
||||
|
||||
case "error":
|
||||
this.clearTool(part.callID)
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: part.callID,
|
||||
toolName: part.tool,
|
||||
state: part.state,
|
||||
}),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private async runningTool(sessionId: string, part: ToolPart) {
|
||||
if (part.state.status !== "running") return
|
||||
|
||||
const output = part.tool === "bash" ? shellOutputSnapshot(part.state) : undefined
|
||||
if (output !== undefined) {
|
||||
if (this.shellSnapshots.get(part.callID) === output) {
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...duplicateRunningToolUpdate({
|
||||
toolCallId: part.callID,
|
||||
toolName: part.tool,
|
||||
state: part.state,
|
||||
}),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
this.shellSnapshots.set(part.callID, output)
|
||||
}
|
||||
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: part.callID,
|
||||
toolName: part.tool,
|
||||
state: part.state,
|
||||
output,
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private async toolStart(sessionId: string, part: ToolPart) {
|
||||
if (this.toolStarts.has(part.callID)) return
|
||||
this.toolStarts.add(part.callID)
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
...pendingToolCall({
|
||||
toolCallId: part.callID,
|
||||
toolName: part.tool,
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private clearTool(toolCallId: string) {
|
||||
this.toolStarts.delete(toolCallId)
|
||||
this.shellSnapshots.delete(toolCallId)
|
||||
}
|
||||
}
|
||||
|
||||
export * as ACPNextEvent from "./event"
|
||||
@@ -4,8 +4,14 @@ import {
|
||||
type AuthenticateResponse,
|
||||
type AuthMethod,
|
||||
type CancelNotification,
|
||||
type CloseSessionRequest,
|
||||
type CloseSessionResponse,
|
||||
type ForkSessionRequest,
|
||||
type ForkSessionResponse,
|
||||
type InitializeRequest,
|
||||
type InitializeResponse,
|
||||
type ListSessionsRequest,
|
||||
type ListSessionsResponse,
|
||||
type LoadSessionRequest,
|
||||
type LoadSessionResponse,
|
||||
type McpServer,
|
||||
@@ -13,6 +19,9 @@ import {
|
||||
type NewSessionResponse,
|
||||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
type ResumeSessionRequest,
|
||||
type ResumeSessionResponse,
|
||||
type SessionInfo,
|
||||
type SetSessionConfigOptionRequest,
|
||||
type SetSessionConfigOptionResponse,
|
||||
type SetSessionModelRequest,
|
||||
@@ -21,17 +30,20 @@ import {
|
||||
type SetSessionModeResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
|
||||
import { Context, Effect, Layer, ManagedRuntime } from "effect"
|
||||
import * as ACPNextError from "./error"
|
||||
import { buildConfigOptions, parseModelSelection } from "./config-option"
|
||||
import { Directory } from "./directory"
|
||||
import { ACPNextEvent } from "./event"
|
||||
import { ACPNextSession } from "./session"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import type { Command } from "@/command"
|
||||
|
||||
export const AuthMethodID = "opencode-login"
|
||||
const log = Log.create({ service: "acp-next-service" })
|
||||
|
||||
export type Error = ACPNextError.Error
|
||||
|
||||
@@ -40,6 +52,10 @@ export type Interface = {
|
||||
readonly authenticate: (input: AuthenticateRequest) => Effect.Effect<AuthenticateResponse, Error>
|
||||
readonly newSession: (input: NewSessionRequest) => Effect.Effect<NewSessionResponse, Error>
|
||||
readonly loadSession: (input: LoadSessionRequest) => Effect.Effect<LoadSessionResponse, Error>
|
||||
readonly listSessions: (input: ListSessionsRequest) => Effect.Effect<ListSessionsResponse, Error>
|
||||
readonly resumeSession: (input: ResumeSessionRequest) => Effect.Effect<ResumeSessionResponse, Error>
|
||||
readonly closeSession: (input: CloseSessionRequest) => Effect.Effect<CloseSessionResponse, Error>
|
||||
readonly forkSession: (input: ForkSessionRequest) => Effect.Effect<ForkSessionResponse, Error>
|
||||
readonly setSessionConfigOption: (
|
||||
input: SetSessionConfigOptionRequest,
|
||||
) => Effect.Effect<SetSessionConfigOptionResponse, Error>
|
||||
@@ -56,10 +72,15 @@ export function make(input: {
|
||||
connection?: Pick<AgentSideConnection, "sessionUpdate">
|
||||
directory?: Directory.Interface
|
||||
session?: ACPNextSession.Interface
|
||||
eventSubscription?: (subscription: ACPNextEvent.Subscription) => void
|
||||
}): Interface {
|
||||
const session = input.session ?? makeSessionService()
|
||||
const directoryService = input.directory ?? makeDirectoryService(input.sdk)
|
||||
const registeredMcp = new Map<string, Set<string>>()
|
||||
const events = input.connection
|
||||
? ACPNextEvent.start({ sdk: input.sdk, connection: input.connection, session })
|
||||
: undefined
|
||||
if (events) input.eventSubscription?.(events)
|
||||
|
||||
const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) {
|
||||
const authMethod: AuthMethod = {
|
||||
@@ -90,6 +111,12 @@ export function make(input: {
|
||||
embeddedContext: true,
|
||||
image: true,
|
||||
},
|
||||
sessionCapabilities: {
|
||||
close: {},
|
||||
fork: {},
|
||||
list: {},
|
||||
resume: {},
|
||||
},
|
||||
},
|
||||
authMethods: [authMethod],
|
||||
agentInfo: {
|
||||
@@ -180,6 +207,137 @@ export function make(input: {
|
||||
|
||||
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers)
|
||||
yield* sendAvailableCommands(input.connection, state.id, snapshot)
|
||||
yield* replayMessages(events, messages)
|
||||
|
||||
return {
|
||||
configOptions: configOptions(snapshot, {
|
||||
model: state.model ?? model,
|
||||
variant: state.variant,
|
||||
modeId: state.modeId,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const listSessions = Effect.fn("ACPNext.listSessions")(function* (params: ListSessionsRequest) {
|
||||
const cursor = params.cursor ? Number(params.cursor) : undefined
|
||||
const limit = 100
|
||||
const sessions = yield* request(
|
||||
() =>
|
||||
input.sdk.session.list(
|
||||
{
|
||||
...(params.cwd ? { directory: params.cwd } : {}),
|
||||
roots: true,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
),
|
||||
"session",
|
||||
)
|
||||
const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
const filtered =
|
||||
cursor === undefined || !Number.isFinite(cursor) ? sorted : sorted.filter((item) => item.time.updated < cursor)
|
||||
const page = filtered.slice(0, limit)
|
||||
const last = page.at(-1)
|
||||
return {
|
||||
sessions: page.map(
|
||||
(item): SessionInfo => ({
|
||||
sessionId: item.id,
|
||||
cwd: item.directory,
|
||||
title: item.title,
|
||||
updatedAt: new Date(item.time.updated).toISOString(),
|
||||
}),
|
||||
),
|
||||
...(filtered.length > limit && last ? { nextCursor: String(last.time.updated) } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
const resumeSession = Effect.fn("ACPNext.resumeSession")(function* (params: ResumeSessionRequest) {
|
||||
const snapshot = yield* directorySnapshot(params.cwd)
|
||||
yield* request(
|
||||
() => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }),
|
||||
"session",
|
||||
)
|
||||
const messages = yield* request(
|
||||
() =>
|
||||
input.sdk.session.messages(
|
||||
{ directory: params.cwd, sessionID: params.sessionId, limit: 20 },
|
||||
{ throwOnError: true },
|
||||
),
|
||||
"session",
|
||||
)
|
||||
const restored = restoreFromMessages(messages.map((item) => item.info))
|
||||
const model = restored.model ?? selectDefaultModel(snapshot)
|
||||
const state = yield* session.load({
|
||||
id: params.sessionId,
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers ?? [],
|
||||
model,
|
||||
variant: restored.variant ?? selectVariant(snapshot, model),
|
||||
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
|
||||
})
|
||||
|
||||
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
|
||||
yield* sendAvailableCommands(input.connection, state.id, snapshot)
|
||||
yield* replayMessages(events, messages)
|
||||
|
||||
return {
|
||||
configOptions: configOptions(snapshot, {
|
||||
model: state.model ?? model,
|
||||
variant: state.variant,
|
||||
modeId: state.modeId,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const closeSession = Effect.fn("ACPNext.closeSession")(function* (params: CloseSessionRequest) {
|
||||
const removed = yield* session.remove(params.sessionId)
|
||||
registeredMcp.delete(params.sessionId)
|
||||
if (!removed) return {}
|
||||
|
||||
yield* request(
|
||||
() => input.sdk.session.abort({ directory: removed.cwd, sessionID: params.sessionId }, { throwOnError: true }),
|
||||
"session",
|
||||
).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId })
|
||||
}),
|
||||
),
|
||||
)
|
||||
return {}
|
||||
})
|
||||
|
||||
const forkSession = Effect.fn("ACPNext.forkSession")(function* (params: ForkSessionRequest) {
|
||||
const snapshot = yield* directorySnapshot(params.cwd)
|
||||
const forked = yield* request(
|
||||
() =>
|
||||
input.sdk.session.fork(
|
||||
{
|
||||
directory: params.cwd,
|
||||
sessionID: params.sessionId,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
),
|
||||
"session",
|
||||
)
|
||||
const messages = yield* request(
|
||||
() =>
|
||||
input.sdk.session.messages({ directory: params.cwd, sessionID: forked.id, limit: 20 }, { throwOnError: true }),
|
||||
"session",
|
||||
)
|
||||
const restored = restoreFromMessages(messages.map((item) => item.info))
|
||||
const model = restored.model ?? selectDefaultModel(snapshot)
|
||||
const state = yield* session.load({
|
||||
id: forked.id,
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers ?? [],
|
||||
model,
|
||||
variant: restored.variant ?? selectVariant(snapshot, model),
|
||||
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
|
||||
})
|
||||
|
||||
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
|
||||
yield* sendAvailableCommands(input.connection, state.id, snapshot)
|
||||
yield* replayMessages(events, messages)
|
||||
|
||||
return {
|
||||
sessionId: state.id,
|
||||
@@ -278,6 +436,10 @@ export function make(input: {
|
||||
authenticate,
|
||||
newSession,
|
||||
loadSession,
|
||||
listSessions,
|
||||
resumeSession,
|
||||
closeSession,
|
||||
forkSession,
|
||||
setSessionConfigOption,
|
||||
setSessionMode,
|
||||
setSessionModel,
|
||||
@@ -311,6 +473,17 @@ function makeDirectoryService(sdk: OpencodeClient) {
|
||||
).runSync(Directory.Service.use((service) => Effect.succeed(service)))
|
||||
}
|
||||
|
||||
function replayMessages(subscription: ACPNextEvent.Subscription | undefined, messages: SessionMessageResponse[]) {
|
||||
if (!subscription) return Effect.void
|
||||
return Effect.promise(async () => {
|
||||
for (const message of messages) {
|
||||
await subscription.replayMessage(message).catch((error: unknown) => {
|
||||
log.error("failed to replay ACP message", { error, messageID: message.info.id })
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type ConfigState = {
|
||||
readonly model: Directory.DefaultModel
|
||||
readonly variant?: string
|
||||
@@ -323,17 +496,13 @@ type SdkResponse<T> = {
|
||||
}
|
||||
|
||||
type MessageInfo = {
|
||||
readonly role?: string
|
||||
readonly model?: {
|
||||
readonly providerID?: string
|
||||
readonly modelID?: string
|
||||
readonly variant?: string
|
||||
}
|
||||
readonly providerID?: string
|
||||
readonly modelID?: string
|
||||
readonly variant?: string
|
||||
readonly mode?: string
|
||||
readonly agent?: string
|
||||
readonly role?: Message["role"]
|
||||
readonly model?: Extract<Message, { role: "user" }>["model"]
|
||||
readonly providerID?: Extract<Message, { role: "assistant" }>["providerID"]
|
||||
readonly modelID?: Extract<Message, { role: "assistant" }>["modelID"]
|
||||
readonly variant?: Extract<Message, { role: "assistant" }>["variant"]
|
||||
readonly mode?: Extract<Message, { role: "assistant" }>["mode"]
|
||||
readonly agent?: Message["agent"]
|
||||
}
|
||||
|
||||
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { McpServer } from "@agentclientprotocol/sdk"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2"
|
||||
import { Context, Effect, Layer, Ref } from "effect"
|
||||
import type { ModelID, ProviderID } from "../provider/schema"
|
||||
import * as ACPNextError from "./error"
|
||||
@@ -11,6 +12,9 @@ export type SelectedModel = {
|
||||
export type KnownMessagePartMetadata = {
|
||||
messageId: string
|
||||
partId: string
|
||||
partType?: Part["type"]
|
||||
role?: Message["role"]
|
||||
ignored?: boolean
|
||||
toolCallId?: string
|
||||
metadata?: unknown
|
||||
}
|
||||
@@ -40,6 +44,9 @@ export type RecordPartMetadataInput = {
|
||||
sessionId: string
|
||||
messageId: string
|
||||
partId: string
|
||||
partType?: Part["type"]
|
||||
role?: Message["role"]
|
||||
ignored?: boolean
|
||||
toolCallId?: string
|
||||
metadata?: unknown
|
||||
}
|
||||
@@ -146,6 +153,9 @@ export const layer = Layer.effect(
|
||||
const metadata = {
|
||||
messageId: input.messageId,
|
||||
partId: input.partId,
|
||||
partType: input.partType,
|
||||
role: input.role,
|
||||
ignored: input.ignored,
|
||||
toolCallId: input.toolCallId,
|
||||
metadata: input.metadata,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk"
|
||||
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
|
||||
|
||||
export type ToolInput = Record<string, unknown>
|
||||
|
||||
@@ -16,6 +16,19 @@ export type CompletedToolState = {
|
||||
readonly attachments?: ReadonlyArray<ToolAttachment>
|
||||
}
|
||||
|
||||
export type RunningToolState = {
|
||||
readonly status: "running"
|
||||
readonly input: ToolInput
|
||||
readonly title?: string
|
||||
}
|
||||
|
||||
export type ErrorToolState = {
|
||||
readonly status: "error"
|
||||
readonly input: ToolInput
|
||||
readonly error: string
|
||||
readonly metadata?: unknown
|
||||
}
|
||||
|
||||
export type ImageAttachment = {
|
||||
readonly mimeType: string
|
||||
readonly data: string
|
||||
@@ -100,6 +113,104 @@ export function completedToolContent(toolName: string, state: CompletedToolState
|
||||
return content
|
||||
}
|
||||
|
||||
export function pendingToolCall(input: { readonly toolCallId: string; readonly toolName: string }): ToolCall {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
title: input.toolName,
|
||||
kind: toToolKind(input.toolName),
|
||||
status: "pending",
|
||||
locations: [],
|
||||
rawInput: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function runningToolUpdate(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly state: RunningToolState
|
||||
readonly output?: string
|
||||
}): ToolCallUpdate {
|
||||
const content = input.output
|
||||
? [
|
||||
{
|
||||
type: "content" as const,
|
||||
content: {
|
||||
type: "text" as const,
|
||||
text: input.output,
|
||||
},
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
status: "in_progress",
|
||||
kind: toToolKind(input.toolName),
|
||||
title: input.state.title ?? input.toolName,
|
||||
locations: toLocations(input.toolName, input.state.input),
|
||||
rawInput: input.state.input,
|
||||
...(content ? { content } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function duplicateRunningToolUpdate(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly state: RunningToolState
|
||||
}): ToolCallUpdate {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
status: "in_progress",
|
||||
kind: toToolKind(input.toolName),
|
||||
title: input.state.title ?? input.toolName,
|
||||
locations: toLocations(input.toolName, input.state.input),
|
||||
rawInput: input.state.input,
|
||||
}
|
||||
}
|
||||
|
||||
export function completedToolUpdate(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly state: CompletedToolState & { readonly title: string }
|
||||
}): ToolCallUpdate {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
status: "completed",
|
||||
kind: toToolKind(input.toolName),
|
||||
title: input.state.title,
|
||||
content: completedToolContent(input.toolName, input.state),
|
||||
rawInput: input.state.input,
|
||||
rawOutput: completedToolRawOutput(input.state),
|
||||
}
|
||||
}
|
||||
|
||||
export function errorToolUpdate(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly state: ErrorToolState
|
||||
}): ToolCallUpdate {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
status: "failed",
|
||||
kind: toToolKind(input.toolName),
|
||||
title: input.toolName,
|
||||
rawInput: input.state.input,
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: input.state.error,
|
||||
},
|
||||
},
|
||||
],
|
||||
rawOutput: {
|
||||
error: input.state.error,
|
||||
metadata: input.state.metadata,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function completedToolRawOutput(state: CompletedToolState) {
|
||||
return {
|
||||
output: state.output,
|
||||
@@ -138,6 +249,11 @@ export const extractLocations = toLocations
|
||||
export const buildCompletedToolContent = completedToolContent
|
||||
export const buildCompletedRawOutput = completedToolRawOutput
|
||||
export const extractShellOutputSnapshot = shellOutputSnapshot
|
||||
export const buildPendingToolCall = pendingToolCall
|
||||
export const buildRunningToolUpdate = runningToolUpdate
|
||||
export const buildDuplicateRunningToolUpdate = duplicateRunningToolUpdate
|
||||
export const buildCompletedToolUpdate = completedToolUpdate
|
||||
export const buildErrorToolUpdate = errorToolUpdate
|
||||
|
||||
function locationFrom(value: unknown): ToolCallLocation[] {
|
||||
const path = stringValue(value)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentSideConnection, Usage } from "@agentclientprotocol/sdk"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@opencode-ai/sdk/v2"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
@@ -8,27 +9,14 @@ import { Context, Effect, Layer, SynchronizedRef } from "effect"
|
||||
|
||||
const log = Log.create({ service: "acp-next-usage" })
|
||||
|
||||
export type AssistantTokenCost = {
|
||||
readonly cost: number
|
||||
readonly tokens: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: {
|
||||
readonly read: number
|
||||
readonly write: number
|
||||
}
|
||||
}
|
||||
}
|
||||
export type AssistantTokenCost = Pick<OpenCodeAssistantMessage, "cost" | "tokens">
|
||||
|
||||
export type AssistantMessage = AssistantTokenCost & {
|
||||
readonly role: "assistant"
|
||||
readonly providerID?: string
|
||||
readonly modelID?: string
|
||||
}
|
||||
export type AssistantMessage = AssistantTokenCost &
|
||||
Pick<OpenCodeAssistantMessage, "role"> &
|
||||
Partial<Pick<OpenCodeAssistantMessage, "providerID" | "modelID">>
|
||||
|
||||
export type SessionMessage = {
|
||||
readonly info: { readonly role: string } | AssistantMessage
|
||||
readonly info: { readonly role: Message["role"] } | AssistantMessage
|
||||
}
|
||||
|
||||
export type MessagesInput = {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import * as Clipboard from "@tui/util/clipboard"
|
||||
import * as Selection from "@tui/util/selection"
|
||||
import * as TuiAudio from "@tui/util/audio"
|
||||
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
||||
import { createCliRenderer, MouseButton, type CliRenderer, type CliRendererConfig } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||
import {
|
||||
Switch,
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
Show,
|
||||
on,
|
||||
} from "solid-js"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import semver from "semver"
|
||||
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
@@ -51,7 +51,7 @@ import { PromptStashProvider } from "./component/prompt/stash"
|
||||
import { DialogAlert } from "./ui/dialog-alert"
|
||||
import { DialogConfirm } from "./ui/dialog-confirm"
|
||||
import { ToastProvider, useToast } from "./ui/toast"
|
||||
import { ExitProvider, useExit } from "./context/exit"
|
||||
import { createExit, ExitProvider, useExit, type Exit } from "./context/exit"
|
||||
import { Session as SessionApi } from "@/session/session"
|
||||
import { TuiEvent } from "./event"
|
||||
import { KVProvider, useKV } from "./context/kv"
|
||||
@@ -123,7 +123,7 @@ const appBindingCommands = [
|
||||
"app.toggle.session_directory_filter",
|
||||
] as const
|
||||
|
||||
function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
|
||||
export function tuiRendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
|
||||
const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true)
|
||||
|
||||
return {
|
||||
@@ -146,6 +146,34 @@ function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
|
||||
}
|
||||
}
|
||||
|
||||
export function createTuiRenderer(config: TuiConfig.Resolved) {
|
||||
return createCliRenderer(tuiRendererConfig(config))
|
||||
}
|
||||
|
||||
export type TuiHandle = {
|
||||
ready: Promise<void>
|
||||
done: Promise<void>
|
||||
exit: Exit
|
||||
}
|
||||
|
||||
type TuiInput = {
|
||||
url: string
|
||||
args: Args
|
||||
config: TuiConfig.Resolved
|
||||
renderer: CliRenderer
|
||||
onSnapshot?: () => Promise<string[]>
|
||||
directory?: string
|
||||
fetch?: typeof fetch
|
||||
headers?: RequestInit["headers"]
|
||||
events?: EventSource
|
||||
}
|
||||
|
||||
type TuiLifecycle = {
|
||||
exit: Exit
|
||||
exited: Promise<void>
|
||||
fail(error: unknown): Promise<never>
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
const formatted = FormatError(error)
|
||||
if (formatted !== undefined) return formatted
|
||||
@@ -163,105 +191,175 @@ function errorMessage(error: unknown) {
|
||||
return FormatUnknownError(error)
|
||||
}
|
||||
|
||||
export function tui(input: {
|
||||
url: string
|
||||
args: Args
|
||||
config: TuiConfig.Resolved
|
||||
onSnapshot?: () => Promise<string[]>
|
||||
directory?: string
|
||||
fetch?: typeof fetch
|
||||
headers?: RequestInit["headers"]
|
||||
events?: EventSource
|
||||
}) {
|
||||
// promise to prevent immediate exit
|
||||
// oxlint-disable-next-line no-async-promise-executor -- intentional: async executor used for sequential setup before resolve
|
||||
return new Promise<void>(async (resolve) => {
|
||||
const unguard = win32InstallCtrlCGuard()
|
||||
win32DisableProcessedInput()
|
||||
export function tui(input: TuiInput): TuiHandle {
|
||||
const unguard = win32InstallCtrlCGuard()
|
||||
win32DisableProcessedInput()
|
||||
|
||||
const onExit = async () => {
|
||||
unguard?.()
|
||||
resolve()
|
||||
}
|
||||
const onBeforeExit = async () => {
|
||||
offKeymap()
|
||||
const renderer = input.renderer
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
const unregisterKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
|
||||
const lifecycle = createTuiLifecycle({
|
||||
renderer,
|
||||
unguard,
|
||||
cleanup: async () => {
|
||||
unregisterKeymap()
|
||||
await TuiPluginRuntime.dispose()
|
||||
TuiAudio.dispose()
|
||||
}
|
||||
|
||||
const renderer = await createCliRenderer(rendererConfig(input.config))
|
||||
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
||||
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
||||
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
|
||||
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
const offKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
|
||||
|
||||
await render(() => {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => (
|
||||
<ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} />
|
||||
)}
|
||||
>
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<SDKProvider
|
||||
url={input.url}
|
||||
directory={input.directory}
|
||||
fetch={input.fetch}
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<SyncProviderV2>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProviderV2>
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ExitProvider>
|
||||
</ArgsProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}, renderer)
|
||||
},
|
||||
})
|
||||
const ready = mountTui({ ...input, keymap, exit: lifecycle.exit }).catch((error) => lifecycle.fail(error))
|
||||
const done = waitUntilDone(ready, lifecycle.exited)
|
||||
|
||||
return { ready, done, exit: lifecycle.exit }
|
||||
}
|
||||
|
||||
async function mountTui(input: TuiInput & { keymap: ReturnType<typeof createDefaultOpenTuiKeymap>; exit: Exit }) {
|
||||
const renderer = input.renderer
|
||||
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
||||
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
||||
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
|
||||
if (renderer.isDestroyed) return
|
||||
|
||||
await render(() => {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} exit={input.exit} mode={mode} />}
|
||||
>
|
||||
<OpencodeKeymapProvider keymap={input.keymap}>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ExitProvider exit={input.exit}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<SDKProvider
|
||||
url={input.url}
|
||||
directory={input.directory}
|
||||
fetch={input.fetch}
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<SyncProviderV2>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProviderV2>
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ExitProvider>
|
||||
</ArgsProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}, renderer)
|
||||
}
|
||||
|
||||
function createTuiLifecycle(input: {
|
||||
renderer: CliRenderer
|
||||
unguard?: () => void
|
||||
cleanup: () => Promise<void>
|
||||
}): TuiLifecycle {
|
||||
let resolveExited!: () => void
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
resolveExited = resolve
|
||||
})
|
||||
let exitCompleted = false
|
||||
let exiting = false
|
||||
let cleanupTask: Promise<void> | undefined
|
||||
|
||||
const completeExit = () => {
|
||||
if (exitCompleted) return
|
||||
exitCompleted = true
|
||||
resolveExited()
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
cleanupTask ??= (async () => {
|
||||
process.off("SIGHUP", onSighup)
|
||||
try {
|
||||
await input.cleanup()
|
||||
} finally {
|
||||
input.unguard?.()
|
||||
}
|
||||
})()
|
||||
return cleanupTask
|
||||
}
|
||||
|
||||
const exit = createExit(async (reason, message) => {
|
||||
exiting = true
|
||||
await cleanup()
|
||||
if (!input.renderer.isDestroyed) {
|
||||
input.renderer.setTerminalTitle("")
|
||||
input.renderer.destroy()
|
||||
}
|
||||
win32FlushInputBuffer()
|
||||
if (reason) {
|
||||
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
|
||||
if (formatted) process.stderr.write(formatted + "\n")
|
||||
}
|
||||
const text = message()
|
||||
if (text) process.stdout.write(text + "\n")
|
||||
completeExit()
|
||||
})
|
||||
const onSighup = () => {
|
||||
void exit()
|
||||
}
|
||||
|
||||
input.renderer.once("destroy", () => {
|
||||
if (exiting) return
|
||||
void cleanup().finally(() => {
|
||||
win32FlushInputBuffer()
|
||||
completeExit()
|
||||
})
|
||||
})
|
||||
process.on("SIGHUP", onSighup)
|
||||
|
||||
return {
|
||||
exit,
|
||||
exited,
|
||||
async fail(error) {
|
||||
exiting = true
|
||||
await cleanup().catch(() => {})
|
||||
if (!input.renderer.isDestroyed) input.renderer.destroy()
|
||||
completeExit()
|
||||
throw error
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilDone(ready: Promise<void>, exited: Promise<void>) {
|
||||
await ready
|
||||
await exited
|
||||
}
|
||||
|
||||
function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
|
||||
@@ -67,7 +67,6 @@ export const AttachCommand = cmd({
|
||||
})()
|
||||
const headers = ServerAuth.headers({ password: args.password, username: args.username })
|
||||
const config = await TuiConfig.get()
|
||||
const { tui } = await import("./app")
|
||||
|
||||
try {
|
||||
await validateSession({
|
||||
@@ -82,9 +81,12 @@ export const AttachCommand = cmd({
|
||||
return
|
||||
}
|
||||
|
||||
await tui({
|
||||
const { createTuiRenderer, tui } = await import("./app")
|
||||
const renderer = await createTuiRenderer(config)
|
||||
const handle = tui({
|
||||
url: args.url,
|
||||
config,
|
||||
renderer,
|
||||
args: {
|
||||
continue: args.continue,
|
||||
sessionID: args.session,
|
||||
@@ -93,6 +95,7 @@ export const AttachCommand = cmd({
|
||||
directory,
|
||||
headers,
|
||||
})
|
||||
await handle.done
|
||||
} finally {
|
||||
unguard?.()
|
||||
}
|
||||
|
||||
@@ -262,6 +262,13 @@ function AutoMethod(props: AutoMethodProps) {
|
||||
method: props.index,
|
||||
})
|
||||
if (result.error) {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
message:
|
||||
"name" in result.error && result.error.name === "ProviderAuthOauthCallbackFailed"
|
||||
? "OAuth authorization failed. Try /connect again."
|
||||
: JSON.stringify(result.error),
|
||||
})
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,32 +1,21 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import * as Clipboard from "@tui/util/clipboard"
|
||||
import { createSignal } from "solid-js"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { win32FlushInputBuffer } from "../win32"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
|
||||
export function ErrorComponent(props: {
|
||||
error: Error
|
||||
reset: () => void
|
||||
onBeforeExit?: () => Promise<void>
|
||||
onExit: () => Promise<void>
|
||||
exit: () => Promise<void>
|
||||
mode?: "dark" | "light"
|
||||
}) {
|
||||
const term = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
|
||||
const handleExit = async () => {
|
||||
await props.onBeforeExit?.()
|
||||
renderer.setTerminalTitle("")
|
||||
renderer.destroy()
|
||||
win32FlushInputBuffer()
|
||||
await props.onExit()
|
||||
}
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.ctrl && evt.name === "c") {
|
||||
void handleExit()
|
||||
void props.exit()
|
||||
}
|
||||
})
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
@@ -79,7 +68,7 @@ export function ErrorComponent(props: {
|
||||
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
|
||||
<text fg={colors.bg}>Reset TUI</text>
|
||||
</box>
|
||||
<box onMouseUp={handleExit} backgroundColor={colors.primary} padding={1}>
|
||||
<box onMouseUp={() => void props.exit()} backgroundColor={colors.primary} padding={1}>
|
||||
<text fg={colors.bg}>Exit</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||
import { win32FlushInputBuffer } from "../win32"
|
||||
type Exit = ((reason?: unknown) => Promise<void>) & {
|
||||
|
||||
export type Exit = ((reason?: unknown) => Promise<void>) & {
|
||||
message: {
|
||||
set: (value?: string) => () => void
|
||||
clear: () => void
|
||||
@@ -10,51 +8,35 @@ type Exit = ((reason?: unknown) => Promise<void>) & {
|
||||
}
|
||||
}
|
||||
|
||||
export function createExit(run: (reason: unknown | undefined, message: () => string | undefined) => Promise<void>) {
|
||||
let message: string | undefined
|
||||
let task: Promise<void> | undefined
|
||||
const store = {
|
||||
set: (value?: string) => {
|
||||
const prev = message
|
||||
message = value
|
||||
return () => {
|
||||
message = prev
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
message = undefined
|
||||
},
|
||||
get: () => message,
|
||||
}
|
||||
|
||||
return Object.assign(
|
||||
(reason?: unknown) => {
|
||||
task ??= run(reason, store.get)
|
||||
return task
|
||||
},
|
||||
{
|
||||
message: store,
|
||||
},
|
||||
) satisfies Exit
|
||||
}
|
||||
|
||||
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
name: "Exit",
|
||||
init: (input: { onBeforeExit?: () => Promise<void>; onExit?: () => Promise<void> }) => {
|
||||
const renderer = useRenderer()
|
||||
let message: string | undefined
|
||||
let task: Promise<void> | undefined
|
||||
const store = {
|
||||
set: (value?: string) => {
|
||||
const prev = message
|
||||
message = value
|
||||
return () => {
|
||||
message = prev
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
message = undefined
|
||||
},
|
||||
get: () => message,
|
||||
}
|
||||
const exit: Exit = Object.assign(
|
||||
(reason?: unknown) => {
|
||||
if (task) return task
|
||||
task = (async () => {
|
||||
await input.onBeforeExit?.()
|
||||
// Reset window title before destroying renderer
|
||||
renderer.setTerminalTitle("")
|
||||
renderer.destroy()
|
||||
win32FlushInputBuffer()
|
||||
if (reason) {
|
||||
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
|
||||
if (formatted) {
|
||||
process.stderr.write(formatted + "\n")
|
||||
}
|
||||
}
|
||||
const text = store.get()
|
||||
if (text) process.stdout.write(text + "\n")
|
||||
await input.onExit?.()
|
||||
})()
|
||||
return task
|
||||
},
|
||||
{
|
||||
message: store,
|
||||
},
|
||||
)
|
||||
process.on("SIGHUP", () => exit())
|
||||
return exit
|
||||
},
|
||||
init: (input: { exit: Exit }) => input.exit,
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { createEffect, createMemo, createResource, createSignal, For, Match, onC
|
||||
import { DiffViewerFileTree } from "./diff-viewer-file-tree"
|
||||
import { Panel, PanelGroup, Separator } from "./diff-viewer-ui"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { getScrollAcceleration } from "@tui/util/scroll"
|
||||
import {
|
||||
allExpandedFileTreeDirectories,
|
||||
buildFileTree,
|
||||
@@ -133,6 +134,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
const [activePatchFileIndex, setActivePatchFileIndex] = createSignal<number | undefined>()
|
||||
const [selectedFileIndex, setSelectedFileIndex] = createSignal<number | undefined>()
|
||||
const [reviewedFileNames, setReviewedFileNames] = createSignal<ReadonlySet<string>>(new Set())
|
||||
const patchScrollAcceleration = createMemo(() => getScrollAcceleration(props.api.tuiConfig))
|
||||
const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes()))
|
||||
const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree())))
|
||||
const focusRunner = (input: Record<DiffViewerFocus, () => void>) => () => input[focus()]()
|
||||
@@ -713,6 +715,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
flexGrow={1}
|
||||
minHeight={0}
|
||||
scrollAcceleration={patchScrollAcceleration()}
|
||||
verticalScrollbarOptions={{ visible: false }}
|
||||
horizontalScrollbarOptions={{ visible: false }}
|
||||
>
|
||||
|
||||
@@ -228,9 +228,11 @@ export const TuiThreadCommand = cmd({
|
||||
}, 1000).unref?.()
|
||||
|
||||
try {
|
||||
const { tui } = await import("./app")
|
||||
await tui({
|
||||
const { createTuiRenderer, tui } = await import("./app")
|
||||
const renderer = await createTuiRenderer(config)
|
||||
const handle = tui({
|
||||
url: transport.url,
|
||||
renderer,
|
||||
async onSnapshot() {
|
||||
const tui = writeHeapSnapshot("tui.heapsnapshot")
|
||||
const server = await client.call("snapshot", undefined)
|
||||
@@ -249,6 +251,7 @@ export const TuiThreadCommand = cmd({
|
||||
fork: args.fork,
|
||||
},
|
||||
})
|
||||
await handle.done
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
|
||||
@@ -762,7 +762,14 @@ export const layer = Layer.effect(
|
||||
result.permission = mergeDeep(perms, result.permission ?? {})
|
||||
}
|
||||
|
||||
if (!result.username) result.username = os.userInfo().username
|
||||
if (!result.username) {
|
||||
try {
|
||||
result.username = os.userInfo().username || "user"
|
||||
} catch (err) {
|
||||
log.warn("failed to read system username, using fallback", { err })
|
||||
result.username = "user"
|
||||
}
|
||||
}
|
||||
|
||||
if (result.autoshare === true && !result.share) {
|
||||
result.share = "auto"
|
||||
|
||||
@@ -46,7 +46,14 @@ export function parseManagedPlist(json: string): string {
|
||||
export async function readManagedPreferences() {
|
||||
if (process.platform !== "darwin") return
|
||||
|
||||
const user = os.userInfo().username
|
||||
const user = (() => {
|
||||
try {
|
||||
return os.userInfo().username || "user"
|
||||
} catch (err) {
|
||||
log.warn("failed to read system username, using fallback", { err })
|
||||
return "user"
|
||||
}
|
||||
})()
|
||||
const paths = [
|
||||
path.join("/Library/Managed Preferences", user, `${MANAGED_PLIST_DOMAIN}.plist`),
|
||||
path.join("/Library/Managed Preferences", `${MANAGED_PLIST_DOMAIN}.plist`),
|
||||
|
||||
@@ -234,6 +234,7 @@ interface AuthResult {
|
||||
// --- Effect Service ---
|
||||
|
||||
interface State {
|
||||
config: Record<string, ConfigMCP.Info>
|
||||
status: Record<string, Status>
|
||||
clients: Record<string, MCPClient>
|
||||
defs: Record<string, MCPToolDef[]>
|
||||
@@ -525,6 +526,7 @@ export const layer = Layer.effect(
|
||||
const bridge = yield* EffectBridge.make()
|
||||
const config = cfg.mcp ?? {}
|
||||
const s: State = {
|
||||
config: {},
|
||||
status: {},
|
||||
clients: {},
|
||||
defs: {},
|
||||
@@ -619,6 +621,10 @@ export const layer = Layer.effect(
|
||||
result[key] = s.status[key] ?? { status: "disabled" }
|
||||
}
|
||||
|
||||
for (const key of Object.keys(s.config)) {
|
||||
result[key] = s.status[key] ?? { status: "disabled" }
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -642,8 +648,9 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const add = Effect.fn("MCP.add")(function* (name: string, mcp: ConfigMCP.Info) {
|
||||
yield* createAndStore(name, mcp)
|
||||
const s = yield* InstanceState.get(state)
|
||||
s.config[name] = mcp
|
||||
yield* createAndStore(name, mcp)
|
||||
return { status: s.status }
|
||||
})
|
||||
|
||||
@@ -677,7 +684,7 @@ export const layer = Layer.effect(
|
||||
([clientName, client]) =>
|
||||
Effect.gen(function* () {
|
||||
const mcpConfig = config[clientName]
|
||||
const entry = mcpConfig && isMcpConfigured(mcpConfig) ? mcpConfig : undefined
|
||||
const entry = mcpConfig && isMcpConfigured(mcpConfig) ? mcpConfig : s.config[clientName]
|
||||
|
||||
const listed = s.defs[clientName]
|
||||
if (!listed) {
|
||||
@@ -756,6 +763,9 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
if (s.config[mcpName]) return s.config[mcpName]
|
||||
|
||||
const cfg = yield* cfgSvc.get()
|
||||
const mcpConfig = cfg.mcp?.[mcpName]
|
||||
if (!mcpConfig || !isMcpConfigured(mcpConfig)) return undefined
|
||||
|
||||
@@ -3,18 +3,20 @@ import type { Model } from "@opencode-ai/sdk/v2"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { createServer } from "http"
|
||||
import open from "open"
|
||||
|
||||
const log = Log.create({ service: "plugin.digitalocean" })
|
||||
|
||||
const DO_OAUTH_CLIENT_ID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82"
|
||||
const DO_AUTHORIZE_URL = "https://cloud.digitalocean.com/v1/oauth/authorize"
|
||||
const DO_API_BASE = "https://api.digitalocean.com"
|
||||
const DO_GENAI_API = `${DO_API_BASE}/v2/gen-ai`
|
||||
const DO_INFERENCE_BASE = "https://inference.do-ai.run/v1"
|
||||
const OAUTH_PORT = 1456
|
||||
const OAUTH_REDIRECT_PATH = "/auth/callback"
|
||||
const OAUTH_TOKEN_PATH = "/auth/token"
|
||||
const ROUTER_REFRESH_INTERVAL_MS = 5 * 60 * 1000
|
||||
const MAK_NAME_PREFIX = "opencode-oauth"
|
||||
const OAUTH_SCOPES = "genai:read inference:query"
|
||||
|
||||
interface ImplicitTokenPayload {
|
||||
access_token: string
|
||||
@@ -28,12 +30,6 @@ interface PendingOAuth {
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
interface ApiKeyInfo {
|
||||
uuid: string
|
||||
name: string
|
||||
secret_key: string
|
||||
}
|
||||
|
||||
interface RouterEntry {
|
||||
name: string
|
||||
uuid?: string
|
||||
@@ -59,7 +55,7 @@ function buildAuthorizeUrl(state: string): string {
|
||||
response_type: "token",
|
||||
client_id: DO_OAUTH_CLIENT_ID,
|
||||
redirect_uri: redirectUri(),
|
||||
scope: "genai:create genai:read",
|
||||
scope: OAUTH_SCOPES,
|
||||
state,
|
||||
})
|
||||
return `${DO_AUTHORIZE_URL}?${params.toString()}`
|
||||
@@ -91,15 +87,20 @@ const HTML_CALLBACK = `<!doctype html>
|
||||
const errorDescription = params.get("error_description") || search.get("error_description")
|
||||
const titleEl = document.getElementById("title")
|
||||
const msgEl = document.getElementById("msg")
|
||||
const tokenUrl = new URL(${JSON.stringify(OAUTH_TOKEN_PATH)}, window.location.origin).href
|
||||
try {
|
||||
const body = error
|
||||
? { error, error_description: errorDescription || "" }
|
||||
: { access_token: params.get("access_token") || "", expires_in: params.get("expires_in") || "0", state: params.get("state") || "" }
|
||||
await fetch(${JSON.stringify(OAUTH_TOKEN_PATH)}, {
|
||||
const res = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(function () { return "" })
|
||||
throw new Error(detail || ("callback failed (" + res.status + ")"))
|
||||
}
|
||||
if (error) {
|
||||
titleEl.textContent = "Authorization Failed"
|
||||
msgEl.textContent = errorDescription || error
|
||||
@@ -225,31 +226,10 @@ function waitForOAuthCallback(state: string): Promise<ImplicitTokenPayload> {
|
||||
})
|
||||
}
|
||||
|
||||
async function createModelAccessKey(bearer: string): Promise<ApiKeyInfo> {
|
||||
// Suffix-on-collision strategy keeps re-`/connect` non-destructive.
|
||||
const name = `${MAK_NAME_PREFIX}-${Math.floor(Date.now() / 1000)}`
|
||||
const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/api_keys`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${bearer}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
},
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "")
|
||||
throw new Error(`Failed to create Model Access Key (${res.status}): ${body}`)
|
||||
}
|
||||
const data = (await res.json()) as { api_key_info?: ApiKeyInfo }
|
||||
if (!data.api_key_info?.secret_key) throw new Error("Model Access Key response missing secret_key")
|
||||
return data.api_key_info
|
||||
}
|
||||
|
||||
async function listRouters(
|
||||
bearer: string,
|
||||
): Promise<{ ok: true; routers: RouterEntry[] } | { ok: false; status: number }> {
|
||||
const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/routers`, {
|
||||
const res = await fetch(`${DO_GENAI_API}/models/routers`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${bearer}`,
|
||||
Accept: "application/json",
|
||||
@@ -362,15 +342,16 @@ export async function DigitalOceanAuthPlugin(input: PluginInput): Promise<Hooks>
|
||||
await startOAuthServer()
|
||||
const state = generateState()
|
||||
const callbackPromise = waitForOAuthCallback(state)
|
||||
const url = buildAuthorizeUrl(state)
|
||||
await open(url).catch(() => undefined)
|
||||
return {
|
||||
url: buildAuthorizeUrl(state),
|
||||
url,
|
||||
instructions:
|
||||
"Sign in to DigitalOcean in your browser. OpenCode will create a Model Access Key named opencode-oauth-* and load your Inference Routers. Re-run /connect to refresh routers later.",
|
||||
"Sign in to DigitalOcean in your browser. OpenCode will use your DigitalOcean API token directly for inference and load your Inference Routers. Re-run /connect to refresh routers later.",
|
||||
method: "auto" as const,
|
||||
async callback() {
|
||||
try {
|
||||
const tokens = await callbackPromise
|
||||
const apiKeyInfo = await createModelAccessKey(tokens.access_token)
|
||||
const routerResult = await listRouters(tokens.access_token)
|
||||
const routers = routerResult.ok ? routerResult.routers : []
|
||||
if (!routerResult.ok) {
|
||||
@@ -379,12 +360,11 @@ export async function DigitalOceanAuthPlugin(input: PluginInput): Promise<Hooks>
|
||||
return {
|
||||
type: "success" as const,
|
||||
provider: "digitalocean",
|
||||
key: apiKeyInfo.secret_key,
|
||||
key: tokens.access_token,
|
||||
metadata: {
|
||||
mak_uuid: apiKeyInfo.uuid,
|
||||
mak_name: apiKeyInfo.name,
|
||||
oauth_access: tokens.access_token,
|
||||
oauth_expires: String(Date.now() + tokens.expires_in * 1000),
|
||||
oauth_scopes: OAUTH_SCOPES,
|
||||
routers: JSON.stringify(
|
||||
routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description })),
|
||||
),
|
||||
|
||||
@@ -644,7 +644,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
if (input.model.providerID !== "openai") return
|
||||
output.headers.originator = "opencode"
|
||||
output.headers["User-Agent"] = `opencode/${InstallationVersion} (${os.platform()} ${os.release()}; ${os.arch()})`
|
||||
output.headers.session_id = input.sessionID
|
||||
output.headers["session-id"] = input.sessionID
|
||||
// Temporary fetch-layer hack: title generation currently shares the conversation
|
||||
// session ID, so the OpenAI plugin marks it for HTTP fallback until transport
|
||||
// context can be passed directly instead of smuggled through headers.
|
||||
|
||||
@@ -151,7 +151,7 @@ function invalidate(entry: PoolEntry) {
|
||||
}
|
||||
|
||||
function poolKey(headers: Record<string, string>) {
|
||||
const sessionID = headers["x-session-affinity"] ?? headers["session_id"]
|
||||
const sessionID = headers["x-session-affinity"] ?? headers["session-id"]
|
||||
if (!sessionID) return undefined
|
||||
|
||||
return [
|
||||
|
||||
Reference in New Issue
Block a user