feat(mini): migrate mini to v2 (#34895)

feat(run): migrate non-interactive prompts to V2
feat(run): route mini prompts through V2
fix(run): use current session contracts
fix(run): fix V2 prompt turns
feat(cli): add mini subcommand
feat(run): use settled execution events
fix(run): handle remote prompt file attachments
feat(run): send prompt files as attachments
feat(run): use current APIs for run state
fix(run): adopt app-node runtime deps
feat(run): track subagent sessions
feat(run): move catalogs and default model onto current APIs
This commit is contained in:
Simon Klee
2026-07-02 12:06:49 +02:00
committed by GitHub
parent 7ac6e9dc79
commit 3e36163298
61 changed files with 5203 additions and 7105 deletions
-50
View File
@@ -43,32 +43,8 @@ export const AttachCommand = cmd({
alias: ["u"],
type: "string",
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
})
.option("mini", {
type: "boolean",
describe: "start the minimal interactive interface",
default: false,
})
.option("replay", {
type: "boolean",
hidden: true,
})
.option("no-replay", {
type: "boolean",
describe: "disable mini session history replay on resume and after resize",
})
.option("replay-limit", {
type: "number",
describe: "cap visible mini replay to the newest N messages",
}),
handler: async (args) => {
if (args.replay === true) {
UI.error("--replay is not supported; replay is enabled by default")
process.exitCode = 1
return
}
const noReplay = args.replay === false || args.noReplay === true
const directory = (() => {
if (!args.dir) return undefined
try {
@@ -80,32 +56,6 @@ export const AttachCommand = cmd({
}
})()
if (args.mini) {
const { runMini } = await import("./run")
await runMini({
attach: args.url,
directory,
password: args.password,
username: args.username,
continue: args.continue,
session: args.session,
fork: args.fork,
replay: noReplay ? false : undefined,
replayLimit: args.replayLimit,
})
return
}
const unsupported = [
["--no-replay", noReplay],
["--replay-limit", args.replayLimit !== undefined],
].find((entry) => entry[1])?.[0]
if (unsupported) {
UI.error(`${unsupported} requires --mini`)
process.exitCode = 1
return
}
const { TuiConfig } = await import("@/config/tui")
if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session")
+172
View File
@@ -0,0 +1,172 @@
import type { Argv } from "yargs"
import { cmd } from "./cmd"
import { UI } from "@/cli/ui"
import { resolveThreadDirectory } from "./tui"
type ReplayArgs = {
replay?: boolean
noReplay?: boolean
}
type MiniArgs = ReplayArgs & {
continue?: boolean
session?: string
fork?: boolean
replayLimit?: number
}
type MiniLocalArgs = MiniArgs & {
project?: string
model?: string
agent?: string
prompt?: string
demo?: boolean
}
type MiniAttachArgs = MiniArgs & {
url: string
dir?: string
password?: string
username?: string
}
function replay(args: ReplayArgs) {
if (args.replay === true) {
UI.error("--replay is not supported; replay is enabled by default")
process.exitCode = 1
return "invalid" as const
}
return args.replay === false || args.noReplay === true ? false : undefined
}
function miniOptions<T>(yargs: Argv<T>) {
return yargs
.option("continue", {
alias: ["c"],
describe: "continue the last session",
type: "boolean",
})
.option("session", {
alias: ["s"],
describe: "session id to continue",
type: "string",
})
.option("fork", {
type: "boolean",
describe: "fork the session when continuing (use with --continue or --session)",
})
.option("replay", {
type: "boolean",
hidden: true,
})
.option("no-replay", {
type: "boolean",
describe: "disable session history replay on resume and after resize",
})
.option("replay-limit", {
type: "number",
describe: "cap visible replay to the newest N messages",
})
}
/** @internal Exported for CLI parser tests. */
export const MiniLocalCommand = cmd<{}, MiniLocalArgs>({
command: "$0 [project]",
describe: "start the minimal interactive interface",
builder: (yargs) =>
miniOptions(
yargs
.positional("project", {
type: "string",
describe: "path to start opencode in",
})
.option("model", {
type: "string",
alias: ["m"],
describe: "model to use in the format of provider/model",
})
.option("agent", {
type: "string",
describe: "agent to use",
})
.option("prompt", {
type: "string",
describe: "prompt to use",
})
.option("demo", {
type: "boolean",
hidden: true,
}),
),
handler: async (args) => {
const shouldReplay = replay(args)
if (shouldReplay === "invalid") return
const { runMini } = await import("./run")
await runMini({
directory: resolveThreadDirectory(args.project),
continue: args.continue,
session: args.session,
fork: args.fork,
model: args.model,
agent: args.agent,
prompt: args.prompt,
replay: shouldReplay,
replayLimit: args.replayLimit,
demo: args.demo,
})
},
})
/** @internal Exported for CLI parser tests. */
export const MiniAttachCommand = cmd<{}, MiniAttachArgs>({
command: "attach <url>",
describe: "attach to a running opencode server with the minimal interface",
builder: (yargs) =>
miniOptions(
yargs
.positional("url", {
type: "string",
describe: "http://localhost:4096",
demandOption: true,
})
.option("dir", {
type: "string",
describe: "directory on the remote server",
})
.option("password", {
alias: ["p"],
type: "string",
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
})
.option("username", {
alias: ["u"],
type: "string",
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
}),
),
handler: async (args) => {
const shouldReplay = replay(args)
if (shouldReplay === "invalid") return
const { runMini } = await import("./run")
await runMini({
attach: args.url,
directory: args.dir,
password: args.password,
username: args.username,
continue: args.continue,
session: args.session,
fork: args.fork,
replay: shouldReplay,
replayLimit: args.replayLimit,
})
},
})
export const MiniCommand = cmd({
command: "mini",
describe: "start the minimal interactive interface",
builder: (yargs) => yargs.command(MiniLocalCommand).command(MiniAttachCommand).demandCommand(),
handler: async () => {},
})
+262 -129
View File
@@ -1,13 +1,13 @@
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { FSUtil } from "@opencode-ai/core/fs-util"
// CLI entry point for `opencode run` and `opencode --mini`.
// CLI entry point for `opencode run` and `opencode mini`.
//
// Handles three modes:
// 1. Non-interactive (default): sends a single prompt, streams events to
// stdout, and exits when the session goes idle.
// 2. Interactive local (`opencode --mini`): boots the split-footer direct mode
// 2. Interactive local (`opencode mini`): boots the split-footer direct mode
// with an in-process server (no external HTTP).
// 3. Interactive attach (`opencode --mini --attach`): connects to a running
// 3. Interactive attach (`opencode mini attach`): connects to a running
// opencode server and runs interactive mode against it.
//
// Also supports `--command` for slash-command execution, `--format json` for
@@ -25,6 +25,8 @@ import { Filesystem } from "@/util/filesystem"
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { FormatError, FormatUnknownError } from "../error"
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin"
import { isImageAttachment, isPdfAttachment } from "@/util/media"
import { loadRunAgents } from "./run/catalog.shared"
type ModelInput = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
@@ -49,6 +51,14 @@ function resolveRunInput(value?: string, piped?: string): string | undefined {
return value + "\n" + piped
}
function isBinaryContent(bytes: Uint8Array) {
if (bytes.length === 0) return false
if (bytes.includes(0)) return true
return (
bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3
)
}
type FilePart = {
type: "file"
url: string
@@ -68,6 +78,7 @@ type SessionInfo = {
id: string
title?: string
directory?: string
current?: boolean
}
function inline(info: Inline) {
@@ -158,10 +169,6 @@ export const RunCommand = effectCmd({
describe: "fork the session before continuing (requires --continue or --session)",
type: "boolean",
})
.option("share", {
type: "boolean",
describe: "share the session",
})
.option("model", {
type: "string",
alias: ["m"],
@@ -217,11 +224,6 @@ export const RunCommand = effectCmd({
type: "boolean",
describe: "show thinking blocks",
})
.option("mini", {
type: "boolean",
hidden: true,
default: false,
})
.option("replay", {
type: "boolean",
default: true,
@@ -270,7 +272,7 @@ export const RunCommand = effectCmd({
const localInstance = yield* InstanceRef
yield* Effect.promise(async () => {
const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
const interactive = args.mini
const interactive = (args as typeof args & { mini?: boolean }).mini === true
const auto = args.auto || args.yolo || args["dangerously-skip-permissions"]
const thinking = interactive ? (args.thinking ?? true) : (args.thinking ?? false)
const die = (message: string): never => {
@@ -290,23 +292,23 @@ export const RunCommand = effectCmd({
.join(" ")
if (interactive && args.command) {
die("--mini cannot be used with --command")
die("opencode mini cannot be used with --command")
}
if (interactive && args._?.[0] !== "mini") {
die("--mini must be used without the run subcommand")
die("opencode mini must be run with the mini command")
}
if (args.demo && !interactive) {
die("--demo requires --mini")
die("--demo requires opencode mini")
}
if (interactive && args.format === "json") {
die("--mini cannot be used with --format json")
die("opencode mini cannot be used with --format json")
}
if (args["replay-limit"] !== undefined && !interactive) {
die("--replay-limit requires --mini")
die("--replay-limit requires opencode mini")
}
if (
@@ -317,7 +319,7 @@ export const RunCommand = effectCmd({
}
if (interactive && !process.stdout.isTTY) {
die("--mini requires a TTY stdout")
die("opencode mini requires a TTY stdout")
}
if (interactive) {
@@ -355,6 +357,12 @@ export const RunCommand = effectCmd({
}
const files: FilePart[] = []
const fileInputs: Array<{
filePath: string
resolvedPath: string
stat: ReturnType<typeof Filesystem.stat>
isDirectory: boolean
}> = []
if (args.file) {
const list = Array.isArray(args.file) ? args.file : [args.file]
@@ -371,45 +379,7 @@ export const RunCommand = effectCmd({
UI.error(`Cannot attach local directory without a shared filesystem: ${filePath}`)
process.exit(1)
}
const content = await (async () => {
if (!args.attach) return
const handle = await open(resolvedPath, "r")
try {
const opened = await handle.stat()
if (!opened.isFile() || Number(opened.size) > ATTACH_FILE_MAX_BYTES) {
UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${filePath}`)
process.exit(1)
}
if (opened.size === 0) return Buffer.alloc(0)
const buffer = Buffer.alloc(Number(opened.size))
let offset = 0
while (offset < buffer.length) {
const read = await handle.read(buffer, offset, buffer.length - offset, offset)
if (read.bytesRead === 0) break
offset += read.bytesRead
}
return buffer.subarray(0, offset)
} finally {
await handle.close()
}
})()
const detected = FSUtil.mimeType(resolvedPath)
const text = content?.toString("utf8")
const mime = !args.attach
? isDirectory
? "application/x-directory"
: "text/plain"
: content && text !== undefined && Buffer.from(text, "utf8").equals(content)
? "text/plain"
: detected
files.push({
type: "file",
url: content ? `data:${mime};base64,${content.toString("base64")}` : pathToFileURL(resolvedPath).href,
filename: path.basename(resolvedPath),
mime,
})
fileInputs.push({ filePath, resolvedPath, stat, isDirectory })
}
}
@@ -446,6 +416,53 @@ export const RunCommand = effectCmd({
pattern: "*",
},
]
const currentPrompt = !interactive && !args.command && fileInputs.every((file) => !file.isDirectory)
const inlineFiles = interactive || currentPrompt
for (const file of fileInputs) {
const content = await (async () => {
if (file.isDirectory || !inlineFiles) return
if (!file.stat?.isFile() || file.stat.size > ATTACH_FILE_MAX_BYTES) {
UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${file.filePath}`)
process.exit(1)
}
const handle = await open(file.resolvedPath, "r")
try {
const opened = await handle.stat()
if (!opened.isFile() || Number(opened.size) > ATTACH_FILE_MAX_BYTES) {
UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${file.filePath}`)
process.exit(1)
}
if (opened.size === 0) return Buffer.alloc(0)
const buffer = Buffer.alloc(Number(opened.size))
let offset = 0
while (offset < buffer.length) {
const read = await handle.read(buffer, offset, buffer.length - offset, offset)
if (read.bytesRead === 0) break
offset += read.bytesRead
}
return buffer.subarray(0, offset)
} finally {
await handle.close()
}
})()
const detected = FSUtil.mimeType(file.resolvedPath)
const text = content?.toString("utf8")
const mime = file.isDirectory
? "application/x-directory"
: isImageAttachment(detected) || isPdfAttachment(detected)
? detected
: content && !isBinaryContent(content) && text !== undefined && Buffer.from(text, "utf8").equals(content)
? "text/plain"
: detected
files.push({
type: "file",
url: content ? `data:${mime};base64,${content.toString("base64")}` : pathToFileURL(file.resolvedPath).href,
filename: path.basename(file.resolvedPath),
mime,
})
}
function title() {
if (args.title === undefined) return
@@ -453,58 +470,122 @@ export const RunCommand = effectCmd({
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
}
async function session(sdk: OpencodeClient): Promise<SessionInfo | undefined> {
if (args.session) {
const current = await sdk.session
.get({
sessionID: args.session,
})
.catch(() => undefined)
async function currentSession(sdk: OpencodeClient, sessionID: string): Promise<SessionInfo | undefined> {
const listed = await sdk.v2.session
.list({
directory: await current(sdk),
limit: 50,
order: "desc",
})
.then((result) => result.data?.data.find((item) => item.id === sessionID))
.catch(() => undefined)
const selected =
listed ??
(await sdk.v2.session
.get({ sessionID })
.then((result) => result.data?.data)
.catch(() => undefined))
const legacy =
selected ??
(await sdk.session
.get({ sessionID })
.then((result) => result.data)
.catch(() => undefined))
const transcript = await transcriptKind(sdk, legacy?.id ?? sessionID)
if (!legacy && transcript === "empty") {
return
}
if (interactive && transcript === "legacy") {
throw new Error("Mini cannot resume a legacy Session transcript")
}
if (!current?.data) {
UI.error("Session not found")
process.exit(1)
}
if (args.fork) {
const forked = await sdk.session.fork({
sessionID: args.session,
})
const id = forked.data?.id
if (!id) {
return
}
return {
id,
title: forked.data?.title ?? current.data.title,
directory: forked.data?.directory ?? current.data.directory,
}
}
return {
id: legacy?.id ?? sessionID,
title: legacy?.title,
directory: legacy ? ("location" in legacy ? legacy.location.directory : legacy.directory) : await current(sdk),
current: transcript !== "legacy",
}
}
async function forkSession(sdk: OpencodeClient, session: SessionInfo): Promise<SessionInfo | undefined> {
if (session.current !== false) {
const forked = await sdk.v2.session.fork(
{ sessionID: session.id, messageID: undefined },
{ throwOnError: true },
)
await waitForFork(sdk, session.id, forked.data.data.id)
return {
id: current.data.id,
title: current.data.title,
directory: current.data.directory,
id: forked.data.data.id,
title: forked.data.data.title,
directory: forked.data.data.location.directory,
current: true,
}
}
const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined
const forked = await sdk.session.fork({
sessionID: session.id,
})
const id = forked.data?.id
if (!id) {
return
}
if (base && args.fork) {
const forked = await sdk.session.fork({
sessionID: base.id,
})
const id = forked.data?.id
if (!id) {
return {
id,
title: forked.data?.title ?? session.title,
directory: forked.data?.directory ?? session.directory,
current: false,
}
}
async function waitForFork(sdk: OpencodeClient, parentID: string, sessionID: string) {
const parentHasMessages = await sdk.v2.session
.messages({ sessionID: parentID, limit: 1 })
.then((result) => (result.data?.data.length ?? 0) > 0)
.catch(() => false)
if (!parentHasMessages) {
return
}
const deadline = Date.now() + 3000
while (Date.now() < deadline) {
const forkedHasMessages = await sdk.v2.session
.messages({ sessionID, limit: 1 })
.then((result) => (result.data?.data.length ?? 0) > 0)
.catch(() => false)
if (forkedHasMessages) {
return
}
return {
id,
title: forked.data?.title ?? base.title,
directory: forked.data?.directory ?? base.directory,
await Bun.sleep(25)
}
}
async function session(sdk: OpencodeClient): Promise<SessionInfo | undefined> {
if (args.session) {
const current = await currentSession(sdk, args.session)
if (!current) {
UI.error("Session not found")
process.exit(1)
}
if (!interactive && !currentPrompt && current.current !== false) {
throw new Error("This operation is not available for a current Session transcript")
}
if (args.fork) {
return forkSession(sdk, current)
}
return current
}
const base = args.continue ? await currentRootSession(sdk) : undefined
if (base && !interactive && !currentPrompt && base.current !== false) {
throw new Error("This operation is not available for a current Session transcript")
}
if (base && args.fork) {
return forkSession(sdk, base)
}
if (base) {
@@ -512,6 +593,23 @@ export const RunCommand = effectCmd({
id: base.id,
title: base.title,
directory: base.directory,
current: "current" in base ? base.current : false,
}
}
if (interactive || currentPrompt) {
const name = title()
const result = await sdk.v2.session.create({
location: { directory: await current(sdk) },
})
const created = result.data?.data
if (!created) return
if (name) await sdk.v2.session.rename({ sessionID: created.id, title: name })
return {
id: created.id,
title: name ?? created.title,
directory: created.location.directory,
current: true,
}
}
@@ -529,30 +627,45 @@ export const RunCommand = effectCmd({
id,
title: result.data?.title ?? name,
directory: result.data?.directory,
current: false,
}
}
async function share(sdk: OpencodeClient, sessionID: string) {
const cfg = await sdk.config.get()
if (!cfg.data) return
if (cfg.data.share !== "auto" && !flags.autoShare && !args.share) return
const res = await sdk.session.share({ sessionID }).catch((error) => {
if (error instanceof Error && error.message.includes("disabled")) {
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
}
return { error }
async function currentRootSession(sdk: OpencodeClient): Promise<SessionInfo | undefined> {
const response = await sdk.v2.session.list({
directory: await current(sdk),
limit: 50,
order: "desc",
})
if (!res.error && "data" in res && res.data?.share?.url) {
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url)
const root = (response.data?.data ?? [])
.filter((session) => !session.parentID)
.toSorted((a, b) => b.time.updated - a.time.updated)[0]
if (!root) return
return currentSession(sdk, root.id)
}
async function transcriptKind(sdk: OpencodeClient, sessionID: string) {
const current = await sdk.v2.session.messages({ sessionID, limit: 1 }).then((result) => (result.data?.data.length ?? 0) > 0)
// Ordinary prompt flows assume a transcript with current messages is
// current-owned; only legacy-only modes (--command, directory
// attachments) still probe legacy history for mixed transcripts.
if (current && (interactive || currentPrompt)) return "current" as const
const legacy = await sdk.session.messages({ sessionID, limit: 1 }).then((result) => (result.data?.length ?? 0) > 0)
if (current) {
if (legacy) throw new Error("Session contains mixed legacy and current transcripts")
return "current" as const
}
if (legacy) return "legacy" as const
return "empty" as const
}
async function createFreshSession(
sdk: OpencodeClient,
input: { agent: string | undefined; model: ModelInput | undefined; variant: string | undefined },
): Promise<SessionInfo> {
const result = await sdk.session.create({
title: args.title !== undefined && args.title !== "" ? args.title : undefined,
const name = args.title !== undefined && args.title !== "" ? args.title : undefined
const result = await sdk.v2.session.create({
agent: input.agent,
model: input.model
? {
@@ -561,17 +674,18 @@ export const RunCommand = effectCmd({
variant: input.variant,
}
: undefined,
permission: [...rules],
location: { directory: await current(sdk) },
})
const id = result.data?.id
const created = result.data?.data
const id = created?.id
if (!id) {
throw new Error("Failed to create session")
}
if (name) await sdk.v2.session.rename({ sessionID: id, title: name })
void share(sdk, id).catch(() => {})
return {
id,
title: result.data?.title,
title: name ?? created.title,
}
}
@@ -580,8 +694,8 @@ export const RunCommand = effectCmd({
return directory ?? root
}
const next = await sdk.path
.get()
const next = await sdk.v2.location
.get(undefined, { throwOnError: true })
.then((x) => x.data?.directory)
.catch(() => undefined)
if (next) {
@@ -622,10 +736,7 @@ export const RunCommand = effectCmd({
if (!args.agent) return undefined
const name = args.agent
const modes = await sdk.app
.agents(undefined, { throwOnError: true })
.then((x) => x.data ?? [])
.catch(() => undefined)
const modes = await loadRunAgents(sdk, await current(sdk)).catch(() => undefined)
if (!modes) {
UI.println(
@@ -636,7 +747,7 @@ export const RunCommand = effectCmd({
return undefined
}
const agent = modes.find((a) => a.name === name)
const agent = modes.find((item) => item.name === name)
if (!agent) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
@@ -823,9 +934,33 @@ export const RunCommand = effectCmd({
// Validate agent if specified
const agent = await pickAgent(client)
await share(client, sessionID)
if (!interactive) {
if (currentPrompt && sess.current !== false) {
const model = pick(args.model)
const { runNonInteractivePrompt } = await import("./run/noninteractive")
try {
await runNonInteractivePrompt({
client,
sessionID,
message,
files,
agent,
model,
variant: args.variant,
thinking,
format: args.format === "json" ? "json" : "default",
dangerouslySkipPermissions: args["dangerously-skip-permissions"],
renderTool: tool,
renderToolError: toolError,
})
} catch (error) {
const output = error instanceof Error ? { type: "unknown", message: error.message } : error
if (!emit("error", { error: output })) UI.error(formatRunError(error))
process.exitCode = 1
}
return
}
const events = await client.event.subscribe()
const completed = loop(client, events).catch((e) => {
console.error(e)
@@ -880,7 +1015,7 @@ export const RunCommand = effectCmd({
directory: cwd,
sessionID,
sessionTitle: sess.title,
resume: Boolean(args.session || args.continue) && !args.fork,
resume: Boolean(args.session || args.continue),
replay,
replayLimit: args["replay-limit"],
agent,
@@ -917,7 +1052,6 @@ export const RunCommand = effectCmd({
fetch: fetchFn,
resolveAgent: localAgent,
session,
share,
createSession: createFreshSession,
agent: args.agent,
model,
@@ -984,7 +1118,6 @@ export async function runMini(input: MiniCommandInput) {
continue: input.continue,
session: input.session,
fork: input.fork,
share: undefined,
model: input.model,
agent: input.agent,
format: "default",
@@ -1007,5 +1140,5 @@ export async function runMini(input: MiniCommandInput) {
"dangerously-skip-permissions": false,
dangerouslySkipPermissions: false,
demo: input.demo ?? false,
})
} as Parameters<NonNullable<typeof RunCommand.handler>>[0] & { mini: boolean })
}
@@ -0,0 +1,114 @@
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import type { RunAgent, RunCommand, RunProvider, RunReference } from "./types"
type CurrentAgent = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["agent"]["list"]>>["data"]>["data"][number]
type CurrentCommand = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["command"]["list"]>>["data"]>["data"][number]
type CurrentSkill = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["skill"]["list"]>>["data"]>["data"][number]
type CurrentProvider = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["provider"]["list"]>>["data"]>["data"][number]
type CurrentModel = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["model"]["list"]>>["data"]>["data"][number]
function location(directory: string) {
return {
location: {
directory,
},
}
}
function defaultCost(model: CurrentModel) {
const picked = model.cost.find((cost) => cost.tier === undefined) ?? model.cost[0]
if (!picked) {
return undefined
}
return {
...picked,
input: model.cost.every((cost) => cost.input === 0) ? 0 : picked.input,
}
}
export function runAgent(input: CurrentAgent): RunAgent {
return {
name: input.id,
description: input.description,
mode: input.mode,
hidden: input.hidden,
}
}
export function runCommand(input: CurrentCommand): RunCommand {
return {
name: input.name,
description: input.description,
}
}
export function runSkill(input: CurrentSkill): RunCommand {
return {
name: input.name,
description: input.description,
source: "skill",
}
}
export function runProviders(providers: CurrentProvider[], models: CurrentModel[]): RunProvider[] {
const grouped = new Map<string, RunProvider>()
for (const provider of providers) {
grouped.set(provider.id, {
id: provider.id,
name: provider.name,
models: {},
})
}
for (const model of models) {
const provider = grouped.get(model.providerID) ?? {
id: model.providerID,
name: model.providerID,
models: {},
}
provider.models[model.id] = {
id: model.id,
providerID: model.providerID,
name: model.name,
capabilities: model.capabilities,
cost: defaultCost(model),
limit: model.limit,
status: model.status,
variants: Object.fromEntries(model.variants.map((variant) => [variant.id, {}])),
}
grouped.set(provider.id, provider)
}
return [...grouped.values()]
}
export async function loadRunAgents(sdk: OpencodeClient, directory: string): Promise<RunAgent[]> {
const result = await sdk.v2.agent.list(location(directory), { throwOnError: true })
return (result.data?.data ?? []).map(runAgent)
}
export async function loadRunCommands(sdk: OpencodeClient, directory: string): Promise<RunCommand[]> {
const [commands, skills] = await Promise.all([
sdk.v2.command.list(location(directory), { throwOnError: true }),
sdk.v2.skill.list(location(directory), { throwOnError: true }),
])
return [
...(commands.data?.data ?? []).map(runCommand),
...(skills.data?.data ?? []).filter((skill) => skill.slash !== false).map(runSkill),
]
}
export async function loadRunReferences(sdk: OpencodeClient, directory: string): Promise<RunReference[]> {
const result = await sdk.v2.reference.list(location(directory), { throwOnError: true })
return (result.data?.data ?? []).filter((reference) => !reference.hidden)
}
export async function loadRunProviders(sdk: OpencodeClient, directory: string): Promise<RunProvider[]> {
const [providers, models] = await Promise.all([
sdk.v2.provider.list(location(directory), { throwOnError: true }),
sdk.v2.model.list(location(directory), { throwOnError: true }),
])
return runProviders(providers.data?.data ?? [], models.data?.data ?? [])
}
@@ -141,7 +141,9 @@ export function RunPermissionBody(props: {
const info = createMemo(() => permissionInfo(props.request))
const ft = createMemo(() => toolFiletype(info().file))
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
const opts = createMemo(() => permissionOptions(state().stage))
const opts = createMemo(() =>
permissionOptions(state().stage).filter((option) => option !== "always" || props.request.always.length > 0),
)
const busy = createMemo(() => state().submitting)
const title = createMemo(() => {
if (state().stage === "always") {
@@ -165,7 +167,7 @@ export function RunPermissionBody(props: {
})
const shift = (dir: -1 | 1) => {
setState((prev) => permissionShift(prev, dir))
setState((prev) => permissionShift(prev, dir, opts()))
}
const submit = async (next: PermissionReply) => {
@@ -1,7 +1,7 @@
// Prompt composer and its state machine for direct interactive mode.
//
// createPromptState() wires keymap command layers, history navigation, and
// `@` autocomplete for files, subagents, and MCP resources.
// `@` autocomplete for files, subagents, and project references.
// It produces a PromptState that RunPromptBody renders as a slim single-line
// composer while the footer view renders any active menus below it.
/** @jsxImportSource @opentui/solid */
@@ -27,7 +27,7 @@ import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap"
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme"
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource, RunTuiConfig } from "./types"
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference, RunTuiConfig } from "./types"
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
const AUTOCOMPLETE_BOTTOM_ROWS = 1
@@ -59,7 +59,7 @@ type PromptInput = {
directory: string
findFiles: (query: string) => Promise<string[]>
agents: Accessor<RunAgent[]>
resources: Accessor<RunResource[]>
references: Accessor<RunReference[]>
commands: Accessor<RunCommand[] | undefined>
tuiConfig: RunTuiConfig
state: Accessor<FooterState>
@@ -333,21 +333,20 @@ export function createPromptState(input: PromptInput): PromptState {
},
}))
})
const resources = createMemo<Auto[]>(() => {
return input.resources().map((item) => ({
const references = createMemo<Auto[]>(() => {
return input.references().map((item) => ({
kind: "mention",
display: Locale.truncateMiddle(`@${item.name} (${item.uri})`, width()),
display: Locale.truncateMiddle("@" + item.name, width()),
value: item.name,
description: item.description,
description: item.description ?? (item.source.type === "git" ? item.source.repository : item.source.path),
part: {
type: "file",
mime: item.mimeType ?? "text/plain",
mime: "application/x-directory",
filename: item.name,
url: item.uri,
url: pathToFileURL(item.path).href,
source: {
type: "resource",
clientName: item.client,
uri: item.uri,
type: "file",
path: item.name,
text: {
start: 0,
end: 0,
@@ -402,7 +401,7 @@ export function createPromptState(input: PromptInput): PromptState {
},
{ initialValue: [] as Auto[] },
)
const mentionOptions = createMemo(() => [...agents(), ...files(), ...resources()])
const mentionOptions = createMemo(() => [...agents(), ...files(), ...references()])
const skillCommands = createMemo(() => (input.commands() ?? []).filter((item) => item.source === "skill"))
const hasSkillsCommand = createMemo(() =>
(input.commands() ?? []).some((item) => item.source !== "skill" && item.name === "skills"),
@@ -462,7 +461,7 @@ export function createPromptState(input: PromptInput): PromptState {
return [
...fuzzysort.go(next, agents(), { keys: ["value", "display", "description"] }).map((item) => item.obj),
...files(),
...fuzzysort.go(next, resources(), { keys: ["value", "display", "description"] }).map((item) => item.obj),
...fuzzysort.go(next, references(), { keys: ["value", "display", "description"] }).map((item) => item.obj),
]
}
@@ -53,6 +53,9 @@ export function RunFooterSubagentBody(props: {
diffStyle?: RunDiffStyle
onCycle: (dir: -1 | 1) => void
onClose: () => void
// Formatted interrupt shortcut from the registered keymap binding; the
// command itself is dispatched through the keymap in footer.view.
interrupt?: () => string | undefined
}) {
const theme = createMemo(() => props.theme())
const footer = createMemo(() => theme().footer)
@@ -89,6 +92,11 @@ export function RunFooterSubagentBody(props: {
))
let scroll: ScrollBoxRenderable | undefined
const interruptHint = createMemo(() => {
if (tab()?.status !== "running") return undefined
return props.interrupt?.()
})
useKeyboard((event) => {
if (!props.active()) {
return
@@ -139,6 +147,13 @@ export function RunFooterSubagentBody(props: {
<span style={{ fg: footer().muted }}>{" " + subtitle()}</span>
</Show>
</text>
<Show when={interruptHint()}>
{(hint) => (
<text fg={footer().muted} wrapMode="none" truncate flexShrink={0}>
{hint()} interrupt
</text>
)}
</Show>
<Show when={props.total() > 1 && props.index() > 0}>
<text fg={footer().muted} wrapMode="none" truncate flexShrink={0}>
{props.index()} of {props.total()}
+11 -9
View File
@@ -55,7 +55,7 @@ import type {
RunInput,
RunPrompt,
RunProvider,
RunResource,
RunReference,
RunTuiConfig,
StreamCommit,
} from "./types"
@@ -71,7 +71,7 @@ type RunFooterOptions = {
directory: string
findFiles: (query: string) => Promise<string[]>
agents: RunAgent[]
resources: RunResource[]
references: RunReference[]
commands?: RunCommand[]
wrote?: boolean
sessionID: () => string | undefined
@@ -97,6 +97,7 @@ type RunFooterOptions = {
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onExit?: () => void
onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void
treeSitterClient?: TreeSitterClient
}
@@ -180,8 +181,8 @@ export class RunFooter implements FooterApi {
private rows = TEXTAREA_MIN_ROWS
private agents: Accessor<RunAgent[]>
private setAgents: Setter<RunAgent[]>
private resources: Accessor<RunResource[]>
private setResources: Setter<RunResource[]>
private references: Accessor<RunReference[]>
private setReferences: Setter<RunReference[]>
private commands: Accessor<RunCommand[] | undefined>
private setCommands: Setter<RunCommand[] | undefined>
private providers: Accessor<RunProvider[] | undefined>
@@ -255,9 +256,9 @@ export class RunFooter implements FooterApi {
const [agents, setAgents] = createSignal(options.agents)
this.agents = agents
this.setAgents = setAgents
const [resources, setResources] = createSignal(options.resources)
this.resources = resources
this.setResources = setResources
const [references, setReferences] = createSignal(options.references)
this.references = references
this.setReferences = setReferences
const [commands, setCommands] = createSignal<RunCommand[] | undefined>(options.commands)
this.commands = commands
this.setCommands = setCommands
@@ -311,7 +312,7 @@ export class RunFooter implements FooterApi {
queuedPrompts: footer.queuedPrompts,
findFiles: options.findFiles,
agents: footer.agents,
resources: footer.resources,
references: footer.references,
commands: footer.commands,
providers: footer.providers,
currentModel: footer.currentModel,
@@ -341,6 +342,7 @@ export class RunFooter implements FooterApi {
onLayout: footer.syncLayout,
onStatus: footer.setStatus,
onSubagentSelect: options.onSubagentSelect,
onSubagentInterrupt: options.onSubagentInterrupt,
onQueuedRemove: footer.handleQueuedRemove,
})
},
@@ -411,7 +413,7 @@ export class RunFooter implements FooterApi {
}
this.setAgents(next.agents)
this.setResources(next.resources)
this.setReferences(next.references)
if (next.commands !== undefined) {
this.setCommands(next.commands)
}
@@ -50,7 +50,7 @@ import type {
RunInput,
RunPrompt,
RunProvider,
RunResource,
RunReference,
RunTuiConfig,
} from "./types"
import type { RunTheme } from "./theme"
@@ -74,7 +74,7 @@ type RunFooterViewProps = {
directory: string
findFiles: (query: string) => Promise<string[]>
agents: () => RunAgent[]
resources: () => RunResource[]
references: () => RunReference[]
commands: () => RunCommand[] | undefined
providers: () => RunProvider[] | undefined
currentModel: () => RunInput["model"]
@@ -108,6 +108,7 @@ type RunFooterViewProps = {
onLayout: (input: { route: FooterPromptRoute; autocomplete: boolean; subagentRows: number }) => void
onStatus: (text: string) => void
onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void
onQueuedRemove: (messageID: string) => Promise<boolean>
}
@@ -213,6 +214,15 @@ export function RunFooterView(props: RunFooterViewProps) {
props.tuiConfig,
) ?? "",
)
const subagentInterruptShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["subagent.interrupt"] })
.get("subagent.interrupt")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const interrupt = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
@@ -358,7 +368,7 @@ export function RunFooterView(props: RunFooterViewProps) {
directory: props.directory,
findFiles: props.findFiles,
agents: props.agents,
resources: props.resources,
references: props.references,
commands: props.commands,
tuiConfig: props.tuiConfig,
state: props.state,
@@ -520,7 +530,7 @@ export function RunFooterView(props: RunFooterViewProps) {
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents(),
enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents() && !!props.onBackground,
priority: 1,
commands: [
{
@@ -561,6 +571,32 @@ export function RunFooterView(props: RunFooterViewProps) {
bindings: props.tuiConfig.keybinds.get("session.queued_prompts"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled:
active().type === "prompt" &&
route().type === "subagent" &&
selectedTab()?.status === "running" &&
!!props.onSubagentInterrupt,
priority: 1,
commands: [
{
name: "subagent.interrupt",
title: "Interrupt subagent",
category: "Session",
run: () => {
const current = selectedTab()
if (current?.status !== "running") {
return
}
props.onSubagentInterrupt?.(current.sessionID)
},
},
],
bindings: [{ key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "subagent.interrupt" }],
}))
createEffect(() => {
const current = route()
if (current.type !== "subagent") {
@@ -935,6 +971,7 @@ export function RunFooterView(props: RunFooterViewProps) {
diffStyle={props.diffStyle}
onCycle={cycleTab}
onClose={closeTab}
interrupt={() => subagentInterruptShortcut() || undefined}
/>
</box>
</Show>
@@ -0,0 +1,459 @@
import type {
OpencodeClient,
ReasoningPart,
StepFinishPart,
StepStartPart,
TextPart,
ToolPart,
V2Event,
} from "@opencode-ai/sdk/v2"
import { EOL } from "node:os"
import { MessageID } from "@/session/schema"
import { UI } from "../../ui"
type Model = {
providerID: string
modelID: string
}
type File = {
url: string
filename: string
mime: string
}
type Input = {
client: OpencodeClient
sessionID: string
message: string
files: File[]
agent?: string
model?: Model
variant?: string
thinking: boolean
format: "default" | "json"
dangerouslySkipPermissions: boolean
renderTool: (part: ToolPart) => Promise<void>
renderToolError: (part: ToolPart) => Promise<void>
}
type StartedPart = {
id: string
timestamp: number
}
type ToolState = StartedPart & {
assistantMessageID: string
tool: string
input: Record<string, unknown>
raw?: string
provider?: unknown
}
export async function runNonInteractivePrompt(input: Input) {
const controller = new AbortController()
const events = await input.client.v2.event.subscribe({
signal: controller.signal,
sseMaxRetryAttempts: 0,
throwOnError: true,
})
const stream = events.stream[Symbol.asyncIterator]() as AsyncGenerator<V2Event>
const connected = await stream.next()
if (connected.done) throw new Error("Event stream disconnected before prompt admission")
const messageID = MessageID.ascending()
const starts = new Map<string, StartedPart>()
const tools = new Map<string, ToolState>()
let submitted = false
let promoted = false
let emittedError = false
let questionRejected = false
let permissionRejected = false
let interrupted = false
let admission: AbortController | undefined
const emit = (type: string, timestamp: number, data: Record<string, unknown>) => {
if (input.format !== "json") return false
process.stdout.write(JSON.stringify({ type, timestamp, sessionID: input.sessionID, ...data }) + EOL)
return true
}
const writeText = (part: TextPart, timestamp: number) => {
if (emit("text", timestamp, { part })) return
const text = part.text.trim()
if (!text) return
if (!process.stdout.isTTY) {
process.stdout.write(text + EOL)
return
}
UI.empty()
UI.println(text)
UI.empty()
}
const replyPermission = async (request: { id: string; action: string; resources: string[] }) => {
if (!input.dangerouslySkipPermissions) {
permissionRejected = true
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL +
`permission requested: ${request.action} (${request.resources.join(", ")}); auto-rejecting`,
)
}
await input.client.v2.session.permission
.reply({
sessionID: input.sessionID,
requestID: request.id,
reply: input.dangerouslySkipPermissions ? "once" : "reject",
})
.catch(() => {})
if (!input.dangerouslySkipPermissions) {
await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
}
}
const rejectQuestion = async (request: { id: string }) => {
questionRejected = true
await input.client.v2.session.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {})
}
const consume = async () => {
while (!controller.signal.aborted) {
const next = await stream.next()
if (next.done) throw new Error("Event stream disconnected during prompt execution")
const event = next.value
if (event.type === "permission.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
await replyPermission(event.data)
continue
}
if (event.type === "question.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
await rejectQuestion(event.data)
continue
}
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
const time = "timestamp" in event.data ? toMillis(event.data.timestamp) : Date.now()
if (event.type === "session.next.prompted") {
if (event.data.messageID === messageID) {
promoted = true
continue
}
if (promoted && event.data.delivery === "queue") return
}
if (
event.type === "session.next.execution.settled" &&
event.data.outcome === "interrupted" &&
(interrupted || permissionRejected || questionRejected)
) {
return
}
if (!promoted) continue
if (event.type === "session.next.step.started") {
const part: StepStartPart = {
id: partID(event.id),
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "step-start",
snapshot: event.data.snapshot,
}
if (!emit("step_start", time, { part }) && input.format !== "json") {
UI.empty()
UI.println(`> ${event.data.agent} · ${event.data.model.id}`)
UI.empty()
}
continue
}
if (event.type === "session.next.text.started") {
starts.set(event.data.textID, { id: partID(event.id), timestamp: time })
continue
}
if (event.type === "session.next.text.ended") {
const started = starts.get(event.data.textID)
const part: TextPart = {
id: started?.id ?? partID(event.id),
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "text",
text: event.data.text,
time: { start: started?.timestamp ?? time, end: time },
}
writeText(part, time)
continue
}
if (event.type === "session.next.reasoning.started") {
starts.set(event.data.reasoningID, { id: partID(event.id), timestamp: time })
continue
}
if (event.type === "session.next.reasoning.ended" && input.thinking) {
const started = starts.get(event.data.reasoningID)
const part: ReasoningPart = {
id: started?.id ?? partID(event.id),
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "reasoning",
text: event.data.text,
metadata: event.data.providerMetadata,
time: { start: started?.timestamp ?? time, end: time },
}
if (emit("reasoning", time, { part })) continue
const text = part.text.trim()
if (!text) continue
const line = `Thinking: ${text}`
if (!process.stdout.isTTY) {
process.stdout.write(line + EOL)
continue
}
UI.empty()
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
UI.empty()
continue
}
if (event.type === "session.next.tool.input.started") {
tools.set(event.data.callID, {
id: partID(event.id),
timestamp: time,
assistantMessageID: event.data.assistantMessageID,
tool: event.data.name,
input: {},
})
continue
}
if (event.type === "session.next.tool.input.ended") {
const current = tools.get(event.data.callID)
if (current) current.raw = event.data.text
continue
}
if (event.type === "session.next.tool.called") {
const current = tools.get(event.data.callID)
tools.set(event.data.callID, {
id: current?.id ?? partID(event.id),
timestamp: current?.timestamp ?? time,
assistantMessageID: event.data.assistantMessageID,
tool: event.data.tool,
input: event.data.input,
raw: current?.raw,
provider: event.data.provider,
})
continue
}
if (event.type === "session.next.tool.success") {
const current = tools.get(event.data.callID) ?? fallbackTool(event)
const part: ToolPart = {
id: current.id,
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "tool",
callID: event.data.callID,
tool: current.tool,
state: {
status: "completed",
input: current.input,
output: event.data.content
.filter((item) => item.type === "text")
.map((item) => item.text)
.join("\n"),
title: current.tool,
metadata: {
structured: event.data.structured,
content: event.data.content,
outputPaths: event.data.outputPaths,
result: event.data.result,
providerCall: current.provider,
providerResult: event.data.provider,
rawInput: current.raw,
},
time: { start: current.timestamp, end: time },
},
}
tools.delete(event.data.callID)
if (!emit("tool_use", time, { part })) await input.renderTool(part)
continue
}
if (event.type === "session.next.tool.failed") {
const current = tools.get(event.data.callID) ?? fallbackTool(event)
const error = event.data.error.message
const part: ToolPart = {
id: current.id,
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "tool",
callID: event.data.callID,
tool: current.tool,
state: {
status: "error",
input: current.input,
error,
metadata: {
result: event.data.result,
providerCall: current.provider,
providerResult: event.data.provider,
rawInput: current.raw,
},
time: { start: current.timestamp, end: time },
},
}
tools.delete(event.data.callID)
if (!emit("tool_use", time, { part })) {
await input.renderToolError(part)
UI.error(error)
}
continue
}
if (event.type === "session.next.step.ended") {
const part: StepFinishPart = {
id: partID(event.id),
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "step-finish",
reason: event.data.finish,
snapshot: event.data.snapshot,
cost: event.data.cost,
tokens: event.data.tokens,
}
emit("step_finish", time, { part })
continue
}
if (event.type === "session.next.step.failed") {
if (interrupted || permissionRejected || questionRejected) continue
emittedError = true
process.exitCode = 1
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
continue
}
if (event.type === "session.next.execution.settled") {
if (event.data.outcome === "failure" && !emittedError && !questionRejected) {
emittedError = true
process.exitCode = 1
const error = event.data.error ?? { type: "unknown", message: "Session execution failed" }
if (!emit("error", toMillis(event.data.timestamp), { error })) UI.error(error.message)
}
if (event.data.outcome === "interrupted" && interrupted) process.exitCode = 130
return
}
}
}
const interrupt = () => {
if (interrupted) process.exit(130)
interrupted = true
process.exitCode = 130
admission?.abort()
void input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
}
process.on("SIGINT", interrupt)
let completed: Promise<void> | undefined
try {
if (input.agent) {
await input.client.v2.session.switchAgent(
{ sessionID: input.sessionID, agent: input.agent },
{ throwOnError: true },
)
}
const selected = input.model
? { providerID: input.model.providerID, id: input.model.modelID, variant: input.variant }
: input.variant
? await input.client.v2.session
.get({ sessionID: input.sessionID }, { throwOnError: true })
.then((result) => result.data.data.model)
.then(async (model) => {
if (model) return { ...model, variant: input.variant }
const result = await input.client.v2.model.default(undefined, { throwOnError: true })
const fallback = result.data.data
return fallback ? { providerID: fallback.providerID, id: fallback.id, variant: input.variant } : undefined
})
: undefined
if (input.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
if (selected) {
await input.client.v2.session.switchModel({ sessionID: input.sessionID, model: selected }, { throwOnError: true })
}
const prepared = await Promise.all(input.files.map(prepareFile))
if (interrupted) return
submitted = true
completed = consume()
admission = new AbortController()
const response = await input.client.v2.session
.prompt(
{
sessionID: input.sessionID,
id: messageID,
prompt: {
text: [input.message, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"),
files: prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])),
},
delivery: "steer",
},
{ throwOnError: true, signal: admission.signal },
)
.catch(async (error) => {
if (interrupted) {
await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
}
controller.abort()
await completed?.catch(() => {})
if (interrupted) return undefined
throw error
})
admission = undefined
if (!response) return
if (!response.data.data) throw new Error("Prompt was not admitted")
if (interrupted) await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
const [permissions, questions] = await Promise.all([
input.client.v2.session.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.v2.session.question.list({ sessionID: input.sessionID }).catch(() => undefined),
])
await Promise.all([
...(permissions?.data?.data ?? []).map(replyPermission),
...(questions?.data?.data ?? []).map(rejectQuestion),
])
await completed
} finally {
process.off("SIGINT", interrupt)
controller.abort()
await stream.return?.(undefined).catch(() => {})
}
}
function partID(eventID: string) {
return `prt_${eventID.replace(/^evt_/, "")}`
}
function fallbackTool(event: {
id: string
data: { timestamp: number; assistantMessageID: string; callID: string }
}): ToolState {
return {
id: partID(event.id),
timestamp: toMillis(event.data.timestamp),
assistantMessageID: event.data.assistantMessageID,
tool: "tool",
input: {},
}
}
function toMillis(value: unknown) {
if (typeof value === "number") return value
if (typeof value === "string") return new Date(value).getTime()
return Date.now()
}
async function prepareFile(file: File) {
if (file.mime !== "text/plain") {
const uri = file.url.startsWith("data:")
? file.url
: `data:${file.mime};base64,${Buffer.from(await Bun.file(new URL(file.url)).arrayBuffer()).toString("base64")}`
return { attachment: { uri, mime: file.mime, name: file.filename } }
}
const content = file.url.startsWith("data:")
? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8")
: await Bun.file(new URL(file.url)).text()
return { text: `<file name="${file.filename}">\n${content}\n</file>` }
}
@@ -150,8 +150,11 @@ export function permissionReply(requestID: string, reply: PermissionReply["reply
}
}
export function permissionShift(state: PermissionBodyState, dir: -1 | 1): PermissionBodyState {
const list = permissionOptions(state.stage)
export function permissionShift(
state: PermissionBodyState,
dir: -1 | 1,
list = permissionOptions(state.stage),
): PermissionBodyState {
if (list.length === 0) {
return state
}
@@ -8,9 +8,12 @@
import { Context, Effect, Layer } from "effect"
import { resolve } from "@opencode-ai/tui/config"
import { TuiConfig } from "@/config/tui"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
import { makeRuntime } from "@/effect/run-service"
import { loadRunProviders } from "./catalog.shared"
import { reusePendingTask } from "./runtime.shared"
import { resolveSession, sessionHistory } from "./session.shared"
import { resolveCurrentSession, sessionHistory } from "./session.shared"
import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
import { pickVariant } from "./variant.shared"
@@ -95,20 +98,7 @@ const layer = Layer.effect(
directory: string,
model: RunInput["model"],
) {
const connected = yield* Effect.promise(() =>
sdk.config
.providers({ directory })
.then((item) => item.data?.providers)
.catch(() => undefined),
)
const providers = yield* Effect.promise(() =>
connected
? Promise.resolve(connected)
: sdk.provider
.list()
.then((item) => item.data?.all ?? [])
.catch(() => []),
)
const providers = yield* Effect.promise(() => loadRunProviders(sdk, directory))
const limits = Object.fromEntries(
providers.flatMap((provider) =>
Object.entries(provider.models ?? {}).flatMap(([modelID, info]) => {
@@ -143,7 +133,7 @@ const layer = Layer.effect(
sessionID: string,
model: RunInput["model"],
) {
const session = yield* Effect.promise(() => resolveSession(sdk, sessionID).catch(() => undefined))
const session = yield* Effect.promise(() => resolveCurrentSession(sdk, sessionID).catch(() => undefined))
if (!session) {
return emptySessionInfo()
}
@@ -172,7 +162,8 @@ const layer = Layer.effect(
}),
)
const runtime = makeRuntime(Service, layer)
const node = makeGlobalNode({ service: Service, layer, deps: [] })
const runtime = makeRuntime(Service, AppNodeBuilder.build(node))
// Fetches available variants and context limits for every provider/model pair.
export async function resolveModelInfo(
@@ -27,7 +27,7 @@ import type {
RunAgent,
RunInput,
RunPrompt,
RunResource,
RunReference,
RunTuiConfig,
} from "./types"
import { formatModelLabel } from "./variant.shared"
@@ -55,7 +55,7 @@ export type LifecycleInput = {
directory: string
findFiles: (query: string) => Promise<string[]>
agents: RunAgent[]
resources: RunResource[]
references: RunReference[]
sessionID: string
sessionTitle?: string
getSessionID?: () => string | undefined
@@ -75,6 +75,7 @@ export type LifecycleInput = {
onInterrupt?: () => void
onBackground?: () => void
onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void
}
export type Lifecycle = {
@@ -233,7 +234,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
directory: input.directory,
findFiles: input.findFiles,
agents: input.agents,
resources: input.resources,
references: input.references,
sessionID: input.getSessionID ?? (() => input.sessionID),
...labels,
model: input.model,
@@ -276,6 +277,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
}
},
onSubagentSelect: input.onSubagentSelect,
onSubagentInterrupt: input.onSubagentInterrupt,
})
const sigint = () => {
@@ -1,7 +1,7 @@
import fs from "fs"
import * as tty from "node:tty"
export const INTERACTIVE_INPUT_ERROR = "--mini requires a controlling terminal for input"
export const INTERACTIVE_INPUT_ERROR = "opencode mini requires a controlling terminal for input"
type InteractiveStdin = {
stdin: NodeJS.ReadStream
+35 -35
View File
@@ -1,4 +1,4 @@
// Top-level orchestrator for `opencode --mini`.
// Top-level orchestrator for `opencode mini`.
//
// Wires the boot sequence, lifecycle (renderer + footer), stream transport,
// and prompt queue together into a single session loop. Two entry points:
@@ -15,6 +15,7 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { Flag } from "@opencode-ai/core/flag/flag"
import { MessageID } from "@/session/schema"
import { loadRunAgents, loadRunCommands, loadRunReferences } from "./catalog.shared"
import { createRunDemo } from "./demo"
import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
import { createRuntimeLifecycle } from "./runtime.lifecycle"
@@ -62,7 +63,6 @@ type RunLocalInput = {
fetch: typeof globalThis.fetch
resolveAgent: () => Promise<string | undefined>
session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string } | undefined>
share: (sdk: RunInput["sdk"], sessionID: string) => Promise<void>
createSession?: CreateSession
agent: RunInput["agent"]
model: RunInput["model"]
@@ -77,7 +77,7 @@ type RunLocalInput = {
}
type StreamTransportModule = Pick<
Awaited<typeof import("./stream.transport")>,
Awaited<typeof import("./stream-v2.transport")>,
"createSessionTransport" | "formatUnknownError"
>
@@ -164,11 +164,9 @@ async function resolveExitTitle(
return undefined
}
return ctx.sdk.session
.get({
sessionID: state.sessionID,
})
.then((x) => x.data?.title)
return ctx.sdk.v2.session
.get({ sessionID: state.sessionID })
.then((x) => x.data?.data.title)
.catch(() => undefined)
}
@@ -233,7 +231,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
.then((x) => x.data ?? [])
.catch(() => []),
agents: [],
resources: [],
references: [],
sessionID: state.sessionID,
sessionTitle: state.sessionTitle,
getSessionID: () => state.sessionID,
@@ -250,21 +248,25 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}
log?.write("send.permission.reply", next)
await ctx.sdk.permission.reply(next)
await ctx.sdk.v2.session.permission.reply({ sessionID: state.sessionID, ...next })
},
onQuestionReply: async (next) => {
if (state.demo?.questionReply(next)) {
return
}
await ctx.sdk.question.reply(next)
await ctx.sdk.v2.session.question.reply({
sessionID: state.sessionID,
requestID: next.requestID,
questionV2Reply: { answers: next.answers ?? [] },
})
},
onQuestionReject: async (next) => {
if (state.demo?.questionReject(next)) {
return
}
await ctx.sdk.question.reject(next)
await ctx.sdk.v2.session.question.reject({ sessionID: state.sessionID, ...next })
},
onCycleVariant: () => {
if (!state.model || state.variants.length === 0) {
@@ -339,22 +341,30 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
},
onInterrupt: () => {
if (!hasSession(input, state) || state.aborting) {
return
return false
}
state.aborting = true
void ctx.sdk.session
.abort({
sessionID: state.sessionID,
})
void (state.stream
? state.stream.then((item) => item.handle.interruptActiveTurn())
: ctx.sdk.v2.session.interrupt({ sessionID: state.sessionID }))
.catch(() => {})
.finally(() => {
state.aborting = false
})
return true
},
onBackground: () => {
if (!hasSession(input, state)) return
void ctx.sdk.experimental.session.background({ sessionID: state.sessionID }).catch(() => {})
if (!hasSession(input, state)) {
return
}
log?.write("send.background", { sessionID: state.sessionID })
void ctx.sdk.v2.session.background({ sessionID: state.sessionID }).catch(() => {})
},
onSubagentInterrupt: (sessionID) => {
log?.write("send.subagent.interrupt", { sessionID })
void ctx.sdk.v2.session.interrupt({ sessionID }).catch(() => {})
},
onSubagentSelect: (sessionID) => {
state.selectSubagent?.(sessionID)
@@ -373,19 +383,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
return
}
const [agents, resources, commands] = await Promise.all([
ctx.sdk.app
.agents({ directory: ctx.directory })
.then((x) => x.data ?? [])
.catch(() => []),
ctx.sdk.experimental.resource
.list({ directory: ctx.directory })
.then((x) => Object.values(x.data ?? {}))
.catch(() => []),
ctx.sdk.command
.list({ directory: ctx.directory })
.then((x) => x.data ?? [])
.catch(() => []),
const [agents, references, commands] = await Promise.all([
loadRunAgents(ctx.sdk, ctx.directory).catch(() => []),
loadRunReferences(ctx.sdk, ctx.directory).catch(() => []),
loadRunCommands(ctx.sdk, ctx.directory).catch(() => []),
])
if (footer.isClosed) {
return
@@ -394,7 +395,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
footer.event({
type: "catalog",
agents,
resources,
references,
commands,
})
}
@@ -453,7 +454,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
})
})
const streamTask = deps.streamTransport ?? import("./stream.transport")
const streamTask = deps.streamTransport ?? import("./stream-v2.transport")
const ensureStream = () => {
if (state.stream) {
return state.stream
@@ -758,7 +759,6 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
throw new Error("Session not found")
}
void input.share(sdk, next.id).catch(() => {})
return {
sessionID: next.id,
sessionTitle: next.title,
@@ -5,9 +5,9 @@
// - 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 caller
// (stream.transport.ts) feeds events in and forwards output to the footer
// through stream.ts.
// 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:
//
@@ -24,7 +24,7 @@
// `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, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
import type { Event, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
import * as Locale from "@/util/locale"
import { toolView } from "./tool"
import type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types"
@@ -280,33 +280,6 @@ function remove(list: Array<{ id: string }>, id: string): boolean {
return true
}
export function bootstrapSessionData(input: {
data: SessionData
messages: Array<{
parts: Part[]
}>
permissions: PermissionRequest[]
questions: QuestionRequest[]
}) {
for (const message of input.messages) {
for (const part of message.parts) {
if (part.type !== "tool") {
continue
}
input.data.call.set(key(part.messageID, part.callID), part.state.input)
}
}
for (const request of input.permissions.slice().sort((a, b) => a.id.localeCompare(b.id))) {
upsert(input.data.permissions, enrichPermission(input.data, request))
}
for (const request of input.questions.slice().sort((a, b) => a.id.localeCompare(b.id))) {
upsert(input.data.questions, request)
}
}
function key(msg: string, call: string): string {
return `${msg}:${call}`
}
@@ -740,26 +713,6 @@ function failTool(part: ToolPart, text: string): SessionCommit {
})
}
// Emits "interrupted" final entries for all in-flight parts. Called when a turn is aborted.
export function flushInterrupted(data: SessionData, commits: SessionCommit[]) {
for (const partID of data.part.keys()) {
if (data.ids.has(partID)) {
continue
}
const msg = data.msg.get(partID)
if (msg && data.role.get(msg) === "user" && !data.includeUserText) {
data.ids.add(partID)
drop(data, partID)
continue
}
flushPart(data, commits, partID, true)
data.ids.add(partID)
drop(data, partID)
}
}
// 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.
//
@@ -1,374 +0,0 @@
import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
import { bootstrapSessionData, createSessionData, reduceSessionData, type SessionData } from "./session-data"
import { messagePrompt, type SessionMessages } from "./session.shared"
import { messageTurnSummaryCommit } from "./turn-summary"
import type { FooterPatch, LocalReplayRow, RunProvider, StreamCommit } from "./types"
type ReplayInput = {
messages: SessionMessages
permissions: PermissionRequest[]
questions: QuestionRequest[]
thinking: boolean
limits: Record<string, number>
providers?: RunProvider[]
}
type ReplayConfig = {
limits: Record<string, number>
providers?: RunProvider[]
summaries: ReadonlySet<string>
}
export type SessionReplay = {
data: SessionData
commits: StreamCommit[]
patch?: FooterPatch
}
type ReplayMessage = {
commits: StreamCommit[]
patch?: FooterPatch
}
const SHELL_SYNTHETIC_USER_TEXT = "The following tool was executed by the user"
function apply(data: SessionData, event: Event, sessionID: string, thinking: boolean, limits: Record<string, number>) {
return reduceSessionData({
data,
event,
sessionID,
thinking,
limits,
})
}
function mergePatch(left: FooterPatch | undefined, right: FooterPatch | undefined) {
if (!left) {
return right
}
if (!right) {
return left
}
return {
...left,
...right,
}
}
function active(data: SessionData) {
return data.part.size > 0 || data.tools.size > 0
}
function replayPatch(data: SessionData, patch: FooterPatch | undefined) {
if (active(data)) {
if (!patch) {
return {
phase: "running",
} satisfies FooterPatch
}
return {
...patch,
phase: "running",
} satisfies FooterPatch
}
if (data.permissions.length > 0 || data.questions.length > 0) {
if (!patch) {
return {
phase: "idle",
} satisfies FooterPatch
}
return {
...patch,
phase: "idle",
} satisfies FooterPatch
}
if (!patch) {
return undefined
}
return {
...patch,
phase: "idle",
status: "",
} satisfies FooterPatch
}
function isShellSyntheticUser(message: SessionMessages[number]) {
if (message.info.role !== "user") {
return false
}
const prompt = messagePrompt(message)
return (
!prompt.text.trim() &&
prompt.parts.length === 0 &&
message.parts.some((part) => part.type === "text" && part.synthetic && part.text === SHELL_SYNTHETIC_USER_TEXT)
)
}
function isShellSyntheticAssistant(message: SessionMessages[number], shellParents: ReadonlySet<string>) {
return (
message.info.role === "assistant" &&
shellParents.has(message.info.parentID) &&
message.parts.some((part) => part.type === "tool" && part.tool === "bash")
)
}
function summaryMessageIDs(messages: SessionMessages): ReadonlySet<string> {
const shellParents = new Set(messages.filter(isShellSyntheticUser).map((message) => message.info.id))
const parents = new Set<string>()
const summaries = new Set<string>()
for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
const message = messages[idx]
if (!message || message.info.role !== "assistant") {
continue
}
if (isShellSyntheticAssistant(message, shellParents)) {
continue
}
if (parents.has(message.info.parentID)) {
continue
}
parents.add(message.info.parentID)
const completed = message.info.time.completed
if (typeof completed === "number" && completed > message.info.time.created) {
summaries.add(message.info.id)
}
}
return summaries
}
function replayMessage(
data: SessionData,
message: SessionMessages[number],
thinking: boolean,
config: ReplayConfig,
): ReplayMessage {
if (message.info.role === "user") {
const prompt = messagePrompt(message)
if (!prompt.text.trim()) {
return {
commits: [],
}
}
return {
commits: [
{
kind: "user",
text: prompt.text,
phase: "start",
source: "system",
messageID: message.info.id,
},
],
}
}
const commits: StreamCommit[] = []
let patch: FooterPatch | undefined
const info = apply(
data,
{
id: `bootstrap:message:${message.info.id}`,
type: "message.updated",
properties: {
sessionID: message.info.sessionID,
info: message.info,
},
},
message.info.sessionID,
thinking,
config.limits,
)
commits.push(...info.commits)
patch = mergePatch(patch, info.footer?.patch)
for (const part of message.parts) {
const next = apply(
data,
{
id: `bootstrap:part:${part.id}`,
type: "message.part.updated",
properties: {
sessionID: part.sessionID,
part,
time: 0,
},
},
message.info.sessionID,
thinking,
config.limits,
)
patch = mergePatch(patch, next.footer?.patch)
commits.push(...next.commits)
}
const summary = config.summaries.has(message.info.id)
? messageTurnSummaryCommit(message, config.providers)
: undefined
if (summary) {
commits.push(summary)
}
return {
commits,
patch,
}
}
export function replaySession(input: ReplayInput): SessionReplay {
const data = createSessionData()
const commits: StreamCommit[] = []
let patch: FooterPatch | undefined
const summaries = summaryMessageIDs(input.messages)
bootstrapSessionData({
data,
messages: input.messages,
permissions: input.permissions,
questions: input.questions,
})
for (const message of input.messages) {
const next = replayMessage(data, message, input.thinking, {
limits: input.limits,
providers: input.providers,
summaries,
})
commits.push(...next.commits)
patch = mergePatch(patch, next.patch)
}
return {
data,
commits,
patch: replayPatch(data, patch),
}
}
export function replayLocalRows(
messages: SessionMessages,
commits: StreamCommit[],
rows: LocalReplayRow[],
): StreamCommit[] {
const persisted = new Set(messages.map((message) => message.info.id))
return rows.reduce((out, local) => {
const row = local.commit
if (row.kind === "user" && row.messageID && persisted.has(row.messageID)) {
return out
}
if (!row.messageID) {
return [...out, row]
}
const exact = local.after
? out.findIndex(
(commit) =>
commit.kind === local.after?.kind &&
commit.text === local.after.text &&
commit.phase === local.after.phase &&
commit.toolState === local.after.toolState &&
(local.after.partID ? commit.partID === local.after.partID : commit.messageID === local.after.messageID),
)
: -1
const anchored =
exact !== -1
? exact
: local.after
? out.findLastIndex((commit) =>
local.after?.partID
? commit.partID === local.after.partID
: commit.kind === local.after?.kind && commit.messageID === local.after.messageID,
)
: -1
if (anchored !== -1) {
const commit = out[anchored]
const visible = local.after?.visible
if (commit && visible && commit.text.startsWith(visible) && commit.text.length > visible.length) {
return [
...out.slice(0, anchored),
{ ...commit, text: visible },
row,
{ ...commit, text: commit.text.slice(visible.length) },
...out.slice(anchored + 1),
]
}
return [...out.slice(0, anchored + 1), row, ...out.slice(anchored + 1)]
}
const after = out.findIndex((commit) => commit.kind === "user" && commit.messageID === row.messageID)
if (after !== -1) {
return [...out.slice(0, after + 1), row, ...out.slice(after + 1)]
}
const before = out.findIndex((commit) => commit.messageID && row.messageID! < commit.messageID)
if (before === -1) {
return [...out, row]
}
return [...out.slice(0, before), row, ...out.slice(before)]
}, commits)
}
export function replayActiveText(data: SessionData, current: SessionData): StreamCommit[] {
return [...current.part.entries()].flatMap(([partID, kind]) => {
if (kind === "user" || current.end.has(partID) || data.ids.has(partID)) {
return []
}
const text = current.text.get(partID) ?? ""
const existing = data.text.get(partID) ?? ""
const sent = current.sent.get(partID) ?? 0
const existingSent = data.sent.get(partID) ?? 0
const visible = current.visible.get(partID) ?? ""
const existingVisible = data.visible.get(partID) ?? ""
if (!text.startsWith(existing) || existingSent > sent || !visible.startsWith(existingVisible)) {
return []
}
data.part.set(partID, kind)
data.text.set(partID, text)
data.sent.set(partID, sent)
data.visible.set(partID, visible)
const messageID = current.msg.get(partID)
if (messageID) {
data.msg.set(partID, messageID)
const role = current.role.get(messageID)
if (role) {
data.role.set(messageID, role)
}
}
const chunk = visible.slice(existingVisible.length)
if (!chunk) {
return []
}
return [
{
kind,
text: chunk,
phase: "progress",
source: kind,
...(messageID ? { messageID } : {}),
partID,
},
] satisfies StreamCommit[]
})
}
@@ -152,12 +152,52 @@ export function createSession(messages: SessionMessages): RunSession {
}
}
export async function resolveSession(sdk: RunInput["sdk"], sessionID: string, limit = LIMIT): Promise<RunSession> {
const response = await sdk.session.messages({
sessionID,
limit,
})
return createSession(response.data ?? [])
export async function resolveCurrentSession(
sdk: RunInput["sdk"],
sessionID: string,
limit = LIMIT,
): Promise<RunSession> {
const response = await sdk.v2.session.messages({ sessionID, limit, order: "desc" }, { throwOnError: true })
const messages = response.data.data.toReversed()
const session = await sdk.v2.session.get({ sessionID }, { throwOnError: true })
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.uri,
mime: file.mime,
filename: file.name,
source: file.source
? {
type: "file" as const,
path: file.name ?? file.uri,
text: { start: file.source.start, end: file.source.end, value: file.source.text },
}
: undefined,
})),
...(message.agents ?? []).map((agent) => ({
type: "agent" as const,
name: agent.name,
source: agent.source
? { start: agent.source.start, end: agent.source.end, value: agent.source.text }
: undefined,
})),
],
},
provider: session.data.data.model?.providerID,
model: session.data.data.model?.id,
variant: session.data.data.model?.variant,
},
]
}),
}
}
export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] {
+1 -1
View File
@@ -234,7 +234,7 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
lines,
body_left + label.length,
top + 1,
`opencode --mini -s ${meta.session_id}`,
`opencode mini -s ${meta.session_id}`,
right,
undefined,
TextAttributes.BOLD,
@@ -0,0 +1,698 @@
// Current-native subagent (child Session) tracking for the mini transport.
//
// Discovers child Sessions of the active parent from four current sources:
// 1. projected subagent tool output (`structured.sessionID`) during hydration
// 2. the current session list filtered by `parentID` during hydration
// 3. the process-local active-session map during hydration
// 4. live events from unknown sessions whose `parentID` matches the parent
//
// Tracks one footer tab per child and a detail transcript for the selected
// child, reduced from the same current live event stream the parent uses.
// Detail transcripts rebuild from projected messages on discovery, selection,
// and reconnect, then continue from live deltas using the same
// projected-prefix dedup the parent transport uses.
//
// 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 {
OpencodeClient,
SessionMessage,
SessionMessageAssistantTool,
ToolPart,
V2Event,
} from "@opencode-ai/sdk/v2"
import { Locale } from "@/util/locale"
import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
const CHILD_MESSAGE_LIMIT = 80
const CHILD_FRAME_LIMIT = 80
const DISCOVERY_BUFFER_LIMIT = 64
const FAMILY_LIST_LIMIT = 100
const FALLBACK_LABEL = "Subagent"
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: {
sessionID: string
messageID: string
callID: string
name: string
state: SessionMessageAssistantTool["state"]
time: SessionMessageAssistantTool["time"]
provider?: SessionMessageAssistantTool["provider"]
}): ToolPart {
const base = {
id: `prt_${input.callID}`,
sessionID: input.sessionID,
messageID: input.messageID,
type: "tool" as const,
callID: input.callID,
tool: input.name,
}
if (input.state.status === "pending") {
return {
...base,
state: { status: "pending", input: {}, raw: input.state.input },
}
}
if (input.state.status === "running") {
return {
...base,
state: {
status: "running",
input: input.state.input,
title: input.name,
metadata: { structured: input.state.structured, content: input.state.content, providerCall: input.provider },
time: { start: input.time.ran ?? input.time.created },
},
}
}
if (input.state.status === "completed") {
return {
...base,
state: {
status: "completed",
input: input.state.input,
output: outputText(input.state.content),
title: input.name,
metadata: {
structured: input.state.structured,
content: input.state.content,
outputPaths: input.state.outputPaths,
result: input.state.result,
providerCall: input.provider,
},
time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created },
},
}
}
return {
...base,
state: {
status: "error",
input: input.state.input,
error: input.state.error.message,
metadata: {
structured: input.state.structured,
content: input.state.content,
result: input.state.result,
providerCall: input.provider,
},
time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created },
},
}
}
export function toolCommit(part: ToolPart, phase: "start" | "progress" | "final"): StreamCommit {
const status = part.state.status
const text =
status === "running"
? part.tool === "task"
? "running task"
: `running ${part.tool}`
: status === "completed"
? part.state.output
: status === "error"
? part.state.error
: ""
return {
kind: "tool",
source: "tool",
text,
phase,
messageID: part.messageID,
partID: part.id,
tool: part.tool,
part,
toolState: status === "error" ? "error" : status === "completed" ? "completed" : "running",
toolError: status === "error" ? part.state.error : undefined,
}
}
type Frame = {
key: string
commit: StreamCommit
}
type ToolTrack = {
name: string
input: Record<string, unknown>
started: number
}
type ChildState = {
sessionID: string
label: string
description: string
status: FooterSubagentTab["status"]
background: boolean
title?: string
callIDs: Set<string>
lastUpdatedAt: number
frames: Frame[]
text: Map<string, string>
projectedText: Map<string, string>
reasoning: Map<string, string>
projectedReasoning: Map<string, string>
tools: Map<string, ToolTrack>
finishedTools: Set<string>
messageIDs: Set<string>
hydrated: boolean
}
export type SubagentTrackerInput = {
sdk: OpencodeClient
sessionID: string
thinking: boolean
emit: () => void
}
export type SubagentTracker = {
main(event: V2Event): void
foreign(sessionID: string, event: V2Event): void
hydrate(next: { messages: SessionMessage[]; active: Record<string, unknown> }): Promise<void>
select(sessionID: string | undefined): void
snapshot(): FooterSubagentState
}
function record(value: unknown): Record<string, unknown> | undefined {
if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as Record<string, unknown>
return undefined
}
function text(value: unknown): string | undefined {
if (typeof value !== "string") return undefined
const next = value.trim()
return next || undefined
}
function childSessionID(structured: Record<string, unknown> | undefined) {
const sessionID = text(structured?.sessionID)
if (!sessionID || !sessionID.startsWith("ses")) return undefined
const status = structured?.status
if (status !== "running" && status !== "completed") return undefined
return { sessionID, running: status === "running" }
}
function tab(child: ChildState): FooterSubagentTab {
return {
sessionID: child.sessionID,
partID: `subagent:${child.sessionID}`,
callID: `subagent:${child.sessionID}`,
label: child.label,
description: child.description || child.title || "",
status: child.status,
background: child.background ? true : undefined,
title: child.title,
toolCalls: child.callIDs.size > 0 ? child.callIDs.size : undefined,
lastUpdatedAt: child.lastUpdatedAt,
}
}
export function createSubagentTracker(input: SubagentTrackerInput): SubagentTracker {
const children = new Map<string, ChildState>()
// Live subagent tool calls in the parent, so tool.success structured output
// can be joined with the call's input metadata.
const pendingCalls = new Map<string, Record<string, unknown>>()
// Foreign sessions already resolved through session.get. Non-children stay
// cached so unrelated concurrent sessions are checked at most once.
const checked = new Set<string>()
// Foreign events buffered while a session.get discovery is in flight, so a
// fast child (including its settled event) is not lost mid-discovery.
const pendingEvents = new Map<string, V2Event[]>()
const hydrations = new Map<string, Promise<void>>()
let selected: string | undefined
const ensureChild = (sessionID: string): ChildState => {
const existing = children.get(sessionID)
const child: ChildState = existing ?? {
sessionID,
label: FALLBACK_LABEL,
description: "",
status: "running",
background: false,
callIDs: new Set(),
lastUpdatedAt: Date.now(),
frames: [],
text: new Map(),
projectedText: new Map(),
reasoning: new Map(),
projectedReasoning: new Map(),
tools: new Map(),
finishedTools: new Set(),
messageIDs: new Set(),
hydrated: false,
}
if (!existing) children.set(sessionID, child)
// Adopting a child while its session.get discovery is still in flight:
// drain the buffered events now. They arrived before whatever the caller
// applies next, so replaying them first preserves bus order, and the
// resolved discovery can no longer replay stale events (e.g. step.started)
// after a terminal settled event was applied directly.
const buffered = pendingEvents.get(sessionID)
if (buffered) {
pendingEvents.delete(sessionID)
for (const event of buffered) reduce(child, event)
}
return child
}
const touch = (child: ChildState, timestamp?: number) => {
child.lastUpdatedAt = Math.max(child.lastUpdatedAt, timestamp ?? Date.now())
}
const notifyDetail = (child: ChildState) => {
if (child.sessionID === selected) input.emit()
}
const setFrame = (child: ChildState, key: string, commit: StreamCommit) => {
const index = child.frames.findIndex((item) => item.key === key)
if (index === -1) {
child.frames.push({ key, commit })
if (child.frames.length > CHILD_FRAME_LIMIT) child.frames.splice(0, child.frames.length - CHILD_FRAME_LIMIT)
return
}
child.frames[index] = { key, commit }
}
const applyMeta = (child: ChildState, meta: Record<string, unknown> | undefined) => {
if (!meta) return
const agent = text(meta.agent)
if (agent) child.label = Locale.titlecase(agent)
const description = text(meta.description)
if (description) child.description = description
if (meta.background === true) child.background = true
}
const userFrame = (child: ChildState, messageID: string, value: string) => {
if (child.messageIDs.has(messageID)) return false
child.messageIDs.add(messageID)
setFrame(child, `user:${messageID}`, {
kind: "user",
source: "system",
text: value,
phase: "start",
messageID,
})
return true
}
const childTool = (child: ChildState, item: SessionMessageAssistantTool, messageID: string) => {
const part = legacyTool({
sessionID: child.sessionID,
messageID,
callID: item.id,
name: item.name,
state: item.state,
time: item.time,
provider: item.provider,
})
if (item.state.status === "pending") return
child.callIDs.add(item.id)
if (item.state.status === "running") {
setFrame(child, `tool:${item.id}`, toolCommit(part, "start"))
return
}
child.finishedTools.add(item.id)
child.tools.delete(item.id)
setFrame(child, `tool:${item.id}`, toolCommit(part, "final"))
}
const rebuild = (child: ChildState, messages: SessionMessage[]) => {
child.frames = []
child.text.clear()
child.projectedText.clear()
child.reasoning.clear()
child.projectedReasoning.clear()
child.finishedTools.clear()
child.messageIDs.clear()
child.callIDs.clear()
for (const message of messages) {
if (message.type === "user") {
userFrame(child, message.id, message.text)
continue
}
if (message.type !== "assistant") continue
child.messageIDs.add(message.id)
for (const item of message.content) {
if (item.type === "text") {
child.text.set(item.id, item.text)
child.projectedText.set(item.id, item.text)
setFrame(child, `text:${item.id}`, {
kind: "assistant",
source: "assistant",
text: item.text,
phase: "progress",
messageID: message.id,
partID: item.id,
})
continue
}
if (item.type === "reasoning") {
child.reasoning.set(item.id, item.text)
child.projectedReasoning.set(item.id, item.text)
if (input.thinking)
setFrame(child, `reasoning:${item.id}`, {
kind: "reasoning",
source: "reasoning",
text: `Thinking: ${item.text}`,
phase: "progress",
messageID: message.id,
partID: item.id,
})
continue
}
childTool(child, item, message.id)
}
if (message.error) {
setFrame(child, `error:${message.id}`, {
kind: "error",
source: "system",
text: message.error.message,
phase: "start",
messageID: message.id,
})
}
}
}
const hydrateChild = (child: ChildState): Promise<void> => {
const existing = hydrations.get(child.sessionID)
if (existing) return existing
const task = input.sdk.v2.session
.messages({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" }, { throwOnError: true })
.then((response) => {
rebuild(child, response.data.data.toReversed())
child.hydrated = true
notifyDetail(child)
})
.catch(() => {})
.finally(() => {
hydrations.delete(child.sessionID)
})
hydrations.set(child.sessionID, task)
return task
}
const discover = (sessionID: string) => {
if (checked.has(sessionID) || children.has(sessionID) || sessionID === input.sessionID) return
checked.add(sessionID)
if (!pendingEvents.has(sessionID)) pendingEvents.set(sessionID, [])
void input.sdk.v2.session
.get({ sessionID }, { throwOnError: true })
.then((response) => {
const session = response.data.data
const buffered = pendingEvents.get(sessionID) ?? []
pendingEvents.delete(sessionID)
if (session.parentID !== input.sessionID) return
const child = ensureChild(sessionID)
if (session.agent) child.label = Locale.titlecase(session.agent)
child.title = session.title
for (const event of buffered) reduce(child, event)
touch(child)
input.emit()
void hydrateChild(child)
})
.catch(() => {
// Allow a later event to retry discovery after transient failures.
pendingEvents.delete(sessionID)
checked.delete(sessionID)
})
}
const reduce = (child: ChildState, event: V2Event) => {
if (event.type === "session.next.prompted") {
if (userFrame(child, event.data.messageID, event.data.prompt.text)) {
touch(child, event.data.timestamp)
notifyDetail(child)
}
return
}
if (event.type === "session.next.step.started") {
touch(child, event.data.timestamp)
if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent)
if (child.status !== "running") child.status = "running"
input.emit()
return
}
if (event.type === "session.next.text.delta") {
const projected = child.projectedText.get(event.data.textID)
const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) {
child.projectedText.set(event.data.textID, projected.slice(covered + event.data.delta.length))
return
}
const next = (child.text.get(event.data.textID) ?? "") + event.data.delta
child.text.set(event.data.textID, next)
setFrame(child, `text:${event.data.textID}`, {
kind: "assistant",
source: "assistant",
text: next,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.textID,
})
touch(child, event.data.timestamp)
notifyDetail(child)
return
}
if (event.type === "session.next.text.ended") {
child.text.set(event.data.textID, event.data.text)
child.projectedText.delete(event.data.textID)
setFrame(child, `text:${event.data.textID}`, {
kind: "assistant",
source: "assistant",
text: event.data.text,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.textID,
})
touch(child, event.data.timestamp)
notifyDetail(child)
return
}
if (event.type === "session.next.reasoning.delta") {
const projected = child.projectedReasoning.get(event.data.reasoningID)
const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) {
child.projectedReasoning.set(event.data.reasoningID, projected.slice(covered + event.data.delta.length))
return
}
const next = (child.reasoning.get(event.data.reasoningID) ?? "") + event.data.delta
child.reasoning.set(event.data.reasoningID, next)
if (!input.thinking) return
setFrame(child, `reasoning:${event.data.reasoningID}`, {
kind: "reasoning",
source: "reasoning",
text: `Thinking: ${next}`,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.reasoningID,
})
notifyDetail(child)
return
}
if (event.type === "session.next.reasoning.ended") {
child.reasoning.set(event.data.reasoningID, event.data.text)
child.projectedReasoning.delete(event.data.reasoningID)
if (!input.thinking) return
setFrame(child, `reasoning:${event.data.reasoningID}`, {
kind: "reasoning",
source: "reasoning",
text: `Thinking: ${event.data.text}`,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.reasoningID,
})
notifyDetail(child)
return
}
if (event.type === "session.next.tool.input.started") {
child.tools.set(event.data.callID, { name: event.data.name, input: {}, started: event.data.timestamp })
return
}
if (event.type === "session.next.tool.called") {
const current = child.tools.get(event.data.callID)
child.tools.set(event.data.callID, {
name: event.data.tool,
input: event.data.input,
started: current?.started ?? event.data.timestamp,
})
childTool(
child,
{
type: "tool",
id: event.data.callID,
name: event.data.tool,
provider: event.data.provider,
state: { status: "running", input: event.data.input, structured: {}, content: [] },
time: { created: current?.started ?? event.data.timestamp, ran: event.data.timestamp },
},
event.data.assistantMessageID,
)
touch(child, event.data.timestamp)
notifyDetail(child)
return
}
if (event.type === "session.next.tool.success" || event.type === "session.next.tool.failed") {
if (child.finishedTools.has(event.data.callID)) return
const current = child.tools.get(event.data.callID)
const failed = event.type === "session.next.tool.failed"
childTool(
child,
{
type: "tool",
id: event.data.callID,
name: current?.name ?? "tool",
provider: event.data.provider,
state: failed
? {
status: "error",
input: current?.input ?? {},
structured: {},
content: [],
error: event.data.error,
result: event.data.result,
}
: {
status: "completed",
input: current?.input ?? {},
structured: event.data.structured,
content: event.data.content,
outputPaths: event.data.outputPaths,
result: event.data.result,
},
time: {
created: current?.started ?? event.data.timestamp,
ran: current?.started,
completed: event.data.timestamp,
},
},
event.data.assistantMessageID,
)
touch(child, event.data.timestamp)
notifyDetail(child)
return
}
if (event.type === "session.next.step.failed") {
setFrame(child, `error:step:${event.data.assistantMessageID}`, {
kind: "error",
source: "system",
text: event.data.error.message,
phase: "start",
messageID: event.data.assistantMessageID,
})
touch(child, event.data.timestamp)
notifyDetail(child)
return
}
if (event.type === "session.next.execution.settled") {
child.status =
event.data.outcome === "success" ? "completed" : event.data.outcome === "interrupted" ? "cancelled" : "error"
touch(child, event.data.timestamp)
input.emit()
}
}
const mainTool = (item: SessionMessageAssistantTool, active?: Record<string, unknown>) => {
if (item.name !== "subagent" || item.state.status !== "completed") return
const found = childSessionID(record(item.state.structured))
if (!found) return
const child = ensureChild(found.sessionID)
applyMeta(child, record(item.state.input))
if (found.running) child.background = true
if (child.status === "running") {
const running = found.running && (!active || found.sessionID in active)
child.status = running ? "running" : "completed"
}
touch(child, item.time.completed ?? item.time.created)
}
return {
main(event) {
if (event.type === "session.next.tool.called") {
if (event.data.tool === "subagent") pendingCalls.set(event.data.callID, event.data.input)
return
}
if (event.type === "session.next.tool.failed") {
pendingCalls.delete(event.data.callID)
return
}
if (event.type !== "session.next.tool.success") return
const pending = pendingCalls.get(event.data.callID)
pendingCalls.delete(event.data.callID)
const found = childSessionID(record(event.data.structured))
if (!found) return
const child = ensureChild(found.sessionID)
applyMeta(child, pending)
if (found.running) {
child.background = true
child.status = "running"
}
if (!found.running && child.status === "running") child.status = "completed"
touch(child, event.data.timestamp)
input.emit()
if (!child.hydrated) void hydrateChild(child)
},
foreign(sessionID, event) {
const child = children.get(sessionID)
if (child) {
reduce(child, event)
return
}
discover(sessionID)
const buffered = pendingEvents.get(sessionID)
if (buffered && buffered.length < DISCOVERY_BUFFER_LIMIT) buffered.push(event)
},
async hydrate(next) {
for (const message of next.messages) {
if (message.type !== "assistant") continue
for (const item of message.content) {
if (item.type === "tool") mainTool(item, next.active)
}
}
// Family index: adopt children directly from the current session list so
// historical subagents beyond the projected message window still get tabs.
const family = await input.sdk.v2.session
.list({ limit: FAMILY_LIST_LIMIT, order: "desc" }, { throwOnError: true })
.then((response) => response.data.data.filter((session) => session.parentID === input.sessionID))
.catch(() => [])
for (const session of family) {
const child = ensureChild(session.id)
if (session.agent && child.label === FALLBACK_LABEL) child.label = Locale.titlecase(session.agent)
if (!child.title) child.title = session.title
touch(child, session.time.updated)
}
for (const sessionID of Object.keys(next.active)) discover(sessionID)
for (const child of children.values()) {
// Reconnect can miss a child's settled event; the active map is the
// authoritative live signal for still-running children.
if (child.status === "running" && !(child.sessionID in next.active)) child.status = "completed"
}
const current = selected ? children.get(selected) : undefined
if (current) await hydrateChild(current)
if (children.size > 0) input.emit()
},
select(sessionID) {
selected = sessionID
const child = sessionID ? children.get(sessionID) : undefined
if (child && !child.hydrated) void hydrateChild(child)
input.emit()
},
snapshot() {
const tabs = [...children.values()].map(tab).toSorted((a, b) => {
const active = Number(b.status === "running") - Number(a.status === "running")
if (active !== 0) return active
return b.lastUpdatedAt - a.lastUpdatedAt
})
const child = selected ? children.get(selected) : undefined
const details: Record<string, FooterSubagentDetail> = child
? { [child.sessionID]: { sessionID: child.sessionID, commits: child.frames.map((item) => item.commit) } }
: {}
return { tabs, details, permissions: [], questions: [] }
},
}
}
@@ -0,0 +1,798 @@
import type {
OpencodeClient,
PermissionRequest,
PermissionV2Request,
QuestionRequest,
QuestionV2Request,
SessionMessage,
SessionMessageAssistant,
SessionMessageAssistantTool,
V2Event,
} from "@opencode-ai/sdk/v2"
import { blockerStatus, pickBlockerView } from "./session-data"
import { writeSessionOutput } from "./stream"
import { createSubagentTracker, legacyTool, toolCommit } from "./stream-v2.subagent"
import type {
FooterApi,
FooterView,
LocalReplayAnchor,
LocalReplayRow,
RunFilePart,
RunInput,
RunPrompt,
RunPromptPart,
RunProvider,
StreamCommit,
} from "./types"
type Trace = {
write(type: string, data?: unknown): void
}
type StreamInput = {
sdk: OpencodeClient
directory?: string
sessionID: string
thinking: boolean
replay?: boolean
replayLimit?: number
limits: () => Record<string, number>
providers?: () => RunProvider[]
footer: FooterApi
trace?: Trace
signal?: AbortSignal
}
export type SessionTurnInput = {
agent: string | undefined
model: RunInput["model"]
variant: string | undefined
prompt: RunPrompt
files: RunFilePart[]
includeFiles: boolean
onVisibleOutput?: (anchor: LocalReplayAnchor) => void
signal?: AbortSignal
}
export type SessionResizeReplayInput = {
localRows: () => LocalReplayRow[]
reset: () => Promise<void>
}
export type SessionTransport = {
runPromptTurn(input: SessionTurnInput): Promise<void>
interruptActiveTurn(): Promise<void>
selectSubagent(sessionID: string | undefined): void
replayOnResize(input: SessionResizeReplayInput): Promise<boolean>
close(): Promise<void>
}
type Wait = {
messageID: string
promoted: boolean
interrupted: boolean
failureRendered: boolean
resolve: () => void
reject: (error: unknown) => void
onVisibleOutput?: (anchor: LocalReplayAnchor) => void
}
type RunV2Event = V2Event
type PromptFilePart = Extract<RunPromptPart, { type: "file" }>
type ToolState = {
messageID: string
name: string
input: Record<string, unknown>
started: number
running: boolean
}
type State = {
permissions: PermissionRequest[]
questions: QuestionRequest[]
view: FooterView
messageIDs: Set<string>
text: Map<string, string>
projectedText: Map<string, string>
reasoning: Map<string, string>
projectedReasoning: Map<string, string>
tools: Map<string, ToolState>
finishedTools: Set<string>
wait?: Wait
connected: boolean
closed: boolean
initial: boolean
buffered?: RunV2Event[]
errors: Set<string>
}
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
export function formatUnknownError(error: unknown): string {
if (typeof error === "string") return error
if (error instanceof Error) return error.message || error.name
if (error && typeof error === "object") {
const message = Reflect.get(error, "message")
if (typeof message === "string" && message.trim()) return message
const tag = Reflect.get(error, "_tag")
if (typeof tag === "string" && tag.trim()) return tag
}
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,
tool: request.tool,
}
}
function sessionID(event: RunV2Event) {
return "sessionID" in event.data && typeof event.data.sessionID === "string" ? event.data.sessionID : undefined
}
function errorMessage(error: { message?: string; _tag?: string }) {
return error.message || error._tag || "Session execution failed"
}
function wait(delay: number, signal: AbortSignal) {
return new Promise<void>((resolve) => {
const timer = setTimeout(done, delay)
signal.addEventListener("abort", done, { once: true })
function done() {
clearTimeout(timer)
signal.removeEventListener("abort", done)
resolve()
}
})
}
async function prepareFile(file: RunFilePart) {
if (file.mime !== "text/plain") return { attachment: { uri: file.url, mime: file.mime, name: file.filename } }
const content = file.url.startsWith("data:")
? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8")
: await Bun.file(new URL(file.url)).text()
return { text: `<file name="${file.filename}">\n${content}\n</file>` }
}
function promptFileSource(part: PromptFilePart) {
if (!part.source?.text) return
return {
start: part.source.text.start,
end: part.source.text.end,
text: part.source.text.value,
}
}
function streamPartKey(messageID: string, partID: string) {
return `${messageID}\u0000${partID}`
}
async function resolveSelectedModel(input: StreamInput, next: Pick<SessionTurnInput, "model" | "variant" | "signal">) {
if (next.model) return { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
if (!next.variant) return
const session = await input.sdk.v2.session
.get({ sessionID: input.sessionID }, { throwOnError: true, signal: next.signal })
.then((response) => response.data.data.model)
if (session) return { ...session, variant: next.variant }
const fallback = await input.sdk.v2.model
.default(undefined, { throwOnError: true, signal: next.signal })
.then((response) => response.data.data)
if (!fallback) return
return { providerID: fallback.providerID, id: fallback.id, variant: next.variant }
}
export async function createSessionTransport(input: StreamInput): Promise<SessionTransport> {
const controller = new AbortController()
input.signal?.addEventListener("abort", () => controller.abort(), { once: true })
const state: State = {
permissions: [],
questions: [],
view: { type: "prompt" },
messageIDs: new Set(),
text: new Map(),
projectedText: new Map(),
reasoning: new Map(),
projectedReasoning: new Map(),
tools: new Map(),
finishedTools: new Set(),
connected: false,
closed: false,
initial: true,
errors: new Set(),
}
let readyResolve!: () => void
let readyReject!: (error: unknown) => void
const ready = new Promise<void>((resolve, reject) => {
readyResolve = resolve
readyReject = reject
})
const abortReady = () => readyReject(new Error("Mini closed before the event stream connected"))
controller.signal.addEventListener("abort", abortReady, { once: true })
const offFooterClose = input.footer.onClose(() => controller.abort())
const subagents = createSubagentTracker({
sdk: input.sdk,
sessionID: input.sessionID,
thinking: input.thinking,
emit: () => {
if (state.closed || input.footer.isClosed) return
writeSessionOutput(
{ footer: input.footer, trace: input.trace },
{ commits: [], footer: { subagent: subagents.snapshot() } },
)
},
})
const write = (commits: StreamCommit[], patch?: { phase?: "idle" | "running"; status?: string; usage?: string }) => {
const visible = commits.at(-1)
if (visible) {
state.wait?.onVisibleOutput?.({
kind: visible.kind,
text: visible.text,
phase: visible.phase,
messageID: visible.messageID,
partID: visible.partID,
toolState: visible.toolState,
})
}
writeSessionOutput({ footer: input.footer, trace: input.trace }, { commits, footer: patch ? { patch } : undefined })
}
const syncBlockers = () => {
const next = pickBlockerView({ permission: state.permissions[0], question: state.questions[0] })
if (next.type === "prompt" && state.view.type === "prompt") return
if (next.type !== "prompt" && state.view.type === next.type && next.request.id === state.view.request.id) return
state.view = next
writeSessionOutput(
{ footer: input.footer, trace: input.trace },
{ commits: [], footer: { view: next, patch: { status: blockerStatus(next) } } },
)
}
const renderTool = (messageID: string, item: SessionMessageAssistantTool) => {
const part = legacyTool({
sessionID: input.sessionID,
messageID,
callID: item.id,
name: item.name,
state: item.state,
time: item.time,
provider: item.provider,
})
if (item.state.status === "pending") return
if (item.state.status === "running") {
if (state.tools.get(item.id)?.running) return
state.tools.set(item.id, {
messageID,
name: item.name,
input: item.state.input,
started: item.time.ran ?? item.time.created,
running: true,
})
write([toolCommit(part, "start")], { phase: "running", status: `running ${item.name}` })
return
}
if (state.finishedTools.has(item.id)) return
if (!state.tools.get(item.id)?.running) write([toolCommit(part, "start")])
state.finishedTools.add(item.id)
state.tools.delete(item.id)
write([toolCommit(part, item.state.status === "completed" && part.state.status === "completed" && part.state.output ? "progress" : "final")])
}
const renderMessage = (message: SessionMessage, render: boolean, reuseVisibleWait: boolean) => {
if (message.type === "user") {
const waiting = state.wait?.messageID === message.id
if (waiting && state.wait) state.wait.promoted = true
if (!render || state.messageIDs.has(message.id)) return
state.messageIDs.add(message.id)
if (reuseVisibleWait && waiting) return
write([{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id }])
return
}
if (message.type !== "assistant") return
state.messageIDs.add(message.id)
for (const item of message.content) {
if (item.type === "text") {
const key = streamPartKey(message.id, item.id)
const sent = state.text.get(key)?.length ?? 0
state.text.set(key, item.text)
if (render) state.projectedText.set(key, item.text)
if (render && item.text.length > sent)
write([
{
kind: "assistant",
source: "assistant",
text: item.text.slice(sent),
phase: "progress",
messageID: message.id,
partID: item.id,
},
])
continue
}
if (item.type === "reasoning") {
const key = streamPartKey(message.id, item.id)
const sent = state.reasoning.get(key)?.length ?? 0
state.reasoning.set(key, item.text)
if (render) state.projectedReasoning.set(key, item.text)
if (render && input.thinking && item.text.length > sent)
write([
{
kind: "reasoning",
source: "reasoning",
text: sent === 0 ? `Thinking: ${item.text}` : item.text.slice(sent),
phase: "progress",
messageID: message.id,
partID: item.id,
},
])
continue
}
if (render) renderTool(message.id, item)
}
if (render && message.error && !state.errors.has(message.id)) {
state.errors.add(message.id)
write([
{
kind: "error",
source: "system",
text: errorMessage(message.error),
phase: "start",
messageID: message.id,
},
])
}
}
const hydrate = async (next: { render: boolean; reuseVisibleWait: boolean }) => {
const [messages, permissions, questions, active] = await Promise.all([
input.sdk.v2.session.messages(
{ sessionID: input.sessionID, limit: input.replayLimit ?? 200, order: "desc" },
{ throwOnError: true },
),
input.sdk.v2.session.permission.list({ sessionID: input.sessionID }, { throwOnError: true }),
input.sdk.v2.session.question.list({ sessionID: input.sessionID }, { throwOnError: true }),
input.sdk.v2.session.active({ throwOnError: true }),
])
const projected = messages.data.data.toReversed()
for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait)
state.permissions = permissions.data.data.map(permission)
state.questions = questions.data.data.map(question)
syncBlockers()
await subagents.hydrate({ messages: projected, active: active.data.data })
const running = input.sessionID in active.data.data
write([], { phase: running ? "running" : "idle", status: running ? "assistant responding" : "" })
if (!running && state.wait && (state.wait.promoted || state.wait.interrupted)) {
const current = state.wait
state.wait = undefined
current.resolve()
}
}
const apply = (event: RunV2Event) => {
const source = sessionID(event)
if (source !== input.sessionID) {
if (source) subagents.foreign(source, event)
return
}
input.trace?.write("recv.event", event)
subagents.main(event)
if (event.type === "session.next.prompted") {
if (state.wait?.messageID === event.data.messageID) state.wait.promoted = true
state.messageIDs.add(event.data.messageID)
write([], { phase: "running", status: "waiting for assistant" })
return
}
if (event.type === "session.next.step.started") {
write([], { phase: "running", status: "assistant responding" })
return
}
if (event.type === "session.next.text.delta") {
const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
const projected = state.projectedText.get(key)
const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) {
state.projectedText.set(key, projected.slice(covered + event.data.delta.length))
return
}
const previous = state.text.get(key) ?? ""
state.text.set(key, previous + event.data.delta)
write([
{
kind: "assistant",
source: "assistant",
text: event.data.delta,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.textID,
},
])
return
}
if (event.type === "session.next.text.ended") {
const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
const previous = state.text.get(key) ?? ""
if (event.data.text.length > previous.length)
write([
{
kind: "assistant",
source: "assistant",
text: event.data.text.slice(previous.length),
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.textID,
},
])
state.text.set(key, event.data.text)
state.projectedText.delete(key)
return
}
if (event.type === "session.next.reasoning.delta") {
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID)
const projected = state.projectedReasoning.get(key)
const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) {
state.projectedReasoning.set(key, projected.slice(covered + event.data.delta.length))
return
}
const previous = state.reasoning.get(key) ?? ""
state.reasoning.set(key, previous + event.data.delta)
if (input.thinking)
write([
{
kind: "reasoning",
source: "reasoning",
text: previous ? event.data.delta : `Thinking: ${event.data.delta}`,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.reasoningID,
},
])
return
}
if (event.type === "session.next.reasoning.ended") {
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID)
const previous = state.reasoning.get(key) ?? ""
if (input.thinking && event.data.text.length > previous.length)
write([
{
kind: "reasoning",
source: "reasoning",
text: previous ? event.data.text.slice(previous.length) : `Thinking: ${event.data.text}`,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.reasoningID,
},
])
state.reasoning.set(key, event.data.text)
state.projectedReasoning.delete(key)
return
}
if (event.type === "session.next.tool.input.started") {
state.tools.set(event.data.callID, {
messageID: event.data.assistantMessageID,
name: event.data.name,
input: {},
started: event.data.timestamp,
running: false,
})
return
}
if (event.type === "session.next.tool.called") {
if (state.finishedTools.has(event.data.callID)) return
const current = state.tools.get(event.data.callID)
const item: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
name: event.data.tool,
provider: event.data.provider,
state: { status: "running", input: event.data.input, structured: {}, content: [] },
time: { created: current?.started ?? event.data.timestamp, ran: event.data.timestamp },
}
renderTool(event.data.assistantMessageID, item)
return
}
if (event.type === "session.next.tool.progress") return
if (event.type === "session.next.tool.success" || event.type === "session.next.tool.failed") {
const current = state.tools.get(event.data.callID)
const failed = event.type === "session.next.tool.failed"
const item: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
name: current?.name ?? "tool",
provider: event.data.provider,
state: failed
? { status: "error", input: current?.input ?? {}, structured: {}, content: [], error: event.data.error, result: event.data.result }
: {
status: "completed",
input: current?.input ?? {},
structured: event.data.structured,
content: event.data.content,
outputPaths: event.data.outputPaths,
result: event.data.result,
},
time: { created: current?.started ?? event.data.timestamp, ran: current?.started, completed: event.data.timestamp },
}
renderTool(event.data.assistantMessageID, item)
return
}
if (event.type === "permission.v2.asked") {
if (!state.permissions.some((item) => item.id === event.data.id)) state.permissions.push(permission(event.data))
syncBlockers()
return
}
if (event.type === "permission.v2.replied") {
state.permissions = state.permissions.filter((item) => item.id !== event.data.requestID)
syncBlockers()
return
}
if (event.type === "question.v2.asked") {
if (!state.questions.some((item) => item.id === event.data.id)) state.questions.push(question(event.data))
syncBlockers()
return
}
if (event.type === "question.v2.replied" || event.type === "question.v2.rejected") {
state.questions = state.questions.filter((item) => item.id !== event.data.requestID)
syncBlockers()
return
}
if (event.type === "session.next.step.ended") {
const total =
event.data.tokens.input +
event.data.tokens.output +
event.data.tokens.reasoning +
event.data.tokens.cache.read +
event.data.tokens.cache.write
const usage = total > 0 ? total.toLocaleString() : ""
write([], { phase: event.data.finish === "tool-calls" ? "running" : "idle", usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage })
return
}
if (event.type === "session.next.step.failed") {
state.errors.add(event.data.assistantMessageID)
if (state.wait) state.wait.failureRendered = true
write([{ kind: "error", source: "system", text: errorMessage(event.data.error), phase: "start" }])
return
}
if (event.type === "session.next.execution.settled") {
write([], { phase: "idle", status: "" })
const current = state.wait
if (!current || (!current.promoted && !current.interrupted)) return
state.wait = undefined
if (current.interrupted) {
current.resolve()
return
}
if (event.data.outcome === "failure") {
if (current.failureRendered) {
current.resolve()
return
}
current.reject(new Error(event.data.error ? errorMessage(event.data.error) : "Session execution failed"))
return
}
current.resolve()
}
}
const receive = (event: RunV2Event) => {
if (state.buffered) {
state.buffered.push(event)
return
}
apply(event)
}
const connect = async () => {
while (!controller.signal.aborted && !input.footer.isClosed) {
const error = await (async () => {
const connection = new AbortController()
const abortConnection = () => connection.abort()
controller.signal.addEventListener("abort", abortConnection, { once: true })
const response = await input.sdk.v2.event.subscribe({
signal: connection.signal,
sseMaxRetryAttempts: 0,
throwOnError: true,
})
const stream = response.stream[Symbol.asyncIterator]() as AsyncGenerator<RunV2Event>
try {
const first = await stream.next()
if (first.done || first.value.type !== "server.connected") throw new Error("Event stream disconnected")
const buffered: RunV2Event[] = []
let booting = true
const consume = (async () => {
while (!connection.signal.aborted) {
const next = await stream.next()
if (next.done) throw new Error("Event stream disconnected")
if (booting) buffered.push(next.value)
else receive(next.value)
}
})()
void consume.catch(() => {})
await hydrate({ render: state.initial ? input.replay === true : true, reuseVisibleWait: !state.initial })
state.initial = false
booting = false
for (const event of buffered.splice(0)) apply(event)
state.connected = true
readyResolve()
await consume
} finally {
controller.signal.removeEventListener("abort", abortConnection)
connection.abort()
void stream.return?.(undefined).catch(() => {})
}
})().catch((error) => error)
state.connected = false
if (controller.signal.aborted || input.footer.isClosed) return
input.trace?.write("recv.reconnect", { error: formatUnknownError(error) })
write([], { phase: "running", status: "reconnecting" })
await wait(250, controller.signal)
}
}
const connection = connect()
try {
await ready
} catch (error) {
offFooterClose()
throw error
} finally {
controller.signal.removeEventListener("abort", abortReady)
}
return {
async runPromptTurn(next) {
if (next.prompt.mode === "shell") throw new Error("Shell is not yet available for current Session transcripts")
if (next.prompt.command) throw new Error("Commands are not yet available for current Session transcripts")
if (state.wait) throw new Error("prompt already running")
if (!state.connected) throw new Error("Event stream is reconnecting")
if (next.agent) {
await input.sdk.v2.session.switchAgent(
{ sessionID: input.sessionID, agent: next.agent },
{ throwOnError: true, signal: next.signal },
)
}
const selected = await resolveSelectedModel(input, next)
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
if (selected)
await input.sdk.v2.session.switchModel(
{ sessionID: input.sessionID, model: selected },
{ throwOnError: true, signal: next.signal },
)
const prepared = await Promise.all((next.includeFiles ? next.files : []).map(prepareFile))
const promptFiles = next.prompt.parts.flatMap((part) =>
part.type === "file"
? [
{
uri: part.url,
name: part.filename,
source: promptFileSource(part),
},
]
: [],
)
const attachments = [
...prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])),
...promptFiles,
]
const agents = next.prompt.parts.flatMap((part) =>
part.type === "agent"
? [
{
name: part.name,
source: part.source
? { start: part.source.start, end: part.source.end, text: part.source.value }
: undefined,
},
]
: [],
)
const messageID = next.prompt.messageID
if (!messageID) throw new Error("Prompt message ID is required")
let resolve!: () => void
let reject!: (error: unknown) => void
const done = new Promise<void>((done, fail) => {
resolve = done
reject = fail
})
const active: Wait = {
messageID,
promoted: false,
interrupted: false,
failureRendered: false,
resolve,
reject,
onVisibleOutput: next.onVisibleOutput,
}
state.wait = active
const interrupt = () => {
active.interrupted = true
void input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
}
next.signal?.addEventListener("abort", interrupt, { once: true })
try {
input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID })
await input.sdk.v2.session.prompt(
{
sessionID: input.sessionID,
id: messageID,
prompt: {
text: [
next.prompt.text,
...prepared.flatMap((file) => (file.text ? [file.text] : [])),
].join("\n\n"),
files: attachments.length ? attachments : undefined,
agents: agents.length ? agents : undefined,
},
delivery: "steer",
},
{ throwOnError: true, signal: next.signal },
)
await done
} catch (error) {
if (state.wait === active) state.wait = undefined
if (next.signal?.aborted) return
throw error
} finally {
next.signal?.removeEventListener("abort", interrupt)
}
},
async interruptActiveTurn() {
if (state.wait) state.wait.interrupted = true
await input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
},
selectSubagent(sessionID) {
subagents.select(sessionID)
},
async replayOnResize(next) {
if (!input.replay || state.closed || input.footer.isClosed) return false
const buffered: RunV2Event[] = []
state.buffered = buffered
try {
await input.footer.idle()
await next.reset()
state.messageIDs.clear()
state.text.clear()
state.projectedText.clear()
state.reasoning.clear()
state.projectedReasoning.clear()
state.tools.clear()
state.finishedTools.clear()
state.errors.clear()
await hydrate({ render: true, reuseVisibleWait: false })
} finally {
state.buffered = undefined
}
for (const event of buffered) apply(event)
for (const row of next.localRows()) {
if (row.commit.messageID && state.messageIDs.has(row.commit.messageID)) continue
input.footer.append(row.commit)
}
return true
},
async close() {
state.closed = true
offFooterClose()
controller.abort()
void connection.catch(() => {})
},
}
}
File diff suppressed because it is too large Load Diff
@@ -1,876 +0,0 @@
import type { Event, Message, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
import * as Locale from "@/util/locale"
import {
bootstrapSessionData,
createSessionData,
formatError,
reduceSessionData,
type SessionData,
} from "./session-data"
import type { FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
export const SUBAGENT_BOOTSTRAP_LIMIT = 200
export const SUBAGENT_CALL_BOOTSTRAP_LIMIT = 80
const SUBAGENT_COMMIT_LIMIT = 80
const SUBAGENT_CALL_LIMIT = 32
const SUBAGENT_ROLE_LIMIT = 32
const SUBAGENT_ERROR_LIMIT = 16
const SUBAGENT_ECHO_LIMIT = 8
type SessionMessage = {
parts: Part[]
}
type BootstrapChildMessage = SessionMessage & {
info: Message
}
type Frame = {
key: string
commit: StreamCommit
}
type DetailState = {
sessionID: string
data: SessionData
frames: Frame[]
}
export type SubagentData = {
tabs: Map<string, FooterSubagentTab>
details: Map<string, DetailState>
}
export type BootstrapSubagentInput = {
data: SubagentData
messages: SessionMessage[]
children: Array<{ id: string; title?: string }>
permissions: PermissionRequest[]
questions: QuestionRequest[]
}
function createDetail(sessionID: string): DetailState {
return {
sessionID,
data: createSessionData({
includeUserText: true,
}),
frames: [],
}
}
function ensureDetail(data: SubagentData, sessionID: string) {
const current = data.details.get(sessionID)
if (current) {
return current
}
const next = createDetail(sessionID)
data.details.set(sessionID, next)
return next
}
export function sameSubagentTab(a: FooterSubagentTab | undefined, b: FooterSubagentTab | undefined) {
if (!a || !b) {
return false
}
return (
a.sessionID === b.sessionID &&
a.partID === b.partID &&
a.callID === b.callID &&
a.label === b.label &&
a.description === b.description &&
a.status === b.status &&
a.background === b.background &&
a.title === b.title &&
a.toolCalls === b.toolCalls &&
a.lastUpdatedAt === b.lastUpdatedAt
)
}
function sameQueue<T extends { id: string }>(left: T[], right: T[]) {
return (
left.length === right.length && left.every((item, index) => item.id === right[index]?.id && item === right[index])
)
}
function queueSnapshot(data: SessionData) {
return {
permissions: data.permissions.slice(),
questions: data.questions.slice(),
}
}
function queueChanged(data: SessionData, before: ReturnType<typeof queueSnapshot>) {
return !sameQueue(before.permissions, data.permissions) || !sameQueue(before.questions, data.questions)
}
function sameCommit(left: StreamCommit, right: StreamCommit) {
return (
left.kind === right.kind &&
left.text === right.text &&
left.phase === right.phase &&
left.source === right.source &&
left.messageID === right.messageID &&
left.partID === right.partID &&
left.tool === right.tool &&
left.interrupted === right.interrupted &&
left.toolState === right.toolState &&
left.toolError === right.toolError
)
}
function text(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined
}
const next = value.trim()
return next || undefined
}
function num(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
return value
}
return undefined
}
function inputLabel(input: Record<string, unknown>): string | undefined {
const description = text(input.description)
if (description) {
return description
}
const command = text(input.command)
if (command) {
return command
}
const filePath = text(input.filePath) ?? text(input.filepath)
if (filePath) {
return filePath
}
const pattern = text(input.pattern)
if (pattern) {
return pattern
}
const query = text(input.query)
if (query) {
return query
}
const url = text(input.url)
if (url) {
return url
}
const path = text(input.path)
if (path) {
return path
}
const prompt = text(input.prompt)
if (prompt) {
return prompt
}
return undefined
}
function stateTitle(part: ToolPart) {
return text("title" in part.state ? part.state.title : undefined)
}
function callKey(messageID: string | undefined, callID: string | undefined): string | undefined {
if (!messageID || !callID) {
return undefined
}
return `${messageID}:${callID}`
}
function compactToolState(part: ToolPart): ToolPart["state"] {
if (part.state.status === "pending") {
return {
status: "pending",
input: part.state.input,
raw: part.state.raw,
}
}
if (part.state.status === "running") {
return {
status: "running",
input: part.state.input,
time: part.state.time,
...(part.state.metadata ? { metadata: part.state.metadata } : {}),
...(part.state.title ? { title: part.state.title } : {}),
}
}
if (part.state.status === "completed") {
return {
status: "completed",
input: part.state.input,
output: part.state.output,
title: part.state.title,
metadata: part.state.metadata,
time: part.state.time,
}
}
return {
status: "error",
input: part.state.input,
error: part.state.error,
time: part.state.time,
...(part.state.metadata ? { metadata: part.state.metadata } : {}),
}
}
function recent<T>(input: Iterable<T>, limit: number) {
const list = [...input]
return list.slice(Math.max(0, list.length - limit))
}
function copyMap<K, V>(source: Map<K, V>, keep: Set<K>) {
const out = new Map<K, V>()
for (const [key, value] of source) {
if (!keep.has(key)) {
continue
}
out.set(key, value)
}
return out
}
function compactToolPart(part: ToolPart): ToolPart {
return {
id: part.id,
type: "tool",
sessionID: part.sessionID,
messageID: part.messageID,
callID: part.callID,
tool: part.tool,
state: compactToolState(part),
...(part.metadata ? { metadata: part.metadata } : {}),
}
}
function compactCommit(commit: StreamCommit): StreamCommit {
if (!commit.part) {
return commit
}
return {
...commit,
part: compactToolPart(commit.part),
}
}
function stateUpdatedAt(part: ToolPart) {
if (!("time" in part.state)) {
return Date.now()
}
const time = part.state.time
if (!("end" in time)) {
return time.start ?? Date.now()
}
return time.end ?? time.start ?? Date.now()
}
function metadata(part: ToolPart, key: string) {
return ("metadata" in part.state ? part.state.metadata?.[key] : undefined) ?? part.metadata?.[key]
}
function taskStatus(part: ToolPart): FooterSubagentTab["status"] {
if (part.state.status === "completed") {
return "completed"
}
if (part.state.status === "error") {
if (metadata(part, "interrupted") === true || text(part.state.error) === "Tool execution aborted") {
return "cancelled"
}
return "error"
}
return "running"
}
function taskTab(part: ToolPart, sessionID: string): FooterSubagentTab {
const label = Locale.titlecase(text(part.state.input.subagent_type) ?? "general")
const description = text(part.state.input.description) ?? stateTitle(part) ?? inputLabel(part.state.input) ?? ""
return {
sessionID,
partID: part.id,
callID: part.callID,
label,
description,
status: taskStatus(part),
background: metadata(part, "background") === true,
title: stateTitle(part),
toolCalls: num(metadata(part, "toolcalls")) ?? num(metadata(part, "toolCalls")) ?? num(metadata(part, "calls")),
lastUpdatedAt: stateUpdatedAt(part),
}
}
function taskSessionID(part: ToolPart) {
return text(metadata(part, "sessionId")) ?? text(metadata(part, "sessionID"))
}
function syncTaskTab(data: SubagentData, part: ToolPart, children?: Set<string>) {
if (part.tool !== "task") {
return false
}
const sessionID = taskSessionID(part)
if (!sessionID) {
return false
}
if (children && children.size > 0 && !children.has(sessionID)) {
return false
}
const next = taskTab(part, sessionID)
if (sameSubagentTab(data.tabs.get(sessionID), next)) {
ensureDetail(data, sessionID)
return false
}
data.tabs.set(sessionID, next)
ensureDetail(data, sessionID)
return true
}
function frameKey(commit: StreamCommit) {
if (commit.partID) {
return `${commit.kind}:${commit.partID}:${commit.phase}`
}
if (commit.messageID) {
return `${commit.kind}:${commit.messageID}:${commit.phase}`
}
return `${commit.kind}:${commit.phase}:${commit.text}`
}
function limitFrames(detail: DetailState) {
if (detail.frames.length <= SUBAGENT_COMMIT_LIMIT) {
return
}
detail.frames.splice(0, detail.frames.length - SUBAGENT_COMMIT_LIMIT)
}
function mergeLiveCommit(current: StreamCommit, next: StreamCommit) {
if (current.phase !== "progress" || next.phase !== "progress") {
if (sameCommit(current, next)) {
return current
}
return next
}
const merged = {
...current,
...next,
text: current.text + next.text,
}
if (sameCommit(current, merged)) {
return current
}
return merged
}
function appendCommits(detail: DetailState, commits: StreamCommit[]) {
let changed = false
for (const commit of commits.map(compactCommit)) {
const key = frameKey(commit)
const index = detail.frames.findIndex((item) => item.key === key)
if (index === -1) {
detail.frames.push({
key,
commit,
})
changed = true
continue
}
const next = mergeLiveCommit(detail.frames[index].commit, commit)
if (sameCommit(detail.frames[index].commit, next)) {
continue
}
detail.frames[index] = {
key,
commit: next,
}
changed = true
}
if (changed) {
limitFrames(detail)
}
return changed
}
function ensureBlockerTab(
data: SubagentData,
sessionID: string,
title: string | undefined,
kind: "permission" | "question",
) {
const current = data.tabs.get(sessionID)
if (current) {
ensureDetail(data, sessionID)
if (current.status !== "running") {
return false
}
const next = {
...current,
description: kind === "permission" ? "Pending permission" : "Pending question",
status: "running" as const,
title: current.title ?? title,
lastUpdatedAt: Date.now(),
}
if (sameSubagentTab(current, next)) {
return false
}
data.tabs.set(sessionID, next)
return true
}
data.tabs.set(sessionID, {
sessionID,
partID: `bootstrap:${sessionID}`,
callID: `bootstrap:${sessionID}`,
label: text(title) ?? Locale.titlecase(kind),
description: kind === "permission" ? "Pending permission" : "Pending question",
status: "running",
lastUpdatedAt: Date.now(),
})
ensureDetail(data, sessionID)
return true
}
function isAbortedAssistantMessage(info: Message) {
return info.role === "assistant" && info.error?.name === "MessageAbortedError"
}
function cancelSubagentTab(data: SubagentData, sessionID: string) {
const current = data.tabs.get(sessionID)
if (!current || current.status !== "running") {
return false
}
const next = {
...current,
status: "cancelled" as const,
lastUpdatedAt: Date.now(),
}
if (sameSubagentTab(current, next)) {
return false
}
data.tabs.set(sessionID, next)
return true
}
function compactCallMap(detail: DetailState) {
const keep = new Set(recent(detail.data.call.keys(), SUBAGENT_CALL_LIMIT))
for (const request of detail.data.permissions) {
const key = callKey(request.tool?.messageID, request.tool?.callID)
if (key) {
keep.add(key)
}
}
for (const item of detail.frames) {
const key = callKey(item.commit.part?.messageID, item.commit.part?.callID)
if (key) {
keep.add(key)
}
}
return copyMap(detail.data.call, keep)
}
function compactEchoMap(data: SessionData, messageIDs: Set<string>) {
const keys = new Set([...messageIDs, ...recent(data.echo.keys(), SUBAGENT_ECHO_LIMIT)])
return copyMap(data.echo, keys)
}
function compactIDs(detail: DetailState) {
return new Set(recent(detail.data.ids, SUBAGENT_COMMIT_LIMIT + SUBAGENT_ERROR_LIMIT))
}
function compactDetail(detail: DetailState) {
const next = createSessionData({
includeUserText: true,
})
const activePartIDs = new Set(detail.data.part.keys())
const framePartIDs = new Set(detail.frames.flatMap((item) => (item.commit.partID ? [item.commit.partID] : [])))
const partIDs = new Set([...activePartIDs, ...framePartIDs, ...detail.data.tools])
const messageIDs = new Set([
...[...activePartIDs]
.map((partID) => detail.data.msg.get(partID))
.filter((item): item is string => typeof item === "string"),
...recent(detail.data.role.keys(), SUBAGENT_ROLE_LIMIT),
])
next.announced = detail.data.announced
next.permissions = detail.data.permissions
next.questions = detail.data.questions
next.ids = compactIDs(detail)
next.tools = new Set([...detail.data.tools].filter((item) => partIDs.has(item)))
next.call = compactCallMap(detail)
next.role = copyMap(detail.data.role, messageIDs)
next.msg = copyMap(detail.data.msg, activePartIDs)
next.part = copyMap(detail.data.part, activePartIDs)
next.text = copyMap(detail.data.text, activePartIDs)
next.sent = copyMap(detail.data.sent, activePartIDs)
next.end = new Set([...detail.data.end].filter((item) => activePartIDs.has(item)))
next.echo = compactEchoMap(detail.data, messageIDs)
detail.data = next
}
function applyChildEvent(input: {
detail: DetailState
event: Event
thinking: boolean
limits: Record<string, number>
}) {
const before = queueSnapshot(input.detail.data)
const out = reduceSessionData({
data: input.detail.data,
event: input.event,
sessionID: input.detail.sessionID,
thinking: input.thinking,
limits: input.limits,
})
const changed = appendCommits(input.detail, out.commits)
compactDetail(input.detail)
return changed || queueChanged(input.detail.data, before)
}
function bootstrapChildEvent(input: {
detail: DetailState
event: Event
thinking: boolean
limits: Record<string, number>
}) {
const out = reduceSessionData({
data: input.detail.data,
event: input.event,
sessionID: input.detail.sessionID,
thinking: input.thinking,
limits: input.limits,
})
return appendCommits(input.detail, out.commits)
}
function bootstrapChildMessages(input: {
detail: DetailState
messages: BootstrapChildMessage[]
thinking: boolean
limits: Record<string, number>
}) {
let changed = false
for (const message of input.messages) {
changed =
bootstrapChildEvent({
detail: input.detail,
event: {
id: `bootstrap:message:${message.info.id}`,
type: "message.updated",
properties: {
sessionID: input.detail.sessionID,
info: message.info,
},
},
thinking: input.thinking,
limits: input.limits,
}) || changed
for (const part of message.parts) {
changed =
bootstrapChildEvent({
detail: input.detail,
event: {
id: `bootstrap:part:${part.id}`,
type: "message.part.updated",
properties: {
sessionID: input.detail.sessionID,
part,
time: 0,
},
},
thinking: input.thinking,
limits: input.limits,
}) || changed
}
}
compactDetail(input.detail)
return changed
}
function knownSession(data: SubagentData, sessionID: string) {
return data.tabs.has(sessionID)
}
export function listSubagentPermissions(data: SubagentData) {
return [...data.details.values()].flatMap((detail) => detail.data.permissions)
}
export function listSubagentQuestions(data: SubagentData) {
return [...data.details.values()].flatMap((detail) => detail.data.questions)
}
export function createSubagentData(): SubagentData {
return {
tabs: new Map(),
details: new Map(),
}
}
function snapshotDetail(detail: DetailState) {
return {
sessionID: detail.sessionID,
commits: detail.frames.map((item) => item.commit),
}
}
export function listSubagentTabs(data: SubagentData) {
return [...data.tabs.values()].sort((a, b) => {
const active = Number(b.status === "running") - Number(a.status === "running")
if (active !== 0) {
return active
}
return b.lastUpdatedAt - a.lastUpdatedAt
})
}
function snapshotQueues(data: SubagentData) {
return {
permissions: listSubagentPermissions(data).sort((a, b) => a.id.localeCompare(b.id)),
questions: listSubagentQuestions(data).sort((a, b) => a.id.localeCompare(b.id)),
}
}
function snapshotState(data: SubagentData, details: FooterSubagentState["details"]): FooterSubagentState {
return {
tabs: listSubagentTabs(data),
details,
...snapshotQueues(data),
}
}
export function snapshotSubagentData(data: SubagentData): FooterSubagentState {
return snapshotState(
data,
Object.fromEntries([...data.details.entries()].map(([sessionID, detail]) => [sessionID, snapshotDetail(detail)])),
)
}
export function snapshotSelectedSubagentData(
data: SubagentData,
selectedSessionID: string | undefined,
): FooterSubagentState {
const detail = selectedSessionID ? data.details.get(selectedSessionID) : undefined
return snapshotState(data, detail ? { [detail.sessionID]: snapshotDetail(detail) } : {})
}
export function bootstrapSubagentData(input: BootstrapSubagentInput) {
const child = new Map(input.children.map((item) => [item.id, item]))
const children = new Set(child.keys())
let changed = false
for (const message of input.messages) {
for (const part of message.parts) {
if (part.type !== "tool") {
continue
}
changed = syncTaskTab(input.data, part, children) || changed
}
}
for (const item of input.permissions) {
if (!children.has(item.sessionID)) {
continue
}
changed = ensureBlockerTab(input.data, item.sessionID, child.get(item.sessionID)?.title, "permission") || changed
}
for (const item of input.questions) {
if (!children.has(item.sessionID)) {
continue
}
changed = ensureBlockerTab(input.data, item.sessionID, child.get(item.sessionID)?.title, "question") || changed
}
for (const sessionID of input.data.tabs.keys()) {
const detail = ensureDetail(input.data, sessionID)
const before = queueSnapshot(detail.data)
bootstrapSessionData({
data: detail.data,
messages: [],
permissions: input.permissions
.filter((item) => item.sessionID === sessionID)
.sort((a, b) => a.id.localeCompare(b.id)),
questions: input.questions
.filter((item) => item.sessionID === sessionID)
.sort((a, b) => a.id.localeCompare(b.id)),
})
compactDetail(detail)
changed = queueChanged(detail.data, before) || changed
}
return changed
}
export function bootstrapSubagentCalls(input: {
data: SubagentData
sessionID: string
messages: BootstrapChildMessage[]
thinking: boolean
limits: Record<string, number>
}) {
if (!knownSession(input.data, input.sessionID) || input.messages.length === 0) {
return false
}
const detail = ensureDetail(input.data, input.sessionID)
const before = queueSnapshot(detail.data)
const beforeCallCount = detail.data.call.size
bootstrapSessionData({
data: detail.data,
messages: input.messages,
permissions: detail.data.permissions,
questions: detail.data.questions,
})
const changed = bootstrapChildMessages({
detail,
messages: input.messages,
thinking: input.thinking,
limits: input.limits,
})
return changed || beforeCallCount !== detail.data.call.size || queueChanged(detail.data, before)
}
export function reduceSubagentData(input: {
data: SubagentData
event: Event
sessionID: string
thinking: boolean
limits: Record<string, number>
}) {
const event = input.event
if (event.type === "message.part.updated") {
const part = event.properties.part
if (part.sessionID === input.sessionID) {
if (part.type !== "tool") {
return false
}
return syncTaskTab(input.data, part)
}
}
const sessionID =
event.type === "message.updated" ||
event.type === "message.part.delta" ||
event.type === "permission.asked" ||
event.type === "permission.replied" ||
event.type === "question.asked" ||
event.type === "question.replied" ||
event.type === "question.rejected" ||
event.type === "session.error" ||
event.type === "session.status"
? event.properties.sessionID
: event.type === "message.part.updated"
? event.properties.part.sessionID
: undefined
if (!sessionID || !knownSession(input.data, sessionID)) {
return false
}
const detail = ensureDetail(input.data, sessionID)
const cancelled =
event.type === "message.updated" && isAbortedAssistantMessage(event.properties.info)
? cancelSubagentTab(input.data, sessionID)
: false
if (event.type === "session.status") {
if (event.properties.status.type !== "retry") {
return cancelled
}
return (
appendCommits(detail, [
{
kind: "error",
text: event.properties.status.message,
phase: "start",
source: "system",
messageID: `retry:${event.properties.status.attempt}`,
},
]) || cancelled
)
}
if (event.type === "session.error" && event.properties.error) {
return (
appendCommits(detail, [
{
kind: "error",
text: formatError(event.properties.error),
phase: "start",
source: "system",
messageID: `session.error:${event.properties.sessionID}:${formatError(event.properties.error)}`,
},
]) || cancelled
)
}
return (
applyChildEvent({
detail,
event,
thinking: input.thinking,
limits: input.limits,
}) || cancelled
)
}
@@ -1,6 +1,4 @@
import * as Locale from "@/util/locale"
import type { SessionMessages } from "./session.shared"
import type { RunProvider, StreamCommit } from "./types"
import type { StreamCommit } from "./types"
export function turnSummaryCommit(input: {
agent: string
@@ -21,27 +19,3 @@ export function turnSummaryCommit(input: {
messageID: input.messageID,
}
}
export function messageTurnSummaryCommit(
message: SessionMessages[number],
providers?: RunProvider[],
): StreamCommit | undefined {
const info = message.info
if (info.role !== "assistant") {
return
}
const completed = info.time.completed
if (typeof completed !== "number" || completed <= info.time.created) {
return
}
const model = providers?.find((item) => item.id === info.providerID)?.models[info.modelID]?.name
return turnSummaryCommit({
agent: Locale.titlecase(info.agent),
model: model ?? info.modelID,
duration: Locale.duration(completed - info.time.created),
messageID: info.id,
})
}
+67 -8
View File
@@ -1,4 +1,4 @@
// Shared type vocabulary for the direct interactive mode (`opencode --mini`).
// Shared type vocabulary for the direct interactive mode (`opencode mini`).
//
// Direct mode uses a split-footer terminal layout: immutable scrollback for the
// session transcript, and a mutable footer for prompt input, status, and
@@ -26,9 +26,63 @@ type PromptInput = Parameters<OpencodeClient["session"]["prompt"]>[0]
export type RunPromptPart = NonNullable<PromptInput["parts"]>[number]
export type RunCommand = NonNullable<Awaited<ReturnType<OpencodeClient["command"]["list"]>>["data"]>[number]
export type RunCommand = {
name: string
description?: string
source?: string
template?: string
hints?: unknown[]
agent?: string
model?: {
[key: string]: unknown
}
subtask?: boolean
}
export type RunProvider = NonNullable<Awaited<ReturnType<OpencodeClient["provider"]["list"]>>["data"]>["all"][number]
export type RunProviderModel = {
id: string
providerID: string
api?: {
[key: string]: unknown
}
name?: string
capabilities?: {
[key: string]: unknown
}
cost?: {
input: number
output?: number
cache?: {
read: number
write: number
}
}
limit?: {
context: number
input?: number
output?: number
}
status?: string
options?: {
[key: string]: unknown
}
headers?: {
[key: string]: string
}
release_date?: string
variants?: Record<string, unknown>
}
export type RunProvider = {
id: string
name: string
source?: string
env?: string[]
options?: {
[key: string]: unknown
}
models: Record<string, RunProviderModel>
}
export type RunPrompt = {
messageID?: string
@@ -48,11 +102,16 @@ export type FooterQueuedPrompt = {
prompt: RunPrompt
}
export type RunAgent = NonNullable<Awaited<ReturnType<OpencodeClient["app"]["agents"]>>["data"]>[number]
export type RunAgent = {
name: string
description?: string
mode: "subagent" | "primary" | "all"
hidden: boolean
}
type RunResourceMap = NonNullable<Awaited<ReturnType<OpencodeClient["experimental"]["resource"]["list"]>>["data"]>
export type RunResource = RunResourceMap[string]
export type RunReference = NonNullable<
Awaited<ReturnType<OpencodeClient["v2"]["reference"]["list"]>>["data"]
>["data"][number]
export type RunInput = {
sdk: OpencodeClient
@@ -224,7 +283,7 @@ export type FooterEvent =
| {
type: "catalog"
agents: RunAgent[]
resources: RunResource[]
references: RunReference[]
commands?: RunCommand[]
}
| {
@@ -10,6 +10,8 @@ import path from "path"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Context, Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
import { makeRuntime } from "@/effect/run-service"
import { Global } from "@opencode-ai/core/global"
import { isRecord } from "@/util/record"
@@ -136,69 +138,69 @@ function state(value: unknown): ModelState {
}
}
function createLayer(fs = AppNodeBuilder.build(FSUtil.node)) {
return Layer.fresh(
Layer.effect(
Service,
Effect.gen(function* () {
const file = yield* FSUtil.Service
const layer = Layer.fresh(
Layer.effect(
Service,
Effect.gen(function* () {
const file = yield* FSUtil.Service
const read = Effect.fn("RunVariant.read")(function* () {
return yield* file.readJson(MODEL_FILE).pipe(
Effect.map(state),
Effect.catchCause(() => Effect.succeed(state(undefined))),
)
})
const read = Effect.fn("RunVariant.read")(function* () {
return yield* file.readJson(MODEL_FILE).pipe(
Effect.map(state),
Effect.catchCause(() => Effect.succeed(state(undefined))),
)
})
const resolveSavedVariant = Effect.fn("RunVariant.resolveSavedVariant")(function* (model: RunInput["model"]) {
if (!model) {
return undefined
}
const resolveSavedVariant = Effect.fn("RunVariant.resolveSavedVariant")(function* (model: RunInput["model"]) {
if (!model) {
return undefined
}
return (yield* read()).variant?.[variantKey(model)]
})
return (yield* read()).variant?.[variantKey(model)]
})
const saveVariant = Effect.fn("RunVariant.saveVariant")(function* (
model: RunInput["model"],
variant: string | undefined,
) {
if (!model) {
return
}
const saveVariant = Effect.fn("RunVariant.saveVariant")(function* (
model: RunInput["model"],
variant: string | undefined,
) {
if (!model) {
return
}
const current = yield* read()
const next = {
...current.variant,
}
const key = variantKey(model)
if (variant) {
next[key] = variant
}
const current = yield* read()
const next = {
...current.variant,
}
const key = variantKey(model)
if (variant) {
next[key] = variant
}
if (!variant) {
delete next[key]
}
if (!variant) {
delete next[key]
}
yield* file
.writeJson(MODEL_FILE, {
...current,
variant: next,
})
.pipe(Effect.orElseSucceed(() => undefined))
})
yield* file
.writeJson(MODEL_FILE, {
...current,
variant: next,
})
.pipe(Effect.orElseSucceed(() => undefined))
})
return Service.of({
resolveSavedVariant,
saveVariant,
})
}),
).pipe(Layer.provide(fs)),
)
}
return Service.of({
resolveSavedVariant,
saveVariant,
})
}),
),
)
const node = makeGlobalNode({ service: Service, layer, deps: [FSUtil.node] })
/** @internal Exported for testing. */
export function createVariantRuntime(fs = AppNodeBuilder.build(FSUtil.node)): VariantRuntime {
const runtime = makeRuntime(Service, createLayer(fs))
export function createVariantRuntime(replacements?: readonly LayerNode.Replacement[]): VariantRuntime {
const runtime = makeRuntime(Service, AppNodeBuilder.build(node, replacements))
return {
resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined),
saveVariant: (model, variant) => runtime.runPromise((svc) => svc.saveVariant(model, variant)).catch(() => {}),
-65
View File
@@ -89,73 +89,8 @@ export const TuiThreadCommand = cmd({
type: "boolean",
hidden: true,
default: false,
})
.option("mini", {
type: "boolean",
describe: "start the minimal interactive interface",
default: false,
})
.option("replay", {
type: "boolean",
hidden: true,
})
.option("no-replay", {
type: "boolean",
describe: "disable mini session history replay on resume and after resize",
})
.option("replay-limit", {
type: "number",
describe: "cap visible mini replay to the newest N messages",
})
.option("demo", {
type: "boolean",
hidden: true,
}),
handler: async (args) => {
if (args.replay === true) {
UI.error("--replay is not supported; replay is enabled by default")
process.exitCode = 1
return
}
const noReplay = args.replay === false || args.noReplay === true
if (args.mini) {
const network = ["--port", "--hostname", "--mdns", "--no-mdns", "--mdns-domain", "--cors"].find((option) =>
process.argv.some((arg) => arg === option || arg.startsWith(option + "=")),
)
if (network) {
UI.error(`${network} cannot be used with --mini`)
process.exitCode = 1
return
}
const { runMini } = await import("./run")
await runMini({
directory: resolveThreadDirectory(args.project),
continue: args.continue,
session: args.session,
fork: args.fork,
model: args.model,
agent: args.agent,
prompt: args.prompt,
replay: noReplay ? false : undefined,
replayLimit: args.replayLimit,
demo: args.demo,
})
return
}
const unsupported = [
["--no-replay", noReplay],
["--replay-limit", args.replayLimit !== undefined],
["--demo", args.demo !== undefined],
].find((entry) => entry[1])?.[0]
if (unsupported) {
UI.error(`${unsupported} requires --mini`)
process.exitCode = 1
return
}
const unguard = win32InstallCtrlCGuard()
try {
const { TuiConfig } = await import("@/config/tui")