From 358d4746a99ccc87d28e4404a44952eb3d6d777c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 13 Jul 2026 18:02:49 -0400 Subject: [PATCH] refactor(cli): remove legacy sdk dependency --- bun.lock | 1 - packages/cli/package.json | 1 - .../cli/src/commands/handlers/debug/agents.ts | 8 +- .../cli/src/commands/handlers/mcp/auth.ts | 22 +- .../cli/src/commands/handlers/mcp/list.ts | 8 +- .../cli/src/commands/handlers/mcp/logout.ts | 6 +- .../cli/src/commands/handlers/mcp/resolve.ts | 13 +- packages/cli/src/mini/demo.ts | 419 ++----- packages/cli/src/mini/footer.permission.tsx | 6 +- packages/cli/src/mini/footer.question.tsx | 4 +- packages/cli/src/mini/noninteractive.ts | 20 +- packages/cli/src/mini/permission.shared.ts | 29 +- packages/cli/src/mini/question.shared.ts | 30 +- packages/cli/src/mini/run.ts | 6 +- packages/cli/src/mini/runtime.ts | 1 - packages/cli/src/mini/session-data.ts | 1069 +---------------- packages/cli/src/mini/session.shared.ts | 249 +--- packages/cli/src/mini/stream-v2.subagent.ts | 18 +- packages/cli/src/mini/stream-v2.transport.ts | 53 +- packages/cli/src/mini/stream.ts | 14 +- packages/cli/src/mini/tool.ts | 7 +- packages/cli/src/mini/types.ts | 69 +- 22 files changed, 349 insertions(+), 1704 deletions(-) diff --git a/bun.lock b/bun.lock index 7589f01e7f..c0f7eb05a6 100644 --- a/bun.lock +++ b/bun.lock @@ -108,7 +108,6 @@ "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/schema": "workspace:*", - "@opencode-ai/sdk": "workspace:*", "@opencode-ai/server": "workspace:*", "@opencode-ai/tui": "workspace:*", "@opentui/core": "catalog:", diff --git a/packages/cli/package.json b/packages/cli/package.json index e5172df3c4..217ac4f86c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -36,7 +36,6 @@ "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/schema": "workspace:*", - "@opencode-ai/sdk": "workspace:*", "@opencode-ai/server": "workspace:*", "@opencode-ai/tui": "workspace:*", "@opentui/core": "catalog:", diff --git a/packages/cli/src/commands/handlers/debug/agents.ts b/packages/cli/src/commands/handlers/debug/agents.ts index ec7925fc44..3126e8d277 100644 --- a/packages/cli/src/commands/handlers/debug/agents.ts +++ b/packages/cli/src/commands/handlers/debug/agents.ts @@ -1,6 +1,6 @@ import { EOL } from "os" import { Effect } from "effect" -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { OpenCode } from "@opencode-ai/client" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { Service } from "@opencode-ai/client/effect" @@ -12,11 +12,11 @@ export default Runtime.handler( const options = yield* ServiceConfig.options() const found = yield* Service.discover(options) const endpoint = found ?? (yield* Service.start(options)) - const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) - const response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } })) + const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) + const response = yield* Effect.promise(() => client.agent.list({ location: { directory: process.cwd() } })) process.stdout.write( JSON.stringify( - response.data?.data.toSorted((a, b) => a.id.localeCompare(b.id)), + response.data.toSorted((a, b) => a.id.localeCompare(b.id)), null, 2, ) + EOL, diff --git a/packages/cli/src/commands/handlers/mcp/auth.ts b/packages/cli/src/commands/handlers/mcp/auth.ts index 03a5e687fd..6fc4200ff4 100644 --- a/packages/cli/src/commands/handlers/mcp/auth.ts +++ b/packages/cli/src/commands/handlers/mcp/auth.ts @@ -1,11 +1,11 @@ import { EOL } from "node:os" import { Effect } from "effect" import { - createOpencodeClient, + OpenCode, type IntegrationAttemptStatus, type IntegrationOAuthMethod, - type OpencodeClient, -} from "@opencode-ai/sdk/v2/client" + type OpenCodeClient, +} from "@opencode-ai/client" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { Service } from "@opencode-ai/client/effect" @@ -20,7 +20,7 @@ export default Runtime.handler( const options = yield* ServiceConfig.options() const found = yield* Service.discover(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) 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`)) 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 - if (!attempt) return yield* Effect.fail(new Error(started.error?.message ?? "Failed to start OAuth attempt")) + const attempt = started.data if (attempt.mode === "code") 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 = ( - client: OpencodeClient, + client: OpenCodeClient, attemptID: string, ): Effect.Effect> => Effect.gen(function* () { - const response = yield* Effect.promise(() => client.v2.integration.attempt.status({ attemptID, location })) - const status = response.data?.data - if (!status || status.status === "pending") { + const status = yield* Effect.promise(() => client.integration.attempt.status({ attemptID, location })).pipe( + Effect.map((result) => result.data), + ) + if (status.status === "pending") { yield* Effect.sleep("1 second") return yield* poll(client, attemptID) } diff --git a/packages/cli/src/commands/handlers/mcp/list.ts b/packages/cli/src/commands/handlers/mcp/list.ts index 3c44a839e3..eb4412eedf 100644 --- a/packages/cli/src/commands/handlers/mcp/list.ts +++ b/packages/cli/src/commands/handlers/mcp/list.ts @@ -1,6 +1,6 @@ import { EOL } from "node:os" 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 { Runtime } from "../../../framework/runtime" import { Service } from "@opencode-ai/client/effect" @@ -12,9 +12,9 @@ export default Runtime.handler( const options = yield* ServiceConfig.options() const found = yield* Service.discover(options) const endpoint = found ?? (yield* Service.start(options)) - const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) - const response = yield* Effect.promise(() => client.v2.mcp.list({ location: { directory: process.cwd() } })) - const servers = (response.data?.data ?? []).toSorted((a, b) => a.name.localeCompare(b.name)) + const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) + const response = yield* Effect.promise(() => client.mcp.list({ location: { directory: process.cwd() } })) + const servers = response.data.toSorted((a, b) => a.name.localeCompare(b.name)) if (servers.length === 0) { process.stdout.write("No MCP servers configured" + EOL) return diff --git a/packages/cli/src/commands/handlers/mcp/logout.ts b/packages/cli/src/commands/handlers/mcp/logout.ts index 271953c306..bcf06ea983 100644 --- a/packages/cli/src/commands/handlers/mcp/logout.ts +++ b/packages/cli/src/commands/handlers/mcp/logout.ts @@ -1,6 +1,6 @@ import { EOL } from "node:os" import { Effect } from "effect" -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { OpenCode } from "@opencode-ai/client" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { Service } from "@opencode-ai/client/effect" @@ -15,7 +15,7 @@ export default Runtime.handler( const options = yield* ServiceConfig.options() const found = yield* Service.discover(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) if (!integration) { @@ -31,7 +31,7 @@ export default Runtime.handler( yield* Effect.forEach( credentials, - (connection) => Effect.promise(() => client.v2.credential.remove({ credentialID: connection.id, location })), + (connection) => Effect.promise(() => client.credential.remove({ credentialID: connection.id, location })), { discard: true }, ) process.stdout.write(`Removed OAuth credentials for ${input.name}` + EOL) diff --git a/packages/cli/src/commands/handlers/mcp/resolve.ts b/packages/cli/src/commands/handlers/mcp/resolve.ts index 58580e6def..1ee5e19568 100644 --- a/packages/cli/src/commands/handlers/mcp/resolve.ts +++ b/packages/cli/src/commands/handlers/mcp/resolve.ts @@ -1,17 +1,18 @@ 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 // 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 // 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* () { - const servers = yield* Effect.promise(() => client.v2.mcp.list({ location })) - const server = (servers.data?.data ?? []).find((entry) => entry.name === name) + const servers = yield* Effect.promise(() => client.mcp.list({ location })) + const server = servers.data.find((entry) => entry.name === name) if (!server) return yield* Effect.fail(new Error(`MCP server not found: ${name}`)) const integrationID = server.integrationID if (!integrationID) return undefined - const found = yield* Effect.promise(() => client.v2.integration.get({ integrationID, location })) - return found.data?.data + return yield* Effect.promise(() => client.integration.get({ integrationID, location })).pipe( + Effect.map((result) => result.data ?? undefined), + ) }) diff --git a/packages/cli/src/mini/demo.ts b/packages/cli/src/mini/demo.ts index 93790ce653..b37f711317 100644 --- a/packages/cli/src/mini/demo.ts +++ b/packages/cli/src/mini/demo.ts @@ -1,7 +1,7 @@ // Demo mode for testing direct interactive mode without a real SDK. // -// Enabled with `--demo`. Intercepts prompt submissions and generates synthetic -// SDK events that feed through the real reducer and footer pipeline. This +// Enabled with `--demo`. Intercepts prompt submissions and drives the same +// presentation commits and footer actions as the live transport. This // lets you test scrollback formatting, permission UI, question UI, and tool // snapshots without making actual model calls. Pass a demo slash command as // the initial interactive message to trigger a preview immediately. @@ -15,10 +15,18 @@ // Demo mode also handles permission and question replies locally, completing // or failing the synthetic tool parts as appropriate. import path from "path" -import type { Event, ToolPart } from "@opencode-ai/sdk/v2" -import { createSessionData, reduceSessionData, type SessionData } from "./session-data" +import type { PermissionV2Request, QuestionV2Request } from "@opencode-ai/client/promise" 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 = [ "markdown", @@ -124,7 +132,7 @@ type Permit = { ref: Ref permission: string patterns: string[] - metadata?: Record + metadata?: PermissionV2Request["metadata"] always: string[] done: Perm["done"] } @@ -132,9 +140,7 @@ type Permit = { type State = { id: string thinking: boolean - data: SessionData footer: FooterApi - limits: () => Record msg: number part: number call: number @@ -142,12 +148,12 @@ type State = { ask: number perms: Map asks: Map + started: Set } type Input = { sessionID: string thinking: boolean - limits: () => Record footer: FooterApi } @@ -255,185 +261,69 @@ function take(state: State, key: "msg" | "part" | "call" | "perm" | "ask", prefi return `demo_${prefix}_${state[key]}` } -function feed(state: State, event: Event): void { - const out = reduceSessionData({ - data: state.data, - event, - sessionID: state.id, - thinking: state.thinking, - limits: state.limits(), - }) - state.data = out.data +function present(state: State, commits: StreamCommit[], view?: QuestionV2Request | PermissionV2Request): void { 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 { - const id = 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 + return take(state, "msg", "msg") } async function emitText(state: State, body: string, signal?: AbortSignal): Promise { const msg = open(state) 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)) { if (signal?.aborted) { return } - next += item - feed(state, { - type: "message.part.delta", - properties: { - sessionID: state.id, - messageID: msg, - partID: part, - field: "text", - delta: item, - }, - } as Event) + present(state, [{ kind: "assistant", source: "assistant", text: item, phase: "progress", messageID: msg, partID: part }]) 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 { const msg = open(state) 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: "reasoning", - text: "", - time: { - start, - }, - }, - }, - } as Event) - - let next = "" + let first = true for (const item of split(body)) { if (signal?.aborted) { return } - next += item - feed(state, { - type: "message.part.delta", - properties: { - sessionID: state.id, - messageID: msg, - partID: part, - field: "text", - delta: item, - }, - } as Event) + if (state.thinking) { + present(state, [ + { + kind: "reasoning", + source: "reasoning", + text: first ? `Thinking: ${item.replace(/\[REDACTED\]/g, "")}` : item.replace(/\[REDACTED\]/g, ""), + phase: "progress", + messageID: msg, + partID: part, + }, + ]) + first = false + } 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): Ref { @@ -448,29 +338,23 @@ function make(state: State, tool: string, input: Record): Ref { } function startTool(state: State, ref: Ref, metadata: Record = {}): void { - feed(state, { - type: "message.part.updated", - properties: { - sessionID: state.id, - time: Date.now(), - part: { - id: ref.part, - sessionID: state.id, - messageID: ref.msg, - type: "tool", - callID: ref.call, - tool: ref.tool, - state: { - status: "running", - input: ref.input, - metadata, - time: { - start: ref.start, - }, + state.started.add(ref.part) + present( + state, + [ + toolCommit( + { + id: ref.part, + sessionID: state.id, + messageID: ref.msg, + callID: ref.call, + tool: ref.tool, + state: { status: "running", input: ref.input, metadata, time: { start: ref.start } }, }, - }, - }, - } as Event) + "start", + ), + ], + ) } function askPermission(state: State, item: Permit): void { @@ -482,21 +366,15 @@ function askPermission(state: State, item: Permit): void { done: item.done, }) - feed(state, { - type: "permission.asked", - properties: { - id, - sessionID: state.id, - permission: item.permission, - patterns: item.patterns, - metadata: item.metadata ?? {}, - always: item.always, - tool: { - messageID: item.ref.msg, - callID: item.ref.call, - }, - }, - } as Event) + present(state, [], { + id, + sessionID: state.id, + action: item.permission, + resources: item.patterns, + metadata: item.metadata ?? {}, + save: item.always, + source: { type: "tool", messageID: item.ref.msg, callID: item.ref.call }, + }) } function doneTool( @@ -508,77 +386,53 @@ function doneTool( metadata?: Record }, ): void { - feed(state, { - type: "message.part.updated", - properties: { - sessionID: state.id, - time: Date.now(), - part: { - id: ref.part, - sessionID: state.id, - messageID: ref.msg, - type: "tool", - callID: ref.call, - tool: ref.tool, - state: { - status: "completed", - input: ref.input, - output: output.output, - title: output.title, - metadata: output.metadata ?? {}, - time: { - start: ref.start, - end: Date.now(), - }, - }, - }, + if (!state.started.has(ref.part)) startTool(state, ref) + const part: MiniToolPart = { + id: ref.part, + sessionID: state.id, + messageID: ref.msg, + callID: ref.call, + tool: ref.tool, + state: { + status: "completed", + 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 { - feed(state, { - type: "message.part.updated", - properties: { - sessionID: state.id, - time: Date.now(), - part: { - id: ref.part, - sessionID: state.id, - messageID: ref.msg, - type: "tool", - callID: ref.call, - tool: ref.tool, - state: { - status: "error", - input: ref.input, - error, - metadata: {}, - time: { - start: ref.start, - end: Date.now(), + if (!state.started.has(ref.part)) startTool(state, ref) + present( + state, + [ + toolCommit( + { + id: ref.part, + sessionID: state.id, + messageID: ref.msg, + callID: ref.call, + tool: ref.tool, + state: { + status: "error", + input: ref.input, + error, + metadata: {}, + time: { start: ref.start, end: Date.now() }, }, }, - }, - }, - } as Event) + "final", + ), + ], + ) } function emitError(state: State, text: string): void { - const event = { - 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) + present(state, [{ kind: "error", source: "system", text, phase: "start" }]) } async function emitBash(state: State, signal?: AbortSignal): Promise { @@ -685,7 +539,7 @@ function emitTask(state: State): void { start: Date.now(), }, }, - } satisfies ToolPart + } satisfies MiniToolPart showSubagent(state, { sessionID: "sub_demo_1", partID: ref.part, @@ -979,18 +833,12 @@ function emitQuestion(state: State, kind: QuestionKind = "multi"): void { const id = take(state, "ask", "ask") state.asks.set(id, { ref }) - feed(state, { - type: "question.asked", - properties: { - id, - sessionID: state.id, - questions, - tool: { - messageID: ref.msg, - callID: ref.call, - }, - }, - } as Event) + present(state, [], { + id, + sessionID: state.id, + questions, + tool: { messageID: ref.msg, callID: ref.call }, + }) } async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise { @@ -1089,9 +937,7 @@ export function createRunDemo(input: Input) { const state: State = { id: input.sessionID, thinking: input.thinking, - data: createSessionData(), footer: input.footer, - limits: input.limits, msg: 0, part: 0, call: 0, @@ -1099,6 +945,7 @@ export function createRunDemo(input: Input) { ask: 0, perms: new Map(), asks: new Map(), + started: new Set(), } const start = async (): Promise => { @@ -1166,16 +1013,7 @@ export function createRunDemo(input: Input) { } state.perms.delete(input.requestID) - const event = { - 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) + clearBlocker(state) if (input.reply === "reject") { failTool(state, item.ref, input.message || "permission rejected") @@ -1193,16 +1031,7 @@ export function createRunDemo(input: Input) { } state.asks.delete(input.requestID) - const event = { - 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) + clearBlocker(state) doneTool(state, ask.ref, { title: "question", output: "", @@ -1220,13 +1049,7 @@ export function createRunDemo(input: Input) { } state.asks.delete(input.requestID) - feed(state, { - type: "question.rejected", - properties: { - sessionID: state.id, - requestID: input.requestID, - }, - } as Event) + clearBlocker(state) failTool(state, ask.ref, "question rejected") return true } diff --git a/packages/cli/src/mini/footer.permission.tsx b/packages/cli/src/mini/footer.permission.tsx index f513b4ab62..c9629364b4 100644 --- a/packages/cli/src/mini/footer.permission.tsx +++ b/packages/cli/src/mini/footer.permission.tsx @@ -14,7 +14,7 @@ import type { TextareaRenderable } from "@opentui/core" import { useKeyboard, useTerminalDimensions } from "@opentui/solid" 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 { createPermissionBodyState, permissionAlwaysLines, @@ -130,7 +130,7 @@ export function RejectField(props: { } export function RunPermissionBody(props: { - request: PermissionRequest + request: PermissionV2Request theme: RunFooterTheme block: RunBlockTheme diffStyle?: RunDiffStyle @@ -142,7 +142,7 @@ export function RunPermissionBody(props: { const ft = createMemo(() => toolFiletype(info().file)) const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow) 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 title = createMemo(() => { diff --git a/packages/cli/src/mini/footer.question.tsx b/packages/cli/src/mini/footer.question.tsx index 6b0b40bbd0..f6014c1168 100644 --- a/packages/cli/src/mini/footer.question.tsx +++ b/packages/cli/src/mini/footer.question.tsx @@ -16,7 +16,7 @@ import type { TextareaRenderable } from "@opentui/core" import { useKeyboard, useTerminalDimensions } from "@opentui/solid" 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 { createQuestionBodyState, questionConfirm, @@ -45,7 +45,7 @@ import type { RunFooterTheme } from "./theme" import type { QuestionReject, QuestionReply } from "./types" export function RunQuestionBody(props: { - request: QuestionRequest + request: QuestionV2Request theme: RunFooterTheme onReply: (input: QuestionReply) => void | Promise onReject: (input: QuestionReject) => void | Promise diff --git a/packages/cli/src/mini/noninteractive.ts b/packages/cli/src/mini/noninteractive.ts index 978bacacc5..0ea58394a8 100644 --- a/packages/cli/src/mini/noninteractive.ts +++ b/packages/cli/src/mini/noninteractive.ts @@ -1,8 +1,8 @@ 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 { EOL } from "node:os" import { UI } from "./ui" +import type { MiniToolPart } from "./types" type Model = { providerID: string @@ -28,8 +28,8 @@ type Input = { auto: boolean /** True when the client is attached to a shared server rather than an exclusive in-process one. */ attached: boolean - renderTool: (part: ToolPart) => Promise - renderToolError: (part: ToolPart) => Promise + renderTool: (part: MiniToolPart) => Promise + renderToolError: (part: MiniToolPart) => Promise } type StartedPart = { @@ -77,7 +77,7 @@ export async function runNonInteractivePrompt(input: Input) { return true } - const writeText = (part: TextPart, timestamp: number) => { + const writeText = (part: { text: string; [key: string]: unknown }, timestamp: number) => { if (emit("text", timestamp, { part })) return const text = part.text.trim() if (!text) return @@ -169,7 +169,7 @@ export async function runNonInteractivePrompt(input: Input) { if (!promoted) continue if (event.type === "session.step.started") { - const part: StepStartPart = { + const part = { id: partID(event.id), sessionID: input.sessionID, messageID: event.data.assistantMessageID, @@ -191,7 +191,7 @@ export async function runNonInteractivePrompt(input: Input) { if (event.type === "session.text.ended") { const started = starts.get("text") starts.delete("text") - const part: TextPart = { + const part = { id: started?.id ?? partID(event.id), sessionID: input.sessionID, messageID: event.data.assistantMessageID, @@ -210,7 +210,7 @@ export async function runNonInteractivePrompt(input: Input) { if (event.type === "session.reasoning.ended" && input.thinking) { const started = starts.get("reasoning") starts.delete("reasoning") - const part: ReasoningPart = { + const part = { id: started?.id ?? partID(event.id), sessionID: input.sessionID, messageID: event.data.assistantMessageID, @@ -263,7 +263,7 @@ export async function runNonInteractivePrompt(input: Input) { } if (event.type === "session.tool.success") { const current = tools.get(event.data.callID) ?? fallbackTool(event) - const part: ToolPart = { + const part: MiniToolPart = { id: current.id, sessionID: input.sessionID, messageID: event.data.assistantMessageID, @@ -296,7 +296,7 @@ export async function runNonInteractivePrompt(input: Input) { if (event.type === "session.tool.failed") { const current = tools.get(event.data.callID) ?? fallbackTool(event) const error = event.data.error.message - const part: ToolPart = { + const part: MiniToolPart = { id: current.id, sessionID: input.sessionID, messageID: event.data.assistantMessageID, @@ -325,7 +325,7 @@ export async function runNonInteractivePrompt(input: Input) { } if (event.type === "session.step.ended") { - const part: StepFinishPart = { + const part = { id: partID(event.id), sessionID: input.sessionID, messageID: event.data.assistantMessageID, diff --git a/packages/cli/src/mini/permission.shared.ts b/packages/cli/src/mini/permission.shared.ts index 09cbf36df2..8dc79d1631 100644 --- a/packages/cli/src/mini/permission.shared.ts +++ b/packages/cli/src/mini/permission.shared.ts @@ -13,7 +13,7 @@ // // permissionInfo() extracts display info (icon, title, lines, diff) from // 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 { toolPath, toolPermissionInfo } from "./tool" @@ -55,7 +55,7 @@ function text(v: unknown): string { return typeof v === "string" ? v : "" } -function data(request: PermissionRequest): Dict { +function data(request: PermissionV2Request): Dict { const meta = dict(request.metadata) return { ...meta, @@ -63,8 +63,8 @@ function data(request: PermissionRequest): Dict { } } -function patterns(request: PermissionRequest): string[] { - return request.patterns.filter((item): item is string => typeof item === "string") +function patterns(request: PermissionV2Request): string[] { + return request.resources.filter((item): item is string => typeof item === "string") } export function createPermissionBodyState(requestID: string): PermissionBodyState { @@ -89,15 +89,15 @@ export function permissionOptions(stage: PermissionStage): PermissionOption[] { return [] } -export function permissionInfo(request: PermissionRequest): PermissionInfo { +export function permissionInfo(request: PermissionV2Request): PermissionInfo { const pats = patterns(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) { return info } - if (request.permission === "external_directory") { + if (request.action === "external_directory") { const meta = dict(request.metadata) const raw = text(meta.parentDir) || text(meta.filepath) || pats[0] || "" 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 { icon: "⟳", title: "Continue after repeated failures", @@ -118,19 +118,20 @@ export function permissionInfo(request: PermissionRequest): PermissionInfo { return { icon: "⚙", - title: `Call tool ${request.permission}`, - lines: [`Tool: ${request.permission}`], + title: `Call tool ${request.action}`, + lines: [`Tool: ${request.action}`], } } -export function permissionAlwaysLines(request: PermissionRequest): string[] { - if (request.always.length === 1 && request.always[0] === "*") { - return [`This will allow ${request.permission} until OpenCode is restarted.`] +export function permissionAlwaysLines(request: PermissionV2Request): string[] { + const save = request.save ?? [] + if (save.length === 1 && save[0] === "*") { + return [`This will allow ${request.action} until OpenCode is restarted.`] } return [ "This will allow the following patterns until OpenCode is restarted.", - ...request.always.map((item) => `- ${item}`), + ...save.map((item) => `- ${item}`), ] } diff --git a/packages/cli/src/mini/question.shared.ts b/packages/cli/src/mini/question.shared.ts index 2821240d58..3a21b668e6 100644 --- a/packages/cli/src/mini/question.shared.ts +++ b/packages/cli/src/mini/question.shared.ts @@ -13,7 +13,7 @@ // // 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. -import type { QuestionInfo, QuestionRequest } from "@opencode-ai/sdk/v2" +import type { QuestionV2Info, QuestionV2Request } from "@opencode-ai/client/promise" import type { QuestionReject, QuestionReply } from "./types" export type QuestionBodyState = { @@ -51,23 +51,23 @@ export function questionSync(state: QuestionBodyState, requestID: string): Quest 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 } -export function questionTabs(request: QuestionRequest): number { +export function questionTabs(request: QuestionV2Request): number { 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 } -export function questionInfo(request: QuestionRequest, state: QuestionBodyState): QuestionInfo | undefined { +export function questionInfo(request: QuestionV2Request, state: QuestionBodyState): QuestionV2Info | undefined { 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 } @@ -84,7 +84,7 @@ export function questionPicked(state: QuestionBodyState): boolean { 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) if (!info || info.custom === false) { return false @@ -93,7 +93,7 @@ export function questionOther(request: QuestionRequest, state: QuestionBodyState 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) if (!info) { return 0 @@ -156,7 +156,7 @@ export function questionStoreCustom(state: QuestionBodyState, tab: number, text: function questionPick( state: QuestionBodyState, - request: QuestionRequest, + request: QuestionV2Request, answer: string, custom = false, ): QuestionStep { @@ -204,7 +204,7 @@ function questionToggle(state: QuestionBodyState, answer: string): QuestionBodyS 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) if (total === 0) { 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) if (!info) { return { state } @@ -255,7 +255,7 @@ export function questionSelect(state: QuestionBodyState, request: QuestionReques 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) if (!info) { return { state } @@ -305,20 +305,20 @@ export function questionSave(state: QuestionBodyState, request: QuestionRequest) return questionPick(state, request, value, true) } -export function questionSubmit(request: QuestionRequest, state: QuestionBodyState): QuestionReply { +export function questionSubmit(request: QuestionV2Request, state: QuestionBodyState): QuestionReply { return { requestID: request.id, answers: questionAnswers(state, request.questions.length), } } -export function questionReject(request: QuestionRequest): QuestionReject { +export function questionReject(request: QuestionV2Request): QuestionReject { return { requestID: request.id, } } -export function questionHint(request: QuestionRequest, state: QuestionBodyState): string { +export function questionHint(request: QuestionV2Request, state: QuestionBodyState): string { if (state.submitting) { return "Waiting for question event..." } diff --git a/packages/cli/src/mini/run.ts b/packages/cli/src/mini/run.ts index 6f7e461a8c..2fc849bce0 100644 --- a/packages/cli/src/mini/run.ts +++ b/packages/cli/src/mini/run.ts @@ -2,13 +2,13 @@ import { Service } from "@opencode-ai/client/effect" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import { FSUtil } from "@opencode-ai/core/fs-util" import { Model } from "@opencode-ai/schema/model" -import type { ToolPart } from "@opencode-ai/sdk/v2" import { open } from "node:fs/promises" import path from "node:path" import { Server } from "../services/server" import { loadRunAgents, waitForCatalogReady } from "./catalog.shared" import { runNonInteractivePrompt } from "./noninteractive" import { toolInlineInfo } from "./tool" +import type { MiniToolPart } from "./types" import { UI } from "./ui" 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 } -async function renderTool(part: ToolPart) { +async function renderTool(part: MiniToolPart) { const info = toolInlineInfo(part) if (info.mode === "block") { 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) UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`) } diff --git a/packages/cli/src/mini/runtime.ts b/packages/cli/src/mini/runtime.ts index 7f91f2c01d..881dd2950a 100644 --- a/packages/cli/src/mini/runtime.ts +++ b/packages/cli/src/mini/runtime.ts @@ -547,7 +547,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep footer, sessionID: state.sessionID, thinking: input.thinking, - limits: () => state.limits, }) } diff --git a/packages/cli/src/mini/session-data.ts b/packages/cli/src/mini/session-data.ts index 263b8e86d4..30a7f6328e 100644 --- a/packages/cli/src/mini/session-data.ts +++ b/packages/cli/src/mini/session-data.ts @@ -1,1066 +1,17 @@ -// Core reducer for direct interactive mode. -// -// Takes raw SDK events and produces two outputs: -// - StreamCommit[]: append-only scrollback entries (text, tool, error, etc.) -// - FooterOutput: status bar patches and view transitions (permission, question) -// -// The reducer mutates SessionData in place for performance but has no -// external side effects -- no IO, no footer calls. The demo runtime -// (demo.ts) feeds events in and forwards output to the footer through -// stream.ts; the current transport reuses the blocker helpers below. -// -// Key design decisions: -// -// - Text parts buffer in `data.text` until their message role is confirmed as -// "assistant". This prevents echoing user-role text parts. The `ready()` -// check gates output: if we see a text delta before the message.updated -// event that tells us the role, we stash it and flush later via `replay()`. -// -// - Tool echo stripping: bash tools may echo their own output in the next -// assistant text part. `stashEcho()` records completed bash output, and -// `stripEcho()` removes it from the start of the next assistant chunk. -// -// - Permission and question requests queue in `data.permissions` and -// `data.questions`. The footer shows whichever is first. When a reply -// event arrives, the queue entry is removed and the footer falls back -// to the next pending request or to the prompt view. -import type { Event, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" -import { Locale } from "@opencode-ai/tui/util/locale" -import { toolView } from "./tool" -import type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types" - -const money = new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", -}) - -type Tokens = { - input?: number - output?: number - reasoning?: number - cache?: { - read?: number - write?: number - } -} - -type PartKind = "assistant" | "reasoning" | "user" -type MessageRole = "assistant" | "user" -type Dict = Record -type SessionCommit = StreamCommit - -// Mutable accumulator for the reducer. Each field tracks a different aspect -// of the stream so we can produce correct incremental output: -// -// - ids: parts and error keys we've already committed (dedup guard) -// - tools: tool parts we've emitted a "start" for but not yet completed -// - call: tool call inputs, keyed by msg:call, for enriching permission views -// - role: message ID → "assistant" | "user", learned from message.updated -// - msg: part ID → message ID -// - part: part ID → "assistant" | "reasoning" (text parts only) -// - text: part ID → full accumulated text so far -// - sent: part ID → byte offset of last flushed text (for incremental output) -// - visible: part ID → rendered text for an active part after display transforms -// - end: part IDs whose time.end has arrived (part is finished) -// - shell: shell ID → chosen transcript source for direct shell calls -// - echo: message ID → bash outputs to strip from the next assistant chunk -type ShellCall = { - source: "shell" | "tool" - command?: string -} - -export type SessionData = { - includeUserText: boolean - announced: boolean - ids: Set - tools: Set - call: Map - shell: Map - permissions: PermissionRequest[] - questions: QuestionRequest[] - role: Map - msg: Map - part: Map - text: Map - sent: Map - visible: Map - end: Set - echo: Map> -} - -export type SessionDataInput = { - data: SessionData - event: Event - sessionID: string - thinking: boolean - limits: Record -} - -export type SessionDataOutput = { - data: SessionData - commits: SessionCommit[] - footer?: FooterOutput -} - -export function createSessionData( - input: { - includeUserText?: boolean - } = {}, -): SessionData { - return { - includeUserText: input.includeUserText ?? false, - announced: false, - ids: new Set(), - tools: new Set(), - call: new Map(), - shell: new Map(), - permissions: [], - questions: [], - role: new Map(), - msg: new Map(), - part: new Map(), - text: new Map(), - sent: new Map(), - visible: new Map(), - end: new Set(), - echo: new Map(), - } -} - -function modelKey(provider: string, model: string): string { - return `${provider}/${model}` -} - -function formatUsage( - tokens: Tokens | undefined, - limit: number | undefined, - cost: number | undefined, -): string | undefined { - const total = - (tokens?.input ?? 0) + - (tokens?.output ?? 0) + - (tokens?.reasoning ?? 0) + - (tokens?.cache?.read ?? 0) + - (tokens?.cache?.write ?? 0) - - if (total <= 0) { - if (typeof cost === "number" && cost > 0) { - return money.format(cost) - } - return undefined - } - - const text = - limit && limit > 0 ? `${Locale.number(total)} (${Math.round((total / limit) * 100)}%)` : Locale.number(total) - - if (typeof cost === "number" && cost > 0) { - return `${text} · ${money.format(cost)}` - } - - return text -} - -export function formatError(error: { - name?: string - message?: string - data?: { - message?: string - } -}): string { - if (error.data?.message) { - return error.data.message - } - - if (error.message) { - return error.message - } - - if (error.name) { - return error.name - } - - return "unknown error" -} - -function isAbort(error: { name?: string } | undefined): boolean { - return error?.name === "MessageAbortedError" -} - -function msgErr(id: string): string { - return `msg:${id}:error` -} - -function patch(patch?: FooterPatch, view?: FooterView): FooterOutput | undefined { - if (!patch && !view) { - return undefined - } - - return { - patch, - view, - } -} - -function out(data: SessionData, commits: SessionCommit[], footer?: FooterOutput): SessionDataOutput { - if (!footer) { - return { - data, - commits, - } - } - - return { - data, - commits, - footer, - } -} - -export function pickBlockerView(input: { permission?: PermissionRequest; question?: QuestionRequest }): FooterView { - if (input.permission) { - return { type: "permission", request: input.permission } - } - - if (input.question) { - return { type: "question", request: input.question } - } +import type { PermissionV2Request, QuestionV2Request } from "@opencode-ai/client/promise" +import type { FooterView } from "./types" +export function pickBlockerView(input: { + permission?: PermissionV2Request + question?: QuestionV2Request +}): FooterView { + if (input.permission) return { type: "permission", request: input.permission } + if (input.question) return { type: "question", request: input.question } return { type: "prompt" } } export function blockerStatus(view: FooterView) { - if (view.type === "permission") { - return "awaiting permission" - } - - if (view.type === "question") { - return "awaiting answer" - } - + if (view.type === "permission") return "awaiting permission" + if (view.type === "question") return "awaiting answer" return "" } - -function pickSessionView(data: SessionData): FooterView { - return pickBlockerView({ - permission: data.permissions[0], - question: data.questions[0], - }) -} - -function queueFooter(data: SessionData): FooterOutput { - const view = pickSessionView(data) - - return { - view, - patch: { status: blockerStatus(view) }, - } -} - -function queueOut(data: SessionData, commits: SessionCommit[]): SessionDataOutput { - return out(data, commits, queueFooter(data)) -} - -function upsert(list: T[], item: T) { - const idx = list.findIndex((entry) => entry.id === item.id) - if (idx === -1) { - list.push(item) - return - } - - list[idx] = item -} - -function remove(list: Array<{ id: string }>, id: string): boolean { - const idx = list.findIndex((entry) => entry.id === id) - if (idx === -1) { - return false - } - - list.splice(idx, 1) - return true -} - -function key(msg: string, call: string): string { - return `${msg}:${call}` -} - -function enrichPermission(data: SessionData, request: PermissionRequest): PermissionRequest { - if (!request.tool) { - return request - } - - const input = data.call.get(key(request.tool.messageID, request.tool.callID)) - if (!input) { - return request - } - - const meta = request.metadata ?? {} - if (meta.input === input) { - return request - } - - return { - ...request, - metadata: { - ...meta, - input, - }, - } -} - -// Updates the active permission request when the matching tool part gets -// new input (e.g., a diff). This keeps the permission UI in sync with the -// tool's evolving state. Only triggers a footer update if the currently -// displayed permission was the one that changed. -function syncPermission(data: SessionData, part: ToolPart): FooterOutput | undefined { - data.call.set(key(part.messageID, part.callID), part.state.input) - if (data.permissions.length === 0) { - return undefined - } - - let changed = false - let active = false - data.permissions = data.permissions.map((request, index) => { - if (!request.tool || request.tool.messageID !== part.messageID || request.tool.callID !== part.callID) { - return request - } - - const next = enrichPermission(data, request) - if (next === request) { - return request - } - - changed = true - active ||= index === 0 - return next - }) - - if (!changed || !active) { - return undefined - } - - return { - view: pickSessionView(data), - } -} - -// Question tool replies can complete without a matching question.replied event. -// When that happens, drop the recovered pending request tied to this tool call so -// the footer can return to the next blocker or to the prompt. -function syncQuestion(data: SessionData, part: ToolPart): FooterOutput | undefined { - if (part.tool !== "question") { - return undefined - } - - if (part.state.status !== "completed" && part.state.status !== "error") { - return undefined - } - - const next = data.questions.filter( - (request) => request.tool?.messageID !== part.messageID || request.tool?.callID !== part.callID, - ) - if (next.length === data.questions.length) { - return undefined - } - - data.questions = next - return queueFooter(data) -} - -function toolStatus(part: ToolPart): string { - if (part.tool !== "task") { - return `running ${part.tool}` - } - - const state = part.state as { - input?: { - description?: unknown - subagent_type?: unknown - } - } - const desc = state.input?.description - if (typeof desc === "string" && desc.trim()) { - return `running ${desc.trim()}` - } - - const type = state.input?.subagent_type - if (typeof type === "string" && type.trim()) { - return `running ${type.trim()}` - } - - return "running task" -} - -// Returns true if we can flush this part's text to scrollback. -// -// We gate on the message role being "assistant" because user-role messages -// also contain text parts (the user's own input) which we don't want to -// echo. If we haven't received the message.updated event yet, we return -// false and the text stays buffered until replay() flushes it. -function ready(data: SessionData, partID: string): boolean { - const msg = data.msg.get(partID) - if (!msg) { - return true - } - - const role = data.role.get(msg) - if (!role) { - return false - } - - if (role === "assistant") { - return true - } - - return data.includeUserText && role === "user" -} - -function syncText(data: SessionData, partID: string, next: string) { - const prev = data.text.get(partID) ?? "" - if (!next) { - return prev - } - - if (!prev || next.length >= prev.length) { - data.text.set(partID, next) - return next - } - - return prev -} - -// Records bash tool output for echo stripping. Some models echo bash output -// verbatim at the start of their next text part. We save both the raw and -// trimmed forms so stripEcho() can match either. -function stashEcho(data: SessionData, part: ToolPart) { - if (part.tool !== "bash") { - return - } - - if (typeof part.messageID !== "string" || !part.messageID) { - return - } - - const output = "output" in part.state ? part.state.output : undefined - if (typeof output !== "string") { - return - } - - const text = output.replace(/^\n+/, "") - if (!text.trim()) { - return - } - - const set = data.echo.get(part.messageID) ?? new Set() - set.add(text) - const trim = text.replace(/\n+$/, "") - if (trim && trim !== text) { - set.add(trim) - } - data.echo.set(part.messageID, set) -} - -function stripEcho(data: SessionData, msg: string | undefined, chunk: string): string { - if (!msg) { - return chunk - } - - const set = data.echo.get(msg) - if (!set || set.size === 0) { - return chunk - } - - data.echo.delete(msg) - const list = [...set].sort((a, b) => b.length - a.length) - for (const item of list) { - if (!item || !chunk.startsWith(item)) { - continue - } - - return chunk.slice(item.length).replace(/^\n+/, "") - } - - return chunk -} - -function flushPart(data: SessionData, commits: SessionCommit[], partID: string, interrupted = false) { - const kind = data.part.get(partID) - if (!kind) { - return - } - - const text = data.text.get(partID) ?? "" - const sent = data.sent.get(partID) ?? 0 - let chunk = text.slice(sent) - const msg = data.msg.get(partID) - - if (sent === 0) { - chunk = chunk.replace(/^\n+/, "") - // Some models emit a standalone whitespace token before real content. - // Keep buffering until we have visible text so scrollback doesn't get a blank row. - if (!chunk.trim()) { - return - } - if (kind === "reasoning" && chunk) { - chunk = `Thinking: ${chunk.replace(/\[REDACTED\]/g, "")}` - } - if (kind === "assistant" && chunk) { - chunk = stripEcho(data, msg, chunk) - if (!chunk.trim()) { - return - } - } - } - - if (chunk) { - data.sent.set(partID, text.length) - data.visible.set(partID, (data.visible.get(partID) ?? "") + chunk) - commits.push({ - kind, - text: chunk, - phase: "progress", - source: kind === "user" ? "system" : kind, - messageID: msg, - partID, - }) - } - - if (!interrupted) { - return - } - - commits.push({ - kind, - text: "", - phase: "final", - source: kind === "user" ? "system" : kind, - messageID: msg, - partID, - interrupted: true, - }) -} - -function drop(data: SessionData, partID: string) { - data.part.delete(partID) - data.text.delete(partID) - data.sent.delete(partID) - data.visible.delete(partID) - data.msg.delete(partID) - data.end.delete(partID) -} - -// Called when we learn a message's role (from message.updated). Flushes any -// buffered text parts that were waiting on role confirmation. User-role -// parts are silently dropped. -function replay(data: SessionData, commits: SessionCommit[], messageID: string, role: MessageRole, thinking: boolean) { - for (const [partID, msg] of data.msg.entries()) { - if (msg !== messageID || data.ids.has(partID)) { - continue - } - - if (role === "user" && !data.includeUserText) { - data.ids.add(partID) - drop(data, partID) - continue - } - - const kind = data.part.get(partID) - if (!kind) { - continue - } - - if (role === "user" && kind === "assistant") { - data.part.set(partID, "user") - } - - if (kind === "reasoning" && !thinking) { - if (data.end.has(partID)) { - data.ids.add(partID) - } - drop(data, partID) - continue - } - - flushPart(data, commits, partID) - - if (!data.end.has(partID)) { - continue - } - - data.ids.add(partID) - drop(data, partID) - } -} - -function toolCommit( - part: ToolPart, - next: Pick & { toolError?: string }, -): SessionCommit { - return { - kind: "tool", - source: "tool", - messageID: part.messageID, - partID: part.id, - tool: part.tool, - part, - ...next, - } -} - -function shellPartID(shellID: string): string { - return `shell:${shellID}` -} - -function claimShell(data: SessionData, shellID: string, source: ShellCall["source"], command?: string): ShellCall { - const current = data.shell.get(shellID) - if (current) { - if (command && !current.command) { - current.command = command - } - - return current - } - - const next = { - source, - ...(command ? { command } : {}), - } satisfies ShellCall - data.shell.set(shellID, next) - return next -} - -function bashCommand(part: ToolPart): string | undefined { - if (part.tool !== "bash") { - return undefined - } - - const input = part.state.input - if (!input || typeof input !== "object" || Array.isArray(input)) { - return undefined - } - - const command = Reflect.get(input, "command") - return typeof command === "string" ? command : undefined -} - -function shellCommit( - input: { - callID: string - command: string - }, - next: Pick, -): SessionCommit { - return { - kind: "tool", - source: "tool", - partID: shellPartID(input.callID), - tool: "bash", - shell: input, - ...next, - } -} - -function startShell(callID: string, command: string): SessionCommit { - return shellCommit( - { - callID, - command, - }, - { - text: "running shell", - phase: "start", - toolState: "running", - }, - ) -} - -function doneShell(callID: string, command: string, output: string): SessionCommit { - return shellCommit( - { - callID, - command, - }, - { - text: output, - phase: "progress", - toolState: "completed", - }, - ) -} - -function startTool(part: ToolPart): SessionCommit { - return toolCommit(part, { - text: toolStatus(part), - phase: "start", - toolState: "running", - }) -} - -function doneTool(part: ToolPart): SessionCommit { - return toolCommit(part, { - text: "", - phase: "final", - toolState: "completed", - }) -} - -function failTool(part: ToolPart, text: string): SessionCommit { - return toolCommit(part, { - text, - phase: "final", - toolState: "error", - toolError: text, - }) -} - -// The main reducer. Takes one SDK event and returns scrollback commits and -// footer updates. Called once per event from the stream transport's watch loop. -// -// Event handling follows the SDK event types: -// message.updated → learn role, flush buffered parts, track usage -// message.part.delta → accumulate text, flush if ready -// message.part.updated → handle text/reasoning/tool state transitions -// permission.* → manage the permission queue, drive footer view -// question.* → manage the question queue, drive footer view -// session.error → emit error scrollback entry -export function reduceSessionData(input: SessionDataInput): SessionDataOutput { - const commits: SessionCommit[] = [] - const data = input.data - const event = input.event - - if (event.type === "session.shell.started") { - if (event.properties.sessionID !== input.sessionID) { - return out(data, commits) - } - - const shell = claimShell(data, event.properties.shell.id, "shell", event.properties.shell.command) - if (shell.source !== "shell") { - return out(data, commits) - } - - const partID = shellPartID(event.properties.shell.id) - if (data.ids.has(partID) || data.tools.has(partID)) { - return out(data, commits, patch({ status: "running shell" })) - } - - data.tools.add(partID) - commits.push(startShell(event.properties.shell.id, shell.command ?? event.properties.shell.command)) - return out(data, commits, patch({ status: "running shell" })) - } - - if (event.type === "session.shell.ended") { - if (event.properties.sessionID !== input.sessionID) { - return out(data, commits) - } - - const shell = claimShell(data, event.properties.shell.id, "shell") - if (shell.source !== "shell") { - return out(data, commits) - } - - const partID = shellPartID(event.properties.shell.id) - const seen = data.tools.has(partID) - const command = shell.command ?? "" - data.tools.delete(partID) - if (data.ids.has(partID)) { - return out(data, commits) - } - - if (!seen && command) { - commits.push(startShell(event.properties.shell.id, command)) - } - - data.ids.add(partID) - commits.push(doneShell(event.properties.shell.id, command, event.properties.output.output)) - return out(data, commits) - } - - if (event.type === "message.updated") { - if (event.properties.sessionID !== input.sessionID) { - return out(data, commits) - } - - const info = event.properties.info - if (typeof info.id === "string") { - data.role.set(info.id, info.role) - replay(data, commits, info.id, info.role, input.thinking) - } - - if (info.role !== "assistant") { - return out(data, commits) - } - - let next: FooterPatch | undefined - if (!data.announced) { - data.announced = true - next = { status: "assistant responding" } - } - - const usage = formatUsage( - info.tokens, - input.limits[modelKey(info.providerID, info.modelID)], - typeof info.cost === "number" ? info.cost : undefined, - ) - if (usage) { - next = { - ...next, - usage, - } - } - - if (typeof info.id === "string" && info.error && !isAbort(info.error) && !data.ids.has(msgErr(info.id))) { - data.ids.add(msgErr(info.id)) - commits.push({ - kind: "error", - text: formatError(info.error), - phase: "start", - source: "system", - messageID: info.id, - }) - } - - return out(data, commits, patch(next)) - } - - if (event.type === "message.part.delta") { - if (event.properties.sessionID !== input.sessionID) { - return out(data, commits) - } - - if ( - typeof event.properties.partID !== "string" || - typeof event.properties.field !== "string" || - typeof event.properties.delta !== "string" - ) { - return out(data, commits) - } - - if (event.properties.field !== "text") { - return out(data, commits) - } - - const partID = event.properties.partID - if (data.ids.has(partID)) { - return out(data, commits) - } - - if (typeof event.properties.messageID === "string") { - data.msg.set(partID, event.properties.messageID) - } - - const text = data.text.get(partID) ?? "" - data.text.set(partID, text + event.properties.delta) - - const kind = data.part.get(partID) - if (!kind) { - return out(data, commits) - } - - if (kind === "reasoning" && !input.thinking) { - return out(data, commits) - } - - if (!ready(data, partID)) { - return out(data, commits) - } - - flushPart(data, commits, partID) - return out(data, commits) - } - - if (event.type === "message.part.updated") { - const part = event.properties.part - if (part.sessionID !== input.sessionID) { - return out(data, commits) - } - - if (part.type === "tool") { - const view = syncPermission(data, part) ?? syncQuestion(data, part) - if (part.tool === "bash" && part.callID) { - if (claimShell(data, part.callID, "tool", bashCommand(part)).source === "shell") { - return out(data, commits, view) - } - } - - if (part.state.status === "running") { - if (data.ids.has(part.id)) { - return out(data, commits, view) - } - - if (!data.tools.has(part.id)) { - data.tools.add(part.id) - commits.push(startTool(part)) - } - - return out(data, commits, view ?? patch({ status: toolStatus(part) })) - } - - if (part.state.status === "completed") { - const seen = data.tools.has(part.id) - const mode = toolView(part.tool) - data.tools.delete(part.id) - if (data.ids.has(part.id)) { - return out(data, commits, view) - } - - if (!seen) { - commits.push(startTool(part)) - } - - data.ids.add(part.id) - stashEcho(data, part) - - const output = part.state.output - if (mode.output && typeof output === "string" && output.trim()) { - commits.push({ - kind: "tool", - text: output, - phase: "progress", - source: "tool", - messageID: part.messageID, - partID: part.id, - tool: part.tool, - part, - toolState: "completed", - }) - } - - if (mode.final) { - commits.push(doneTool(part)) - } - - return out(data, commits, view) - } - - if (part.state.status === "error") { - const seen = data.tools.has(part.id) - data.tools.delete(part.id) - if (data.ids.has(part.id)) { - return out(data, commits, view) - } - - if (!seen) { - commits.push(startTool(part)) - } - - data.ids.add(part.id) - const text = - typeof part.state.error === "string" && part.state.error.trim() ? part.state.error : "unknown error" - commits.push(failTool(part, text)) - return out(data, commits, view) - } - } - - if (part.type !== "text" && part.type !== "reasoning") { - return out(data, commits) - } - - if (data.ids.has(part.id)) { - return out(data, commits) - } - - const kind = part.type === "text" ? "assistant" : "reasoning" - if (typeof part.messageID === "string") { - data.msg.set(part.id, part.messageID) - } - - const msg = part.messageID - const role = msg ? data.role.get(msg) : undefined - if (role === "user" && part.type === "text" && !data.includeUserText) { - data.ids.add(part.id) - drop(data, part.id) - return out(data, commits) - } - - if (kind === "reasoning" && !input.thinking) { - if (part.time?.end) { - data.ids.add(part.id) - } - drop(data, part.id) - return out(data, commits) - } - - data.part.set(part.id, role === "user" && kind === "assistant" ? "user" : kind) - syncText(data, part.id, part.text) - - if (part.time?.end) { - data.end.add(part.id) - } - - if (msg && !role) { - return out(data, commits) - } - - if (!ready(data, part.id)) { - return out(data, commits) - } - - flushPart(data, commits, part.id) - - if (!part.time?.end) { - return out(data, commits) - } - - data.ids.add(part.id) - drop(data, part.id) - return out(data, commits) - } - - if (event.type === "permission.asked") { - if (event.properties.sessionID !== input.sessionID) { - return out(data, commits) - } - - upsert(data.permissions, enrichPermission(data, event.properties)) - return queueOut(data, commits) - } - - if (event.type === "permission.replied") { - if (event.properties.sessionID !== input.sessionID) { - return out(data, commits) - } - - if (!remove(data.permissions, event.properties.requestID)) { - return out(data, commits) - } - - return queueOut(data, commits) - } - - if (event.type === "question.asked") { - if (event.properties.sessionID !== input.sessionID) { - return out(data, commits) - } - - upsert(data.questions, event.properties) - return queueOut(data, commits) - } - - if (event.type === "question.replied" || event.type === "question.rejected") { - if (event.properties.sessionID !== input.sessionID) { - return out(data, commits) - } - - if (!remove(data.questions, event.properties.requestID)) { - return out(data, commits) - } - - return queueOut(data, commits) - } - - if (event.type === "session.error") { - if (event.properties.sessionID !== input.sessionID || !event.properties.error) { - return out(data, commits) - } - - commits.push({ - kind: "error", - text: formatError(event.properties.error), - phase: "start", - source: "system", - }) - return out(data, commits) - } - - return out(data, commits) -} diff --git a/packages/cli/src/mini/session.shared.ts b/packages/cli/src/mini/session.shared.ts index b6f1f6dd11..2b0a99f716 100644 --- a/packages/cli/src/mini/session.shared.ts +++ b/packages/cli/src/mini/session.shared.ts @@ -1,15 +1,10 @@ -// Session message extraction and prompt history. -// -// 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 type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise" import { promptCopy, promptSame } from "./prompt.shared" -import type { Message, Part } from "@opencode-ai/sdk/v2" import type { RunInput, RunPrompt } from "./types" const LIMIT = 200 -export type SessionMessages = Array<{ info: Message; parts: Part[] }> +export type SessionMessages = SessionMessageInfo[] type Turn = { prompt: RunPrompt @@ -25,133 +20,42 @@ export type RunSession = { variant?: string } -function fileName(url: string, filename?: string) { - 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, - text: { start: number; end: number; value: string }, -) { - if (part.source) { - return { - ...structuredClone(part.source), - text, - } - } - +function messagePrompt(message: SessionMessageUser): RunPrompt { return { - type: "file" as const, - path: part.filename ?? part.url, - text, - } -} - -export function messagePrompt(msg: SessionMessages[number]): RunPrompt { - const parts: RunPrompt["parts"] = [] - let text = msg.parts - .filter((part): part is Extract => { - return part.type === "text" && !part.synthetic - }) - .map((part) => part.text) - .join("") - let cursor = Bun.stringWidth(text) - const used: Array<{ start: number; end: number }> = [] - - const take = (value: string): { start: number; end: number; value: string } | undefined => { - let from = 0 - while (true) { - const idx = text.indexOf(value, from) - 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, + 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", + 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, + })), + ], } } export function createSession(messages: SessionMessages): RunSession { return { first: messages.length === 0, - turns: messages.flatMap((msg) => { - const item = turn(msg) - return item ? [item] : [] - }), + turns: messages.flatMap((message) => + message.type === "user" + ? [{ 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.session.get({ sessionID }), ]) - const messages = response.data.toReversed() + const current = createSession(response.data.toReversed()) return { - first: messages.length === 0, - turns: messages.flatMap((message) => { - if (message.type !== "user") return [] - return [ - { - prompt: { - 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, - }, - ] - }), + ...current, + turns: current.turns.map((turn) => ({ + ...turn, + provider: session.model?.providerID, + model: session.model?.id, + variant: session.model?.variant, + })), ...(session.model && { - model: { - providerID: session.model.providerID, - modelID: session.model.id, - }, + model: { providerID: session.model.providerID, modelID: session.model.id }, variant: session.model.variant, }), } } export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] { - const out: RunPrompt[] = [] - - for (const turn of session.turns) { - if (!turn.prompt.text.trim()) { - continue - } - - if (out[out.length - 1] && promptSame(out[out.length - 1], turn.prompt)) { - continue - } - - out.push(promptCopy(turn.prompt)) - } - - return out.slice(-limit) + return session.turns + .map((turn) => turn.prompt) + .filter((prompt) => prompt.text.trim()) + .filter((prompt, index, prompts) => index === 0 || !promptSame(prompts[index - 1], prompt)) + .map(promptCopy) + .slice(-limit) } export function sessionVariant(session: RunSession, model: RunInput["model"]): string | undefined { - if (!model) { - return undefined - } + if (!model) return + 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.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 + return session.turns.findLast((turn) => turn.provider === model.providerID && turn.model === model.modelID)?.variant } diff --git a/packages/cli/src/mini/stream-v2.subagent.ts b/packages/cli/src/mini/stream-v2.subagent.ts index 911d2d0d4e..fd716f4f75 100644 --- a/packages/cli/src/mini/stream-v2.subagent.ts +++ b/packages/cli/src/mini/stream-v2.subagent.ts @@ -15,10 +15,14 @@ // Per-child interruption uses `v2.session.interrupt(childID)`. Per-child // backgrounding is intentionally absent: subagent jobs block the parent // session, so only whole-session `v2.session.background(parentID)` exists. -import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise" -import type { SessionMessageAssistantTool, SessionMessageInfo, ToolPart } from "@opencode-ai/sdk/v2" +import type { + EventSubscribeOutput, + OpenCodeClient, + SessionMessageAssistantTool, + SessionMessageInfo, +} from "@opencode-ai/client/promise" 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_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") } -export function legacyTool(input: { +export function miniTool(input: { sessionID: string messageID: string tool: SessionMessageAssistantTool -}): ToolPart { +}): MiniToolPart { const tool = input.tool const providerCall = 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 text = status === "running" @@ -310,7 +314,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac } const childTool = (child: ChildState, item: SessionMessageAssistantTool, messageID: string) => { - const part = legacyTool({ + const part = miniTool({ sessionID: child.sessionID, messageID, tool: item, diff --git a/packages/cli/src/mini/stream-v2.transport.ts b/packages/cli/src/mini/stream-v2.transport.ts index 26b8266849..bf2ccd5bf9 100644 --- a/packages/cli/src/mini/stream-v2.transport.ts +++ b/packages/cli/src/mini/stream-v2.transport.ts @@ -1,14 +1,15 @@ -import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise" import type { - PermissionRequest, - QuestionRequest, - SessionMessageInfo, + EventSubscribeOutput, + OpenCodeClient, + PermissionV2Request, + QuestionV2Request, SessionMessageAssistantTool, -} from "@opencode-ai/sdk/v2" + SessionMessageInfo, +} from "@opencode-ai/client/promise" import { Event } from "@opencode-ai/schema/event" import { blockerStatus, pickBlockerView } from "./session-data" import { writeSessionOutput } from "./stream" -import { createSubagentTracker, legacyTool, toolCommit } from "./stream-v2.subagent" +import { createSubagentTracker, miniTool, toolCommit } from "./stream-v2.subagent" import type { FooterApi, FooterView, @@ -87,8 +88,6 @@ type ShellWait = { } type RunV2Event = EventSubscribeOutput -type PermissionV2Request = Extract["data"] -type QuestionV2Request = Extract["data"] type PromptFilePart = Extract type ToolState = { @@ -101,8 +100,8 @@ type ToolState = { } type State = { - permissions: PermissionRequest[] - questions: QuestionRequest[] + permissions: PermissionV2Request[] + questions: QuestionV2Request[] view: FooterView messageIDs: Set text: Map @@ -138,27 +137,6 @@ export function formatUnknownError(error: unknown): string { 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) { 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}` } -// Matches the commit shapes the legacy session-data reducer produced for direct -// shell calls: one "start" commit rendering `$ command` and one "progress" +// Direct shell calls use one "start" commit rendering `$ command` and one "progress" // commit rendering the merged output (see toolEntryBody in tool.ts). function shellCommit( callID: string, @@ -384,7 +361,7 @@ export async function createSessionTransport(input: StreamInput): Promise { - const part = legacyTool({ + const part = miniTool({ sessionID: input.sessionID, messageID, tool: item, @@ -536,8 +513,8 @@ export async function createSessionTransport(input: StreamInput): Promise 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() return } @@ -780,7 +757,7 @@ export async function createSessionTransport(input: StreamInput): Promise 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() return } diff --git a/packages/cli/src/mini/stream.ts b/packages/cli/src/mini/stream.ts index 3efd864604..ce50a2ae21 100644 --- a/packages/cli/src/mini/stream.ts +++ b/packages/cli/src/mini/stream.ts @@ -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 // footer.event() respectively, adding trace writes along the way. It also // 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" type Trace = { @@ -103,9 +103,9 @@ export function traceSubagentState(state: FooterSubagentState) { permissions: state.permissions.map((item) => ({ id: item.id, sessionID: item.sessionID, - permission: item.permission, - patterns: item.patterns, - tool: item.tool, + action: item.action, + resources: item.resources, + source: item.source, metadata: 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 { for (const commit of out.commits) { input.trace?.write("ui.commit", commit) diff --git a/packages/cli/src/mini/tool.ts b/packages/cli/src/mini/tool.ts index 9c13d1ee29..3772debdc5 100644 --- a/packages/cli/src/mini/tool.ts +++ b/packages/cli/src/mini/tool.ts @@ -15,10 +15,9 @@ import os from "os" import path from "path" import stripAnsi from "strip-ansi" -import type { ToolPart } from "@opencode-ai/sdk/v2" import { LANGUAGE_EXTENSIONS } from "@opencode-ai/tui/util/filetype" 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 = { output: boolean @@ -1177,7 +1176,7 @@ function rule(name?: string): AnyToolRule | undefined { return TOOL_RULES[name] } -function frame(part: ToolPart): ToolFrame { +function frame(part: MiniToolPart): ToolFrame { const state = dict(part.state) return { 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 draw = rule(ctx.name)?.run try { diff --git a/packages/cli/src/mini/types.ts b/packages/cli/src/mini/types.ts index caa90fb512..c34a25bfa6 100644 --- a/packages/cli/src/mini/types.ts +++ b/packages/cli/src/mini/types.ts @@ -7,12 +7,16 @@ // // Data flow through the system: // -// SDK events → session-data reducer → StreamCommit[] + FooterOutput +// V2 events / demo actions → StreamCommit[] + FooterOutput // → stream.ts bridges to footer API // → footer.ts queues commits and patches the footer view // → OpenTUI split-footer renderer writes to terminal -import type { OpenCodeClient, ReferenceListOutput } from "@opencode-ai/client/promise" -import type { FilePart, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" +import type { + OpenCodeClient, + PermissionV2Request, + QuestionV2Request, + ReferenceListOutput, +} from "@opencode-ai/client/promise" import type { TuiConfig } from "@opencode-ai/tui/config/v1" export type RunFilePart = { @@ -30,7 +34,11 @@ export type RunPromptPart = url: string filename?: 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 } } @@ -210,6 +218,41 @@ export type ToolQuestionSnapshot = { export type ToolSnapshot = ToolCodeSnapshot | ToolDiffSnapshot | ToolTaskSnapshot | ToolQuestionSnapshot +export type MiniToolState = + | { status: "pending"; input: Record; raw?: string } + | { + status: "running" + input: Record + title?: string + metadata?: Record + time: { start: number } + } + | { + status: "completed" + input: Record + output: string + title?: string + metadata?: Record + time: { start: number; end: number } + } + | { + status: "error" + input: Record + error: string + metadata?: Record + 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 RunEntryBody = @@ -220,13 +263,13 @@ export type RunEntryBody = | { type: "structured"; snapshot: ToolSnapshot } // 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 // "prompt". export type FooterView = | { type: "prompt" } - | { type: "permission"; request: PermissionRequest } - | { type: "question"; request: QuestionRequest } + | { type: "permission"; request: PermissionV2Request } + | { type: "question"; request: QuestionV2Request } export type FooterPromptRoute = | { type: "composer" } @@ -259,11 +302,11 @@ export type FooterSubagentDetail = { export type FooterSubagentState = { tabs: FooterSubagentTab[] details: Record - permissions: PermissionRequest[] - questions: QuestionRequest[] + permissions: PermissionV2Request[] + 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 = { patch?: FooterPatch view?: FooterView @@ -357,8 +400,8 @@ export type StreamSource = "assistant" | "reasoning" | "tool" | "system" export type StreamToolState = "running" | "completed" | "error" -// A single append-only commit to scrollback. The session-data reducer produces -// these from SDK events, and RunFooter.append() queues them for the next +// A single append-only commit to scrollback. The transport produces these from +// V2 events, and RunFooter.append() queues them for the next // microtask flush. Once flushed, they become immutable terminal scrollback // rows -- they cannot be rewritten. export type StreamCommit = { @@ -370,7 +413,7 @@ export type StreamCommit = { messageID?: string partID?: string tool?: string - part?: ToolPart + part?: MiniToolPart interrupted?: boolean toolState?: StreamToolState toolError?: string