fix(cli): preserve v2 mini interfaces

This commit is contained in:
Aiden Cline 2026-07-13 23:09:28 +00:00
commit 262add0801
22 changed files with 352 additions and 1707 deletions

View file

@ -98,7 +98,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@opencode-ai/cli", "name": "@opencode-ai/cli",
"version": "1.17.20", "version": "1.17.18",
"bin": { "bin": {
"opencode2": "./bin/opencode2.cjs", "opencode2": "./bin/opencode2.cjs",
}, },
@ -108,7 +108,6 @@
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*", "@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*", "@opencode-ai/tui": "workspace:*",
"@opentui/core": "catalog:", "@opentui/core": "catalog:",

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli", "name": "@opencode-ai/cli",
"version": "1.17.20", "version": "1.17.18",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@ -36,7 +36,6 @@
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*", "@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*", "@opencode-ai/tui": "workspace:*",
"@opentui/core": "catalog:", "@opentui/core": "catalog:",

View file

@ -1,6 +1,6 @@
import { EOL } from "os" import { EOL } from "os"
import { Effect } from "effect" import { Effect } from "effect"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { OpenCode } from "@opencode-ai/client"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect"
@ -12,11 +12,11 @@ export default Runtime.handler(
const options = yield* ServiceConfig.options() const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options) const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.start(options)) const endpoint = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } })) const response = yield* Effect.promise(() => client.agent.list({ location: { directory: process.cwd() } }))
process.stdout.write( process.stdout.write(
JSON.stringify( JSON.stringify(
response.data?.data.toSorted((a, b) => a.id.localeCompare(b.id)), response.data.toSorted((a, b) => a.id.localeCompare(b.id)),
null, null,
2, 2,
) + EOL, ) + EOL,

View file

@ -1,11 +1,11 @@
import { EOL } from "node:os" import { EOL } from "node:os"
import { Effect } from "effect" import { Effect } from "effect"
import { import {
createOpencodeClient, OpenCode,
type IntegrationAttemptStatus, type IntegrationAttemptStatus,
type IntegrationOAuthMethod, type IntegrationOAuthMethod,
type OpencodeClient, type OpenCodeClient,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/client"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect"
@ -20,7 +20,7 @@ export default Runtime.handler(
const options = yield* ServiceConfig.options() const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options) const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.start(options)) const endpoint = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const integration = yield* resolveIntegration(client, input.name, location) const integration = yield* resolveIntegration(client, input.name, location)
if (!integration) if (!integration)
@ -32,10 +32,9 @@ export default Runtime.handler(
return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`)) return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`))
const started = yield* Effect.promise(() => const started = yield* Effect.promise(() =>
client.v2.integration.connect.oauth({ integrationID: integration.id, methodID: method.id, inputs: {}, location }), client.integration.connect.oauth({ integrationID: integration.id, methodID: method.id, inputs: {}, location }),
) )
const attempt = started.data?.data const attempt = started.data
if (!attempt) return yield* Effect.fail(new Error(started.error?.message ?? "Failed to start OAuth attempt"))
if (attempt.mode === "code") if (attempt.mode === "code")
return yield* Effect.fail(new Error("This server requires manual code entry, which the CLI does not support")) return yield* Effect.fail(new Error("This server requires manual code entry, which the CLI does not support"))
@ -52,13 +51,14 @@ export default Runtime.handler(
) )
const poll = ( const poll = (
client: OpencodeClient, client: OpenCodeClient,
attemptID: string, attemptID: string,
): Effect.Effect<Exclude<IntegrationAttemptStatus, { status: "pending" }>> => ): Effect.Effect<Exclude<IntegrationAttemptStatus, { status: "pending" }>> =>
Effect.gen(function* () { Effect.gen(function* () {
const response = yield* Effect.promise(() => client.v2.integration.attempt.status({ attemptID, location })) const status = yield* Effect.promise(() => client.integration.attempt.status({ attemptID, location })).pipe(
const status = response.data?.data Effect.map((result) => result.data),
if (!status || status.status === "pending") { )
if (status.status === "pending") {
yield* Effect.sleep("1 second") yield* Effect.sleep("1 second")
return yield* poll(client, attemptID) return yield* poll(client, attemptID)
} }

View file

@ -1,6 +1,6 @@
import { EOL } from "node:os" import { EOL } from "node:os"
import { Effect } from "effect" import { Effect } from "effect"
import { createOpencodeClient, type McpServer } from "@opencode-ai/sdk/v2/client" import { OpenCode, type McpServer } from "@opencode-ai/client"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect"
@ -12,9 +12,9 @@ export default Runtime.handler(
const options = yield* ServiceConfig.options() const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options) const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.start(options)) const endpoint = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.v2.mcp.list({ location: { directory: process.cwd() } })) const response = yield* Effect.promise(() => client.mcp.list({ location: { directory: process.cwd() } }))
const servers = (response.data?.data ?? []).toSorted((a, b) => a.name.localeCompare(b.name)) const servers = response.data.toSorted((a, b) => a.name.localeCompare(b.name))
if (servers.length === 0) { if (servers.length === 0) {
process.stdout.write("No MCP servers configured" + EOL) process.stdout.write("No MCP servers configured" + EOL)
return return

View file

@ -1,6 +1,6 @@
import { EOL } from "node:os" import { EOL } from "node:os"
import { Effect } from "effect" import { Effect } from "effect"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { OpenCode } from "@opencode-ai/client"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect"
@ -15,7 +15,7 @@ export default Runtime.handler(
const options = yield* ServiceConfig.options() const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options) const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.start(options)) const endpoint = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const integration = yield* resolveIntegration(client, input.name, location) const integration = yield* resolveIntegration(client, input.name, location)
if (!integration) { if (!integration) {
@ -31,7 +31,7 @@ export default Runtime.handler(
yield* Effect.forEach( yield* Effect.forEach(
credentials, credentials,
(connection) => Effect.promise(() => client.v2.credential.remove({ credentialID: connection.id, location })), (connection) => Effect.promise(() => client.credential.remove({ credentialID: connection.id, location })),
{ discard: true }, { discard: true },
) )
process.stdout.write(`Removed OAuth credentials for ${input.name}` + EOL) process.stdout.write(`Removed OAuth credentials for ${input.name}` + EOL)

View file

@ -1,17 +1,18 @@
import { Effect } from "effect" import { Effect } from "effect"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import type { OpenCodeClient } from "@opencode-ai/client"
// Resolve through the MCP-owned integrationID rather than matching integration names: the shared // Resolve through the MCP-owned integrationID rather than matching integration names: the shared
// integration registry also holds provider/plugin integrations, whose names could collide with a server. // integration registry also holds provider/plugin integrations, whose names could collide with a server.
// Fails when the server is unknown; returns undefined when the server has no integration (e.g. a local // Fails when the server is unknown; returns undefined when the server has no integration (e.g. a local
// or anonymous server), leaving that case for the caller to interpret. // or anonymous server), leaving that case for the caller to interpret.
export const resolveIntegration = (client: OpencodeClient, name: string, location: { directory: string }) => export const resolveIntegration = (client: OpenCodeClient, name: string, location: { directory: string }) =>
Effect.gen(function* () { Effect.gen(function* () {
const servers = yield* Effect.promise(() => client.v2.mcp.list({ location })) const servers = yield* Effect.promise(() => client.mcp.list({ location }))
const server = (servers.data?.data ?? []).find((entry) => entry.name === name) const server = servers.data.find((entry) => entry.name === name)
if (!server) return yield* Effect.fail(new Error(`MCP server not found: ${name}`)) if (!server) return yield* Effect.fail(new Error(`MCP server not found: ${name}`))
const integrationID = server.integrationID const integrationID = server.integrationID
if (!integrationID) return undefined if (!integrationID) return undefined
const found = yield* Effect.promise(() => client.v2.integration.get({ integrationID, location })) return yield* Effect.promise(() => client.integration.get({ integrationID, location })).pipe(
return found.data?.data Effect.map((result) => result.data ?? undefined),
)
}) })

View file

@ -1,7 +1,7 @@
// Demo mode for testing direct interactive mode without a real SDK. // Demo mode for testing direct interactive mode without a real SDK.
// //
// Enabled with `--demo`. Intercepts prompt submissions and generates synthetic // Enabled with `--demo`. Intercepts prompt submissions and drives the same
// SDK events that feed through the real reducer and footer pipeline. This // presentation commits and footer actions as the live transport. This
// lets you test scrollback formatting, permission UI, question UI, and tool // lets you test scrollback formatting, permission UI, question UI, and tool
// snapshots without making actual model calls. Pass a demo slash command as // snapshots without making actual model calls. Pass a demo slash command as
// the initial interactive message to trigger a preview immediately. // the initial interactive message to trigger a preview immediately.
@ -15,10 +15,18 @@
// Demo mode also handles permission and question replies locally, completing // Demo mode also handles permission and question replies locally, completing
// or failing the synthetic tool parts as appropriate. // or failing the synthetic tool parts as appropriate.
import path from "path" import path from "path"
import type { Event, ToolPart } from "@opencode-ai/sdk/v2" import type { PermissionV2Request, QuestionV2Request } from "@opencode-ai/client/promise"
import { createSessionData, reduceSessionData, type SessionData } from "./session-data"
import { writeSessionOutput } from "./stream" import { writeSessionOutput } from "./stream"
import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunPrompt, StreamCommit } from "./types" import { toolCommit } from "./stream-v2.subagent"
import type {
FooterApi,
MiniToolPart,
PermissionReply,
QuestionReject,
QuestionReply,
RunPrompt,
StreamCommit,
} from "./types"
const KINDS = [ const KINDS = [
"markdown", "markdown",
@ -124,7 +132,7 @@ type Permit = {
ref: Ref ref: Ref
permission: string permission: string
patterns: string[] patterns: string[]
metadata?: Record<string, unknown> metadata?: PermissionV2Request["metadata"]
always: string[] always: string[]
done: Perm["done"] done: Perm["done"]
} }
@ -132,9 +140,7 @@ type Permit = {
type State = { type State = {
id: string id: string
thinking: boolean thinking: boolean
data: SessionData
footer: FooterApi footer: FooterApi
limits: () => Record<string, number>
msg: number msg: number
part: number part: number
call: number call: number
@ -142,12 +148,12 @@ type State = {
ask: number ask: number
perms: Map<string, Perm> perms: Map<string, Perm>
asks: Map<string, Ask> asks: Map<string, Ask>
started: Set<string>
} }
type Input = { type Input = {
sessionID: string sessionID: string
thinking: boolean thinking: boolean
limits: () => Record<string, number>
footer: FooterApi footer: FooterApi
} }
@ -255,185 +261,69 @@ function take(state: State, key: "msg" | "part" | "call" | "perm" | "ask", prefi
return `demo_${prefix}_${state[key]}` return `demo_${prefix}_${state[key]}`
} }
function feed(state: State, event: Event): void { function present(state: State, commits: StreamCommit[], view?: QuestionV2Request | PermissionV2Request): void {
const out = reduceSessionData({
data: state.data,
event,
sessionID: state.id,
thinking: state.thinking,
limits: state.limits(),
})
state.data = out.data
writeSessionOutput( writeSessionOutput(
{ footer: state.footer },
{ {
footer: state.footer, commits,
footer: view
? {
view: "action" in view ? { type: "permission", request: view } : { type: "question", request: view },
patch: { status: "action" in view ? "awaiting permission" : "awaiting answer" },
}
: undefined,
}, },
out, )
}
function clearBlocker(state: State): void {
writeSessionOutput(
{ footer: state.footer },
{ commits: [], footer: { view: { type: "prompt" }, patch: { status: "" } } },
) )
} }
function open(state: State): string { function open(state: State): string {
const id = take(state, "msg", "msg") return take(state, "msg", "msg")
feed(state, {
type: "message.updated",
properties: {
sessionID: state.id,
info: {
id,
sessionID: state.id,
role: "assistant",
time: {
created: Date.now(),
},
parentID: `user_${id}`,
modelID: "demo",
providerID: "demo",
mode: "demo",
agent: "demo",
path: {
cwd: process.cwd(),
root: process.cwd(),
},
cost: 0.001,
tokens: {
input: 120,
output: 320,
reasoning: 80,
cache: {
read: 0,
write: 0,
},
},
},
},
} as Event)
return id
} }
async function emitText(state: State, body: string, signal?: AbortSignal): Promise<void> { async function emitText(state: State, body: string, signal?: AbortSignal): Promise<void> {
const msg = open(state) const msg = open(state)
const part = take(state, "part", "part") const part = take(state, "part", "part")
const start = Date.now()
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: part,
sessionID: state.id,
messageID: msg,
type: "text",
text: "",
time: {
start,
},
},
},
} as Event)
let next = ""
for (const item of split(body)) { for (const item of split(body)) {
if (signal?.aborted) { if (signal?.aborted) {
return return
} }
next += item present(state, [{ kind: "assistant", source: "assistant", text: item, phase: "progress", messageID: msg, partID: part }])
feed(state, {
type: "message.part.delta",
properties: {
sessionID: state.id,
messageID: msg,
partID: part,
field: "text",
delta: item,
},
} as Event)
await wait(45, signal) await wait(45, signal)
} }
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: part,
sessionID: state.id,
messageID: msg,
type: "text",
text: next,
time: {
start,
end: Date.now(),
},
},
},
} as Event)
} }
async function emitReasoning(state: State, body: string, signal?: AbortSignal): Promise<void> { async function emitReasoning(state: State, body: string, signal?: AbortSignal): Promise<void> {
const msg = open(state) const msg = open(state)
const part = take(state, "part", "part") const part = take(state, "part", "part")
const start = Date.now() let first = true
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: part,
sessionID: state.id,
messageID: msg,
type: "reasoning",
text: "",
time: {
start,
},
},
},
} as Event)
let next = ""
for (const item of split(body)) { for (const item of split(body)) {
if (signal?.aborted) { if (signal?.aborted) {
return return
} }
next += item if (state.thinking) {
feed(state, { present(state, [
type: "message.part.delta", {
properties: { kind: "reasoning",
sessionID: state.id, source: "reasoning",
messageID: msg, text: first ? `Thinking: ${item.replace(/\[REDACTED\]/g, "")}` : item.replace(/\[REDACTED\]/g, ""),
partID: part, phase: "progress",
field: "text", messageID: msg,
delta: item, partID: part,
}, },
} as Event) ])
first = false
}
await wait(45, signal) await wait(45, signal)
} }
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: part,
sessionID: state.id,
messageID: msg,
type: "reasoning",
text: next,
time: {
start,
end: Date.now(),
},
},
},
} as Event)
} }
function make(state: State, tool: string, input: Record<string, unknown>): Ref { function make(state: State, tool: string, input: Record<string, unknown>): Ref {
@ -448,29 +338,23 @@ function make(state: State, tool: string, input: Record<string, unknown>): Ref {
} }
function startTool(state: State, ref: Ref, metadata: Record<string, unknown> = {}): void { function startTool(state: State, ref: Ref, metadata: Record<string, unknown> = {}): void {
feed(state, { state.started.add(ref.part)
type: "message.part.updated", present(
properties: { state,
sessionID: state.id, [
time: Date.now(), toolCommit(
part: { {
id: ref.part, id: ref.part,
sessionID: state.id, sessionID: state.id,
messageID: ref.msg, messageID: ref.msg,
type: "tool", callID: ref.call,
callID: ref.call, tool: ref.tool,
tool: ref.tool, state: { status: "running", input: ref.input, metadata, time: { start: ref.start } },
state: {
status: "running",
input: ref.input,
metadata,
time: {
start: ref.start,
},
}, },
}, "start",
}, ),
} as Event) ],
)
} }
function askPermission(state: State, item: Permit): void { function askPermission(state: State, item: Permit): void {
@ -482,21 +366,15 @@ function askPermission(state: State, item: Permit): void {
done: item.done, done: item.done,
}) })
feed(state, { present(state, [], {
type: "permission.asked", id,
properties: { sessionID: state.id,
id, action: item.permission,
sessionID: state.id, resources: item.patterns,
permission: item.permission, metadata: item.metadata ?? {},
patterns: item.patterns, save: item.always,
metadata: item.metadata ?? {}, source: { type: "tool", messageID: item.ref.msg, callID: item.ref.call },
always: item.always, })
tool: {
messageID: item.ref.msg,
callID: item.ref.call,
},
},
} as Event)
} }
function doneTool( function doneTool(
@ -508,77 +386,53 @@ function doneTool(
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
}, },
): void { ): void {
feed(state, { if (!state.started.has(ref.part)) startTool(state, ref)
type: "message.part.updated", const part: MiniToolPart = {
properties: { id: ref.part,
sessionID: state.id, sessionID: state.id,
time: Date.now(), messageID: ref.msg,
part: { callID: ref.call,
id: ref.part, tool: ref.tool,
sessionID: state.id, state: {
messageID: ref.msg, status: "completed",
type: "tool", input: ref.input,
callID: ref.call, output: output.output,
tool: ref.tool, title: output.title,
state: { metadata: output.metadata ?? {},
status: "completed", time: { start: ref.start, end: Date.now() },
input: ref.input,
output: output.output,
title: output.title,
metadata: output.metadata ?? {},
time: {
start: ref.start,
end: Date.now(),
},
},
},
}, },
} as Event) }
present(state, [toolCommit(part, output.output ? "progress" : "final")])
} }
function failTool(state: State, ref: Ref, error: string): void { function failTool(state: State, ref: Ref, error: string): void {
feed(state, { if (!state.started.has(ref.part)) startTool(state, ref)
type: "message.part.updated", present(
properties: { state,
sessionID: state.id, [
time: Date.now(), toolCommit(
part: { {
id: ref.part, id: ref.part,
sessionID: state.id, sessionID: state.id,
messageID: ref.msg, messageID: ref.msg,
type: "tool", callID: ref.call,
callID: ref.call, tool: ref.tool,
tool: ref.tool, state: {
state: { status: "error",
status: "error", input: ref.input,
input: ref.input, error,
error, metadata: {},
metadata: {}, time: { start: ref.start, end: Date.now() },
time: {
start: ref.start,
end: Date.now(),
}, },
}, },
}, "final",
}, ),
} as Event) ],
)
} }
function emitError(state: State, text: string): void { function emitError(state: State, text: string): void {
const event = { present(state, [{ kind: "error", source: "system", text, phase: "start" }])
id: `session.error:${state.id}:${Date.now()}`,
type: "session.error",
properties: {
sessionID: state.id,
error: {
name: "UnknownError",
data: {
message: text,
},
},
},
} satisfies Event
feed(state, event)
} }
async function emitBash(state: State, signal?: AbortSignal): Promise<void> { async function emitBash(state: State, signal?: AbortSignal): Promise<void> {
@ -685,7 +539,7 @@ function emitTask(state: State): void {
start: Date.now(), start: Date.now(),
}, },
}, },
} satisfies ToolPart } satisfies MiniToolPart
showSubagent(state, { showSubagent(state, {
sessionID: "sub_demo_1", sessionID: "sub_demo_1",
partID: ref.part, partID: ref.part,
@ -979,18 +833,12 @@ function emitQuestion(state: State, kind: QuestionKind = "multi"): void {
const id = take(state, "ask", "ask") const id = take(state, "ask", "ask")
state.asks.set(id, { ref }) state.asks.set(id, { ref })
feed(state, { present(state, [], {
type: "question.asked", id,
properties: { sessionID: state.id,
id, questions,
sessionID: state.id, tool: { messageID: ref.msg, callID: ref.call },
questions, })
tool: {
messageID: ref.msg,
callID: ref.call,
},
},
} as Event)
} }
async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise<boolean> { async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise<boolean> {
@ -1089,9 +937,7 @@ export function createRunDemo(input: Input) {
const state: State = { const state: State = {
id: input.sessionID, id: input.sessionID,
thinking: input.thinking, thinking: input.thinking,
data: createSessionData(),
footer: input.footer, footer: input.footer,
limits: input.limits,
msg: 0, msg: 0,
part: 0, part: 0,
call: 0, call: 0,
@ -1099,6 +945,7 @@ export function createRunDemo(input: Input) {
ask: 0, ask: 0,
perms: new Map(), perms: new Map(),
asks: new Map(), asks: new Map(),
started: new Set(),
} }
const start = async (): Promise<void> => { const start = async (): Promise<void> => {
@ -1166,16 +1013,7 @@ export function createRunDemo(input: Input) {
} }
state.perms.delete(input.requestID) state.perms.delete(input.requestID)
const event = { clearBlocker(state)
id: `permission.replied:${input.requestID}:${Date.now()}`,
type: "permission.replied",
properties: {
sessionID: state.id,
requestID: input.requestID,
reply: input.reply,
},
} satisfies Event
feed(state, event)
if (input.reply === "reject") { if (input.reply === "reject") {
failTool(state, item.ref, input.message || "permission rejected") failTool(state, item.ref, input.message || "permission rejected")
@ -1193,16 +1031,7 @@ export function createRunDemo(input: Input) {
} }
state.asks.delete(input.requestID) state.asks.delete(input.requestID)
const event = { clearBlocker(state)
id: `question.replied:${input.requestID}:${Date.now()}`,
type: "question.replied",
properties: {
sessionID: state.id,
requestID: input.requestID,
answers: input.answers,
},
} satisfies Event
feed(state, event)
doneTool(state, ask.ref, { doneTool(state, ask.ref, {
title: "question", title: "question",
output: "", output: "",
@ -1220,13 +1049,7 @@ export function createRunDemo(input: Input) {
} }
state.asks.delete(input.requestID) state.asks.delete(input.requestID)
feed(state, { clearBlocker(state)
type: "question.rejected",
properties: {
sessionID: state.id,
requestID: input.requestID,
},
} as Event)
failTool(state, ask.ref, "question rejected") failTool(state, ask.ref, "question rejected")
return true return true
} }

View file

@ -14,7 +14,7 @@
import type { TextareaRenderable } from "@opentui/core" import type { TextareaRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js" import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
import type { PermissionRequest } from "@opencode-ai/sdk/v2" import type { PermissionV2Request } from "@opencode-ai/client/promise"
import { import {
createPermissionBodyState, createPermissionBodyState,
permissionAlwaysLines, permissionAlwaysLines,
@ -130,7 +130,7 @@ export function RejectField(props: {
} }
export function RunPermissionBody(props: { export function RunPermissionBody(props: {
request: PermissionRequest request: PermissionV2Request
theme: RunFooterTheme theme: RunFooterTheme
block: RunBlockTheme block: RunBlockTheme
diffStyle?: RunDiffStyle diffStyle?: RunDiffStyle
@ -142,7 +142,7 @@ export function RunPermissionBody(props: {
const ft = createMemo(() => toolFiletype(info().file)) const ft = createMemo(() => toolFiletype(info().file))
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow) const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
const opts = createMemo(() => const opts = createMemo(() =>
permissionOptions(state().stage).filter((option) => option !== "always" || props.request.always.length > 0), permissionOptions(state().stage).filter((option) => option !== "always" || (props.request.save?.length ?? 0) > 0),
) )
const busy = createMemo(() => state().submitting) const busy = createMemo(() => state().submitting)
const title = createMemo(() => { const title = createMemo(() => {

View file

@ -16,7 +16,7 @@
import type { TextareaRenderable } from "@opentui/core" import type { TextareaRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { For, Show, createEffect, createMemo, createSignal } from "solid-js" import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
import type { QuestionRequest } from "@opencode-ai/sdk/v2" import type { QuestionV2Request } from "@opencode-ai/client/promise"
import { import {
createQuestionBodyState, createQuestionBodyState,
questionConfirm, questionConfirm,
@ -45,7 +45,7 @@ import type { RunFooterTheme } from "./theme"
import type { QuestionReject, QuestionReply } from "./types" import type { QuestionReject, QuestionReply } from "./types"
export function RunQuestionBody(props: { export function RunQuestionBody(props: {
request: QuestionRequest request: QuestionV2Request
theme: RunFooterTheme theme: RunFooterTheme
onReply: (input: QuestionReply) => void | Promise<void> onReply: (input: QuestionReply) => void | Promise<void>
onReject: (input: QuestionReject) => void | Promise<void> onReject: (input: QuestionReject) => void | Promise<void>

View file

@ -1,8 +1,8 @@
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise" import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import type { ReasoningPart, StepFinishPart, StepStartPart, TextPart, ToolPart } from "@opencode-ai/sdk/v2"
import { SessionMessage } from "@opencode-ai/schema/session-message" import { SessionMessage } from "@opencode-ai/schema/session-message"
import { EOL } from "node:os" import { EOL } from "node:os"
import { UI } from "./ui" import { UI } from "./ui"
import type { MiniToolPart } from "./types"
type Model = { type Model = {
providerID: string providerID: string
@ -28,8 +28,8 @@ type Input = {
auto: boolean auto: boolean
/** True when the client is attached to a shared server rather than an exclusive in-process one. */ /** True when the client is attached to a shared server rather than an exclusive in-process one. */
attached: boolean attached: boolean
renderTool: (part: ToolPart) => Promise<void> renderTool: (part: MiniToolPart) => Promise<void>
renderToolError: (part: ToolPart) => Promise<void> renderToolError: (part: MiniToolPart) => Promise<void>
} }
type StartedPart = { type StartedPart = {
@ -77,7 +77,7 @@ export async function runNonInteractivePrompt(input: Input) {
return true return true
} }
const writeText = (part: TextPart, timestamp: number) => { const writeText = (part: { text: string; [key: string]: unknown }, timestamp: number) => {
if (emit("text", timestamp, { part })) return if (emit("text", timestamp, { part })) return
const text = part.text.trim() const text = part.text.trim()
if (!text) return if (!text) return
@ -169,7 +169,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (!promoted) continue if (!promoted) continue
if (event.type === "session.step.started") { if (event.type === "session.step.started") {
const part: StepStartPart = { const part = {
id: partID(event.id), id: partID(event.id),
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
@ -191,7 +191,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.text.ended") { if (event.type === "session.text.ended") {
const started = starts.get("text") const started = starts.get("text")
starts.delete("text") starts.delete("text")
const part: TextPart = { const part = {
id: started?.id ?? partID(event.id), id: started?.id ?? partID(event.id),
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
@ -210,7 +210,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.reasoning.ended" && input.thinking) { if (event.type === "session.reasoning.ended" && input.thinking) {
const started = starts.get("reasoning") const started = starts.get("reasoning")
starts.delete("reasoning") starts.delete("reasoning")
const part: ReasoningPart = { const part = {
id: started?.id ?? partID(event.id), id: started?.id ?? partID(event.id),
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
@ -263,7 +263,7 @@ export async function runNonInteractivePrompt(input: Input) {
} }
if (event.type === "session.tool.success") { if (event.type === "session.tool.success") {
const current = tools.get(event.data.callID) ?? fallbackTool(event) const current = tools.get(event.data.callID) ?? fallbackTool(event)
const part: ToolPart = { const part: MiniToolPart = {
id: current.id, id: current.id,
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
@ -296,7 +296,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.tool.failed") { if (event.type === "session.tool.failed") {
const current = tools.get(event.data.callID) ?? fallbackTool(event) const current = tools.get(event.data.callID) ?? fallbackTool(event)
const error = event.data.error.message const error = event.data.error.message
const part: ToolPart = { const part: MiniToolPart = {
id: current.id, id: current.id,
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
@ -325,7 +325,7 @@ export async function runNonInteractivePrompt(input: Input) {
} }
if (event.type === "session.step.ended") { if (event.type === "session.step.ended") {
const part: StepFinishPart = { const part = {
id: partID(event.id), id: partID(event.id),
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,

View file

@ -13,7 +13,7 @@
// //
// permissionInfo() extracts display info (icon, title, lines, diff) from // permissionInfo() extracts display info (icon, title, lines, diff) from
// the request, delegating to tool.ts for tool-specific formatting. // the request, delegating to tool.ts for tool-specific formatting.
import type { PermissionRequest } from "@opencode-ai/sdk/v2" import type { PermissionV2Request } from "@opencode-ai/client/promise"
import type { PermissionReply } from "./types" import type { PermissionReply } from "./types"
import { toolPath, toolPermissionInfo } from "./tool" import { toolPath, toolPermissionInfo } from "./tool"
@ -55,7 +55,7 @@ function text(v: unknown): string {
return typeof v === "string" ? v : "" return typeof v === "string" ? v : ""
} }
function data(request: PermissionRequest): Dict { function data(request: PermissionV2Request): Dict {
const meta = dict(request.metadata) const meta = dict(request.metadata)
return { return {
...meta, ...meta,
@ -63,8 +63,8 @@ function data(request: PermissionRequest): Dict {
} }
} }
function patterns(request: PermissionRequest): string[] { function patterns(request: PermissionV2Request): string[] {
return request.patterns.filter((item): item is string => typeof item === "string") return request.resources.filter((item): item is string => typeof item === "string")
} }
export function createPermissionBodyState(requestID: string): PermissionBodyState { export function createPermissionBodyState(requestID: string): PermissionBodyState {
@ -89,15 +89,15 @@ export function permissionOptions(stage: PermissionStage): PermissionOption[] {
return [] return []
} }
export function permissionInfo(request: PermissionRequest): PermissionInfo { export function permissionInfo(request: PermissionV2Request): PermissionInfo {
const pats = patterns(request) const pats = patterns(request)
const input = data(request) const input = data(request)
const info = toolPermissionInfo(request.permission, input, dict(request.metadata), pats) const info = toolPermissionInfo(request.action, input, dict(request.metadata), pats)
if (info) { if (info) {
return info return info
} }
if (request.permission === "external_directory") { if (request.action === "external_directory") {
const meta = dict(request.metadata) const meta = dict(request.metadata)
const raw = text(meta.parentDir) || text(meta.filepath) || pats[0] || "" const raw = text(meta.parentDir) || text(meta.filepath) || pats[0] || ""
const dir = raw.includes("*") ? raw.slice(0, raw.indexOf("*")).replace(/[\\/]+$/, "") : raw const dir = raw.includes("*") ? raw.slice(0, raw.indexOf("*")).replace(/[\\/]+$/, "") : raw
@ -108,7 +108,7 @@ export function permissionInfo(request: PermissionRequest): PermissionInfo {
} }
} }
if (request.permission === "doom_loop") { if (request.action === "doom_loop") {
return { return {
icon: "⟳", icon: "⟳",
title: "Continue after repeated failures", title: "Continue after repeated failures",
@ -118,19 +118,20 @@ export function permissionInfo(request: PermissionRequest): PermissionInfo {
return { return {
icon: "⚙", icon: "⚙",
title: `Call tool ${request.permission}`, title: `Call tool ${request.action}`,
lines: [`Tool: ${request.permission}`], lines: [`Tool: ${request.action}`],
} }
} }
export function permissionAlwaysLines(request: PermissionRequest): string[] { export function permissionAlwaysLines(request: PermissionV2Request): string[] {
if (request.always.length === 1 && request.always[0] === "*") { const save = request.save ?? []
return [`This will allow ${request.permission} until OpenCode is restarted.`] if (save.length === 1 && save[0] === "*") {
return [`This will allow ${request.action} until OpenCode is restarted.`]
} }
return [ return [
"This will allow the following patterns until OpenCode is restarted.", "This will allow the following patterns until OpenCode is restarted.",
...request.always.map((item) => `- ${item}`), ...save.map((item) => `- ${item}`),
] ]
} }

View file

@ -13,7 +13,7 @@
// //
// Custom answers: if a question has custom=true, an extra "Type your own // Custom answers: if a question has custom=true, an extra "Type your own
// answer" option appears. Selecting it enters editing mode with a text field. // answer" option appears. Selecting it enters editing mode with a text field.
import type { QuestionInfo, QuestionRequest } from "@opencode-ai/sdk/v2" import type { QuestionV2Info, QuestionV2Request } from "@opencode-ai/client/promise"
import type { QuestionReject, QuestionReply } from "./types" import type { QuestionReject, QuestionReply } from "./types"
export type QuestionBodyState = { export type QuestionBodyState = {
@ -51,23 +51,23 @@ export function questionSync(state: QuestionBodyState, requestID: string): Quest
return createQuestionBodyState(requestID) return createQuestionBodyState(requestID)
} }
export function questionSingle(request: QuestionRequest): boolean { export function questionSingle(request: QuestionV2Request): boolean {
return request.questions.length === 1 && request.questions[0]?.multiple !== true return request.questions.length === 1 && request.questions[0]?.multiple !== true
} }
export function questionTabs(request: QuestionRequest): number { export function questionTabs(request: QuestionV2Request): number {
return questionSingle(request) ? 1 : request.questions.length + 1 return questionSingle(request) ? 1 : request.questions.length + 1
} }
export function questionConfirm(request: QuestionRequest, state: QuestionBodyState): boolean { export function questionConfirm(request: QuestionV2Request, state: QuestionBodyState): boolean {
return !questionSingle(request) && state.tab === request.questions.length return !questionSingle(request) && state.tab === request.questions.length
} }
export function questionInfo(request: QuestionRequest, state: QuestionBodyState): QuestionInfo | undefined { export function questionInfo(request: QuestionV2Request, state: QuestionBodyState): QuestionV2Info | undefined {
return request.questions[state.tab] return request.questions[state.tab]
} }
export function questionCustom(request: QuestionRequest, state: QuestionBodyState): boolean { export function questionCustom(request: QuestionV2Request, state: QuestionBodyState): boolean {
return questionInfo(request, state)?.custom !== false return questionInfo(request, state)?.custom !== false
} }
@ -84,7 +84,7 @@ export function questionPicked(state: QuestionBodyState): boolean {
return state.answers[state.tab]?.includes(value) ?? false return state.answers[state.tab]?.includes(value) ?? false
} }
export function questionOther(request: QuestionRequest, state: QuestionBodyState): boolean { export function questionOther(request: QuestionV2Request, state: QuestionBodyState): boolean {
const info = questionInfo(request, state) const info = questionInfo(request, state)
if (!info || info.custom === false) { if (!info || info.custom === false) {
return false return false
@ -93,7 +93,7 @@ export function questionOther(request: QuestionRequest, state: QuestionBodyState
return state.selected === info.options.length return state.selected === info.options.length
} }
export function questionTotal(request: QuestionRequest, state: QuestionBodyState): number { export function questionTotal(request: QuestionV2Request, state: QuestionBodyState): number {
const info = questionInfo(request, state) const info = questionInfo(request, state)
if (!info) { if (!info) {
return 0 return 0
@ -156,7 +156,7 @@ export function questionStoreCustom(state: QuestionBodyState, tab: number, text:
function questionPick( function questionPick(
state: QuestionBodyState, state: QuestionBodyState,
request: QuestionRequest, request: QuestionV2Request,
answer: string, answer: string,
custom = false, custom = false,
): QuestionStep { ): QuestionStep {
@ -204,7 +204,7 @@ function questionToggle(state: QuestionBodyState, answer: string): QuestionBodyS
return storeAnswers(state, state.tab, list) return storeAnswers(state, state.tab, list)
} }
export function questionMove(state: QuestionBodyState, request: QuestionRequest, dir: -1 | 1): QuestionBodyState { export function questionMove(state: QuestionBodyState, request: QuestionV2Request, dir: -1 | 1): QuestionBodyState {
const total = questionTotal(request, state) const total = questionTotal(request, state)
if (total === 0) { if (total === 0) {
return state return state
@ -216,7 +216,7 @@ export function questionMove(state: QuestionBodyState, request: QuestionRequest,
} }
} }
export function questionSelect(state: QuestionBodyState, request: QuestionRequest): QuestionStep { export function questionSelect(state: QuestionBodyState, request: QuestionV2Request): QuestionStep {
const info = questionInfo(request, state) const info = questionInfo(request, state)
if (!info) { if (!info) {
return { state } return { state }
@ -255,7 +255,7 @@ export function questionSelect(state: QuestionBodyState, request: QuestionReques
return questionPick(state, request, option.label) return questionPick(state, request, option.label)
} }
export function questionSave(state: QuestionBodyState, request: QuestionRequest): QuestionStep { export function questionSave(state: QuestionBodyState, request: QuestionV2Request): QuestionStep {
const info = questionInfo(request, state) const info = questionInfo(request, state)
if (!info) { if (!info) {
return { state } return { state }
@ -305,20 +305,20 @@ export function questionSave(state: QuestionBodyState, request: QuestionRequest)
return questionPick(state, request, value, true) return questionPick(state, request, value, true)
} }
export function questionSubmit(request: QuestionRequest, state: QuestionBodyState): QuestionReply { export function questionSubmit(request: QuestionV2Request, state: QuestionBodyState): QuestionReply {
return { return {
requestID: request.id, requestID: request.id,
answers: questionAnswers(state, request.questions.length), answers: questionAnswers(state, request.questions.length),
} }
} }
export function questionReject(request: QuestionRequest): QuestionReject { export function questionReject(request: QuestionV2Request): QuestionReject {
return { return {
requestID: request.id, requestID: request.id,
} }
} }
export function questionHint(request: QuestionRequest, state: QuestionBodyState): string { export function questionHint(request: QuestionV2Request, state: QuestionBodyState): string {
if (state.submitting) { if (state.submitting) {
return "Waiting for question event..." return "Waiting for question event..."
} }

View file

@ -2,13 +2,13 @@ import { Service } from "@opencode-ai/client/effect"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Model } from "@opencode-ai/schema/model" import { Model } from "@opencode-ai/schema/model"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { open } from "node:fs/promises" import { open } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { Server } from "../services/server" import { Server } from "../services/server"
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared" import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
import { runNonInteractivePrompt } from "./noninteractive" import { runNonInteractivePrompt } from "./noninteractive"
import { toolInlineInfo } from "./tool" import { toolInlineInfo } from "./tool"
import type { MiniToolPart } from "./types"
import { UI } from "./ui" import { UI } from "./ui"
export type RunCommandInput = { export type RunCommandInput = {
@ -224,7 +224,7 @@ function isBinaryContent(bytes: Uint8Array) {
return bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3 return bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3
} }
async function renderTool(part: ToolPart) { async function renderTool(part: MiniToolPart) {
const info = toolInlineInfo(part) const info = toolInlineInfo(part)
if (info.mode === "block") { if (info.mode === "block") {
UI.empty() UI.empty()
@ -240,7 +240,7 @@ async function renderTool(part: ToolPart) {
) )
} }
async function renderToolError(part: ToolPart) { async function renderToolError(part: MiniToolPart) {
const info = toolInlineInfo(part) const info = toolInlineInfo(part)
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`) UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
} }

View file

@ -547,7 +547,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
footer, footer,
sessionID: state.sessionID, sessionID: state.sessionID,
thinking: input.thinking, thinking: input.thinking,
limits: () => state.limits,
}) })
} }

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,10 @@
// Session message extraction and prompt history. import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
//
// Fetches session messages from the SDK and extracts user turn text for
// the prompt history ring. Also finds the most recently used variant for
// the current model so the footer can pre-select it.
import { promptCopy, promptSame } from "./prompt.shared" import { promptCopy, promptSame } from "./prompt.shared"
import type { Message, Part } from "@opencode-ai/sdk/v2"
import type { RunInput, RunPrompt } from "./types" import type { RunInput, RunPrompt } from "./types"
const LIMIT = 200 const LIMIT = 200
export type SessionMessages = Array<{ info: Message; parts: Part[] }> export type SessionMessages = SessionMessageInfo[]
type Turn = { type Turn = {
prompt: RunPrompt prompt: RunPrompt
@ -25,133 +20,42 @@ export type RunSession = {
variant?: string variant?: string
} }
function fileName(url: string, filename?: string) { function messagePrompt(message: SessionMessageUser): RunPrompt {
if (filename) {
return filename
}
try {
const next = new URL(url)
if (next.protocol !== "file:") {
return url
}
const name = next.pathname.split("/").at(-1)
if (name) {
return decodeURIComponent(name)
}
} catch {}
return url
}
function fileSource(
part: Extract<SessionMessages[number]["parts"][number], { type: "file" }>,
text: { start: number; end: number; value: string },
) {
if (part.source) {
return {
...structuredClone(part.source),
text,
}
}
return { return {
type: "file" as const, text: message.text,
path: part.filename ?? part.url, parts: [
text, ...(message.files ?? []).map((file) => ({
} type: "file" as const,
} url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
mime: file.mime,
export function messagePrompt(msg: SessionMessages[number]): RunPrompt { filename: file.name,
const parts: RunPrompt["parts"] = [] source: file.mention
let text = msg.parts ? {
.filter((part): part is Extract<SessionMessages[number]["parts"][number], { type: "text" }> => { type: "file",
return part.type === "text" && !part.synthetic path: file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment"),
}) text: { start: file.mention.start, end: file.mention.end, value: file.mention.text },
.map((part) => part.text) }
.join("") : undefined,
let cursor = Bun.stringWidth(text) })),
const used: Array<{ start: number; end: number }> = [] ...(message.agents ?? []).map((agent) => ({
type: "agent" as const,
const take = (value: string): { start: number; end: number; value: string } | undefined => { name: agent.name,
let from = 0 source: agent.mention
while (true) { ? { start: agent.mention.start, end: agent.mention.end, value: agent.mention.text }
const idx = text.indexOf(value, from) : undefined,
if (idx === -1) { })),
return undefined ],
}
const start = Bun.stringWidth(text.slice(0, idx))
const end = start + Bun.stringWidth(value)
if (!used.some((item) => item.start < end && start < item.end)) {
return { start, end, value }
}
from = idx + value.length
}
}
const add = (value: string) => {
const gap = text ? " " : ""
const start = cursor + Bun.stringWidth(gap)
text += gap + value
const end = start + Bun.stringWidth(value)
cursor = end
return { start, end, value }
}
for (const part of msg.parts) {
if (part.type === "file") {
const next = part.source?.text ? structuredClone(part.source.text) : take("@" + fileName(part.url, part.filename))
const span = next ?? add("@" + fileName(part.url, part.filename))
used.push({ start: span.start, end: span.end })
parts.push({
type: "file",
mime: part.mime,
filename: part.filename,
url: part.url,
source: fileSource(part, span),
})
continue
}
if (part.type !== "agent") {
continue
}
const span = part.source ? structuredClone(part.source) : (take("@" + part.name) ?? add("@" + part.name))
used.push({ start: span.start, end: span.end })
parts.push({
type: "agent",
name: part.name,
source: span,
})
}
return { text, parts }
}
function turn(msg: SessionMessages[number]): Turn | undefined {
if (msg.info.role !== "user") {
return undefined
}
return {
prompt: messagePrompt(msg),
provider: msg.info.model.providerID,
model: msg.info.model.modelID,
variant: msg.info.model.variant,
} }
} }
export function createSession(messages: SessionMessages): RunSession { export function createSession(messages: SessionMessages): RunSession {
return { return {
first: messages.length === 0, first: messages.length === 0,
turns: messages.flatMap((msg) => { turns: messages.flatMap((message) =>
const item = turn(msg) message.type === "user"
return item ? [item] : [] ? [{ prompt: messagePrompt(message), provider: undefined, model: undefined, variant: undefined }]
}), : [],
),
} }
} }
@ -164,89 +68,34 @@ export async function resolveCurrentSession(
sdk.message.list({ sessionID, limit, order: "desc" }), sdk.message.list({ sessionID, limit, order: "desc" }),
sdk.session.get({ sessionID }), sdk.session.get({ sessionID }),
]) ])
const messages = response.data.toReversed() const current = createSession(response.data.toReversed())
return { return {
first: messages.length === 0, ...current,
turns: messages.flatMap((message) => { turns: current.turns.map((turn) => ({
if (message.type !== "user") return [] ...turn,
return [ provider: session.model?.providerID,
{ model: session.model?.id,
prompt: { variant: session.model?.variant,
text: message.text, })),
parts: [
...(message.files ?? []).map((file) => ({
type: "file" as const,
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
mime: file.mime,
filename: file.name,
source: file.mention
? {
type: "file" as const,
path: file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment"),
text: { start: file.mention.start, end: file.mention.end, value: file.mention.text },
}
: undefined,
})),
...(message.agents ?? []).map((agent) => ({
type: "agent" as const,
name: agent.name,
source: agent.mention
? { start: agent.mention.start, end: agent.mention.end, value: agent.mention.text }
: undefined,
})),
],
},
provider: session.model?.providerID,
model: session.model?.id,
variant: session.model?.variant,
},
]
}),
...(session.model && { ...(session.model && {
model: { model: { providerID: session.model.providerID, modelID: session.model.id },
providerID: session.model.providerID,
modelID: session.model.id,
},
variant: session.model.variant, variant: session.model.variant,
}), }),
} }
} }
export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] { export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] {
const out: RunPrompt[] = [] return session.turns
.map((turn) => turn.prompt)
for (const turn of session.turns) { .filter((prompt) => prompt.text.trim())
if (!turn.prompt.text.trim()) { .filter((prompt, index, prompts) => index === 0 || !promptSame(prompts[index - 1], prompt))
continue .map(promptCopy)
} .slice(-limit)
if (out[out.length - 1] && promptSame(out[out.length - 1], turn.prompt)) {
continue
}
out.push(promptCopy(turn.prompt))
}
return out.slice(-limit)
} }
export function sessionVariant(session: RunSession, model: RunInput["model"]): string | undefined { export function sessionVariant(session: RunSession, model: RunInput["model"]): string | undefined {
if (!model) { if (!model) return
return undefined if (session.model?.providerID === model.providerID && session.model.modelID === model.modelID) return session.variant
}
if (session.model?.providerID === model.providerID && session.model.modelID === model.modelID) { return session.turns.findLast((turn) => turn.provider === model.providerID && turn.model === model.modelID)?.variant
return session.variant
}
for (let idx = session.turns.length - 1; idx >= 0; idx -= 1) {
const turn = session.turns[idx]
if (turn.provider !== model.providerID || turn.model !== model.modelID) {
continue
}
return turn.variant
}
return undefined
} }

View file

@ -15,10 +15,14 @@
// Per-child interruption uses `v2.session.interrupt(childID)`. Per-child // Per-child interruption uses `v2.session.interrupt(childID)`. Per-child
// backgrounding is intentionally absent: subagent jobs block the parent // backgrounding is intentionally absent: subagent jobs block the parent
// session, so only whole-session `v2.session.background(parentID)` exists. // session, so only whole-session `v2.session.background(parentID)` exists.
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise" import type {
import type { SessionMessageAssistantTool, SessionMessageInfo, ToolPart } from "@opencode-ai/sdk/v2" EventSubscribeOutput,
OpenCodeClient,
SessionMessageAssistantTool,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { Locale } from "@opencode-ai/tui/util/locale" import { Locale } from "@opencode-ai/tui/util/locale"
import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types" import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, MiniToolPart, StreamCommit } from "./types"
const CHILD_MESSAGE_LIMIT = 80 const CHILD_MESSAGE_LIMIT = 80
const CHILD_FRAME_LIMIT = 80 const CHILD_FRAME_LIMIT = 80
@ -32,11 +36,11 @@ export function outputText(content: ReadonlyArray<{ type: string; text?: string
return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n") return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n")
} }
export function legacyTool(input: { export function miniTool(input: {
sessionID: string sessionID: string
messageID: string messageID: string
tool: SessionMessageAssistantTool tool: SessionMessageAssistantTool
}): ToolPart { }): MiniToolPart {
const tool = input.tool const tool = input.tool
const providerCall = const providerCall =
tool.executed === undefined && tool.providerState === undefined tool.executed === undefined && tool.providerState === undefined
@ -109,7 +113,7 @@ export function legacyTool(input: {
} }
} }
export function toolCommit(part: ToolPart, phase: "start" | "progress" | "final"): StreamCommit { export function toolCommit(part: MiniToolPart, phase: "start" | "progress" | "final"): StreamCommit {
const status = part.state.status const status = part.state.status
const text = const text =
status === "running" status === "running"
@ -310,7 +314,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
} }
const childTool = (child: ChildState, item: SessionMessageAssistantTool, messageID: string) => { const childTool = (child: ChildState, item: SessionMessageAssistantTool, messageID: string) => {
const part = legacyTool({ const part = miniTool({
sessionID: child.sessionID, sessionID: child.sessionID,
messageID, messageID,
tool: item, tool: item,

View file

@ -1,14 +1,15 @@
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import type { import type {
PermissionRequest, EventSubscribeOutput,
QuestionRequest, OpenCodeClient,
SessionMessageInfo, PermissionV2Request,
QuestionV2Request,
SessionMessageAssistantTool, SessionMessageAssistantTool,
} from "@opencode-ai/sdk/v2" SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
import { blockerStatus, pickBlockerView } from "./session-data" import { blockerStatus, pickBlockerView } from "./session-data"
import { writeSessionOutput } from "./stream" import { writeSessionOutput } from "./stream"
import { createSubagentTracker, legacyTool, toolCommit } from "./stream-v2.subagent" import { createSubagentTracker, miniTool, toolCommit } from "./stream-v2.subagent"
import type { import type {
FooterApi, FooterApi,
FooterView, FooterView,
@ -87,8 +88,6 @@ type ShellWait = {
} }
type RunV2Event = EventSubscribeOutput type RunV2Event = EventSubscribeOutput
type PermissionV2Request = Extract<RunV2Event, { type: "permission.v2.asked" }>["data"]
type QuestionV2Request = Extract<RunV2Event, { type: "question.v2.asked" }>["data"]
type PromptFilePart = Extract<RunPromptPart, { type: "file" }> type PromptFilePart = Extract<RunPromptPart, { type: "file" }>
type ToolState = { type ToolState = {
@ -101,8 +100,8 @@ type ToolState = {
} }
type State = { type State = {
permissions: PermissionRequest[] permissions: PermissionV2Request[]
questions: QuestionRequest[] questions: QuestionV2Request[]
view: FooterView view: FooterView
messageIDs: Set<string> messageIDs: Set<string>
text: Map<string, string> text: Map<string, string>
@ -138,27 +137,6 @@ export function formatUnknownError(error: unknown): string {
return "unknown error" return "unknown error"
} }
function permission(request: PermissionV2Request): PermissionRequest {
return {
id: request.id,
sessionID: request.sessionID,
permission: request.action,
patterns: [...request.resources],
metadata: request.metadata ?? {},
always: [...(request.save ?? [])],
tool: request.source?.type === "tool" ? request.source : undefined,
}
}
function question(request: QuestionV2Request): QuestionRequest {
return {
id: request.id,
sessionID: request.sessionID,
questions: request.questions.map((item) => ({ ...item, options: item.options.map((option) => ({ ...option })) })),
tool: request.tool,
}
}
function sessionID(event: RunV2Event) { function sessionID(event: RunV2Event) {
return "sessionID" in event.data && typeof event.data.sessionID === "string" ? event.data.sessionID : undefined return "sessionID" in event.data && typeof event.data.sessionID === "string" ? event.data.sessionID : undefined
} }
@ -229,8 +207,7 @@ function streamPartKey(messageID: string, partID: string) {
return `${messageID}\u0000${partID}` return `${messageID}\u0000${partID}`
} }
// Matches the commit shapes the legacy session-data reducer produced for direct // Direct shell calls use one "start" commit rendering `$ command` and one "progress"
// shell calls: one "start" commit rendering `$ command` and one "progress"
// commit rendering the merged output (see toolEntryBody in tool.ts). // commit rendering the merged output (see toolEntryBody in tool.ts).
function shellCommit( function shellCommit(
callID: string, callID: string,
@ -384,7 +361,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
} }
const renderTool = (messageID: string, item: SessionMessageAssistantTool) => { const renderTool = (messageID: string, item: SessionMessageAssistantTool) => {
const part = legacyTool({ const part = miniTool({
sessionID: input.sessionID, sessionID: input.sessionID,
messageID, messageID,
tool: item, tool: item,
@ -536,8 +513,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
]) ])
const projected = structuredClone(messages.data).toReversed() as SessionMessageInfo[] const projected = structuredClone(messages.data).toReversed() as SessionMessageInfo[]
for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait) for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait)
state.permissions = permissions.map(permission) state.permissions = permissions
state.questions = questions.map(question) state.questions = questions
syncBlockers() syncBlockers()
await subagents.hydrate({ messages: [...projected], active }) await subagents.hydrate({ messages: [...projected], active })
const running = input.sessionID in active const running = input.sessionID in active
@ -770,7 +747,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return return
} }
if (event.type === "permission.v2.asked") { if (event.type === "permission.v2.asked") {
if (!state.permissions.some((item) => item.id === event.data.id)) state.permissions.push(permission(event.data)) if (!state.permissions.some((item) => item.id === event.data.id)) state.permissions.push(event.data)
syncBlockers() syncBlockers()
return return
} }
@ -780,7 +757,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return return
} }
if (event.type === "question.v2.asked") { if (event.type === "question.v2.asked") {
if (!state.questions.some((item) => item.id === event.data.id)) state.questions.push(question(event.data)) if (!state.questions.some((item) => item.id === event.data.id)) state.questions.push(event.data)
syncBlockers() syncBlockers()
return return
} }

View file

@ -1,10 +1,10 @@
// Thin bridge between reducer output and the footer API. // Thin bridge between transport output and the footer API.
// //
// The reducers produce StreamCommit[] and an optional FooterOutput (patch + // Transports produce StreamCommit[] and an optional FooterOutput (patch +
// view + subagent state). This module forwards them to footer.append() and // view + subagent state). This module forwards them to footer.append() and
// footer.event() respectively, adding trace writes along the way. It also // footer.event() respectively, adding trace writes along the way. It also
// defaults status updates to phase "running" if the caller didn't set a // defaults status updates to phase "running" if the caller didn't set a
// phase -- a convenience so reducer code doesn't have to repeat that. // phase -- a convenience so transport code doesn't have to repeat that.
import type { FooterApi, FooterOutput, FooterPatch, FooterSubagentState, StreamCommit } from "./types" import type { FooterApi, FooterOutput, FooterPatch, FooterSubagentState, StreamCommit } from "./types"
type Trace = { type Trace = {
@ -103,9 +103,9 @@ export function traceSubagentState(state: FooterSubagentState) {
permissions: state.permissions.map((item) => ({ permissions: state.permissions.map((item) => ({
id: item.id, id: item.id,
sessionID: item.sessionID, sessionID: item.sessionID,
permission: item.permission, action: item.action,
patterns: item.patterns, resources: item.resources,
tool: item.tool, source: item.source,
metadata: item.metadata metadata: item.metadata
? { ? {
keys: Object.keys(item.metadata), keys: Object.keys(item.metadata),
@ -137,7 +137,7 @@ export function traceFooterOutput(footer?: FooterOutput) {
} }
} }
// Forwards reducer output to the footer: commits go to scrollback, patches update the status bar. // Forwards transport output to the footer: commits go to scrollback, patches update the status bar.
export function writeSessionOutput(input: OutputInput, out: StreamOutput): void { export function writeSessionOutput(input: OutputInput, out: StreamOutput): void {
for (const commit of out.commits) { for (const commit of out.commits) {
input.trace?.write("ui.commit", commit) input.trace?.write("ui.commit", commit)

View file

@ -15,10 +15,9 @@
import os from "os" import os from "os"
import path from "path" import path from "path"
import stripAnsi from "strip-ansi" import stripAnsi from "strip-ansi"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { LANGUAGE_EXTENSIONS } from "@opencode-ai/tui/util/filetype" import { LANGUAGE_EXTENSIONS } from "@opencode-ai/tui/util/filetype"
import { Locale } from "@opencode-ai/tui/util/locale" import { Locale } from "@opencode-ai/tui/util/locale"
import type { RunEntryBody, StreamCommit, ToolSnapshot } from "./types" import type { MiniToolPart, RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
export type ToolView = { export type ToolView = {
output: boolean output: boolean
@ -1177,7 +1176,7 @@ function rule(name?: string): AnyToolRule | undefined {
return TOOL_RULES[name] return TOOL_RULES[name]
} }
function frame(part: ToolPart): ToolFrame { function frame(part: MiniToolPart): ToolFrame {
const state = dict(part.state) const state = dict(part.state)
return { return {
raw: "", raw: "",
@ -1231,7 +1230,7 @@ export function toolStructuredFinal(commit: StreamCommit): boolean {
) )
} }
export function toolInlineInfo(part: ToolPart): ToolInline { export function toolInlineInfo(part: MiniToolPart): ToolInline {
const ctx = frame(part) const ctx = frame(part)
const draw = rule(ctx.name)?.run const draw = rule(ctx.name)?.run
try { try {

View file

@ -7,12 +7,16 @@
// //
// Data flow through the system: // Data flow through the system:
// //
// SDK events → session-data reducer → StreamCommit[] + FooterOutput // V2 events / demo actions → StreamCommit[] + FooterOutput
// → stream.ts bridges to footer API // → stream.ts bridges to footer API
// → footer.ts queues commits and patches the footer view // → footer.ts queues commits and patches the footer view
// → OpenTUI split-footer renderer writes to terminal // → OpenTUI split-footer renderer writes to terminal
import type { OpenCodeClient, ReferenceListOutput } from "@opencode-ai/client/promise" import type {
import type { FilePart, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" OpenCodeClient,
PermissionV2Request,
QuestionV2Request,
ReferenceListOutput,
} from "@opencode-ai/client/promise"
import type { TuiConfig } from "@opencode-ai/tui/config/v1" import type { TuiConfig } from "@opencode-ai/tui/config/v1"
export type RunFilePart = { export type RunFilePart = {
@ -30,7 +34,11 @@ export type RunPromptPart =
url: string url: string
filename?: string filename?: string
mime?: string mime?: string
source?: FilePart["source"] source?: {
type: string
text: { start: number; end: number; value: string }
[key: string]: unknown
}
} }
| { type: "agent"; name: string; source?: { start: number; end: number; value: string } } | { type: "agent"; name: string; source?: { start: number; end: number; value: string } }
@ -210,6 +218,41 @@ export type ToolQuestionSnapshot = {
export type ToolSnapshot = ToolCodeSnapshot | ToolDiffSnapshot | ToolTaskSnapshot | ToolQuestionSnapshot export type ToolSnapshot = ToolCodeSnapshot | ToolDiffSnapshot | ToolTaskSnapshot | ToolQuestionSnapshot
export type MiniToolState =
| { status: "pending"; input: Record<string, unknown>; raw?: string }
| {
status: "running"
input: Record<string, unknown>
title?: string
metadata?: Record<string, unknown>
time: { start: number }
}
| {
status: "completed"
input: Record<string, unknown>
output: string
title?: string
metadata?: Record<string, unknown>
time: { start: number; end: number }
}
| {
status: "error"
input: Record<string, unknown>
error: string
metadata?: Record<string, unknown>
time: { start: number; end: number }
}
export type MiniToolPart = {
id: string
sessionID: string
messageID: string
type?: "tool"
callID: string
tool: string
state: MiniToolState
}
export type EntryLayout = "inline" | "block" export type EntryLayout = "inline" | "block"
export type RunEntryBody = export type RunEntryBody =
@ -220,13 +263,13 @@ export type RunEntryBody =
| { type: "structured"; snapshot: ToolSnapshot } | { type: "structured"; snapshot: ToolSnapshot }
// Which interactive surface the footer is showing. Only one view is active at // Which interactive surface the footer is showing. Only one view is active at
// a time. The reducer drives transitions: when a permission arrives the view // a time. The transport drives transitions: when a permission arrives the view
// switches to "permission", and when the permission resolves it falls back to // switches to "permission", and when the permission resolves it falls back to
// "prompt". // "prompt".
export type FooterView = export type FooterView =
| { type: "prompt" } | { type: "prompt" }
| { type: "permission"; request: PermissionRequest } | { type: "permission"; request: PermissionV2Request }
| { type: "question"; request: QuestionRequest } | { type: "question"; request: QuestionV2Request }
export type FooterPromptRoute = export type FooterPromptRoute =
| { type: "composer" } | { type: "composer" }
@ -259,11 +302,11 @@ export type FooterSubagentDetail = {
export type FooterSubagentState = { export type FooterSubagentState = {
tabs: FooterSubagentTab[] tabs: FooterSubagentTab[]
details: Record<string, FooterSubagentDetail> details: Record<string, FooterSubagentDetail>
permissions: PermissionRequest[] permissions: PermissionV2Request[]
questions: QuestionRequest[] questions: QuestionV2Request[]
} }
// The reducer emits this alongside scrollback commits so the footer can update in the same frame. // The transport emits this alongside scrollback commits so the footer can update in the same frame.
export type FooterOutput = { export type FooterOutput = {
patch?: FooterPatch patch?: FooterPatch
view?: FooterView view?: FooterView
@ -357,8 +400,8 @@ export type StreamSource = "assistant" | "reasoning" | "tool" | "system"
export type StreamToolState = "running" | "completed" | "error" export type StreamToolState = "running" | "completed" | "error"
// A single append-only commit to scrollback. The session-data reducer produces // A single append-only commit to scrollback. The transport produces these from
// these from SDK events, and RunFooter.append() queues them for the next // V2 events, and RunFooter.append() queues them for the next
// microtask flush. Once flushed, they become immutable terminal scrollback // microtask flush. Once flushed, they become immutable terminal scrollback
// rows -- they cannot be rewritten. // rows -- they cannot be rewritten.
export type StreamCommit = { export type StreamCommit = {
@ -370,7 +413,7 @@ export type StreamCommit = {
messageID?: string messageID?: string
partID?: string partID?: string
tool?: string tool?: string
part?: ToolPart part?: MiniToolPart
interrupted?: boolean interrupted?: boolean
toolState?: StreamToolState toolState?: StreamToolState
toolError?: string toolError?: string