feat: 品牌替换 + 启动优化 + AGENTS.md 模板定制
- 品牌替换:OpenCode/opencode → AirCoding/aircoding(16+ 文件) - Logo ASCII art:修复 left/right 行数不匹配导致的启动崩溃 - 启动诊断:添加 OPENCODE_PRINT_TIMING 计时探针 - dev 模式默认 --pure 跳过外部插件加载 - AGENTS.md 模板:追加 AirCoding 多 Agent 专项段落 - architect prompt + plugin:强化 AGENTS.md 产出验证
This commit is contained in:
264
packages/opencode/src/cli/cmd/account.ts
Normal file
264
packages/opencode/src/cli/cmd/account.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { cmd } from "./cmd"
|
||||
import { Duration, Effect, Match, Option } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { Account } from "@/account/account"
|
||||
import { AccountID, OrgID, PollExpired, type PollResult, type AccountError } from "@/account/schema"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import * as Prompt from "../effect/prompt"
|
||||
import open from "open"
|
||||
|
||||
const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() => undefined))
|
||||
|
||||
const println = (msg: string) => Effect.sync(() => UI.println(msg))
|
||||
|
||||
const dim = (value: string) => UI.Style.TEXT_DIM + value + UI.Style.TEXT_NORMAL
|
||||
|
||||
const activeSuffix = (isActive: boolean) => (isActive ? dim(" (active)") : "")
|
||||
|
||||
export const defaultConsoleUrl = "https://console.opencode.ai"
|
||||
|
||||
export const formatAccountLabel = (account: { email: string; url: string }, isActive: boolean) =>
|
||||
`${account.email} ${dim(account.url)}${activeSuffix(isActive)}`
|
||||
|
||||
const formatOrgChoiceLabel = (account: { email: string }, org: { name: string }, isActive: boolean) =>
|
||||
`${org.name} (${account.email})${activeSuffix(isActive)}`
|
||||
|
||||
export const formatOrgLine = (
|
||||
account: { email: string; url: string },
|
||||
org: { id: string; name: string },
|
||||
isActive: boolean,
|
||||
) => {
|
||||
const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " "
|
||||
const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
|
||||
return ` ${dot} ${name} ${dim(account.email)} ${dim(account.url)} ${dim(org.id)}`
|
||||
}
|
||||
|
||||
const isActiveOrgChoice = (
|
||||
active: Option.Option<{ id: AccountID; active_org_id: OrgID | null }>,
|
||||
choice: { accountID: AccountID; orgID: OrgID },
|
||||
) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
|
||||
|
||||
const loginEffect = Effect.fn("login")(function* (url: string) {
|
||||
const service = yield* Account.Service
|
||||
|
||||
yield* Prompt.intro("Log in")
|
||||
const login = yield* service.login(url)
|
||||
|
||||
yield* Prompt.log.info("Go to: " + login.url)
|
||||
yield* Prompt.log.info("Enter code: " + login.user)
|
||||
yield* openBrowser(login.url)
|
||||
|
||||
const s = Prompt.spinner()
|
||||
yield* s.start("Waiting for authorization...")
|
||||
|
||||
const poll = (wait: Duration.Duration): Effect.Effect<PollResult, AccountError> =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.sleep(wait)
|
||||
const result = yield* service.poll(login)
|
||||
if (result._tag === "PollPending") return yield* poll(wait)
|
||||
if (result._tag === "PollSlow") return yield* poll(Duration.sum(wait, Duration.seconds(5)))
|
||||
return result
|
||||
})
|
||||
|
||||
const result = yield* poll(login.interval).pipe(
|
||||
Effect.timeout(login.expiry),
|
||||
Effect.catchTag("TimeoutError", () => Effect.succeed(new PollExpired())),
|
||||
)
|
||||
|
||||
yield* Match.valueTags(result, {
|
||||
PollSuccess: (r) =>
|
||||
Effect.gen(function* () {
|
||||
yield* s.stop("Logged in as " + r.email)
|
||||
yield* Prompt.outro("Done")
|
||||
}),
|
||||
PollExpired: () => s.stop("Device code expired", 1),
|
||||
PollDenied: () => s.stop("Authorization denied", 1),
|
||||
PollError: (r) => s.stop("Error: " + String(r.cause), 1),
|
||||
PollPending: () => s.stop("Unexpected state", 1),
|
||||
PollSlow: () => s.stop("Unexpected state", 1),
|
||||
})
|
||||
})
|
||||
|
||||
const logoutEffect = Effect.fn("logout")(function* (email?: string) {
|
||||
const service = yield* Account.Service
|
||||
const accounts = yield* service.list()
|
||||
if (accounts.length === 0) return yield* println("Not logged in")
|
||||
|
||||
if (email) {
|
||||
const match = accounts.find((a) => a.email === email)
|
||||
if (!match) return yield* println("Account not found: " + email)
|
||||
yield* service.remove(match.id)
|
||||
yield* Prompt.outro("Logged out from " + email)
|
||||
return
|
||||
}
|
||||
|
||||
const active = yield* service.active()
|
||||
const activeID = Option.map(active, (a) => a.id)
|
||||
|
||||
yield* Prompt.intro("Log out")
|
||||
|
||||
const opts = accounts.map((a) => {
|
||||
const isActive = Option.isSome(activeID) && activeID.value === a.id
|
||||
return {
|
||||
value: a,
|
||||
label: formatAccountLabel(a, isActive),
|
||||
}
|
||||
})
|
||||
|
||||
const selected = yield* Prompt.select({ message: "Select account to log out", options: opts })
|
||||
if (Option.isNone(selected)) return
|
||||
|
||||
yield* service.remove(selected.value.id)
|
||||
yield* Prompt.outro("Logged out from " + selected.value.email)
|
||||
})
|
||||
|
||||
interface OrgChoice {
|
||||
orgID: OrgID
|
||||
accountID: AccountID
|
||||
label: string
|
||||
}
|
||||
|
||||
const switchEffect = Effect.fn("switch")(function* () {
|
||||
const service = yield* Account.Service
|
||||
|
||||
const groups = yield* service.orgsByAccount()
|
||||
if (groups.length === 0) return yield* println("Not logged in")
|
||||
|
||||
const active = yield* service.active()
|
||||
|
||||
const opts = groups.flatMap((group) =>
|
||||
group.orgs.map((org) => {
|
||||
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
|
||||
return {
|
||||
value: { orgID: org.id, accountID: group.account.id, label: org.name },
|
||||
label: formatOrgChoiceLabel(group.account, org, isActive),
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (opts.length === 0) return yield* println("No orgs found")
|
||||
|
||||
yield* Prompt.intro("Switch org")
|
||||
|
||||
const selected = yield* Prompt.select<OrgChoice>({ message: "Select org", options: opts })
|
||||
if (Option.isNone(selected)) return
|
||||
|
||||
const choice = selected.value
|
||||
yield* service.use(choice.accountID, Option.some(choice.orgID))
|
||||
yield* Prompt.outro("Switched to " + choice.label)
|
||||
})
|
||||
|
||||
const orgsEffect = Effect.fn("orgs")(function* () {
|
||||
const service = yield* Account.Service
|
||||
|
||||
const groups = yield* service.orgsByAccount()
|
||||
if (groups.length === 0) return yield* println("No accounts found")
|
||||
if (!groups.some((group) => group.orgs.length > 0)) return yield* println("No orgs found")
|
||||
|
||||
const active = yield* service.active()
|
||||
|
||||
for (const group of groups) {
|
||||
for (const org of group.orgs) {
|
||||
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
|
||||
yield* println(formatOrgLine(group.account, org, isActive))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const openEffect = Effect.fn("open")(function* () {
|
||||
const service = yield* Account.Service
|
||||
const active = yield* service.active()
|
||||
if (Option.isNone(active)) return yield* println("No active account")
|
||||
|
||||
const url = active.value.url
|
||||
yield* openBrowser(url)
|
||||
yield* Prompt.outro("Opened " + url)
|
||||
})
|
||||
|
||||
export const LoginCommand = effectCmd({
|
||||
command: "login [url]",
|
||||
describe: false,
|
||||
instance: false,
|
||||
builder: (yargs) =>
|
||||
yargs.positional("url", {
|
||||
describe: "server URL",
|
||||
type: "string",
|
||||
}),
|
||||
handler: Effect.fn("Cli.account.login")(function* (args) {
|
||||
UI.empty()
|
||||
yield* Effect.orDie(loginEffect(args.url ?? defaultConsoleUrl))
|
||||
}),
|
||||
})
|
||||
|
||||
export const LogoutCommand = effectCmd({
|
||||
command: "logout [email]",
|
||||
describe: false,
|
||||
instance: false,
|
||||
builder: (yargs) =>
|
||||
yargs.positional("email", {
|
||||
describe: "account email to log out from",
|
||||
type: "string",
|
||||
}),
|
||||
handler: Effect.fn("Cli.account.logout")(function* (args) {
|
||||
UI.empty()
|
||||
yield* Effect.orDie(logoutEffect(args.email))
|
||||
}),
|
||||
})
|
||||
|
||||
export const SwitchCommand = effectCmd({
|
||||
command: "switch",
|
||||
describe: false,
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.account.switch")(function* () {
|
||||
UI.empty()
|
||||
yield* Effect.orDie(switchEffect())
|
||||
}),
|
||||
})
|
||||
|
||||
export const OrgsCommand = effectCmd({
|
||||
command: "orgs",
|
||||
describe: false,
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.account.orgs")(function* () {
|
||||
UI.empty()
|
||||
yield* Effect.orDie(orgsEffect())
|
||||
}),
|
||||
})
|
||||
|
||||
export const OpenCommand = effectCmd({
|
||||
command: "open",
|
||||
describe: false,
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.account.open")(function* () {
|
||||
UI.empty()
|
||||
yield* Effect.orDie(openEffect())
|
||||
}),
|
||||
})
|
||||
|
||||
export const ConsoleCommand = cmd({
|
||||
command: "console",
|
||||
describe: false,
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command({
|
||||
...LoginCommand,
|
||||
describe: "log in to console",
|
||||
})
|
||||
.command({
|
||||
...LogoutCommand,
|
||||
describe: "log out from console",
|
||||
})
|
||||
.command({
|
||||
...SwitchCommand,
|
||||
describe: "switch active org",
|
||||
})
|
||||
.command({
|
||||
...OrgsCommand,
|
||||
describe: "list orgs",
|
||||
})
|
||||
.command({
|
||||
...OpenCommand,
|
||||
describe: "open active console account",
|
||||
})
|
||||
.demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
73
packages/opencode/src/cli/cmd/acp.ts
Normal file
73
packages/opencode/src/cli/cmd/acp.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Effect } from "effect"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { ACPProfile } from "@/acp/profile"
|
||||
|
||||
export const AcpCommand = effectCmd({
|
||||
command: "acp",
|
||||
describe: "start ACP (Agent Client Protocol) server",
|
||||
builder: (yargs) => {
|
||||
return withNetworkOptions(yargs).option("cwd", {
|
||||
describe: "working directory",
|
||||
type: "string",
|
||||
default: process.cwd(),
|
||||
})
|
||||
},
|
||||
handler: Effect.fn("Cli.acp")(function* (args) {
|
||||
const { Server } = yield* Effect.promise(() => import("@/server/server"))
|
||||
const { ACP } = yield* Effect.promise(() => import("@/acp/agent"))
|
||||
ACPProfile.mark("cli.acp.handler")
|
||||
process.env.OPENCODE_CLIENT = "acp"
|
||||
const opts = yield* resolveNetworkOptions(args)
|
||||
const server = yield* Effect.promise(() => ACPProfile.measure("cli.acp.server.listen", () => Server.listen(opts)))
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||
headers: ServerAuth.headers(),
|
||||
})
|
||||
|
||||
const input = new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write(chunk, (err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
const output = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
process.stdin.on("data", (chunk: Buffer) => {
|
||||
controller.enqueue(new Uint8Array(chunk))
|
||||
})
|
||||
process.stdin.on("end", () => controller.close())
|
||||
process.stdin.on("error", (err) => controller.error(err))
|
||||
},
|
||||
})
|
||||
|
||||
const stream = ndJsonStream(input, output)
|
||||
const agent = ACP.init({ sdk })
|
||||
|
||||
new AgentSideConnection((conn) => {
|
||||
ACPProfile.mark("cli.acp.connection.create")
|
||||
return agent.create(conn)
|
||||
}, stream)
|
||||
|
||||
yield* Effect.logInfo("setup connection")
|
||||
process.stdin.resume()
|
||||
yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
process.stdin.on("end", () => resolve())
|
||||
process.stdin.on("error", reject)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
259
packages/opencode/src/cli/cmd/agent.ts
Normal file
259
packages/opencode/src/cli/cmd/agent.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { cmd } from "./cmd"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { UI } from "../ui"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import matter from "gray-matter"
|
||||
import { EOL } from "os"
|
||||
import type { Argv } from "yargs"
|
||||
import { Effect } from "effect"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
|
||||
type AgentMode = "all" | "primary" | "subagent"
|
||||
|
||||
// Permission keys (not raw tool names). Multiple tools can map to a single
|
||||
// permission — e.g. write/edit/apply_patch all gate on `edit` — so we configure
|
||||
// agents at the permission level to match how the runtime actually enforces it.
|
||||
const AVAILABLE_PERMISSIONS = [
|
||||
"bash",
|
||||
"read",
|
||||
"edit",
|
||||
"glob",
|
||||
"grep",
|
||||
"webfetch",
|
||||
"task",
|
||||
"todowrite",
|
||||
"websearch",
|
||||
"lsp",
|
||||
"skill",
|
||||
]
|
||||
|
||||
const AgentCreateCommand = effectCmd({
|
||||
command: "create",
|
||||
describe: "create a new agent",
|
||||
builder: (yargs: Argv) =>
|
||||
yargs
|
||||
.option("path", {
|
||||
type: "string",
|
||||
describe: "directory path to generate the agent file",
|
||||
})
|
||||
.option("description", {
|
||||
type: "string",
|
||||
describe: "what the agent should do",
|
||||
})
|
||||
.option("mode", {
|
||||
type: "string",
|
||||
describe: "agent mode",
|
||||
choices: ["all", "primary", "subagent"] as const,
|
||||
})
|
||||
.option("permissions", {
|
||||
type: "string",
|
||||
alias: ["tools"],
|
||||
describe: `comma-separated list of permissions to allow (default: all). Available: "${AVAILABLE_PERMISSIONS.join(", ")}"`,
|
||||
})
|
||||
.option("model", {
|
||||
type: "string",
|
||||
alias: ["m"],
|
||||
describe: "model to use in the format of provider/model",
|
||||
}),
|
||||
handler: Effect.fn("Cli.agent.create")(function* (args) {
|
||||
const { InstanceRef } = yield* Effect.promise(() => import("@/effect/instance-ref"))
|
||||
const { Agent } = yield* Effect.promise(() => import("../../agent/agent"))
|
||||
const { Provider } = yield* Effect.promise(() => import("@/provider/provider"))
|
||||
const maybeCtx = yield* InstanceRef
|
||||
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
|
||||
const ctx = maybeCtx
|
||||
const agentSvc = yield* Agent.Service
|
||||
const runLocalEffect = <A, E>(effect: Effect.Effect<A, E>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provideService(InstanceRef, ctx)))
|
||||
yield* Effect.promise(async () => {
|
||||
const cliPath = args.path
|
||||
const cliDescription = args.description
|
||||
const cliMode = args.mode as AgentMode | undefined
|
||||
const perms = args.permissions
|
||||
|
||||
const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined
|
||||
|
||||
if (!isFullyNonInteractive) {
|
||||
UI.empty()
|
||||
prompts.intro("Create agent")
|
||||
}
|
||||
|
||||
const project = ctx.project
|
||||
|
||||
// Determine scope/path
|
||||
let targetPath: string
|
||||
if (cliPath) {
|
||||
targetPath = path.join(cliPath, "agents")
|
||||
} else {
|
||||
let scope: "global" | "project" = "global"
|
||||
if (project.vcs === "git") {
|
||||
const scopeResult = await prompts.select({
|
||||
message: "Location",
|
||||
options: [
|
||||
{
|
||||
label: "Current project",
|
||||
value: "project" as const,
|
||||
hint: ctx.worktree,
|
||||
},
|
||||
{
|
||||
label: "Global",
|
||||
value: "global" as const,
|
||||
hint: Global.Path.config,
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
|
||||
scope = scopeResult
|
||||
}
|
||||
targetPath = path.join(scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"), "agents")
|
||||
}
|
||||
|
||||
// Get description
|
||||
let description: string
|
||||
if (cliDescription) {
|
||||
description = cliDescription
|
||||
} else {
|
||||
const query = await prompts.text({
|
||||
message: "Description",
|
||||
placeholder: "What should this agent do?",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(query)) throw new UI.CancelledError()
|
||||
description = query
|
||||
}
|
||||
|
||||
// Generate agent
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Generating agent configuration...")
|
||||
const model = args.model ? Provider.parseModel(args.model) : undefined
|
||||
const generated = await runLocalEffect(agentSvc.generate({ description, model })).catch((error) => {
|
||||
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
|
||||
if (isFullyNonInteractive) process.exit(1)
|
||||
throw new UI.CancelledError()
|
||||
})
|
||||
spinner.stop(`Agent ${generated.identifier} generated`)
|
||||
|
||||
// Select permissions to allow
|
||||
let selected: string[]
|
||||
if (perms !== undefined) {
|
||||
selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS
|
||||
} else {
|
||||
const result = await prompts.multiselect({
|
||||
message: "Select permissions to allow (Space to toggle)",
|
||||
options: AVAILABLE_PERMISSIONS.map((permission) => ({
|
||||
label: permission,
|
||||
value: permission,
|
||||
})),
|
||||
initialValues: AVAILABLE_PERMISSIONS,
|
||||
})
|
||||
if (prompts.isCancel(result)) throw new UI.CancelledError()
|
||||
selected = result
|
||||
}
|
||||
|
||||
// Get mode
|
||||
let mode: AgentMode
|
||||
if (cliMode) {
|
||||
mode = cliMode
|
||||
} else {
|
||||
const modeResult = await prompts.select({
|
||||
message: "Agent mode",
|
||||
options: [
|
||||
{
|
||||
label: "All",
|
||||
value: "all" as const,
|
||||
hint: "Can function in both primary and subagent roles",
|
||||
},
|
||||
{
|
||||
label: "Primary",
|
||||
value: "primary" as const,
|
||||
hint: "Acts as a primary/main agent",
|
||||
},
|
||||
{
|
||||
label: "Subagent",
|
||||
value: "subagent" as const,
|
||||
hint: "Can be used as a subagent by other agents",
|
||||
},
|
||||
],
|
||||
initialValue: "all" as const,
|
||||
})
|
||||
if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
|
||||
mode = modeResult
|
||||
}
|
||||
|
||||
// Build permissions config — deny anything not explicitly selected.
|
||||
const permissions: Record<string, "deny"> = {}
|
||||
for (const permission of AVAILABLE_PERMISSIONS) {
|
||||
if (!selected.includes(permission)) {
|
||||
permissions[permission] = "deny"
|
||||
}
|
||||
}
|
||||
|
||||
// Build frontmatter
|
||||
const frontmatter: {
|
||||
description: string
|
||||
mode: AgentMode
|
||||
permission?: Record<string, "deny">
|
||||
} = {
|
||||
description: generated.whenToUse,
|
||||
mode,
|
||||
}
|
||||
if (Object.keys(permissions).length > 0) {
|
||||
frontmatter.permission = permissions
|
||||
}
|
||||
|
||||
// Write file
|
||||
const content = matter.stringify(generated.systemPrompt, frontmatter)
|
||||
const filePath = path.join(targetPath, `${generated.identifier}.md`)
|
||||
|
||||
await fs.mkdir(targetPath, { recursive: true })
|
||||
|
||||
if (await Filesystem.exists(filePath)) {
|
||||
if (isFullyNonInteractive) {
|
||||
console.error(`Error: Agent file already exists: ${filePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
prompts.log.error(`Agent file already exists: ${filePath}`)
|
||||
throw new UI.CancelledError()
|
||||
}
|
||||
|
||||
await Filesystem.write(filePath, content)
|
||||
|
||||
if (isFullyNonInteractive) {
|
||||
console.log(filePath)
|
||||
} else {
|
||||
prompts.log.success(`Agent created: ${filePath}`)
|
||||
prompts.outro("Done")
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
const AgentListCommand = effectCmd({
|
||||
command: "list",
|
||||
describe: "list all available agents",
|
||||
handler: Effect.fn("Cli.agent.list")(function* () {
|
||||
const { Agent } = yield* Effect.promise(() => import("../../agent/agent"))
|
||||
const agents = yield* Agent.Service.use((svc) => svc.list())
|
||||
const sortedAgents = agents.sort((a, b) => {
|
||||
if (a.native !== b.native) {
|
||||
return a.native ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
for (const agent of sortedAgents) {
|
||||
process.stdout.write(`${agent.name} (${agent.mode})` + EOL)
|
||||
process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL)
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export const AgentCommand = cmd({
|
||||
command: "agent",
|
||||
describe: "manage agents",
|
||||
builder: (yargs) => yargs.command(AgentCreateCommand).command(AgentListCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
97
packages/opencode/src/cli/cmd/attach.ts
Normal file
97
packages/opencode/src/cli/cmd/attach.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { cmd } from "./cmd"
|
||||
import { UI } from "@/cli/ui"
|
||||
import { errorMessage } from "@opencode-ai/tui/util/error"
|
||||
import { validateSession } from "../tui/validate-session"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
|
||||
export const AttachCommand = cmd({
|
||||
command: "attach <url>",
|
||||
describe: "attach to a running aircoding server",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("url", {
|
||||
type: "string",
|
||||
describe: "http://localhost:4096",
|
||||
demandOption: true,
|
||||
})
|
||||
.option("dir", {
|
||||
type: "string",
|
||||
description: "directory to run in",
|
||||
})
|
||||
.option("continue", {
|
||||
alias: ["c"],
|
||||
describe: "continue the last session",
|
||||
type: "boolean",
|
||||
})
|
||||
.option("session", {
|
||||
alias: ["s"],
|
||||
type: "string",
|
||||
describe: "session id to continue",
|
||||
})
|
||||
.option("fork", {
|
||||
type: "boolean",
|
||||
describe: "fork the session when continuing (use with --continue or --session)",
|
||||
})
|
||||
.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 'aircoding')",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const { TuiConfig } = await import("@/config/tui")
|
||||
if (args.fork && !args.continue && !args.session) {
|
||||
UI.error("--fork requires --continue or --session")
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const directory = (() => {
|
||||
if (!args.dir) return undefined
|
||||
try {
|
||||
process.chdir(args.dir)
|
||||
return process.cwd()
|
||||
} catch {
|
||||
// If the directory doesn't exist locally (remote attach), pass it through.
|
||||
return args.dir
|
||||
}
|
||||
})()
|
||||
const headers = ServerAuth.headers({ password: args.password, username: args.username })
|
||||
const config = await TuiConfig.get()
|
||||
|
||||
try {
|
||||
await validateSession({
|
||||
url: args.url,
|
||||
sessionID: args.session,
|
||||
directory,
|
||||
headers,
|
||||
})
|
||||
} catch (error) {
|
||||
UI.error(errorMessage(error))
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const { Effect } = await import("effect")
|
||||
const { run } = await import("../tui/layer")
|
||||
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
|
||||
await Effect.runPromise(
|
||||
run({
|
||||
url: args.url,
|
||||
config,
|
||||
pluginHost: createLegacyTuiPluginHost(),
|
||||
args: {
|
||||
continue: args.continue,
|
||||
sessionID: args.session,
|
||||
fork: args.fork,
|
||||
},
|
||||
directory,
|
||||
headers,
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
7
packages/opencode/src/cli/cmd/cmd.ts
Normal file
7
packages/opencode/src/cli/cmd/cmd.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { CommandModule } from "yargs"
|
||||
|
||||
export type WithDoubleDash<T> = T & { "--"?: string[] }
|
||||
|
||||
export function cmd<T, U>(input: CommandModule<T, WithDoubleDash<U>>) {
|
||||
return input
|
||||
}
|
||||
62
packages/opencode/src/cli/cmd/db.ts
Normal file
62
packages/opencode/src/cli/cmd/db.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { spawn } from "child_process"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Effect } from "effect"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
|
||||
const QueryCommand = effectCmd({
|
||||
command: "$0 [query]",
|
||||
describe: "open an interactive sqlite3 shell or run a query",
|
||||
instance: false,
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs
|
||||
.positional("query", {
|
||||
type: "string",
|
||||
describe: "SQL query to execute",
|
||||
})
|
||||
.option("format", {
|
||||
type: "string",
|
||||
choices: ["json", "tsv"],
|
||||
default: "tsv",
|
||||
describe: "Output format",
|
||||
})
|
||||
},
|
||||
handler: Effect.fn("Cli.db.query")(function* (args: { query?: string; format: string }) {
|
||||
const query = args.query as string | undefined
|
||||
if (query) {
|
||||
const { db } = yield* Database.Service
|
||||
const result = yield* db.all<Record<string, unknown>>(sql.raw(query)).pipe(Effect.orDie)
|
||||
if (args.format === "json") console.log(JSON.stringify(result, null, 2))
|
||||
else if (result.length > 0) {
|
||||
const keys = Object.keys(result[0])
|
||||
console.log(keys.join("\t"))
|
||||
for (const row of result) console.log(keys.map((key) => row[key]).join("\t"))
|
||||
}
|
||||
return
|
||||
}
|
||||
const child = spawn("sqlite3", [Database.path()], {
|
||||
stdio: "inherit",
|
||||
})
|
||||
yield* Effect.promise(() => new Promise((resolve) => child.on("close", resolve)))
|
||||
}),
|
||||
})
|
||||
|
||||
const PathCommand = effectCmd({
|
||||
command: "path",
|
||||
describe: "print the database path",
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.db.path")(function* () {
|
||||
console.log(Database.path())
|
||||
}),
|
||||
})
|
||||
|
||||
export const DbCommand = effectCmd({
|
||||
command: "db",
|
||||
describe: "database tools",
|
||||
instance: false,
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs.command(QueryCommand).command(PathCommand).demandCommand()
|
||||
},
|
||||
handler: Effect.fn("Cli.db")(function* () {}),
|
||||
})
|
||||
193
packages/opencode/src/cli/cmd/debug/agent.handler.ts
Normal file
193
packages/opencode/src/cli/cmd/debug/agent.handler.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||
import { EOL } from "os"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { basename } from "path"
|
||||
import { Cause, Effect } from "effect"
|
||||
import { Agent } from "../../../agent/agent"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Session } from "@/session/session"
|
||||
import type { MessageV2 } from "../../../session/message-v2"
|
||||
import { MessageID, PartID } from "../../../session/schema"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Permission } from "../../../permission"
|
||||
import { iife } from "../../../util/iife"
|
||||
import { fail } from "../../effect-cmd"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
|
||||
export const debugAgent = Effect.fn("Cli.debug.agent")(function* (args: {
|
||||
name: string
|
||||
tool?: string
|
||||
params?: string
|
||||
}) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
return yield* run(args, ctx)
|
||||
})
|
||||
|
||||
const run = Effect.fn("Cli.debug.agent.body")(function* (
|
||||
args: { name: string; tool?: string; params?: string },
|
||||
ctx: InstanceContext,
|
||||
) {
|
||||
const agentName = args.name
|
||||
const agent = yield* Agent.Service.use((svc) => svc.get(agentName))
|
||||
if (!agent) {
|
||||
process.stderr.write(
|
||||
`Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL,
|
||||
)
|
||||
return yield* fail("", 1)
|
||||
}
|
||||
const availableTools = yield* getAvailableTools(agent)
|
||||
const resolvedTools = resolveTools(agent, availableTools)
|
||||
const toolID = args.tool
|
||||
if (toolID) {
|
||||
const tool = availableTools.find((item) => item.id === toolID)
|
||||
if (!tool) {
|
||||
process.stderr.write(`Tool ${toolID} not found for agent ${agentName}` + EOL)
|
||||
return yield* fail("", 1)
|
||||
}
|
||||
if (resolvedTools[toolID] === false) {
|
||||
process.stderr.write(`Tool ${toolID} is disabled for agent ${agentName}` + EOL)
|
||||
return yield* fail("", 1)
|
||||
}
|
||||
const params = parseToolParams(args.params)
|
||||
const toolCtx = yield* createToolContext(agent, ctx)
|
||||
const result = yield* tool.execute(params, toolCtx)
|
||||
process.stdout.write(JSON.stringify({ tool: toolID, input: params, result }, null, 2) + EOL)
|
||||
return
|
||||
}
|
||||
|
||||
const output = {
|
||||
...agent,
|
||||
tools: resolvedTools,
|
||||
}
|
||||
process.stdout.write(JSON.stringify(output, null, 2) + EOL)
|
||||
})
|
||||
|
||||
const getAvailableTools = Effect.fn("Cli.debug.agent.getAvailableTools")(function* (agent: Agent.Info) {
|
||||
const provider = yield* Provider.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const model =
|
||||
agent.model ??
|
||||
(yield* provider.defaultModel().pipe(
|
||||
Effect.matchCauseEffect({
|
||||
onSuccess: Effect.succeed,
|
||||
onFailure: (cause) => {
|
||||
const error = Cause.squash(cause) as Provider.DefaultModelError
|
||||
if (error instanceof Provider.ModelNotFoundError) {
|
||||
return fail(`Model not found: ${error.providerID}/${error.modelID}`)
|
||||
}
|
||||
if (error instanceof Provider.NoModelsError) return fail(`No models found for provider ${error.providerID}`)
|
||||
return fail("No providers found")
|
||||
},
|
||||
}),
|
||||
))
|
||||
return yield* registry.tools({ ...model, agent })
|
||||
})
|
||||
|
||||
function resolveTools(agent: Agent.Info, availableTools: { id: string }[]) {
|
||||
const disabled = Permission.disabled(
|
||||
availableTools.map((tool) => tool.id),
|
||||
agent.permission,
|
||||
)
|
||||
const resolved: Record<string, boolean> = {}
|
||||
for (const tool of availableTools) {
|
||||
resolved[tool.id] = !disabled.has(tool.id)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function parseToolParams(input?: string) {
|
||||
if (!input) return {}
|
||||
const trimmed = input.trim()
|
||||
if (trimmed.length === 0) return {}
|
||||
|
||||
const parsed = iife(() => {
|
||||
try {
|
||||
return JSON.parse(trimmed)
|
||||
} catch (jsonError) {
|
||||
try {
|
||||
return new Function(`return (${trimmed})`)()
|
||||
} catch (evalError) {
|
||||
throw new Error(
|
||||
`Failed to parse --params. Use JSON or a JS object literal. JSON error: ${jsonError}. Eval error: ${evalError}.`,
|
||||
{ cause: evalError },
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Tool params must be an object.")
|
||||
}
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
|
||||
const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(function* (
|
||||
agent: Agent.Info,
|
||||
ctx: InstanceContext,
|
||||
) {
|
||||
const sessionSvc = yield* Session.Service
|
||||
const session = yield* sessionSvc.create({ title: `Debug tool run (${agent.name})` })
|
||||
const messageID = MessageID.ascending()
|
||||
const model = agent.model
|
||||
? agent.model
|
||||
: yield* Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
return yield* provider.defaultModel().pipe(
|
||||
Effect.matchCauseEffect({
|
||||
onSuccess: Effect.succeed,
|
||||
onFailure: (cause) => {
|
||||
const error = Cause.squash(cause) as Provider.DefaultModelError
|
||||
if (error instanceof Provider.ModelNotFoundError) {
|
||||
return fail(`Model not found: ${error.providerID}/${error.modelID}`)
|
||||
}
|
||||
if (error instanceof Provider.NoModelsError)
|
||||
return fail(`No models found for provider ${error.providerID}`)
|
||||
return fail("No providers found")
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
const now = Date.now()
|
||||
const message: SessionV1.Assistant = {
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
role: "assistant",
|
||||
time: { created: now },
|
||||
parentID: messageID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "debug",
|
||||
agent: agent.name,
|
||||
path: {
|
||||
cwd: ctx.directory,
|
||||
root: ctx.worktree,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
}
|
||||
yield* sessionSvc.updateMessage(message)
|
||||
|
||||
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
|
||||
|
||||
return {
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
callID: PartID.ascending(),
|
||||
agent: agent.name,
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask(req: Omit<PermissionV1.Request, "id" | "sessionID" | "tool">) {
|
||||
return Effect.sync(() => {
|
||||
for (const pattern of req.patterns) {
|
||||
const rule = Permission.evaluate(req.permission, pattern, ruleset)
|
||||
if (rule.action === "deny") {
|
||||
throw new PermissionV1.DeniedError({ ruleset })
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
})
|
||||
27
packages/opencode/src/cli/cmd/debug/agent.ts
Normal file
27
packages/opencode/src/cli/cmd/debug/agent.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Effect } from "effect"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
|
||||
export const AgentCommand = effectCmd({
|
||||
command: "agent <name>",
|
||||
describe: "show agent configuration details",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("name", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
description: "Agent name",
|
||||
})
|
||||
.option("tool", {
|
||||
type: "string",
|
||||
description: "Tool id to execute",
|
||||
})
|
||||
.option("params", {
|
||||
type: "string",
|
||||
description: "Tool params as JSON or a JS object literal",
|
||||
}),
|
||||
handler: (args) =>
|
||||
Effect.gen(function* () {
|
||||
const { debugAgent } = yield* Effect.promise(() => import("./agent.handler"))
|
||||
return yield* debugAgent(args)
|
||||
}),
|
||||
})
|
||||
14
packages/opencode/src/cli/cmd/debug/config.ts
Normal file
14
packages/opencode/src/cli/cmd/debug/config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
|
||||
export const ConfigCommand = effectCmd({
|
||||
command: "config",
|
||||
describe: "show resolved configuration",
|
||||
builder: (yargs) => yargs,
|
||||
handler: Effect.fn("Cli.debug.config")(function* () {
|
||||
const { Config } = yield* Effect.promise(() => import("@/config/config"))
|
||||
const config = yield* Config.Service.use((cfg) => cfg.get())
|
||||
process.stdout.write(JSON.stringify(config, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
73
packages/opencode/src/cli/cmd/debug/file.ts
Normal file
73
packages/opencode/src/cli/cmd/debug/file.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
const filesystem = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
Effect.provide(LocationServiceMap.get(Location.Ref.make({ directory: AbsolutePath.make(process.cwd()) }))),
|
||||
Effect.provide(LocationServiceMap.layer),
|
||||
)
|
||||
|
||||
const FileSearchCommand = effectCmd({
|
||||
command: "search <query>",
|
||||
describe: "search files by query",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("query", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
description: "Search query",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.file.search")(function* (args) {
|
||||
const results = yield* Effect.orDie(filesystem(FileSystem.Service.use((svc) => svc.find({ query: args.query }))))
|
||||
process.stdout.write(results.map((item) => item.path).join(EOL) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
const FileReadCommand = effectCmd({
|
||||
command: "read <path>",
|
||||
describe: "read file contents as JSON",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("path", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
description: "File path to read",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.file.read")(function* (args) {
|
||||
const file = yield* filesystem(FileSystem.Service.use((svc) => svc.read({ path: RelativePath.make(args.path) })))
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
{ content: Buffer.from(file.content).toString("base64"), encoding: "base64", mime: file.mime },
|
||||
null,
|
||||
2,
|
||||
) + EOL,
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
const FileListCommand = effectCmd({
|
||||
command: "list <path>",
|
||||
describe: "list files in a directory",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("path", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
description: "File path to list",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.file.list")(function* (args) {
|
||||
const files = yield* filesystem(FileSystem.Service.use((svc) => svc.list({ path: RelativePath.make(args.path) })))
|
||||
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
export const FileCommand = cmd({
|
||||
command: "file",
|
||||
describe: "file system debugging utilities",
|
||||
builder: (yargs) =>
|
||||
yargs.command(FileReadCommand).command(FileListCommand).command(FileSearchCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
87
packages/opencode/src/cli/cmd/debug/index.ts
Normal file
87
packages/opencode/src/cli/cmd/debug/index.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import os from "os"
|
||||
import { Duration, Effect } from "effect"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { cmd } from "../cmd"
|
||||
import { ConfigCommand } from "./config"
|
||||
import { FileCommand } from "./file"
|
||||
import { LSPCommand } from "./lsp"
|
||||
import { RipgrepCommand } from "./ripgrep"
|
||||
import { ScrapCommand } from "./scrap"
|
||||
import { SkillCommand } from "./skill"
|
||||
import { SnapshotCommand } from "./snapshot"
|
||||
import { AgentCommand } from "./agent"
|
||||
import { StartupCommand } from "./startup"
|
||||
import { V2Command } from "./v2"
|
||||
|
||||
export const DebugCommand = cmd({
|
||||
command: "debug",
|
||||
describe: "debugging and troubleshooting tools",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(ConfigCommand)
|
||||
.command(LSPCommand)
|
||||
.command(RipgrepCommand)
|
||||
.command(FileCommand)
|
||||
.command(ScrapCommand)
|
||||
.command(SkillCommand)
|
||||
.command(SnapshotCommand)
|
||||
.command(StartupCommand)
|
||||
.command(AgentCommand)
|
||||
.command(V2Command)
|
||||
.command(InfoCommand)
|
||||
.command(PathsCommand)
|
||||
.command(WaitCommand)
|
||||
.demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
const WaitCommand = effectCmd({
|
||||
command: "wait",
|
||||
describe: "wait indefinitely (for debugging)",
|
||||
handler: Effect.fn("Cli.debug.wait")(function* () {
|
||||
yield* Effect.sleep(Duration.days(1))
|
||||
}),
|
||||
})
|
||||
|
||||
const InfoCommand = effectCmd({
|
||||
command: "info",
|
||||
describe: "show debug information",
|
||||
handler: Effect.fn("Cli.debug.info")(function* () {
|
||||
const { Config } = yield* Effect.promise(() => import("@/config/config"))
|
||||
const { ConfigPlugin } = yield* Effect.promise(() => import("@/config/plugin"))
|
||||
const config = yield* Config.Service.use((cfg) => cfg.get())
|
||||
const termProgram = process.env.TERM_PROGRAM
|
||||
? `${process.env.TERM_PROGRAM}${process.env.TERM_PROGRAM_VERSION ? ` ${process.env.TERM_PROGRAM_VERSION}` : ""}`
|
||||
: undefined
|
||||
const terminal = [termProgram, process.env.TERM].filter((item): item is string => Boolean(item)).join(" / ")
|
||||
|
||||
console.log(`aircoding version: ${InstallationVersion}`)
|
||||
console.log(`os: ${os.type()} ${os.release()} ${os.arch()}`)
|
||||
console.log(`terminal: ${terminal || "unknown"}`)
|
||||
console.log("plugins:")
|
||||
if (Flag.OPENCODE_PURE) {
|
||||
console.log("external plugins disabled (--pure)")
|
||||
return
|
||||
}
|
||||
if (!config.plugin_origins?.length) {
|
||||
console.log("none")
|
||||
return
|
||||
}
|
||||
for (const plugin of config.plugin_origins) {
|
||||
console.log(`- ${ConfigPlugin.pluginSpecifier(plugin.spec)}`)
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
const PathsCommand = cmd({
|
||||
command: "paths",
|
||||
describe: "show global paths (data, config, cache, state)",
|
||||
handler() {
|
||||
for (const [key, value] of Object.entries(Global.Path)) {
|
||||
console.log(key.padEnd(10), value)
|
||||
}
|
||||
},
|
||||
})
|
||||
50
packages/opencode/src/cli/cmd/debug/lsp.ts
Normal file
50
packages/opencode/src/cli/cmd/debug/lsp.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { Effect } from "effect"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { cmd } from "../cmd"
|
||||
import { EOL } from "os"
|
||||
|
||||
export const LSPCommand = cmd({
|
||||
command: "lsp",
|
||||
describe: "LSP debugging utilities",
|
||||
builder: (yargs) =>
|
||||
yargs.command(DiagnosticsCommand).command(SymbolsCommand).command(DocumentSymbolsCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
const DiagnosticsCommand = effectCmd({
|
||||
command: "diagnostics <file>",
|
||||
describe: "get diagnostics for a file",
|
||||
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
|
||||
handler: Effect.fn("Cli.debug.lsp.diagnostics")(function* (args) {
|
||||
const out = yield* LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* lsp.touchFile(args.file, "full")
|
||||
return yield* lsp.diagnostics()
|
||||
}),
|
||||
)
|
||||
process.stdout.write(JSON.stringify(out, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
export const SymbolsCommand = effectCmd({
|
||||
command: "symbols <query>",
|
||||
describe: "search workspace symbols",
|
||||
builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }),
|
||||
handler: Effect.fn("Cli.debug.lsp.symbols")(function* (args) {
|
||||
yield* Effect.logInfo("symbols")
|
||||
const results = yield* LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query))
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
export const DocumentSymbolsCommand = effectCmd({
|
||||
command: "document-symbols <uri>",
|
||||
describe: "get symbols from a document",
|
||||
builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }),
|
||||
handler: Effect.fn("Cli.debug.lsp.documentSymbols")(function* (args) {
|
||||
yield* Effect.logInfo("document-symbols")
|
||||
const results = yield* LSP.Service.use((lsp) => lsp.documentSymbol(args.uri))
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
79
packages/opencode/src/cli/cmd/debug/ripgrep.ts
Normal file
79
packages/opencode/src/cli/cmd/debug/ripgrep.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { cmd } from "../cmd"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
|
||||
export const RipgrepCommand = cmd({
|
||||
command: "rg",
|
||||
describe: "ripgrep debugging utilities",
|
||||
builder: (yargs) => yargs.command(FilesCommand).command(SearchCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
const FilesCommand = effectCmd({
|
||||
command: "files",
|
||||
describe: "list files using ripgrep",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option("query", {
|
||||
type: "string",
|
||||
description: "Filter files by query",
|
||||
})
|
||||
.option("glob", {
|
||||
type: "string",
|
||||
description: "Glob pattern to match files",
|
||||
})
|
||||
.option("limit", {
|
||||
type: "number",
|
||||
description: "Limit number of results",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.rg.files")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const files = yield* ripgrep
|
||||
.glob({
|
||||
cwd: ctx.directory,
|
||||
pattern: args.glob ?? "**/*",
|
||||
limit: args.limit ?? 10_000,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
process.stdout.write(files.map((file) => file.path).join(EOL) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
const SearchCommand = effectCmd({
|
||||
command: "search <pattern>",
|
||||
describe: "search file contents using ripgrep",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("pattern", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
description: "Search pattern",
|
||||
})
|
||||
.option("glob", {
|
||||
type: "array",
|
||||
description: "File glob patterns",
|
||||
})
|
||||
.option("limit", {
|
||||
type: "number",
|
||||
description: "Limit number of results",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.rg.search")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const results = yield* ripgrep
|
||||
.grep({
|
||||
cwd: ctx.directory,
|
||||
pattern: args.pattern,
|
||||
include: args.glob?.[0],
|
||||
limit: args.limit ?? 10_000,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
15
packages/opencode/src/cli/cmd/debug/scrap.ts
Normal file
15
packages/opencode/src/cli/cmd/debug/scrap.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { EOL } from "os"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
export const ScrapCommand = cmd({
|
||||
command: "scrap",
|
||||
describe: "list all known projects",
|
||||
builder: (yargs) => yargs,
|
||||
async handler() {
|
||||
const { Project } = await import("@/project/project")
|
||||
const { makeRuntime } = await import("@opencode-ai/core/effect/runtime")
|
||||
const runtime = makeRuntime(Project.Service, Project.defaultLayer)
|
||||
const list = await runtime.runPromise((project) => project.list())
|
||||
process.stdout.write(JSON.stringify(list, null, 2) + EOL)
|
||||
},
|
||||
})
|
||||
15
packages/opencode/src/cli/cmd/debug/skill.ts
Normal file
15
packages/opencode/src/cli/cmd/debug/skill.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { Skill } from "../../../skill"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
|
||||
export const SkillCommand = effectCmd({
|
||||
command: "skill",
|
||||
describe: "list all available skills",
|
||||
builder: (yargs) => yargs,
|
||||
handler: Effect.fn("Cli.debug.skill")(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const skills = yield* skill.all()
|
||||
process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
50
packages/opencode/src/cli/cmd/debug/snapshot.ts
Normal file
50
packages/opencode/src/cli/cmd/debug/snapshot.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Effect } from "effect"
|
||||
import { Snapshot } from "../../../snapshot"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
export const SnapshotCommand = cmd({
|
||||
command: "snapshot",
|
||||
describe: "snapshot debugging utilities",
|
||||
builder: (yargs) => yargs.command(TrackCommand).command(PatchCommand).command(DiffCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
const TrackCommand = effectCmd({
|
||||
command: "track",
|
||||
describe: "track current snapshot state",
|
||||
handler: Effect.fn("Cli.debug.snapshot.track")(function* () {
|
||||
const out = yield* Snapshot.Service.use((svc) => svc.track())
|
||||
console.log(out)
|
||||
}),
|
||||
})
|
||||
|
||||
const PatchCommand = effectCmd({
|
||||
command: "patch <hash>",
|
||||
describe: "show patch for a snapshot hash",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("hash", {
|
||||
type: "string",
|
||||
description: "hash",
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.snapshot.patch")(function* (args) {
|
||||
const out = yield* Snapshot.Service.use((svc) => svc.patch(args.hash))
|
||||
console.log(out)
|
||||
}),
|
||||
})
|
||||
|
||||
const DiffCommand = effectCmd({
|
||||
command: "diff <hash>",
|
||||
describe: "show diff for a snapshot hash",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("hash", {
|
||||
type: "string",
|
||||
description: "hash",
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.snapshot.diff")(function* (args) {
|
||||
const out = yield* Snapshot.Service.use((svc) => svc.diff(args.hash))
|
||||
console.log(out)
|
||||
}),
|
||||
})
|
||||
11
packages/opencode/src/cli/cmd/debug/startup.ts
Normal file
11
packages/opencode/src/cli/cmd/debug/startup.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { EOL } from "os"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
export const StartupCommand = cmd({
|
||||
command: "startup",
|
||||
describe: "print startup timing",
|
||||
builder: (yargs) => yargs,
|
||||
handler() {
|
||||
process.stdout.write(performance.now().toString() + EOL)
|
||||
},
|
||||
})
|
||||
49
packages/opencode/src/cli/cmd/debug/v2.ts
Normal file
49
packages/opencode/src/cli/cmd/debug/v2.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
|
||||
export const V2Command = effectCmd({
|
||||
command: "v2",
|
||||
describe: "debug v2 catalog and built-in plugins",
|
||||
instance: false,
|
||||
handler: () =>
|
||||
Effect.gen(function* () {
|
||||
yield* PluginBoot.Service.use((service) => service.wait())
|
||||
const catalog = yield* Catalog.Service
|
||||
const providers = (yield* catalog.provider.available()).sort((a, b) => a.id.localeCompare(b.id))
|
||||
const all = (yield* catalog.provider.all()).sort((a, b) => a.id.localeCompare(b.id))
|
||||
const result = {
|
||||
providers,
|
||||
default: catalog.model
|
||||
.default()
|
||||
.pipe(Effect.map(Option.map((item) => item.id)), Effect.map(Option.getOrUndefined)),
|
||||
small: Object.fromEntries(
|
||||
yield* Effect.all(
|
||||
all.map((provider) =>
|
||||
Effect.map(
|
||||
catalog.model.small(provider.id),
|
||||
(model) => [provider.id, Option.getOrUndefined(Option.map(model, (item) => item.id))] as const,
|
||||
),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
),
|
||||
),
|
||||
}
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + EOL)
|
||||
}).pipe(
|
||||
Effect.withSpan("Cli.debug.v2"),
|
||||
Effect.provide(
|
||||
LocationServiceMap.get(
|
||||
Location.Ref.make({
|
||||
directory: AbsolutePath.make(process.cwd()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.provide(LocationServiceMap.layer),
|
||||
),
|
||||
})
|
||||
292
packages/opencode/src/cli/cmd/export.ts
Normal file
292
packages/opencode/src/cli/cmd/export.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { SessionID } from "../../session/schema"
|
||||
import { effectCmd, fail } from "../effect-cmd"
|
||||
import { UI } from "../ui"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
|
||||
function redact(kind: string, id: string, value: string) {
|
||||
return value.trim() ? `[redacted:${kind}:${id}]` : value
|
||||
}
|
||||
|
||||
function data(kind: string, id: string, value: Record<string, unknown> | undefined) {
|
||||
if (!value) return value
|
||||
return Object.keys(value).length ? { redacted: `${kind}:${id}` } : value
|
||||
}
|
||||
|
||||
function span(id: string, value: { value: string; start: number; end: number }) {
|
||||
return {
|
||||
...value,
|
||||
value: redact("file-text", id, value.value),
|
||||
}
|
||||
}
|
||||
|
||||
function diff(kind: string, diffs: { file?: string; patch?: string }[] | undefined) {
|
||||
return diffs?.map((item, i) => ({
|
||||
...item,
|
||||
file: item.file === undefined ? undefined : redact(`${kind}-file`, String(i), item.file),
|
||||
patch: item.patch === undefined ? undefined : redact(`${kind}-patch`, String(i), item.patch),
|
||||
}))
|
||||
}
|
||||
|
||||
function source(part: SessionV1.FilePart) {
|
||||
if (!part.source) return part.source
|
||||
if (part.source.type === "symbol") {
|
||||
return {
|
||||
...part.source,
|
||||
path: redact("file-path", part.id, part.source.path),
|
||||
name: redact("file-symbol", part.id, part.source.name),
|
||||
text: span(part.id, part.source.text),
|
||||
}
|
||||
}
|
||||
if (part.source.type === "resource") {
|
||||
return {
|
||||
...part.source,
|
||||
clientName: redact("file-client", part.id, part.source.clientName),
|
||||
uri: redact("file-uri", part.id, part.source.uri),
|
||||
text: span(part.id, part.source.text),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...part.source,
|
||||
path: redact("file-path", part.id, part.source.path),
|
||||
text: span(part.id, part.source.text),
|
||||
}
|
||||
}
|
||||
|
||||
function filepart(part: SessionV1.FilePart): SessionV1.FilePart {
|
||||
return {
|
||||
...part,
|
||||
url: redact("file-url", part.id, part.url),
|
||||
filename: part.filename === undefined ? undefined : redact("file-name", part.id, part.filename),
|
||||
source: source(part),
|
||||
}
|
||||
}
|
||||
|
||||
function part(part: SessionV1.Part): SessionV1.Part {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return {
|
||||
...part,
|
||||
text: redact("text", part.id, part.text),
|
||||
metadata: data("text-metadata", part.id, part.metadata),
|
||||
}
|
||||
case "reasoning":
|
||||
return {
|
||||
...part,
|
||||
text: redact("reasoning", part.id, part.text),
|
||||
metadata: data("reasoning-metadata", part.id, part.metadata),
|
||||
}
|
||||
case "file":
|
||||
return filepart(part)
|
||||
case "subtask":
|
||||
return {
|
||||
...part,
|
||||
prompt: redact("subtask-prompt", part.id, part.prompt),
|
||||
description: redact("subtask-description", part.id, part.description),
|
||||
command: part.command === undefined ? undefined : redact("subtask-command", part.id, part.command),
|
||||
}
|
||||
case "tool":
|
||||
return {
|
||||
...part,
|
||||
metadata: data("tool-metadata", part.id, part.metadata),
|
||||
state:
|
||||
part.state.status === "pending"
|
||||
? {
|
||||
...part.state,
|
||||
input: data("tool-input", part.id, part.state.input) ?? part.state.input,
|
||||
raw: redact("tool-raw", part.id, part.state.raw),
|
||||
}
|
||||
: part.state.status === "running"
|
||||
? {
|
||||
...part.state,
|
||||
input: data("tool-input", part.id, part.state.input) ?? part.state.input,
|
||||
title: part.state.title === undefined ? undefined : redact("tool-title", part.id, part.state.title),
|
||||
metadata: data("tool-state-metadata", part.id, part.state.metadata),
|
||||
}
|
||||
: part.state.status === "completed"
|
||||
? {
|
||||
...part.state,
|
||||
input: data("tool-input", part.id, part.state.input) ?? part.state.input,
|
||||
output: redact("tool-output", part.id, part.state.output),
|
||||
title: redact("tool-title", part.id, part.state.title),
|
||||
metadata: data("tool-state-metadata", part.id, part.state.metadata) ?? part.state.metadata,
|
||||
attachments: part.state.attachments?.map(filepart),
|
||||
}
|
||||
: {
|
||||
...part.state,
|
||||
input: data("tool-input", part.id, part.state.input) ?? part.state.input,
|
||||
metadata: data("tool-state-metadata", part.id, part.state.metadata),
|
||||
},
|
||||
}
|
||||
case "patch":
|
||||
return {
|
||||
...part,
|
||||
hash: redact("patch", part.id, part.hash),
|
||||
files: part.files.map((item: string, i: number) => redact("patch-file", `${part.id}-${i}`, item)),
|
||||
}
|
||||
case "snapshot":
|
||||
return {
|
||||
...part,
|
||||
snapshot: redact("snapshot", part.id, part.snapshot),
|
||||
}
|
||||
case "step-start":
|
||||
return {
|
||||
...part,
|
||||
snapshot: part.snapshot === undefined ? undefined : redact("snapshot", part.id, part.snapshot),
|
||||
}
|
||||
case "step-finish":
|
||||
return {
|
||||
...part,
|
||||
snapshot: part.snapshot === undefined ? undefined : redact("snapshot", part.id, part.snapshot),
|
||||
}
|
||||
case "agent":
|
||||
return {
|
||||
...part,
|
||||
source: !part.source
|
||||
? part.source
|
||||
: {
|
||||
...part.source,
|
||||
value: redact("agent-source", part.id, part.source.value),
|
||||
},
|
||||
}
|
||||
default:
|
||||
return part
|
||||
}
|
||||
}
|
||||
|
||||
const partFn = part
|
||||
|
||||
function sanitize(data: { info: Session.Info; messages: SessionV1.WithParts[] }) {
|
||||
return {
|
||||
info: {
|
||||
...data.info,
|
||||
title: redact("session-title", data.info.id, data.info.title),
|
||||
directory: redact("session-directory", data.info.id, data.info.directory),
|
||||
summary: !data.info.summary
|
||||
? data.info.summary
|
||||
: {
|
||||
...data.info.summary,
|
||||
diffs: diff("session-diff", data.info.summary.diffs),
|
||||
},
|
||||
revert: !data.info.revert
|
||||
? data.info.revert
|
||||
: {
|
||||
...data.info.revert,
|
||||
snapshot:
|
||||
data.info.revert.snapshot === undefined
|
||||
? undefined
|
||||
: redact("revert-snapshot", data.info.id, data.info.revert.snapshot),
|
||||
diff:
|
||||
data.info.revert.diff === undefined
|
||||
? undefined
|
||||
: redact("revert-diff", data.info.id, data.info.revert.diff),
|
||||
},
|
||||
},
|
||||
messages: data.messages.map((msg) => ({
|
||||
info:
|
||||
msg.info.role === "user"
|
||||
? {
|
||||
...msg.info,
|
||||
system: msg.info.system === undefined ? undefined : redact("system", msg.info.id, msg.info.system),
|
||||
summary: !msg.info.summary
|
||||
? msg.info.summary
|
||||
: {
|
||||
...msg.info.summary,
|
||||
title:
|
||||
msg.info.summary.title === undefined
|
||||
? undefined
|
||||
: redact("summary-title", msg.info.id, msg.info.summary.title),
|
||||
body:
|
||||
msg.info.summary.body === undefined
|
||||
? undefined
|
||||
: redact("summary-body", msg.info.id, msg.info.summary.body),
|
||||
diffs: diff("message-diff", msg.info.summary.diffs),
|
||||
},
|
||||
}
|
||||
: {
|
||||
...msg.info,
|
||||
path: {
|
||||
cwd: redact("cwd", msg.info.id, msg.info.path.cwd),
|
||||
root: redact("root", msg.info.id, msg.info.path.root),
|
||||
},
|
||||
},
|
||||
parts: msg.parts.map(partFn),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export const ExportCommand = effectCmd({
|
||||
command: "export [sessionID]",
|
||||
describe: "export session data as JSON",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("sessionID", {
|
||||
describe: "session id to export",
|
||||
type: "string",
|
||||
})
|
||||
.option("sanitize", {
|
||||
describe: "redact sensitive transcript and file data",
|
||||
type: "boolean",
|
||||
}),
|
||||
handler: Effect.fn("Cli.export")(function* (args) {
|
||||
return yield* run(args)
|
||||
}),
|
||||
})
|
||||
|
||||
const run = Effect.fn("Cli.export.body")(function* (args: { sessionID?: string; sanitize?: boolean }) {
|
||||
const svc = yield* Session.Service
|
||||
let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined
|
||||
process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`)
|
||||
|
||||
if (!sessionID) {
|
||||
UI.empty()
|
||||
prompts.intro("Export session", { output: process.stderr })
|
||||
|
||||
const sessions = yield* svc.list()
|
||||
|
||||
if (sessions.length === 0) {
|
||||
prompts.log.error("No sessions found", { output: process.stderr })
|
||||
prompts.outro("Done", { output: process.stderr })
|
||||
return
|
||||
}
|
||||
|
||||
sessions.sort((a, b) => b.time.updated - a.time.updated)
|
||||
|
||||
const selectedSession = yield* Effect.promise(() =>
|
||||
prompts.autocomplete({
|
||||
message: "Select session to export",
|
||||
maxItems: 10,
|
||||
options: sessions.map((session) => ({
|
||||
label: session.title,
|
||||
value: session.id,
|
||||
hint: `${new Date(session.time.updated).toLocaleString()} • ${session.id.slice(-8)}`,
|
||||
})),
|
||||
output: process.stderr,
|
||||
}),
|
||||
)
|
||||
|
||||
if (prompts.isCancel(selectedSession)) {
|
||||
return yield* Effect.die(new UI.CancelledError())
|
||||
}
|
||||
|
||||
sessionID = selectedSession
|
||||
|
||||
prompts.outro("Exporting session...", { output: process.stderr })
|
||||
}
|
||||
|
||||
// Match legacy try/catch — catches both typed failures and defects
|
||||
// (Session.Service.get throws NotFoundError as a defect, not a typed E).
|
||||
return yield* Effect.gen(function* () {
|
||||
const sessionInfo = yield* svc.get(sessionID!)
|
||||
const messages = yield* svc.messages({ sessionID: sessionInfo.id })
|
||||
|
||||
const exportData = { info: sessionInfo, messages }
|
||||
|
||||
process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2))
|
||||
process.stdout.write(EOL)
|
||||
}).pipe(Effect.catchCause(() => fail(`Session not found: ${sessionID!}`)))
|
||||
})
|
||||
54
packages/opencode/src/cli/cmd/generate.ts
Normal file
54
packages/opencode/src/cli/cmd/generate.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { CommandModule } from "yargs"
|
||||
|
||||
type Args = {}
|
||||
|
||||
export const GenerateCommand = {
|
||||
command: "generate",
|
||||
builder: (yargs) => yargs,
|
||||
handler: async () => {
|
||||
const { Server } = await import("../../server/server")
|
||||
const specs = (await Server.openapi()) as {
|
||||
paths: Record<string, Record<string, any>>
|
||||
}
|
||||
for (const item of Object.values(specs.paths)) {
|
||||
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
||||
const operation = item[method]
|
||||
if (!operation?.operationId) continue
|
||||
operation["x-codeSamples"] = [
|
||||
{
|
||||
lang: "js",
|
||||
source: [
|
||||
`import { createOpencodeClient } from "@opencode-ai/sdk`,
|
||||
``,
|
||||
`const client = createOpencodeClient()`,
|
||||
`await client.${operation.operationId}({`,
|
||||
` ...`,
|
||||
`})`,
|
||||
].join("\n"),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
const raw = JSON.stringify(specs, null, 2)
|
||||
|
||||
// Format through prettier so output is byte-identical to committed file
|
||||
// regardless of whether ./script/format.ts runs afterward.
|
||||
const prettier = await import("prettier")
|
||||
const babel = await import("prettier/plugins/babel")
|
||||
const estree = await import("prettier/plugins/estree")
|
||||
const format = prettier.format ?? prettier.default?.format
|
||||
const json = await format(raw, {
|
||||
parser: "json",
|
||||
plugins: [babel.default ?? babel, estree.default ?? estree],
|
||||
printWidth: 120,
|
||||
})
|
||||
|
||||
// Wait for stdout to finish writing before process.exit() is called
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write(json, (err) => {
|
||||
if (err) reject(err)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
},
|
||||
} satisfies CommandModule<object, Args>
|
||||
1593
packages/opencode/src/cli/cmd/github.handler.ts
Normal file
1593
packages/opencode/src/cli/cmd/github.handler.ts
Normal file
File diff suppressed because it is too large
Load Diff
30
packages/opencode/src/cli/cmd/github.shared.ts
Normal file
30
packages/opencode/src/cli/cmd/github.shared.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
|
||||
export { parseGitHubRemote } from "@/util/repository"
|
||||
|
||||
/**
|
||||
* Extracts displayable text from assistant response parts.
|
||||
* Returns null for non-text responses (signals summary needed).
|
||||
* Throws only for truly empty responses.
|
||||
*/
|
||||
export function extractResponseText(parts: SessionV1.Part[]): string | null {
|
||||
const textPart = parts.findLast((p) => p.type === "text")
|
||||
if (textPart) return textPart.text
|
||||
|
||||
// Non-text parts (tools, reasoning, step-start/step-finish, etc.) - signal summary needed
|
||||
if (parts.length > 0) return null
|
||||
|
||||
throw new Error("Failed to parse response: no parts returned")
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a PROMPT_TOO_LARGE error message with details about files in the prompt.
|
||||
* Content is base64 encoded, so we calculate original size by multiplying by 0.75.
|
||||
*/
|
||||
export function formatPromptTooLargeError(files: { filename: string; content: string }[]): string {
|
||||
const fileDetails =
|
||||
files.length > 0
|
||||
? `\n\nFiles in prompt:\n${files.map((f) => ` - ${f.filename} (${((f.content.length * 0.75) / 1024).toFixed(0)} KB)`).join("\n")}`
|
||||
: ""
|
||||
return `PROMPT_TOO_LARGE: The prompt exceeds the model's context limit.${fileDetails}`
|
||||
}
|
||||
42
packages/opencode/src/cli/cmd/github.ts
Normal file
42
packages/opencode/src/cli/cmd/github.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Effect } from "effect"
|
||||
import { cmd } from "./cmd"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
|
||||
export { extractResponseText, formatPromptTooLargeError, parseGitHubRemote } from "./github.shared"
|
||||
|
||||
export const GithubInstallCommand = effectCmd({
|
||||
command: "install",
|
||||
describe: "install the GitHub agent",
|
||||
handler: () =>
|
||||
Effect.gen(function* () {
|
||||
const { githubInstall } = yield* Effect.promise(() => import("./github.handler"))
|
||||
return yield* githubInstall()
|
||||
}),
|
||||
})
|
||||
|
||||
export const GithubRunCommand = effectCmd({
|
||||
command: "run",
|
||||
describe: "run the GitHub agent",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option("event", {
|
||||
type: "string",
|
||||
describe: "GitHub mock event to run the agent for",
|
||||
})
|
||||
.option("token", {
|
||||
type: "string",
|
||||
describe: "GitHub personal access token (github_pat_********)",
|
||||
}),
|
||||
handler: (args) =>
|
||||
Effect.gen(function* () {
|
||||
const { githubRun } = yield* Effect.promise(() => import("./github.handler"))
|
||||
return yield* githubRun(args)
|
||||
}),
|
||||
})
|
||||
|
||||
export const GithubCommand = cmd({
|
||||
command: "github",
|
||||
describe: "manage GitHub agent",
|
||||
builder: (yargs) => yargs.command(GithubInstallCommand).command(GithubRunCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
224
packages/opencode/src/cli/cmd/import.ts
Normal file
224
packages/opencode/src/cli/cmd/import.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { Session } from "@/session/session"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { CliError, effectCmd } from "../effect-cmd"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { SessionTable, MessageTable, PartTable } from "@opencode-ai/core/session/sql"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { EOL } from "os"
|
||||
import path from "path"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
|
||||
const decodeMessageInfo = Schema.decodeUnknownSync(SessionV1.Info)
|
||||
const decodePart = Schema.decodeUnknownSync(SessionV1.Part)
|
||||
|
||||
/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */
|
||||
export type ShareData =
|
||||
| { type: "session"; data: SDKSession }
|
||||
| { type: "message"; data: Message }
|
||||
| { type: "part"; data: Part }
|
||||
| { type: "session_diff"; data: unknown }
|
||||
| { type: "model"; data: unknown }
|
||||
|
||||
/** Extract share ID from a share URL like https://opncd.ai/share/abc123 */
|
||||
export function parseShareUrl(url: string): string | null {
|
||||
const match = url.match(/^https?:\/\/[^/]+\/share\/([a-zA-Z0-9_-]+)$/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
export function shouldAttachShareAuthHeaders(shareUrl: string, accountBaseUrl: string): boolean {
|
||||
try {
|
||||
return new URL(shareUrl).origin === new URL(accountBaseUrl).origin
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform ShareNext API response (flat array) into the nested structure for local file storage.
|
||||
*
|
||||
* The API returns a flat array: [session, message, message, part, part, ...]
|
||||
* Local storage expects: { info: session, messages: [{ info: message, parts: [part, ...] }, ...] }
|
||||
*
|
||||
* This groups parts by their messageID to reconstruct the hierarchy before writing to disk.
|
||||
*/
|
||||
export function transformShareData(shareData: ShareData[]): {
|
||||
info: SDKSession
|
||||
messages: Array<{ info: Message; parts: Part[] }>
|
||||
} | null {
|
||||
const sessionItem = shareData.find((d) => d.type === "session")
|
||||
if (!sessionItem) return null
|
||||
|
||||
const messageMap = new Map<string, Message>()
|
||||
const partMap = new Map<string, Part[]>()
|
||||
|
||||
for (const item of shareData) {
|
||||
if (item.type === "message") {
|
||||
messageMap.set(item.data.id, item.data)
|
||||
} else if (item.type === "part") {
|
||||
if (!partMap.has(item.data.messageID)) {
|
||||
partMap.set(item.data.messageID, [])
|
||||
}
|
||||
partMap.get(item.data.messageID)!.push(item.data)
|
||||
}
|
||||
}
|
||||
|
||||
if (messageMap.size === 0) return null
|
||||
|
||||
return {
|
||||
info: sessionItem.data,
|
||||
messages: Array.from(messageMap.values()).map((msg) => ({
|
||||
info: msg,
|
||||
parts: partMap.get(msg.id) ?? [],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
type ExportData = { info: SDKSession; messages: Array<{ info: Message; parts: Part[] }> }
|
||||
|
||||
export const ImportCommand = effectCmd({
|
||||
command: "import <file>",
|
||||
describe: "import session data from JSON file or URL",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("file", {
|
||||
describe: "path to JSON file or share URL",
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.import")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return yield* Effect.die("InstanceRef not provided")
|
||||
return yield* runImport(args.file, ctx)
|
||||
}),
|
||||
})
|
||||
|
||||
const runImport = Effect.fn("Cli.import.body")(function* (file: string, ctx: InstanceContext) {
|
||||
const share = yield* ShareNext.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const { db } = yield* Database.Service
|
||||
|
||||
let exportData: ExportData | undefined
|
||||
|
||||
const isUrl = file.startsWith("http://") || file.startsWith("https://")
|
||||
|
||||
if (isUrl) {
|
||||
const slug = parseShareUrl(file)
|
||||
if (!slug) {
|
||||
const baseUrl = yield* Effect.orDie(share.url())
|
||||
process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
|
||||
const baseUrl = new URL(file).origin
|
||||
const req = yield* Effect.orDie(share.request())
|
||||
const headers = shouldAttachShareAuthHeaders(file, req.baseUrl) ? req.headers : {}
|
||||
|
||||
const tryFetch = (url: string) =>
|
||||
Effect.tryPromise({
|
||||
try: () => fetch(url, { headers }),
|
||||
catch: (e) =>
|
||||
new CliError({
|
||||
message: `Failed to fetch share data: ${e instanceof Error ? e.message : String(e)}`,
|
||||
}),
|
||||
})
|
||||
|
||||
const dataPath = req.api.data(slug)
|
||||
let response = yield* tryFetch(`${baseUrl}${dataPath}`)
|
||||
|
||||
if (!response.ok && dataPath !== `/api/share/${slug}/data`) {
|
||||
response = yield* tryFetch(`${baseUrl}/api/share/${slug}/data`)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
process.stdout.write(`Failed to fetch share data: ${response.statusText}`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
|
||||
const shareData = yield* Effect.tryPromise({
|
||||
try: () => response.json() as Promise<ShareData[]>,
|
||||
catch: () => new CliError({ message: "Share data was not valid JSON" }),
|
||||
})
|
||||
const transformed = transformShareData(shareData)
|
||||
|
||||
if (!transformed) {
|
||||
process.stdout.write(`Share not found or empty: ${slug}`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
|
||||
exportData = transformed
|
||||
} else {
|
||||
exportData = (yield* fs.readJson(file).pipe(Effect.orElseSucceed(() => undefined))) as
|
||||
| NonNullable<typeof exportData>
|
||||
| undefined
|
||||
if (!exportData) {
|
||||
process.stdout.write(`File not found: ${file}`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!exportData) {
|
||||
process.stdout.write(`Failed to read session data`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
|
||||
const info = Schema.decodeUnknownSync(Session.Info)({
|
||||
...exportData.info,
|
||||
projectID: ctx.project.id,
|
||||
directory: ctx.directory,
|
||||
path: path.relative(path.resolve(ctx.worktree), ctx.directory).replaceAll("\\", "/"),
|
||||
}) as Session.Info
|
||||
const row = Session.toRow(info)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({
|
||||
target: SessionTable.id,
|
||||
set: { project_id: row.project_id, directory: row.directory, path: row.path },
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
for (const msg of exportData.messages) {
|
||||
const msgInfo = decodeMessageInfo(msg.info) as SessionV1.Info
|
||||
const { id, sessionID: _, ...msgData } = msgInfo
|
||||
yield* db
|
||||
.insert(MessageTable)
|
||||
.values({
|
||||
id,
|
||||
session_id: row.id,
|
||||
time_created: msgInfo.time?.created ?? Date.now(),
|
||||
data: msgData as never,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
for (const part of msg.parts) {
|
||||
const partInfo = decodePart(part) as SessionV1.Part
|
||||
const { id: partId, sessionID: _s, messageID, ...partData } = partInfo
|
||||
yield* db
|
||||
.insert(PartTable)
|
||||
.values({
|
||||
id: partId,
|
||||
message_id: messageID,
|
||||
session_id: row.id,
|
||||
data: partData,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`Imported session: ${exportData.info.id}`)
|
||||
process.stdout.write(EOL)
|
||||
})
|
||||
848
packages/opencode/src/cli/cmd/mcp.ts
Normal file
848
packages/opencode/src/cli/cmd/mcp.ts
Normal file
@@ -0,0 +1,848 @@
|
||||
import { cmd } from "./cmd"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { Cause } from "effect"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { UI } from "../ui"
|
||||
import { MCP } from "../../mcp"
|
||||
import { McpAuth } from "../../mcp/auth"
|
||||
import { McpOAuthProvider } from "../../mcp/oauth-provider"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { modify, applyEdits } from "jsonc-parser"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Effect } from "effect"
|
||||
|
||||
function getAuthStatusIcon(status: MCP.AuthStatus): string {
|
||||
switch (status) {
|
||||
case "authenticated":
|
||||
return "✓"
|
||||
case "expired":
|
||||
return "⚠"
|
||||
case "not_authenticated":
|
||||
return "✗"
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthStatusText(status: MCP.AuthStatus): string {
|
||||
switch (status) {
|
||||
case "authenticated":
|
||||
return "authenticated"
|
||||
case "expired":
|
||||
return "expired"
|
||||
case "not_authenticated":
|
||||
return "not authenticated"
|
||||
}
|
||||
}
|
||||
|
||||
type McpEntry = NonNullable<ConfigV1.Info["mcp"]>[string]
|
||||
|
||||
type McpConfigured = ConfigMCPV1.Info
|
||||
function isMcpConfigured(config: McpEntry): config is McpConfigured {
|
||||
return typeof config === "object" && config !== null && "type" in config
|
||||
}
|
||||
|
||||
type McpRemote = Extract<McpConfigured, { type: "remote" }>
|
||||
function isMcpRemote(config: McpEntry): config is McpRemote {
|
||||
return isMcpConfigured(config) && config.type === "remote"
|
||||
}
|
||||
|
||||
function configuredServers(config: ConfigV1.Info) {
|
||||
return Object.entries(config.mcp ?? {}).filter((entry): entry is [string, McpConfigured] => isMcpConfigured(entry[1]))
|
||||
}
|
||||
|
||||
function oauthServers(config: ConfigV1.Info) {
|
||||
return configuredServers(config).filter(
|
||||
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
|
||||
)
|
||||
}
|
||||
|
||||
function listState() {
|
||||
return Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const config = yield* cfg.get()
|
||||
const statuses = yield* mcp.status()
|
||||
const stored = yield* Effect.all(
|
||||
Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return { config, statuses, stored }
|
||||
})
|
||||
}
|
||||
|
||||
function authState() {
|
||||
return Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const config = yield* cfg.get()
|
||||
const auth = yield* Effect.all(
|
||||
Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return { config, auth }
|
||||
})
|
||||
}
|
||||
|
||||
export const McpCommand = cmd({
|
||||
command: "mcp",
|
||||
describe: "manage MCP (Model Context Protocol) servers",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(McpAddCommand)
|
||||
.command(McpListCommand)
|
||||
.command(McpAuthCommand)
|
||||
.command(McpLogoutCommand)
|
||||
.command(McpDebugCommand)
|
||||
.demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
export const McpListCommand = effectCmd({
|
||||
command: "list",
|
||||
aliases: ["ls"],
|
||||
describe: "list MCP servers and their status",
|
||||
handler: Effect.fn("Cli.mcp.list")(function* () {
|
||||
UI.empty()
|
||||
prompts.intro("MCP Servers")
|
||||
|
||||
const { config, statuses, stored } = yield* listState()
|
||||
const servers = configuredServers(config)
|
||||
|
||||
if (servers.length === 0) {
|
||||
prompts.log.warn("No MCP servers configured")
|
||||
prompts.outro("Add servers with: opencode mcp add")
|
||||
return
|
||||
}
|
||||
|
||||
for (const [name, serverConfig] of servers) {
|
||||
const status = statuses[name]
|
||||
const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth
|
||||
const hasStoredTokens = stored[name]
|
||||
|
||||
let statusIcon: string
|
||||
let statusText: string
|
||||
let hint = ""
|
||||
|
||||
if (!status) {
|
||||
statusIcon = "○"
|
||||
statusText = "not initialized"
|
||||
} else if (status.status === "connected") {
|
||||
statusIcon = "✓"
|
||||
statusText = "connected"
|
||||
if (hasOAuth && hasStoredTokens) {
|
||||
hint = " (OAuth)"
|
||||
}
|
||||
} else if (status.status === "disabled") {
|
||||
statusIcon = "○"
|
||||
statusText = "disabled"
|
||||
} else if (status.status === "needs_auth") {
|
||||
statusIcon = "⚠"
|
||||
statusText = "needs authentication"
|
||||
} else if (status.status === "needs_client_registration") {
|
||||
statusIcon = "✗"
|
||||
statusText = "needs client registration"
|
||||
hint = "\n " + status.error
|
||||
} else {
|
||||
statusIcon = "✗"
|
||||
statusText = "failed"
|
||||
hint = "\n " + status.error
|
||||
}
|
||||
|
||||
const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ")
|
||||
prompts.log.info(
|
||||
`${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`,
|
||||
)
|
||||
}
|
||||
|
||||
prompts.outro(`${servers.length} server(s)`)
|
||||
}),
|
||||
})
|
||||
|
||||
export const McpAuthCommand = effectCmd({
|
||||
command: "auth [name]",
|
||||
describe: "authenticate with an OAuth-enabled MCP server",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("name", {
|
||||
describe: "name of the MCP server",
|
||||
type: "string",
|
||||
})
|
||||
.command(McpAuthListCommand),
|
||||
handler: Effect.fn("Cli.mcp.auth")(function* (args) {
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Authentication")
|
||||
|
||||
const { config, auth } = yield* authState()
|
||||
const mcpServers = config.mcp ?? {}
|
||||
const servers = oauthServers(config)
|
||||
|
||||
if (servers.length === 0) {
|
||||
prompts.log.warn("No OAuth-capable MCP servers configured")
|
||||
prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:")
|
||||
prompts.log.info(`
|
||||
"mcp": {
|
||||
"my-server": {
|
||||
"type": "remote",
|
||||
"url": "https://example.com/mcp"
|
||||
}
|
||||
}`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
let serverName = args.name
|
||||
if (!serverName) {
|
||||
// Build options with auth status
|
||||
const options = servers.map(([name, cfg]) => {
|
||||
const authStatus = auth[name]
|
||||
const icon = getAuthStatusIcon(authStatus)
|
||||
const statusText = getAuthStatusText(authStatus)
|
||||
const url = cfg.url
|
||||
return {
|
||||
label: `${icon} ${name} (${statusText})`,
|
||||
value: name,
|
||||
hint: url,
|
||||
}
|
||||
})
|
||||
|
||||
const selected = yield* Effect.promise(() =>
|
||||
prompts.select({
|
||||
message: "Select MCP server to authenticate",
|
||||
options,
|
||||
}),
|
||||
)
|
||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||
serverName = selected
|
||||
}
|
||||
|
||||
const serverConfig = mcpServers[serverName]
|
||||
if (!serverConfig) {
|
||||
prompts.log.error(`MCP server not found: ${serverName}`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
if (!isMcpRemote(serverConfig) || serverConfig.oauth === false) {
|
||||
prompts.log.error(`MCP server ${serverName} is not an OAuth-capable remote server`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already authenticated
|
||||
const authStatus = auth[serverName] ?? (yield* MCP.Service.use((mcp) => mcp.getAuthStatus(serverName)))
|
||||
if (authStatus === "authenticated") {
|
||||
const confirm = yield* Effect.promise(() =>
|
||||
prompts.confirm({
|
||||
message: `${serverName} already has valid credentials. Re-authenticate?`,
|
||||
}),
|
||||
)
|
||||
if (prompts.isCancel(confirm) || !confirm) {
|
||||
prompts.outro("Cancelled")
|
||||
return
|
||||
}
|
||||
} else if (authStatus === "expired") {
|
||||
prompts.log.warn(`${serverName} has expired credentials. Re-authenticating...`)
|
||||
}
|
||||
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Starting OAuth flow...")
|
||||
|
||||
// Subscribe to browser open failure events to show URL for manual opening
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const unsubscribe = yield* events.listen((event) => {
|
||||
if (event.type !== MCP.BrowserOpenFailed.type) return Effect.void
|
||||
const data = event.data as EventV2.Data<typeof MCP.BrowserOpenFailed>
|
||||
if (data.mcpName === serverName) {
|
||||
spinner.stop("Could not open browser automatically")
|
||||
prompts.log.warn("Please open this URL in your browser to authenticate:")
|
||||
prompts.log.info(data.url)
|
||||
spinner.start("Waiting for authorization...")
|
||||
}
|
||||
return Effect.void
|
||||
})
|
||||
|
||||
yield* MCP.Service.use((mcp) => mcp.authenticate(serverName)).pipe(
|
||||
Effect.tap((status) =>
|
||||
Effect.sync(() => {
|
||||
if (status.status === "connected") {
|
||||
spinner.stop("Authentication successful!")
|
||||
} else if (status.status === "needs_client_registration") {
|
||||
spinner.stop("Authentication failed", 1)
|
||||
prompts.log.error(status.error)
|
||||
prompts.log.info("Add clientId to your MCP server config:")
|
||||
prompts.log.info(`
|
||||
"mcp": {
|
||||
"${serverName}": {
|
||||
"type": "remote",
|
||||
"url": "${serverConfig.url}",
|
||||
"oauth": {
|
||||
"clientId": "your-client-id",
|
||||
"clientSecret": "your-client-secret"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
} else if (status.status === "failed") {
|
||||
spinner.stop("Authentication failed", 1)
|
||||
prompts.log.error(status.error)
|
||||
} else {
|
||||
spinner.stop("Unexpected status: " + status.status, 1)
|
||||
}
|
||||
}),
|
||||
),
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.sync(() => {
|
||||
spinner.stop("Authentication failed", 1)
|
||||
const error = Cause.squash(cause)
|
||||
prompts.log.error(error instanceof Error ? error.message : String(error))
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(unsubscribe),
|
||||
)
|
||||
|
||||
prompts.outro("Done")
|
||||
}),
|
||||
})
|
||||
|
||||
export const McpAuthListCommand = effectCmd({
|
||||
command: "list",
|
||||
aliases: ["ls"],
|
||||
describe: "list OAuth-capable MCP servers and their auth status",
|
||||
handler: Effect.fn("Cli.mcp.auth.list")(function* () {
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Status")
|
||||
|
||||
const { config, auth } = yield* authState()
|
||||
const servers = oauthServers(config)
|
||||
|
||||
if (servers.length === 0) {
|
||||
prompts.log.warn("No OAuth-capable MCP servers configured")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
for (const [name, serverConfig] of servers) {
|
||||
const authStatus = auth[name]
|
||||
const icon = getAuthStatusIcon(authStatus)
|
||||
const statusText = getAuthStatusText(authStatus)
|
||||
const url = serverConfig.url
|
||||
|
||||
prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`)
|
||||
}
|
||||
|
||||
prompts.outro(`${servers.length} OAuth-capable server(s)`)
|
||||
}),
|
||||
})
|
||||
|
||||
export const McpLogoutCommand = effectCmd({
|
||||
command: "logout [name]",
|
||||
describe: "remove OAuth credentials for an MCP server",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("name", {
|
||||
describe: "name of the MCP server",
|
||||
type: "string",
|
||||
}),
|
||||
handler: Effect.fn("Cli.mcp.logout")(function* (args) {
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Logout")
|
||||
|
||||
const credentials = yield* McpAuth.Service.use((auth) => auth.all())
|
||||
const serverNames = Object.keys(credentials)
|
||||
|
||||
if (serverNames.length === 0) {
|
||||
prompts.log.warn("No MCP OAuth credentials stored")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
let serverName = args.name
|
||||
if (!serverName) {
|
||||
const selected = yield* Effect.promise(() =>
|
||||
prompts.select({
|
||||
message: "Select MCP server to logout",
|
||||
options: serverNames.map((name) => {
|
||||
const entry = credentials[name]
|
||||
const hasTokens = !!entry.tokens
|
||||
const hasClient = !!entry.clientInfo
|
||||
let hint = ""
|
||||
if (hasTokens && hasClient) hint = "tokens + client"
|
||||
else if (hasTokens) hint = "tokens"
|
||||
else if (hasClient) hint = "client registration"
|
||||
return {
|
||||
label: name,
|
||||
value: name,
|
||||
hint,
|
||||
}
|
||||
}),
|
||||
}),
|
||||
)
|
||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||
serverName = selected
|
||||
}
|
||||
|
||||
if (!credentials[serverName]) {
|
||||
prompts.log.error(`No credentials found for: ${serverName}`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
yield* MCP.Service.use((mcp) => mcp.removeAuth(serverName))
|
||||
prompts.log.success(`Removed OAuth credentials for ${serverName}`)
|
||||
prompts.outro("Done")
|
||||
}),
|
||||
})
|
||||
|
||||
async function resolveConfigPath(baseDir: string, global = false) {
|
||||
// Check for existing config files (prefer .jsonc over .json, check .opencode/ subdirectory too)
|
||||
const candidates = [path.join(baseDir, "opencode.json"), path.join(baseDir, "opencode.jsonc")]
|
||||
|
||||
if (!global) {
|
||||
candidates.push(path.join(baseDir, ".opencode", "opencode.json"), path.join(baseDir, ".opencode", "opencode.jsonc"))
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (await Filesystem.exists(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
// Default to opencode.json if none exist
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
async function addMcpToConfig(name: string, mcpConfig: ConfigMCPV1.Info, configPath: string) {
|
||||
let text = "{}"
|
||||
if (await Filesystem.exists(configPath)) {
|
||||
text = await Filesystem.readText(configPath)
|
||||
}
|
||||
|
||||
// Use jsonc-parser to modify while preserving comments
|
||||
const edits = modify(text, ["mcp", name], mcpConfig, {
|
||||
formattingOptions: { tabSize: 2, insertSpaces: true },
|
||||
})
|
||||
const result = applyEdits(text, edits)
|
||||
|
||||
await Filesystem.write(configPath, result)
|
||||
|
||||
return configPath
|
||||
}
|
||||
|
||||
export const McpAddCommand = effectCmd({
|
||||
command: "add [name]",
|
||||
describe: "add an MCP server",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("name", {
|
||||
describe: "name of the MCP server",
|
||||
type: "string",
|
||||
})
|
||||
.option("url", {
|
||||
describe: "URL for a remote MCP server",
|
||||
type: "string",
|
||||
})
|
||||
.option("env", {
|
||||
describe: "environment variable for a local MCP server (KEY=VALUE)",
|
||||
type: "string",
|
||||
array: true,
|
||||
})
|
||||
.option("header", {
|
||||
describe: "HTTP header for a remote MCP server (KEY=VALUE)",
|
||||
type: "string",
|
||||
array: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.mcp.add")(function* (args) {
|
||||
const maybeCtx = yield* InstanceRef
|
||||
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
|
||||
const ctx = maybeCtx
|
||||
yield* Effect.promise(async () => {
|
||||
const command = args["--"] ?? []
|
||||
if (!args.name && (args.url || args.env?.length || args.header?.length || command.length)) {
|
||||
throw new Error("A server name is required for non-interactive MCP configuration")
|
||||
}
|
||||
if (args.name) {
|
||||
if (!!args.url === !!command.length) {
|
||||
throw new Error("Provide either --url <url> or a command after --")
|
||||
}
|
||||
if (args.url && !URL.canParse(args.url)) {
|
||||
throw new Error(`Invalid URL: ${args.url}`)
|
||||
}
|
||||
if (args.url && args.env?.length) {
|
||||
throw new Error("--env is only valid for local MCP servers")
|
||||
}
|
||||
if (command.length && args.header?.length) {
|
||||
throw new Error("--header is only valid for remote MCP servers")
|
||||
}
|
||||
|
||||
const entries = (values: string[], kind: string) =>
|
||||
Object.fromEntries(
|
||||
values.map((entry) => {
|
||||
const index = entry.indexOf("=")
|
||||
if (index < 1) throw new Error(`Invalid ${kind}: ${entry}. Expected KEY=VALUE`)
|
||||
return [entry.slice(0, index), entry.slice(index + 1)]
|
||||
}),
|
||||
)
|
||||
const environment = entries(args.env ?? [], "environment variable")
|
||||
const headers = entries(args.header ?? [], "HTTP header")
|
||||
const mcpConfig: ConfigMCPV1.Info = args.url
|
||||
? {
|
||||
type: "remote",
|
||||
url: args.url,
|
||||
...(Object.keys(headers).length ? { headers } : {}),
|
||||
}
|
||||
: {
|
||||
type: "local",
|
||||
command,
|
||||
...(Object.keys(environment).length ? { environment } : {}),
|
||||
}
|
||||
|
||||
const configPath = await resolveConfigPath(Global.Path.config, true)
|
||||
await addMcpToConfig(args.name, mcpConfig, configPath)
|
||||
prompts.log.success(`MCP server "${args.name}" added to ${configPath}`)
|
||||
return
|
||||
}
|
||||
|
||||
UI.empty()
|
||||
prompts.intro("Add MCP server")
|
||||
|
||||
const project = ctx.project
|
||||
|
||||
// Resolve config paths eagerly for hints
|
||||
const [projectConfigPath, globalConfigPath] = await Promise.all([
|
||||
resolveConfigPath(ctx.worktree),
|
||||
resolveConfigPath(Global.Path.config, true),
|
||||
])
|
||||
|
||||
// Determine scope
|
||||
let configPath = globalConfigPath
|
||||
if (project.vcs === "git") {
|
||||
const scopeResult = await prompts.select({
|
||||
message: "Location",
|
||||
options: [
|
||||
{
|
||||
label: "Current project",
|
||||
value: projectConfigPath,
|
||||
hint: projectConfigPath,
|
||||
},
|
||||
{
|
||||
label: "Global",
|
||||
value: globalConfigPath,
|
||||
hint: globalConfigPath,
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
|
||||
configPath = scopeResult
|
||||
}
|
||||
|
||||
const name = await prompts.text({
|
||||
message: "Enter MCP server name",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(name)) throw new UI.CancelledError()
|
||||
|
||||
const type = await prompts.select({
|
||||
message: "Select MCP server type",
|
||||
options: [
|
||||
{
|
||||
label: "Local",
|
||||
value: "local",
|
||||
hint: "Run a local command",
|
||||
},
|
||||
{
|
||||
label: "Remote",
|
||||
value: "remote",
|
||||
hint: "Connect to a remote URL",
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(type)) throw new UI.CancelledError()
|
||||
|
||||
if (type === "local") {
|
||||
const command = await prompts.text({
|
||||
message: "Enter command to run",
|
||||
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(command)) throw new UI.CancelledError()
|
||||
|
||||
const mcpConfig: ConfigMCPV1.Info = {
|
||||
type: "local",
|
||||
command: command.split(" "),
|
||||
}
|
||||
|
||||
await addMcpToConfig(name, mcpConfig, configPath)
|
||||
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
|
||||
prompts.outro("MCP server added successfully")
|
||||
return
|
||||
}
|
||||
|
||||
if (type === "remote") {
|
||||
const url = await prompts.text({
|
||||
message: "Enter MCP server URL",
|
||||
placeholder: "e.g., https://example.com/mcp",
|
||||
validate: (x) => {
|
||||
if (!x) return "Required"
|
||||
if (x.length === 0) return "Required"
|
||||
const isValid = URL.canParse(x)
|
||||
return isValid ? undefined : "Invalid URL"
|
||||
},
|
||||
})
|
||||
if (prompts.isCancel(url)) throw new UI.CancelledError()
|
||||
|
||||
const useOAuth = await prompts.confirm({
|
||||
message: "Does this server require OAuth authentication?",
|
||||
initialValue: false,
|
||||
})
|
||||
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
|
||||
|
||||
let mcpConfig: ConfigMCPV1.Info
|
||||
|
||||
if (useOAuth) {
|
||||
const hasClientId = await prompts.confirm({
|
||||
message: "Do you have a pre-registered client ID?",
|
||||
initialValue: false,
|
||||
})
|
||||
if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
|
||||
|
||||
if (hasClientId) {
|
||||
const clientId = await prompts.text({
|
||||
message: "Enter client ID",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(clientId)) throw new UI.CancelledError()
|
||||
|
||||
const hasSecret = await prompts.confirm({
|
||||
message: "Do you have a client secret?",
|
||||
initialValue: false,
|
||||
})
|
||||
if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
|
||||
|
||||
let clientSecret: string | undefined
|
||||
if (hasSecret) {
|
||||
const secret = await prompts.password({
|
||||
message: "Enter client secret",
|
||||
})
|
||||
if (prompts.isCancel(secret)) throw new UI.CancelledError()
|
||||
clientSecret = secret
|
||||
}
|
||||
|
||||
mcpConfig = {
|
||||
type: "remote",
|
||||
url,
|
||||
oauth: {
|
||||
clientId,
|
||||
...(clientSecret && { clientSecret }),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
mcpConfig = {
|
||||
type: "remote",
|
||||
url,
|
||||
oauth: {},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mcpConfig = {
|
||||
type: "remote",
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
await addMcpToConfig(name, mcpConfig, configPath)
|
||||
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
|
||||
}
|
||||
|
||||
prompts.outro("MCP server added successfully")
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
export const McpDebugCommand = effectCmd({
|
||||
command: "debug <name>",
|
||||
describe: "debug OAuth connection for an MCP server",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("name", {
|
||||
describe: "name of the MCP server",
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.mcp.debug")(function* (args) {
|
||||
const config = yield* Config.Service.use((cfg) => cfg.get())
|
||||
const mcp = yield* MCP.Service
|
||||
const auth = yield* McpAuth.Service
|
||||
yield* Effect.promise(async () => {
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Debug")
|
||||
|
||||
const mcpServers = config.mcp ?? {}
|
||||
const serverName = args.name
|
||||
|
||||
const serverConfig = mcpServers[serverName]
|
||||
if (!serverConfig) {
|
||||
prompts.log.error(`MCP server not found: ${serverName}`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
if (!isMcpRemote(serverConfig)) {
|
||||
prompts.log.error(`MCP server ${serverName} is not a remote server`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
if (serverConfig.oauth === false) {
|
||||
prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
prompts.log.info(`Server: ${serverName}`)
|
||||
prompts.log.info(`URL: ${serverConfig.url}`)
|
||||
|
||||
// Check stored auth status — services already in hand, run inline.
|
||||
const { authStatus, entry } = await Effect.runPromise(
|
||||
Effect.all({
|
||||
authStatus: mcp.getAuthStatus(serverName),
|
||||
entry: auth.get(serverName),
|
||||
}),
|
||||
)
|
||||
prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
|
||||
|
||||
if (entry?.tokens) {
|
||||
prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
|
||||
if (entry.tokens.expiresAt) {
|
||||
const expiresDate = new Date(entry.tokens.expiresAt * 1000)
|
||||
const isExpired = entry.tokens.expiresAt < Date.now() / 1000
|
||||
prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`)
|
||||
}
|
||||
if (entry.tokens.refreshToken) {
|
||||
prompts.log.info(` Refresh token: present`)
|
||||
}
|
||||
}
|
||||
if (entry?.clientInfo) {
|
||||
prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`)
|
||||
if (entry.clientInfo.clientSecretExpiresAt) {
|
||||
const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000)
|
||||
prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`)
|
||||
}
|
||||
}
|
||||
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Testing connection...")
|
||||
|
||||
// Test basic HTTP connectivity first
|
||||
try {
|
||||
const response = await fetch(serverConfig.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...serverConfig.headers,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "opencode-debug", version: InstallationVersion },
|
||||
},
|
||||
id: 1,
|
||||
}),
|
||||
})
|
||||
|
||||
spinner.stop(`HTTP response: ${response.status} ${response.statusText}`)
|
||||
|
||||
// Check for WWW-Authenticate header
|
||||
const wwwAuth = response.headers.get("www-authenticate")
|
||||
if (wwwAuth) {
|
||||
prompts.log.info(`WWW-Authenticate: ${wwwAuth}`)
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
prompts.log.warn("Server returned 401 Unauthorized")
|
||||
|
||||
// Try to discover OAuth metadata
|
||||
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
|
||||
const authProvider = new McpOAuthProvider(
|
||||
serverName,
|
||||
serverConfig.url,
|
||||
{
|
||||
clientId: oauthConfig?.clientId,
|
||||
clientSecret: oauthConfig?.clientSecret,
|
||||
scope: oauthConfig?.scope,
|
||||
redirectUri: oauthConfig?.redirectUri,
|
||||
},
|
||||
{
|
||||
onRedirect: async () => {},
|
||||
},
|
||||
auth,
|
||||
)
|
||||
|
||||
prompts.log.info("Testing OAuth flow (without completing authorization)...")
|
||||
|
||||
// Try creating transport with auth provider to trigger discovery
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), {
|
||||
authProvider,
|
||||
requestInit: serverConfig.headers ? { headers: serverConfig.headers } : undefined,
|
||||
})
|
||||
|
||||
try {
|
||||
const client = new Client({
|
||||
name: "opencode-debug",
|
||||
version: InstallationVersion,
|
||||
})
|
||||
await client.connect(transport)
|
||||
prompts.log.success("Connection successful (already authenticated)")
|
||||
await client.close()
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
prompts.log.info(`OAuth flow triggered: ${error.message}`)
|
||||
|
||||
// Check if dynamic registration would be attempted
|
||||
const clientInfo = await authProvider.clientInformation()
|
||||
if (clientInfo) {
|
||||
prompts.log.info(`Client ID available: ${clientInfo.client_id}`)
|
||||
} else {
|
||||
prompts.log.info("No client ID - dynamic registration will be attempted")
|
||||
}
|
||||
} else {
|
||||
prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
} else if (response.status >= 200 && response.status < 300) {
|
||||
prompts.log.success("Server responded successfully (no auth required or already authenticated)")
|
||||
const body = await response.text()
|
||||
try {
|
||||
const json = JSON.parse(body)
|
||||
if (json.result?.serverInfo) {
|
||||
prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`)
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, ignore
|
||||
}
|
||||
} else {
|
||||
prompts.log.warn(`Unexpected status: ${response.status}`)
|
||||
const body = await response.text().catch(() => "")
|
||||
if (body) {
|
||||
prompts.log.info(`Response body: ${body.substring(0, 500)}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.stop("Connection failed", 1)
|
||||
prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
|
||||
prompts.outro("Debug complete")
|
||||
})
|
||||
}),
|
||||
})
|
||||
66
packages/opencode/src/cli/cmd/models.ts
Normal file
66
packages/opencode/src/cli/cmd/models.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { effectCmd, fail } from "../effect-cmd"
|
||||
import { UI } from "../ui"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
|
||||
export const ModelsCommand = effectCmd({
|
||||
command: "models [provider]",
|
||||
describe: "list all available models",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("provider", {
|
||||
describe: "provider ID to filter models by",
|
||||
type: "string",
|
||||
array: false,
|
||||
})
|
||||
.option("verbose", {
|
||||
describe: "use more verbose model output (includes metadata like costs)",
|
||||
type: "boolean",
|
||||
})
|
||||
.option("refresh", {
|
||||
describe: "refresh the models cache from models.dev",
|
||||
type: "boolean",
|
||||
}),
|
||||
handler: Effect.fn("Cli.models")(function* (args) {
|
||||
const { Provider } = yield* Effect.promise(() => import("@/provider/provider"))
|
||||
if (args.refresh) {
|
||||
yield* ModelsDev.Service.use((s) => s.refresh(true))
|
||||
UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL)
|
||||
}
|
||||
|
||||
const provider = yield* Provider.Service
|
||||
const providers = yield* provider.list()
|
||||
|
||||
const print = (providerID: ProviderV2.ID, verbose?: boolean) => {
|
||||
const p = providers[providerID]
|
||||
const sorted = Object.entries(p.models).sort(([a], [b]) => a.localeCompare(b))
|
||||
for (const [modelID, model] of sorted) {
|
||||
process.stdout.write(`${providerID}/${modelID}`)
|
||||
process.stdout.write(EOL)
|
||||
if (verbose) {
|
||||
process.stdout.write(JSON.stringify(model, null, 2))
|
||||
process.stdout.write(EOL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (args.provider) {
|
||||
const providerID = ProviderV2.ID.make(args.provider)
|
||||
if (!providers[providerID]) return yield* fail(`Provider not found: ${args.provider}`)
|
||||
print(providerID, args.verbose)
|
||||
return
|
||||
}
|
||||
|
||||
const ids = Object.keys(providers).sort((a, b) => {
|
||||
const aIsOpencode = a.startsWith("opencode")
|
||||
const bIsOpencode = b.startsWith("opencode")
|
||||
if (aIsOpencode && !bIsOpencode) return -1
|
||||
if (!aIsOpencode && bIsOpencode) return 1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
for (const providerID of ids) print(ProviderV2.ID.make(providerID), args.verbose)
|
||||
}),
|
||||
})
|
||||
230
packages/opencode/src/cli/cmd/plug.ts
Normal file
230
packages/opencode/src/cli/cmd/plug.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { intro, log, outro, spinner } from "@clack/prompts"
|
||||
import { Effect } from "effect"
|
||||
|
||||
import { ConfigPaths } from "@/config/paths"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install"
|
||||
import { resolvePluginTarget } from "../../plugin/shared"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
import { UI } from "../ui"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
|
||||
type Spin = {
|
||||
start: (msg: string) => void
|
||||
stop: (msg: string, code?: number) => void
|
||||
}
|
||||
|
||||
export type PlugDeps = {
|
||||
spinner: () => Spin
|
||||
log: {
|
||||
error: (msg: string) => void
|
||||
info: (msg: string) => void
|
||||
success: (msg: string) => void
|
||||
}
|
||||
resolve: (spec: string) => Promise<string>
|
||||
readText: (file: string) => Promise<string>
|
||||
write: (file: string, text: string) => Promise<void>
|
||||
exists: (file: string) => Promise<boolean>
|
||||
files: (dir: string, name: "opencode" | "tui") => string[]
|
||||
global: string
|
||||
}
|
||||
|
||||
export type PlugInput = {
|
||||
mod: string
|
||||
global?: boolean
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
export type PlugCtx = {
|
||||
vcs?: string
|
||||
worktree: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
const defaultPlugDeps: PlugDeps = {
|
||||
spinner: () => spinner(),
|
||||
log: {
|
||||
error: (msg) => log.error(msg),
|
||||
info: (msg) => log.info(msg),
|
||||
success: (msg) => log.success(msg),
|
||||
},
|
||||
resolve: (spec) => resolvePluginTarget(spec),
|
||||
readText: (file) => Filesystem.readText(file),
|
||||
write: async (file, text) => {
|
||||
await Filesystem.write(file, text)
|
||||
},
|
||||
exists: (file) => Filesystem.exists(file),
|
||||
files: (dir, name) => ConfigPaths.fileInDirectory(dir, name),
|
||||
global: Global.Path.config,
|
||||
}
|
||||
|
||||
function cause(err: unknown) {
|
||||
if (!err || typeof err !== "object") return
|
||||
if (!("cause" in err)) return
|
||||
return (err as { cause?: unknown }).cause
|
||||
}
|
||||
|
||||
export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps) {
|
||||
const mod = input.mod
|
||||
const force = Boolean(input.force)
|
||||
const global = Boolean(input.global)
|
||||
|
||||
return async (ctx: PlugCtx) => {
|
||||
const install = dep.spinner()
|
||||
install.start("Installing plugin package...")
|
||||
const target = await installPlugin(mod, dep)
|
||||
if (!target.ok) {
|
||||
install.stop("Install failed", 1)
|
||||
dep.log.error(`Could not install "${mod}"`)
|
||||
const hit = cause(target.error) ?? target.error
|
||||
if (hit instanceof Process.RunFailedError) {
|
||||
const lines = hit.stderr
|
||||
.toString()
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
const errs = lines.filter((line) => line.startsWith("error:")).map((line) => line.replace(/^error:\s*/, ""))
|
||||
const detail = errs[0] ?? lines.at(-1)
|
||||
if (detail) dep.log.error(detail)
|
||||
if (lines.some((line) => line.includes("No version matching"))) {
|
||||
dep.log.info("This package depends on a version that is not available in your npm registry.")
|
||||
dep.log.info("Check npm registry/auth settings and try again.")
|
||||
}
|
||||
}
|
||||
if (!(hit instanceof Process.RunFailedError)) {
|
||||
dep.log.error(errorMessage(hit))
|
||||
}
|
||||
return false
|
||||
}
|
||||
install.stop("Plugin package ready")
|
||||
|
||||
const inspect = dep.spinner()
|
||||
inspect.start("Reading plugin manifest...")
|
||||
const manifest = await readPluginManifest(target.target)
|
||||
if (!manifest.ok) {
|
||||
if (manifest.code === "manifest_read_failed") {
|
||||
inspect.stop("Manifest read failed", 1)
|
||||
dep.log.error(`Installed "${mod}" but failed to read ${manifest.file}`)
|
||||
dep.log.error(errorMessage(cause(manifest.error) ?? manifest.error))
|
||||
return false
|
||||
}
|
||||
|
||||
if (manifest.code === "manifest_no_targets") {
|
||||
inspect.stop("No plugin targets found", 1)
|
||||
dep.log.error(`"${mod}" does not expose plugin entrypoints in package.json`)
|
||||
dep.log.info(
|
||||
'Expected one of: exports["./tui"], exports["./server"], package.json main for server, or package.json["oc-themes"] for tui themes.',
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
inspect.stop("Manifest read failed", 1)
|
||||
return false
|
||||
}
|
||||
|
||||
inspect.stop(
|
||||
`Detected ${manifest.targets.map((item) => item.kind).join(" + ")} target${manifest.targets.length === 1 ? "" : "s"}`,
|
||||
)
|
||||
|
||||
const patch = dep.spinner()
|
||||
patch.start("Updating plugin config...")
|
||||
const out = await patchPluginConfig(
|
||||
{
|
||||
spec: mod,
|
||||
targets: manifest.targets,
|
||||
force,
|
||||
global,
|
||||
vcs: ctx.vcs,
|
||||
worktree: ctx.worktree,
|
||||
directory: ctx.directory,
|
||||
config: dep.global,
|
||||
},
|
||||
dep,
|
||||
)
|
||||
if (!out.ok) {
|
||||
if (out.code === "invalid_json") {
|
||||
patch.stop(`Failed updating ${out.kind} config`, 1)
|
||||
dep.log.error(`Invalid JSON in ${out.file} (${out.parse} at line ${out.line}, column ${out.col})`)
|
||||
dep.log.info("Fix the config file and run the command again.")
|
||||
return false
|
||||
}
|
||||
|
||||
patch.stop("Failed updating plugin config", 1)
|
||||
dep.log.error(errorMessage(out.error))
|
||||
return false
|
||||
}
|
||||
patch.stop("Plugin config updated")
|
||||
for (const item of out.items) {
|
||||
if (item.mode === "noop") {
|
||||
dep.log.info(`Already configured in ${item.file}`)
|
||||
continue
|
||||
}
|
||||
if (item.mode === "replace") {
|
||||
dep.log.info(`Replaced in ${item.file}`)
|
||||
continue
|
||||
}
|
||||
dep.log.info(`Added to ${item.file}`)
|
||||
}
|
||||
|
||||
dep.log.success(`Installed ${mod}`)
|
||||
dep.log.info(global ? `Scope: global (${out.dir})` : `Scope: local (${out.dir})`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export const PluginCommand = effectCmd({
|
||||
command: "plugin <module>",
|
||||
aliases: ["plug"],
|
||||
describe: "install plugin and update config",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("module", {
|
||||
type: "string",
|
||||
describe: "npm module name",
|
||||
})
|
||||
.option("global", {
|
||||
alias: ["g"],
|
||||
type: "boolean",
|
||||
default: false,
|
||||
describe: "install in global config",
|
||||
})
|
||||
.option("force", {
|
||||
alias: ["f"],
|
||||
type: "boolean",
|
||||
default: false,
|
||||
describe: "replace existing plugin version",
|
||||
}),
|
||||
handler: Effect.fn("Cli.plug")(function* (args) {
|
||||
const mod = String(args.module ?? "").trim()
|
||||
if (!mod) {
|
||||
UI.error("module is required")
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
UI.empty()
|
||||
intro(`Install plugin ${mod}`)
|
||||
|
||||
const run = createPlugTask({
|
||||
mod,
|
||||
global: Boolean(args.global),
|
||||
force: Boolean(args.force),
|
||||
})
|
||||
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const ok = yield* Effect.promise(() =>
|
||||
run({
|
||||
vcs: ctx.project.vcs,
|
||||
worktree: ctx.worktree,
|
||||
directory: ctx.directory,
|
||||
}),
|
||||
)
|
||||
|
||||
outro("Done")
|
||||
if (!ok) process.exitCode = 1
|
||||
}),
|
||||
})
|
||||
115
packages/opencode/src/cli/cmd/pr.ts
Normal file
115
packages/opencode/src/cli/cmd/pr.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Effect } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { effectCmd, fail } from "../effect-cmd"
|
||||
import { Git } from "@/git"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { Process } from "@/util/process"
|
||||
|
||||
export const PrCommand = effectCmd({
|
||||
command: "pr <number>",
|
||||
describe: "fetch and checkout a GitHub PR branch, then run opencode",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("number", {
|
||||
type: "number",
|
||||
describe: "PR number to checkout",
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.pr")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return yield* fail("Could not load instance context")
|
||||
if (ctx.project.vcs !== "git") {
|
||||
return yield* fail("Could not find git repository. Please run this command from a git repository.")
|
||||
}
|
||||
|
||||
const git = yield* Git.Service
|
||||
const worktree = ctx.worktree
|
||||
|
||||
const prNumber = args.number
|
||||
const localBranchName = `pr/${prNumber}`
|
||||
UI.println(`Fetching and checking out PR #${prNumber}...`)
|
||||
|
||||
const checkout = yield* Effect.promise(() =>
|
||||
Process.run(["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"], { nothrow: true }),
|
||||
)
|
||||
if (checkout.code !== 0) {
|
||||
return yield* fail(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`)
|
||||
}
|
||||
|
||||
const prInfoResult = yield* Effect.promise(() =>
|
||||
Process.text(
|
||||
[
|
||||
"gh",
|
||||
"pr",
|
||||
"view",
|
||||
`${prNumber}`,
|
||||
"--json",
|
||||
"headRepository,headRepositoryOwner,isCrossRepository,headRefName,body",
|
||||
],
|
||||
{ nothrow: true },
|
||||
),
|
||||
)
|
||||
|
||||
let sessionId: string | undefined
|
||||
|
||||
if (prInfoResult.code === 0 && prInfoResult.text.trim()) {
|
||||
const prInfo = JSON.parse(prInfoResult.text)
|
||||
|
||||
if (prInfo?.isCrossRepository && prInfo.headRepository && prInfo.headRepositoryOwner) {
|
||||
const forkOwner = prInfo.headRepositoryOwner.login
|
||||
const forkName = prInfo.headRepository.name
|
||||
const remoteName = forkOwner
|
||||
|
||||
const remotes = (yield* git.run(["remote"], { cwd: worktree })).text().trim()
|
||||
if (!remotes.split("\n").includes(remoteName)) {
|
||||
yield* git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
|
||||
cwd: worktree,
|
||||
})
|
||||
UI.println(`Added fork remote: ${remoteName}`)
|
||||
}
|
||||
|
||||
yield* git.run(["branch", `--set-upstream-to=${remoteName}/${prInfo.headRefName}`, localBranchName], {
|
||||
cwd: worktree,
|
||||
})
|
||||
}
|
||||
|
||||
if (prInfo?.body) {
|
||||
const sessionMatch = prInfo.body.match(/https:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/)
|
||||
if (sessionMatch) {
|
||||
const sessionUrl = sessionMatch[0]
|
||||
UI.println(`Found aircoding session: ${sessionUrl}`)
|
||||
UI.println(`Importing session...`)
|
||||
|
||||
const importResult = yield* Effect.promise(() =>
|
||||
Process.text(["opencode", "import", sessionUrl], { nothrow: true }),
|
||||
)
|
||||
if (importResult.code === 0) {
|
||||
const sessionIdMatch = importResult.text.trim().match(/Imported session: ([a-zA-Z0-9_-]+)/)
|
||||
if (sessionIdMatch) {
|
||||
sessionId = sessionIdMatch[1]
|
||||
UI.println(`Session imported: ${sessionId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UI.println(`Successfully checked out PR #${prNumber} as branch '${localBranchName}'`)
|
||||
UI.println()
|
||||
UI.println("Starting aircoding...")
|
||||
UI.println()
|
||||
|
||||
const opencodeArgs = sessionId ? ["-s", sessionId] : []
|
||||
const code = yield* Effect.promise(
|
||||
() =>
|
||||
Process.spawn(["opencode", ...opencodeArgs], {
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
cwd: process.cwd(),
|
||||
}).exited,
|
||||
)
|
||||
// Match legacy throw semantics — propagate as a defect so the top-level
|
||||
// index.ts catch handles it identically (exit 1, "Unexpected error" banner).
|
||||
if (code !== 0) return yield* Effect.die(new Error(`aircoding exited with code ${code}`))
|
||||
}),
|
||||
})
|
||||
1
packages/opencode/src/cli/cmd/prompt-display.ts
Normal file
1
packages/opencode/src/cli/cmd/prompt-display.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "@opencode-ai/tui/prompt/display"
|
||||
553
packages/opencode/src/cli/cmd/providers.ts
Normal file
553
packages/opencode/src/cli/cmd/providers.ts
Normal file
@@ -0,0 +1,553 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { Auth } from "../../auth"
|
||||
import { cmd } from "./cmd"
|
||||
import { CliError, effectCmd, fail } from "../effect-cmd"
|
||||
import { UI } from "../ui"
|
||||
import * as Prompt from "../effect/prompt"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
|
||||
import { map, pipe, sortBy, values } from "remeda"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { Config } from "@/config/config"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Plugin } from "../../plugin"
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import { Process } from "@/util/process"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { Effect, Option } from "effect"
|
||||
|
||||
type PluginAuth = NonNullable<Hooks["auth"]>
|
||||
|
||||
const promptValue = <Value>(value: Option.Option<Value>) => {
|
||||
if (Option.isNone(value)) return Effect.die(new UI.CancelledError())
|
||||
return Effect.succeed(value.value)
|
||||
}
|
||||
|
||||
const put = Effect.fn("Cli.providers.put")(function* (key: string, info: Auth.Info) {
|
||||
const auth = yield* Auth.Service
|
||||
yield* Effect.orDie(auth.set(key, info))
|
||||
})
|
||||
|
||||
const cliTry = <Value>(message: string, fn: () => PromiseLike<Value>) =>
|
||||
Effect.tryPromise({
|
||||
try: fn,
|
||||
catch: (error) => new CliError({ message: message + errorMessage(error) }),
|
||||
})
|
||||
|
||||
const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
|
||||
plugin: { auth: PluginAuth },
|
||||
provider: string,
|
||||
methodName?: string,
|
||||
) {
|
||||
const index = yield* Effect.gen(function* () {
|
||||
if (!methodName) {
|
||||
if (plugin.auth.methods.length <= 1) return 0
|
||||
return yield* promptValue(
|
||||
yield* Prompt.select({
|
||||
message: "Login method",
|
||||
options: plugin.auth.methods.map((x, index) => ({
|
||||
label: x.label,
|
||||
value: index,
|
||||
})),
|
||||
}),
|
||||
)
|
||||
}
|
||||
const match = plugin.auth.methods.findIndex((x) => x.label.toLowerCase() === methodName.toLowerCase())
|
||||
if (match === -1) {
|
||||
return yield* fail(
|
||||
`Unknown method "${methodName}" for ${provider}. Available: ${plugin.auth.methods.map((x) => x.label).join(", ")}`,
|
||||
)
|
||||
}
|
||||
return match
|
||||
})
|
||||
const method = plugin.auth.methods[index]
|
||||
|
||||
yield* Effect.sleep("10 millis")
|
||||
const inputs: Record<string, string> = {}
|
||||
if (method.prompts) {
|
||||
for (const prompt of method.prompts) {
|
||||
if (prompt.when) {
|
||||
const value = inputs[prompt.when.key]
|
||||
if (value === undefined) continue
|
||||
const matches = prompt.when.op === "eq" ? value === prompt.when.value : value !== prompt.when.value
|
||||
if (!matches) continue
|
||||
}
|
||||
if (prompt.condition && !prompt.condition(inputs)) continue
|
||||
if (prompt.type === "select") {
|
||||
const value = yield* Prompt.select({
|
||||
message: prompt.message,
|
||||
options: prompt.options,
|
||||
})
|
||||
inputs[prompt.key] = yield* promptValue(value)
|
||||
continue
|
||||
}
|
||||
const value = yield* Prompt.text({
|
||||
message: prompt.message,
|
||||
placeholder: prompt.placeholder,
|
||||
validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined,
|
||||
})
|
||||
inputs[prompt.key] = yield* promptValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
if (method.type === "oauth") {
|
||||
const authorize = yield* cliTry("Failed to authorize: ", () => method.authorize(inputs))
|
||||
|
||||
if (authorize.url) {
|
||||
yield* Prompt.log.info("Go to: " + authorize.url)
|
||||
}
|
||||
|
||||
if (authorize.method === "auto") {
|
||||
if (authorize.instructions) {
|
||||
yield* Prompt.log.info(authorize.instructions)
|
||||
}
|
||||
const spinner = Prompt.spinner()
|
||||
yield* spinner.start("Waiting for authorization...")
|
||||
const result = yield* cliTry("Failed to authorize: ", () => authorize.callback())
|
||||
if (result.type === "failed") {
|
||||
yield* spinner.stop("Failed to authorize", 1)
|
||||
}
|
||||
if (result.type === "success") {
|
||||
const saveProvider = result.provider ?? provider
|
||||
if ("refresh" in result) {
|
||||
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
|
||||
yield* put(saveProvider, {
|
||||
type: "oauth",
|
||||
refresh,
|
||||
access,
|
||||
expires,
|
||||
...extraFields,
|
||||
})
|
||||
}
|
||||
if ("key" in result) {
|
||||
yield* put(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
...(result.metadata ? { metadata: result.metadata } : {}),
|
||||
})
|
||||
}
|
||||
yield* spinner.stop("Login successful")
|
||||
}
|
||||
}
|
||||
|
||||
if (authorize.method === "code") {
|
||||
const code = yield* Prompt.text({
|
||||
message: "Paste the authorization code here: ",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
const authorizationCode = yield* promptValue(code)
|
||||
const result = yield* cliTry("Failed to authorize: ", () => authorize.callback(authorizationCode))
|
||||
if (result.type === "failed") {
|
||||
yield* Prompt.log.error("Failed to authorize")
|
||||
}
|
||||
if (result.type === "success") {
|
||||
const saveProvider = result.provider ?? provider
|
||||
if ("refresh" in result) {
|
||||
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
|
||||
yield* put(saveProvider, {
|
||||
type: "oauth",
|
||||
refresh,
|
||||
access,
|
||||
expires,
|
||||
...extraFields,
|
||||
})
|
||||
}
|
||||
if ("key" in result) {
|
||||
yield* put(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
...(result.metadata ? { metadata: result.metadata } : {}),
|
||||
})
|
||||
}
|
||||
yield* Prompt.log.success("Login successful")
|
||||
}
|
||||
}
|
||||
|
||||
yield* Prompt.outro("Done")
|
||||
return true
|
||||
}
|
||||
|
||||
if (method.type === "api") {
|
||||
const key = yield* Prompt.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
const apiKey = yield* promptValue(key)
|
||||
|
||||
const metadata = Object.keys(inputs).length ? { metadata: inputs } : {}
|
||||
const authorizeApi = method.authorize
|
||||
if (!authorizeApi) {
|
||||
yield* put(provider, {
|
||||
type: "api",
|
||||
key: apiKey,
|
||||
...metadata,
|
||||
})
|
||||
yield* Prompt.outro("Done")
|
||||
return true
|
||||
}
|
||||
|
||||
const result = yield* cliTry("Failed to authorize: ", () => authorizeApi(inputs))
|
||||
if (result.type === "failed") {
|
||||
yield* Prompt.log.error("Failed to authorize")
|
||||
}
|
||||
if (result.type === "success") {
|
||||
const saveProvider = result.provider ?? provider
|
||||
const merged = { ...(metadata.metadata ?? {}), ...(result.metadata ?? {}) }
|
||||
yield* put(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key ?? apiKey,
|
||||
...(Object.keys(merged).length ? { metadata: merged } : {}),
|
||||
})
|
||||
yield* Prompt.log.success("Login successful")
|
||||
}
|
||||
yield* Prompt.outro("Done")
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
export function resolvePluginProviders(input: {
|
||||
hooks: Hooks[]
|
||||
existingProviders: Record<string, unknown>
|
||||
disabled: Set<string>
|
||||
enabled?: Set<string>
|
||||
providerNames: Record<string, string | undefined>
|
||||
}): Array<{ id: string; name: string }> {
|
||||
const seen = new Set<string>()
|
||||
const result: Array<{ id: string; name: string }> = []
|
||||
|
||||
for (const hook of input.hooks) {
|
||||
if (!hook.auth) continue
|
||||
const id = hook.auth.provider
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
if (Object.hasOwn(input.existingProviders, id)) continue
|
||||
if (input.disabled.has(id)) continue
|
||||
if (input.enabled && !input.enabled.has(id)) continue
|
||||
result.push({
|
||||
id,
|
||||
name: input.providerNames[id] ?? id,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const ProvidersCommand = cmd({
|
||||
command: "providers",
|
||||
aliases: ["auth"],
|
||||
describe: "manage AI providers and credentials",
|
||||
builder: (yargs) =>
|
||||
yargs.command(ProvidersListCommand).command(ProvidersLoginCommand).command(ProvidersLogoutCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
export const ProvidersListCommand = effectCmd({
|
||||
command: "list",
|
||||
aliases: ["ls"],
|
||||
describe: "list providers and credentials",
|
||||
// Lists global credentials + provider env vars; no project instance needed.
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.providers.list")(function* (_args) {
|
||||
const authSvc = yield* Auth.Service
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
|
||||
UI.empty()
|
||||
const authPath = path.join(Global.Path.data, "auth.json")
|
||||
const homedir = os.homedir()
|
||||
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
|
||||
yield* Prompt.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
|
||||
const results = Object.entries(yield* Effect.orDie(authSvc.all()))
|
||||
const database = yield* modelsDev.get()
|
||||
|
||||
for (const [providerID, result] of results) {
|
||||
const name = database[providerID]?.name || providerID
|
||||
yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
|
||||
}
|
||||
|
||||
yield* Prompt.outro(`${results.length} credentials`)
|
||||
|
||||
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
|
||||
|
||||
for (const [providerID, provider] of Object.entries(database)) {
|
||||
for (const envVar of provider.env) {
|
||||
if (process.env[envVar]) {
|
||||
activeEnvVars.push({
|
||||
provider: provider.name || providerID,
|
||||
envVar,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeEnvVars.length > 0) {
|
||||
UI.empty()
|
||||
yield* Prompt.intro("Environment")
|
||||
|
||||
for (const { provider, envVar } of activeEnvVars) {
|
||||
yield* Prompt.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
|
||||
}
|
||||
|
||||
yield* Prompt.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export const ProvidersLoginCommand = effectCmd({
|
||||
command: "login [url]",
|
||||
describe: "log in to a provider",
|
||||
// URL login skips instance bootstrap, which would load remote config with the stale token and crash before re-auth.
|
||||
instance: (args) => !args.url,
|
||||
builder: (yargs: Argv) =>
|
||||
yargs
|
||||
.positional("url", {
|
||||
describe: "opencode auth provider",
|
||||
type: "string",
|
||||
})
|
||||
.option("provider", {
|
||||
alias: ["p"],
|
||||
describe: "provider id or name to log in to (skips provider selection)",
|
||||
type: "string",
|
||||
})
|
||||
.option("method", {
|
||||
alias: ["m"],
|
||||
describe: "login method label (skips method selection)",
|
||||
type: "string",
|
||||
}),
|
||||
handler: Effect.fn("Cli.providers.login")(function* (args) {
|
||||
const authSvc = yield* Auth.Service
|
||||
|
||||
UI.empty()
|
||||
yield* Prompt.intro("Add credential")
|
||||
if (args.url) {
|
||||
const url = args.url.replace(/\/+$/, "")
|
||||
const wellknown = (yield* cliTry(`Failed to load auth provider metadata from ${url}: `, () =>
|
||||
fetch(`${url}/.well-known/opencode`).then((x) => x.json()),
|
||||
)) as {
|
||||
auth: { command: string[]; env: string }
|
||||
}
|
||||
yield* Prompt.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||
const abort = new AbortController()
|
||||
const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit", abort: abort.signal })
|
||||
if (!proc.stdout) {
|
||||
yield* Prompt.log.error("Failed")
|
||||
yield* Prompt.outro("Done")
|
||||
return
|
||||
}
|
||||
const [exit, token] = yield* cliTry("Failed to run auth provider command: ", () =>
|
||||
Promise.all([proc.exited, text(proc.stdout!)]),
|
||||
).pipe(Effect.ensuring(Effect.sync(() => abort.abort())))
|
||||
if (exit !== 0) {
|
||||
yield* Prompt.log.error("Failed")
|
||||
yield* Prompt.outro("Done")
|
||||
return
|
||||
}
|
||||
yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() }))
|
||||
yield* Prompt.log.success("Logged into " + url)
|
||||
yield* Prompt.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
const cfgSvc = yield* Config.Service
|
||||
const pluginSvc = yield* Plugin.Service
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
yield* Effect.ignore(modelsDev.refresh(true))
|
||||
|
||||
const config = yield* cfgSvc.get()
|
||||
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||
|
||||
const allProviders = yield* modelsDev.get()
|
||||
const providers: Record<string, (typeof allProviders)[string]> = {}
|
||||
for (const [key, value] of Object.entries(allProviders)) {
|
||||
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) providers[key] = value
|
||||
}
|
||||
const hooks = yield* pluginSvc.list()
|
||||
|
||||
const priority: Record<string, number> = {
|
||||
opencode: 0,
|
||||
openai: 1,
|
||||
"github-copilot": 2,
|
||||
google: 3,
|
||||
anthropic: 4,
|
||||
openrouter: 5,
|
||||
vercel: 6,
|
||||
}
|
||||
const pluginProviders = resolvePluginProviders({
|
||||
hooks,
|
||||
existingProviders: providers,
|
||||
disabled,
|
||||
enabled,
|
||||
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
|
||||
})
|
||||
const options = [
|
||||
...pipe(
|
||||
providers,
|
||||
values(),
|
||||
sortBy(
|
||||
(x) => priority[x.id] ?? 99,
|
||||
(x) => x.name ?? x.id,
|
||||
),
|
||||
map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: {
|
||||
opencode: "recommended",
|
||||
openai: "ChatGPT Plus/Pro or API key",
|
||||
}[x.id],
|
||||
})),
|
||||
),
|
||||
...pluginProviders.map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: "plugin",
|
||||
})),
|
||||
]
|
||||
|
||||
let provider: string
|
||||
if (args.provider) {
|
||||
const input = args.provider
|
||||
const byID = options.find((x) => x.value === input)
|
||||
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
|
||||
const match = byID ?? byName
|
||||
if (!match) {
|
||||
return yield* fail(`Unknown provider "${input}"`)
|
||||
}
|
||||
provider = match.value
|
||||
} else {
|
||||
provider = yield* promptValue(
|
||||
yield* Prompt.autocomplete({
|
||||
message: "Select provider",
|
||||
maxItems: 8,
|
||||
options: [...options, { value: "other", label: "Other" }],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
|
||||
if (plugin && plugin.auth) {
|
||||
const handled = yield* handlePluginAuth({ auth: plugin.auth! }, provider, args.method)
|
||||
if (handled) return
|
||||
}
|
||||
|
||||
if (provider === "other") {
|
||||
provider = (yield* promptValue(
|
||||
yield* Prompt.text({
|
||||
message: "Enter provider id",
|
||||
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
|
||||
}),
|
||||
)).replace(/^@ai-sdk\//, "")
|
||||
|
||||
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
|
||||
if (customPlugin && customPlugin.auth) {
|
||||
const handled = yield* handlePluginAuth({ auth: customPlugin.auth! }, provider, args.method)
|
||||
if (handled) return
|
||||
}
|
||||
|
||||
yield* Prompt.log.warn(
|
||||
`This only stores a credential for ${provider} - you will need configure it in config, check the docs for examples.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === "amazon-bedrock") {
|
||||
yield* Prompt.log.info(
|
||||
"Amazon Bedrock authentication priority:\n" +
|
||||
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
|
||||
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
|
||||
"Configure via opencode.json options (profile, region, endpoint) or\n" +
|
||||
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === "opencode") {
|
||||
yield* Prompt.log.info("Create an api key at https://opencode.ai/auth")
|
||||
}
|
||||
|
||||
if (provider === "vercel") {
|
||||
yield* Prompt.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
|
||||
}
|
||||
|
||||
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
|
||||
yield* Prompt.log.info(
|
||||
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === "snowflake-cortex") {
|
||||
const account = yield* promptValue(
|
||||
yield* Prompt.text({
|
||||
message: "Snowflake Account Identifier",
|
||||
placeholder: "xy12345.us-east-1",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
}),
|
||||
)
|
||||
const pat = yield* promptValue(
|
||||
yield* Prompt.password({
|
||||
message: "Programmatic Access Token (PAT)",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
}),
|
||||
)
|
||||
yield* Effect.orDie(authSvc.set(provider, { type: "api", key: pat, metadata: { account } }))
|
||||
yield* Prompt.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
const key = yield* Prompt.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
const apiKey = yield* promptValue(key)
|
||||
yield* Effect.orDie(authSvc.set(provider, { type: "api", key: apiKey }))
|
||||
|
||||
yield* Prompt.outro("Done")
|
||||
}),
|
||||
})
|
||||
|
||||
export const ProvidersLogoutCommand = effectCmd({
|
||||
command: "logout [provider]",
|
||||
describe: "log out from a configured provider",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("provider", {
|
||||
describe: "provider id or name to log out from",
|
||||
type: "string",
|
||||
}),
|
||||
// Removes a global auth credential; no project instance needed.
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.providers.logout")(function* (args) {
|
||||
const authSvc = yield* Auth.Service
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
|
||||
UI.empty()
|
||||
const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all()))
|
||||
yield* Prompt.intro("Remove credential")
|
||||
if (credentials.length === 0) {
|
||||
yield* Prompt.log.error("No credentials found")
|
||||
return
|
||||
}
|
||||
const database = yield* modelsDev.get()
|
||||
const options = credentials.map(([key, value]) => ({
|
||||
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
|
||||
value: key,
|
||||
}))
|
||||
const provider = args.provider
|
||||
? options.find(
|
||||
(option) =>
|
||||
option.value === args.provider ||
|
||||
database[option.value]?.name?.toLowerCase() === args.provider?.toLowerCase(),
|
||||
)?.value
|
||||
: yield* promptValue(
|
||||
yield* Prompt.autocomplete({
|
||||
message: "Select provider",
|
||||
maxItems: 8,
|
||||
options,
|
||||
}),
|
||||
)
|
||||
if (!provider) return yield* fail(`Unknown configured provider "${args.provider}"`)
|
||||
yield* Effect.orDie(authSvc.remove(provider))
|
||||
yield* Prompt.outro("Logout successful")
|
||||
}),
|
||||
})
|
||||
888
packages/opencode/src/cli/cmd/run.ts
Normal file
888
packages/opencode/src/cli/cmd/run.ts
Normal file
@@ -0,0 +1,888 @@
|
||||
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||
// CLI entry point for `opencode run`.
|
||||
//
|
||||
// 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 (`--interactive`): boots the split-footer direct mode
|
||||
// with an in-process server (no external HTTP).
|
||||
// 3. Interactive attach (`--interactive --attach`): connects to a running
|
||||
// opencode server and runs interactive mode against it.
|
||||
//
|
||||
// Also supports `--command` for slash-command execution, `--format json` for
|
||||
// raw event streaming, `--continue` / `--session` for session resumption,
|
||||
// and `--fork` for forking before continuing.
|
||||
import type { Argv } from "yargs"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Effect } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { EOL } from "os"
|
||||
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"
|
||||
|
||||
type ModelInput = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
|
||||
|
||||
function pick(value: string | undefined): ModelInput | undefined {
|
||||
if (!value) return undefined
|
||||
const [providerID, ...rest] = value.split("/")
|
||||
return {
|
||||
providerID,
|
||||
modelID: rest.join("/"),
|
||||
} as ModelInput
|
||||
}
|
||||
|
||||
function resolveRunInput(value?: string, piped?: string): string | undefined {
|
||||
if (!value) {
|
||||
return piped
|
||||
}
|
||||
|
||||
if (!piped) {
|
||||
return value
|
||||
}
|
||||
|
||||
return value + "\n" + piped
|
||||
}
|
||||
|
||||
type FilePart = {
|
||||
type: "file"
|
||||
url: string
|
||||
filename: string
|
||||
mime: string
|
||||
}
|
||||
|
||||
type Inline = {
|
||||
icon: string
|
||||
title: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
type SessionInfo = {
|
||||
id: string
|
||||
title?: string
|
||||
directory?: string
|
||||
}
|
||||
|
||||
function inline(info: Inline) {
|
||||
const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : ""
|
||||
UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix)
|
||||
}
|
||||
|
||||
function block(info: Inline, output?: string) {
|
||||
UI.empty()
|
||||
inline(info)
|
||||
if (!output?.trim()) return
|
||||
UI.println(output)
|
||||
UI.empty()
|
||||
}
|
||||
|
||||
function formatRunError(error: unknown) {
|
||||
return FormatError(error) ?? FormatUnknownError(error)
|
||||
}
|
||||
|
||||
async function tool(part: ToolPart) {
|
||||
try {
|
||||
const { toolInlineInfo } = await import("./run/tool")
|
||||
const next = toolInlineInfo(part)
|
||||
if (next.mode === "block") {
|
||||
block(next, next.body)
|
||||
return
|
||||
}
|
||||
|
||||
inline(next)
|
||||
} catch {
|
||||
inline({
|
||||
icon: "\u2699",
|
||||
title: part.tool,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function toolError(part: ToolPart) {
|
||||
try {
|
||||
const { toolInlineInfo } = await import("./run/tool")
|
||||
const next = toolInlineInfo(part)
|
||||
inline({
|
||||
icon: "✗",
|
||||
title: `${next.title} failed`,
|
||||
...(next.description && { description: next.description }),
|
||||
})
|
||||
return
|
||||
} catch {
|
||||
inline({
|
||||
icon: "✗",
|
||||
title: `${part.tool} failed`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const RunCommand = effectCmd({
|
||||
command: "run [message..]",
|
||||
describe: "run aircoding with a message",
|
||||
// --attach connects to a remote server (no local instance needed); the
|
||||
// default path runs an in-process server and needs the project instance.
|
||||
instance: (args) => !args.attach,
|
||||
// For --dir without --attach, load instance for the resolved target dir.
|
||||
// The handler also chdirs (preserving the legacy order: chdir → file resolution).
|
||||
directory: (args) => (args.dir && !args.attach ? path.resolve(process.cwd(), args.dir) : process.cwd()),
|
||||
builder: (yargs: Argv) =>
|
||||
yargs
|
||||
.positional("message", {
|
||||
describe: "message to send",
|
||||
type: "string",
|
||||
array: true,
|
||||
default: [],
|
||||
})
|
||||
.option("command", {
|
||||
describe: "the command to run, use message for args",
|
||||
type: "string",
|
||||
})
|
||||
.option("continue", {
|
||||
alias: ["c"],
|
||||
describe: "continue the last session",
|
||||
type: "boolean",
|
||||
})
|
||||
.option("session", {
|
||||
alias: ["s"],
|
||||
describe: "session id to continue",
|
||||
type: "string",
|
||||
})
|
||||
.option("fork", {
|
||||
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"],
|
||||
describe: "model to use in the format of provider/model",
|
||||
})
|
||||
.option("agent", {
|
||||
type: "string",
|
||||
describe: "agent to use",
|
||||
})
|
||||
.option("format", {
|
||||
type: "string",
|
||||
choices: ["default", "json"],
|
||||
default: "default",
|
||||
describe: "format: default (formatted) or json (raw JSON events)",
|
||||
})
|
||||
.option("file", {
|
||||
alias: ["f"],
|
||||
type: "string",
|
||||
array: true,
|
||||
describe: "file(s) to attach to message",
|
||||
})
|
||||
.option("title", {
|
||||
type: "string",
|
||||
describe: "title for the session (uses truncated prompt if no value provided)",
|
||||
})
|
||||
.option("attach", {
|
||||
type: "string",
|
||||
describe: "attach to a running aircoding server (e.g., http://localhost:4096)",
|
||||
})
|
||||
.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 'aircoding')",
|
||||
})
|
||||
.option("dir", {
|
||||
type: "string",
|
||||
describe: "directory to run in, path on remote server if attaching",
|
||||
})
|
||||
.option("port", {
|
||||
type: "number",
|
||||
describe: "port for the local server (defaults to random port if no value provided)",
|
||||
})
|
||||
.option("variant", {
|
||||
type: "string",
|
||||
describe: "model variant (provider-specific reasoning effort, e.g., high, max, minimal)",
|
||||
})
|
||||
.option("thinking", {
|
||||
type: "boolean",
|
||||
describe: "show thinking blocks",
|
||||
})
|
||||
.option("replay", {
|
||||
type: "boolean",
|
||||
default: true,
|
||||
describe: "replay interactive session history on resume and after resize (use --no-replay to disable)",
|
||||
})
|
||||
.option("replay-limit", {
|
||||
type: "number",
|
||||
describe: "cap visible interactive replay to the newest N messages",
|
||||
})
|
||||
.option("interactive", {
|
||||
alias: ["i"],
|
||||
type: "boolean",
|
||||
describe: "run in direct interactive split-footer mode",
|
||||
default: false,
|
||||
})
|
||||
.option("dangerously-skip-permissions", {
|
||||
type: "boolean",
|
||||
describe: "auto-approve permissions that are not explicitly denied (dangerous!)",
|
||||
default: false,
|
||||
})
|
||||
.option("demo", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
describe: "enable direct interactive demo slash commands; pass one as the message to run it immediately",
|
||||
}),
|
||||
handler: Effect.fn("Cli.run")(function* (args) {
|
||||
const { Agent } = yield* Effect.promise(() => import("@/agent/agent"))
|
||||
const { RuntimeFlags } = yield* Effect.promise(() => import("@/effect/runtime-flags"))
|
||||
const { InstanceRef } = yield* Effect.promise(() => import("@/effect/instance-ref"))
|
||||
const { ServerAuth } = yield* Effect.promise(() => import("@/server/auth"))
|
||||
const agentSvc = yield* Agent.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const localInstance = yield* InstanceRef
|
||||
yield* Effect.promise(async () => {
|
||||
const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
|
||||
const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false)
|
||||
const die = (message: string): never => {
|
||||
UI.error(message)
|
||||
process.exit(1)
|
||||
}
|
||||
const dieInteractive = (error: unknown): never => {
|
||||
if (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR) {
|
||||
die(error.message)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
let message = [...args.message, ...(args["--"] || [])]
|
||||
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
|
||||
.join(" ")
|
||||
|
||||
if (args.interactive && args.command) {
|
||||
die("--interactive cannot be used with --command")
|
||||
}
|
||||
|
||||
if (args.demo && !args.interactive) {
|
||||
die("--demo requires --interactive")
|
||||
}
|
||||
|
||||
if (args.interactive && args.format === "json") {
|
||||
die("--interactive cannot be used with --format json")
|
||||
}
|
||||
|
||||
if (args["replay-limit"] !== undefined && !args.interactive) {
|
||||
die("--replay-limit requires --interactive")
|
||||
}
|
||||
|
||||
if (
|
||||
args["replay-limit"] !== undefined &&
|
||||
(!Number.isInteger(args["replay-limit"]) || args["replay-limit"] <= 0)
|
||||
) {
|
||||
die("--replay-limit must be a positive integer")
|
||||
}
|
||||
|
||||
if (args.interactive && !process.stdout.isTTY) {
|
||||
die("--interactive requires a TTY stdout")
|
||||
}
|
||||
|
||||
if (args.interactive) {
|
||||
try {
|
||||
resolveInteractiveStdin().cleanup?.()
|
||||
} catch (error) {
|
||||
dieInteractive(error)
|
||||
}
|
||||
}
|
||||
|
||||
const replay = args.replay || args["replay-limit"] !== undefined
|
||||
|
||||
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
|
||||
const directory = (() => {
|
||||
if (!args.dir) return args.attach ? undefined : root
|
||||
if (args.attach) return args.dir
|
||||
|
||||
try {
|
||||
process.chdir(path.isAbsolute(args.dir) ? args.dir : path.join(root, args.dir))
|
||||
return process.cwd()
|
||||
} catch {
|
||||
UI.error("Failed to change directory to " + args.dir)
|
||||
process.exit(1)
|
||||
}
|
||||
})()
|
||||
const attachHeaders = args.attach
|
||||
? ServerAuth.headers({ password: args.password, username: args.username })
|
||||
: undefined
|
||||
const attachSDK = (dir?: string) => {
|
||||
return createOpencodeClient({
|
||||
baseUrl: args.attach!,
|
||||
directory: dir,
|
||||
headers: attachHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
const files: FilePart[] = []
|
||||
if (args.file) {
|
||||
const list = Array.isArray(args.file) ? args.file : [args.file]
|
||||
|
||||
for (const filePath of list) {
|
||||
const resolvedPath = path.resolve(args.attach ? root : (directory ?? root), filePath)
|
||||
if (!(await Filesystem.exists(resolvedPath))) {
|
||||
UI.error(`File not found: ${filePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain"
|
||||
|
||||
files.push({
|
||||
type: "file",
|
||||
url: pathToFileURL(resolvedPath).href,
|
||||
filename: path.basename(resolvedPath),
|
||||
mime,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text()
|
||||
message = resolveRunInput(message, piped) ?? ""
|
||||
const initialInput = resolveRunInput(rawMessage, piped)
|
||||
|
||||
if (message.trim().length === 0 && !args.command && !args.interactive) {
|
||||
UI.error("You must provide a message or a command")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (args.fork && !args.continue && !args.session) {
|
||||
UI.error("--fork requires --continue or --session")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const rules: PermissionV1.Ruleset = args.interactive
|
||||
? []
|
||||
: [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
{
|
||||
permission: "plan_enter",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
{
|
||||
permission: "plan_exit",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
]
|
||||
|
||||
function title() {
|
||||
if (args.title === undefined) return
|
||||
if (args.title !== "") return args.title
|
||||
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)
|
||||
|
||||
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: current.data.id,
|
||||
title: current.data.title,
|
||||
directory: current.data.directory,
|
||||
}
|
||||
}
|
||||
|
||||
const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined
|
||||
|
||||
if (base && args.fork) {
|
||||
const forked = await sdk.session.fork({
|
||||
sessionID: base.id,
|
||||
})
|
||||
const id = forked.data?.id
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title: forked.data?.title ?? base.title,
|
||||
directory: forked.data?.directory ?? base.directory,
|
||||
}
|
||||
}
|
||||
|
||||
if (base) {
|
||||
return {
|
||||
id: base.id,
|
||||
title: base.title,
|
||||
directory: base.directory,
|
||||
}
|
||||
}
|
||||
|
||||
const name = title()
|
||||
const result = await sdk.session.create({
|
||||
title: name,
|
||||
permission: [...rules],
|
||||
})
|
||||
const id = result.data?.id
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title: result.data?.title ?? name,
|
||||
directory: result.data?.directory,
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
})
|
||||
if (!res.error && "data" in res && res.data?.share?.url) {
|
||||
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
agent: input.agent,
|
||||
model: input.model
|
||||
? {
|
||||
providerID: input.model.providerID,
|
||||
id: input.model.modelID,
|
||||
variant: input.variant,
|
||||
}
|
||||
: undefined,
|
||||
permission: [...rules],
|
||||
})
|
||||
const id = result.data?.id
|
||||
if (!id) {
|
||||
throw new Error("Failed to create session")
|
||||
}
|
||||
|
||||
void share(sdk, id).catch(() => {})
|
||||
return {
|
||||
id,
|
||||
title: result.data?.title,
|
||||
}
|
||||
}
|
||||
|
||||
async function current(sdk: OpencodeClient): Promise<string> {
|
||||
if (!args.attach) {
|
||||
return directory ?? root
|
||||
}
|
||||
|
||||
const next = await sdk.path
|
||||
.get()
|
||||
.then((x) => x.data?.directory)
|
||||
.catch(() => undefined)
|
||||
if (next) {
|
||||
return next
|
||||
}
|
||||
|
||||
UI.error("Failed to resolve remote directory")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
async function localAgent() {
|
||||
if (!args.agent) return undefined
|
||||
const name = args.agent
|
||||
|
||||
const entry = await Effect.runPromise(
|
||||
agentSvc.get(name).pipe(Effect.provideService(InstanceRef, localInstance)),
|
||||
)
|
||||
if (!entry) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${name}" not found. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
if (entry.mode === "subagent") {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
async function attachAgent(sdk: OpencodeClient) {
|
||||
if (!args.agent) return undefined
|
||||
const name = args.agent
|
||||
|
||||
const modes = await sdk.app
|
||||
.agents(undefined, { throwOnError: true })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => undefined)
|
||||
|
||||
if (!modes) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`failed to list agents from ${args.attach}. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const agent = modes.find((a) => a.name === name)
|
||||
if (!agent) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${name}" not found. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (agent.mode === "subagent") {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
async function pickAgent(sdk: OpencodeClient) {
|
||||
if (!args.agent) return undefined
|
||||
if (args.attach) {
|
||||
return attachAgent(sdk)
|
||||
}
|
||||
|
||||
return localAgent()
|
||||
}
|
||||
|
||||
async function execute(sdk: OpencodeClient) {
|
||||
const sess = await session(sdk)
|
||||
if (!sess?.id) {
|
||||
UI.error("Session not found")
|
||||
process.exit(1)
|
||||
}
|
||||
const sessionID = sess.id
|
||||
|
||||
function emit(type: string, data: Record<string, unknown>) {
|
||||
if (args.format === "json") {
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
type,
|
||||
timestamp: Date.now(),
|
||||
sessionID,
|
||||
...data,
|
||||
}) + EOL,
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Consume one subscribed event stream for the active session and mirror it
|
||||
// to stdout/UI. `client` is passed explicitly because attach mode may
|
||||
// rebind the SDK to the session's directory after the subscription is
|
||||
// created, and replies issued from inside the loop must use that client.
|
||||
async function loop(client: OpencodeClient, events: Awaited<ReturnType<typeof sdk.event.subscribe>>) {
|
||||
const toggles = new Map<string, boolean>()
|
||||
let error: string | undefined
|
||||
|
||||
for await (const event of events.stream) {
|
||||
if (
|
||||
event.type === "message.updated" &&
|
||||
event.properties.sessionID === sessionID &&
|
||||
event.properties.info.role === "assistant" &&
|
||||
args.format !== "json" &&
|
||||
toggles.get("start") !== true
|
||||
) {
|
||||
UI.empty()
|
||||
UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`)
|
||||
UI.empty()
|
||||
toggles.set("start", true)
|
||||
}
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const part = event.properties.part
|
||||
if (part.sessionID !== sessionID) continue
|
||||
|
||||
if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) {
|
||||
if (emit("tool_use", { part })) continue
|
||||
if (part.state.status === "completed") {
|
||||
await tool(part)
|
||||
continue
|
||||
}
|
||||
await toolError(part)
|
||||
UI.error(part.state.error)
|
||||
}
|
||||
|
||||
if (
|
||||
part.type === "tool" &&
|
||||
part.tool === "task" &&
|
||||
part.state.status === "running" &&
|
||||
args.format !== "json"
|
||||
) {
|
||||
if (toggles.get(part.id) === true) continue
|
||||
await tool(part)
|
||||
toggles.set(part.id, true)
|
||||
}
|
||||
|
||||
if (part.type === "step-start") {
|
||||
if (emit("step_start", { part })) continue
|
||||
}
|
||||
|
||||
if (part.type === "step-finish") {
|
||||
if (emit("step_finish", { part })) continue
|
||||
}
|
||||
|
||||
if (part.type === "text" && part.time?.end) {
|
||||
if (emit("text", { part })) continue
|
||||
const text = part.text.trim()
|
||||
if (!text) continue
|
||||
if (!process.stdout.isTTY) {
|
||||
process.stdout.write(text + EOL)
|
||||
continue
|
||||
}
|
||||
UI.empty()
|
||||
UI.println(text)
|
||||
UI.empty()
|
||||
}
|
||||
|
||||
if (part.type === "reasoning" && part.time?.end && thinking) {
|
||||
if (emit("reasoning", { part })) continue
|
||||
const text = part.text.trim()
|
||||
if (!text) continue
|
||||
const line = `Thinking: ${text}`
|
||||
if (process.stdout.isTTY) {
|
||||
UI.empty()
|
||||
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
|
||||
UI.empty()
|
||||
continue
|
||||
}
|
||||
process.stdout.write(line + EOL)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const props = event.properties
|
||||
if (props.sessionID !== sessionID || !props.error) continue
|
||||
let err = String(props.error.name)
|
||||
if ("data" in props.error && props.error.data && "message" in props.error.data) {
|
||||
err = String(props.error.data.message)
|
||||
}
|
||||
error = error ? error + EOL + err : err
|
||||
if (emit("error", { error: props.error })) continue
|
||||
UI.error(err)
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "session.status" &&
|
||||
event.properties.sessionID === sessionID &&
|
||||
event.properties.status.type === "idle"
|
||||
) {
|
||||
break
|
||||
}
|
||||
|
||||
if (event.type === "permission.asked") {
|
||||
const permission = event.properties
|
||||
if (permission.sessionID !== sessionID) continue
|
||||
|
||||
if (args["dangerously-skip-permissions"]) {
|
||||
await client.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "once",
|
||||
})
|
||||
} else {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL +
|
||||
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
|
||||
)
|
||||
await client.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "reject",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return error
|
||||
}
|
||||
const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root)
|
||||
const client = args.attach ? attachSDK(cwd) : sdk
|
||||
|
||||
// Validate agent if specified
|
||||
const agent = await pickAgent(client)
|
||||
|
||||
await share(client, sessionID)
|
||||
|
||||
if (!args.interactive) {
|
||||
const events = await client.event.subscribe()
|
||||
const completed = loop(client, events).catch((e) => {
|
||||
console.error(e)
|
||||
process.exitCode = 1
|
||||
})
|
||||
async function finish() {
|
||||
if (args.attach) return
|
||||
const error = await completed
|
||||
if (error) process.exitCode = 1
|
||||
}
|
||||
|
||||
if (args.command) {
|
||||
const result = await client.session.command({
|
||||
sessionID,
|
||||
agent,
|
||||
model: args.model,
|
||||
command: args.command,
|
||||
arguments: message,
|
||||
variant: args.variant,
|
||||
})
|
||||
if (result.error) {
|
||||
if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
await finish()
|
||||
return
|
||||
}
|
||||
|
||||
const model = pick(args.model)
|
||||
const result = await client.session.prompt({
|
||||
sessionID,
|
||||
agent,
|
||||
model,
|
||||
variant: args.variant,
|
||||
parts: [...files, { type: "text", text: message }],
|
||||
})
|
||||
if (result.error) {
|
||||
if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
await finish()
|
||||
return
|
||||
}
|
||||
|
||||
const model = pick(args.model)
|
||||
const { runInteractiveMode } = await import("./run/runtime")
|
||||
try {
|
||||
await runInteractiveMode({
|
||||
sdk: client,
|
||||
directory: cwd,
|
||||
sessionID,
|
||||
sessionTitle: sess.title,
|
||||
resume: Boolean(args.session || args.continue) && !args.fork,
|
||||
replay,
|
||||
replayLimit: args["replay-limit"],
|
||||
agent,
|
||||
model,
|
||||
variant: args.variant,
|
||||
files,
|
||||
initialInput,
|
||||
createSession: createFreshSession,
|
||||
thinking,
|
||||
backgroundSubagents: flags.experimentalBackgroundSubagents,
|
||||
demo: args.demo,
|
||||
})
|
||||
} catch (error) {
|
||||
dieInteractive(error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (args.interactive && !args.attach && !args.session && !args.continue) {
|
||||
const model = pick(args.model)
|
||||
const { runInteractiveLocalMode } = await import("./run/runtime")
|
||||
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const { Server } = await import("@/server/server")
|
||||
const request = new Request(input, init)
|
||||
return Server.Default().app.fetch(request)
|
||||
}) as typeof globalThis.fetch
|
||||
|
||||
try {
|
||||
return await runInteractiveLocalMode({
|
||||
directory: directory ?? root,
|
||||
fetch: fetchFn,
|
||||
resolveAgent: localAgent,
|
||||
session,
|
||||
share,
|
||||
createSession: createFreshSession,
|
||||
agent: args.agent,
|
||||
model,
|
||||
variant: args.variant,
|
||||
replay,
|
||||
replayLimit: args["replay-limit"],
|
||||
files,
|
||||
initialInput,
|
||||
thinking,
|
||||
backgroundSubagents: flags.experimentalBackgroundSubagents,
|
||||
demo: args.demo,
|
||||
})
|
||||
} catch (error) {
|
||||
dieInteractive(error)
|
||||
}
|
||||
}
|
||||
|
||||
if (args.attach) {
|
||||
const sdk = attachSDK(directory)
|
||||
return await execute(sdk)
|
||||
}
|
||||
|
||||
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const { Server } = await import("@/server/server")
|
||||
const request = new Request(input, init)
|
||||
return Server.Default().app.fetch(request)
|
||||
}) as typeof globalThis.fetch
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: "http://opencode.internal",
|
||||
fetch: fetchFn,
|
||||
directory,
|
||||
})
|
||||
await execute(sdk)
|
||||
})
|
||||
}),
|
||||
})
|
||||
1274
packages/opencode/src/cli/cmd/run/demo.ts
Normal file
1274
packages/opencode/src/cli/cmd/run/demo.ts
Normal file
File diff suppressed because it is too large
Load Diff
205
packages/opencode/src/cli/cmd/run/entry.body.ts
Normal file
205
packages/opencode/src/cli/cmd/run/entry.body.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { toolEntryBody } from "./tool"
|
||||
import type { RunEntryBody, StreamCommit } from "./types"
|
||||
|
||||
export type EntryFlags = {
|
||||
startOnNewLine: boolean
|
||||
trailingNewline: boolean
|
||||
}
|
||||
|
||||
export const RUN_ENTRY_NONE: RunEntryBody = {
|
||||
type: "none",
|
||||
}
|
||||
|
||||
export function cleanRunText(text: string): string {
|
||||
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
}
|
||||
|
||||
function textBody(content: string): RunEntryBody {
|
||||
if (!content) {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
return {
|
||||
type: "text",
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
function codeBody(content: string, filetype?: string): RunEntryBody {
|
||||
if (!content) {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
return {
|
||||
type: "code",
|
||||
content,
|
||||
filetype,
|
||||
}
|
||||
}
|
||||
|
||||
function markdownBody(content: string): RunEntryBody {
|
||||
if (!content) {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
return {
|
||||
type: "markdown",
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
function userBody(raw: string): RunEntryBody {
|
||||
if (!raw.trim()) {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
const lead = raw.match(/^\n+/)?.[0] ?? ""
|
||||
const body = lead ? raw.slice(lead.length) : raw
|
||||
return textBody(`${lead}› ${body}`)
|
||||
}
|
||||
|
||||
function reasoningBody(raw: string): RunEntryBody {
|
||||
const clean = raw.replace(/\[REDACTED\]/g, "")
|
||||
if (!clean) {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
const lead = clean.match(/^\n+/)?.[0] ?? ""
|
||||
const body = lead ? clean.slice(lead.length) : clean
|
||||
const mark = "Thinking:"
|
||||
if (body.startsWith(mark)) {
|
||||
return codeBody(`${lead}_Thinking:_ ${body.slice(mark.length).trimStart()}`, "markdown")
|
||||
}
|
||||
|
||||
return codeBody(clean, "markdown")
|
||||
}
|
||||
|
||||
function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody {
|
||||
return textBody(phase === "progress" ? raw : raw.trim())
|
||||
}
|
||||
|
||||
export function entryFlags(commit: StreamCommit): EntryFlags {
|
||||
if (commit.summary) {
|
||||
return {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.kind === "user") {
|
||||
return {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.kind === "tool") {
|
||||
if (commit.phase === "progress") {
|
||||
return {
|
||||
startOnNewLine: false,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.kind === "assistant" || commit.kind === "reasoning") {
|
||||
if (commit.phase === "progress") {
|
||||
return {
|
||||
startOnNewLine: false,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.kind === "error") {
|
||||
return {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function entryDone(commit: StreamCommit): boolean {
|
||||
if (commit.kind === "assistant" || commit.kind === "reasoning") {
|
||||
return commit.phase === "final"
|
||||
}
|
||||
|
||||
if (commit.kind === "tool") {
|
||||
return commit.phase === "final" || (commit.phase === "progress" && commit.toolState === "completed")
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function entryCanStream(commit: StreamCommit, body: RunEntryBody): boolean {
|
||||
if (commit.phase !== "progress") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (body.type === "none") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (commit.kind === "tool") {
|
||||
return commit.toolState !== "completed"
|
||||
}
|
||||
|
||||
return commit.kind === "assistant" || commit.kind === "reasoning"
|
||||
}
|
||||
|
||||
export function entryBody(commit: StreamCommit): RunEntryBody {
|
||||
if (commit.summary) {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
const raw = cleanRunText(commit.text)
|
||||
|
||||
if (commit.kind === "user") {
|
||||
return userBody(raw)
|
||||
}
|
||||
|
||||
if (commit.kind === "tool") {
|
||||
return toolEntryBody(commit, raw) ?? RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
if (commit.kind === "assistant") {
|
||||
if (commit.phase === "start") {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
if (commit.phase === "final") {
|
||||
return commit.interrupted ? textBody("assistant interrupted") : RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
return markdownBody(raw)
|
||||
}
|
||||
|
||||
if (commit.kind === "reasoning") {
|
||||
if (commit.phase === "start") {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
if (commit.phase === "final") {
|
||||
return commit.interrupted ? textBody("reasoning interrupted") : RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
return reasoningBody(raw)
|
||||
}
|
||||
|
||||
return systemBody(raw, commit.phase)
|
||||
}
|
||||
1085
packages/opencode/src/cli/cmd/run/footer.command.tsx
Normal file
1085
packages/opencode/src/cli/cmd/run/footer.command.tsx
Normal file
File diff suppressed because it is too large
Load Diff
353
packages/opencode/src/cli/cmd/run/footer.menu.tsx
Normal file
353
packages/opencode/src/cli/cmd/run/footer.menu.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { TextAttributes, type ColorInput } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
|
||||
import { transparent, type RunFooterTheme } from "./theme"
|
||||
import * as Locale from "@/util/locale"
|
||||
|
||||
export const FOOTER_MENU_ROWS = 8
|
||||
|
||||
export type RunFooterMenuItem = {
|
||||
display: string
|
||||
description?: string
|
||||
category?: string
|
||||
footer?: string
|
||||
}
|
||||
|
||||
type RunFooterMenuRow =
|
||||
| { type: "header"; label: string }
|
||||
| { type: "item"; item: RunFooterMenuItem; index: number }
|
||||
| { type: "spacer" }
|
||||
|
||||
function maxOffset(count: number, limit: number) {
|
||||
return Math.max(0, count - limit)
|
||||
}
|
||||
|
||||
function previewMargin(limit: number) {
|
||||
return Math.max(0, Math.min(2, Math.floor((limit - 1) / 2)))
|
||||
}
|
||||
|
||||
function revealOffset(value: number, input: { count: number; limit: number; selected: number }) {
|
||||
const max = maxOffset(input.count, input.limit)
|
||||
if (input.selected < value) {
|
||||
return Math.min(max, input.selected)
|
||||
}
|
||||
|
||||
if (input.selected >= value + input.limit) {
|
||||
return Math.min(max, input.selected - input.limit + 1)
|
||||
}
|
||||
|
||||
return Math.min(max, value)
|
||||
}
|
||||
|
||||
function moveOffset(value: number, input: { count: number; limit: number; selected: number; dir: -1 | 1 }) {
|
||||
const max = maxOffset(input.count, input.limit)
|
||||
const margin = previewMargin(input.limit)
|
||||
if (input.dir < 0 && input.selected < value + margin) {
|
||||
return Math.max(0, Math.min(max, input.selected - margin))
|
||||
}
|
||||
|
||||
if (input.dir > 0 && input.selected > value + input.limit - margin - 1) {
|
||||
return Math.min(max, input.selected - input.limit + margin + 1)
|
||||
}
|
||||
|
||||
return Math.min(max, value)
|
||||
}
|
||||
|
||||
export function createFooterMenuState(input: { count: Accessor<number>; limit?: number }) {
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [offset, setOffset] = createSignal(0)
|
||||
const limit = () => input.limit ?? FOOTER_MENU_ROWS
|
||||
const rows = createMemo(() => Math.max(1, Math.min(limit(), input.count())))
|
||||
|
||||
const reveal = (index: number) => {
|
||||
const count = input.count()
|
||||
if (count === 0) {
|
||||
setSelected(0)
|
||||
setOffset(0)
|
||||
return
|
||||
}
|
||||
|
||||
const next = Math.max(0, Math.min(count - 1, index))
|
||||
setSelected(next)
|
||||
setOffset((value) => revealOffset(value, { count, limit: limit(), selected: next }))
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setSelected(0)
|
||||
setOffset(0)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const count = input.count()
|
||||
if (count === 0) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
if (selected() >= count) {
|
||||
setSelected(count - 1)
|
||||
}
|
||||
|
||||
setOffset((value) => revealOffset(value, { count, limit: limit(), selected: selected() }))
|
||||
})
|
||||
|
||||
const move = (dir: -1 | 1) => {
|
||||
const count = input.count()
|
||||
if (count === 0) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
const next = Math.max(0, Math.min(count - 1, selected() + dir))
|
||||
setSelected(next)
|
||||
setOffset((value) => moveOffset(value, { count, limit: limit(), selected: next, dir }))
|
||||
}
|
||||
|
||||
return {
|
||||
selected,
|
||||
offset,
|
||||
rows,
|
||||
reveal,
|
||||
reset,
|
||||
move,
|
||||
}
|
||||
}
|
||||
|
||||
export function RunFooterMenu(props: {
|
||||
id?: string
|
||||
theme: Accessor<RunFooterTheme>
|
||||
items: Accessor<RunFooterMenuItem[]>
|
||||
selected: Accessor<number>
|
||||
offset: Accessor<number>
|
||||
rows: Accessor<number>
|
||||
limit?: number
|
||||
empty?: string
|
||||
border?: boolean
|
||||
paddingLeft?: number
|
||||
paddingRight?: number
|
||||
grouped?: boolean
|
||||
background?: boolean
|
||||
headerColor?: ColorInput
|
||||
}) {
|
||||
const term = useTerminalDimensions()
|
||||
const limit = () => props.limit ?? FOOTER_MENU_ROWS
|
||||
const border = () => props.border ?? true
|
||||
const [groupOffset, setGroupOffset] = createSignal(0)
|
||||
let previous = -1
|
||||
const groupedRows = createMemo<RunFooterMenuRow[]>(() => {
|
||||
const all: RunFooterMenuRow[] = []
|
||||
let category = ""
|
||||
props.items().forEach((item, index) => {
|
||||
if (item.category && item.category !== category) {
|
||||
if (all.length > 0) {
|
||||
all.push({ type: "spacer" })
|
||||
}
|
||||
|
||||
category = item.category
|
||||
all.push({ type: "header", label: item.category })
|
||||
}
|
||||
|
||||
all.push({ type: "item", item, index })
|
||||
})
|
||||
return all
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.grouped) {
|
||||
return
|
||||
}
|
||||
|
||||
const all = groupedRows()
|
||||
const selected = all.findIndex((item) => item.type === "item" && item.index === props.selected())
|
||||
if (all.length === 0 || selected === -1) {
|
||||
setGroupOffset(0)
|
||||
previous = props.selected()
|
||||
return
|
||||
}
|
||||
|
||||
const dir = props.selected() === previous + 1 ? 1 : props.selected() === previous - 1 ? -1 : undefined
|
||||
setGroupOffset((value) =>
|
||||
dir
|
||||
? moveOffset(value, { count: all.length, limit: limit(), selected, dir })
|
||||
: revealOffset(value, { count: all.length, limit: limit(), selected }),
|
||||
)
|
||||
previous = props.selected()
|
||||
})
|
||||
|
||||
const rows = createMemo<RunFooterMenuRow[]>(() => {
|
||||
if (!props.grouped) {
|
||||
return props
|
||||
.items()
|
||||
.slice(props.offset(), props.offset() + limit())
|
||||
.map((item, index) => ({
|
||||
type: "item",
|
||||
item,
|
||||
index: index + props.offset(),
|
||||
}))
|
||||
}
|
||||
|
||||
const all = groupedRows()
|
||||
const start = Math.max(0, Math.min(groupOffset(), all.length - limit()))
|
||||
return all.slice(start, start + limit())
|
||||
})
|
||||
const descriptionColumn = createMemo(() => {
|
||||
const width = Math.max(
|
||||
0,
|
||||
...props
|
||||
.items()
|
||||
.filter((item) => item.description)
|
||||
.map((item) => Bun.stringWidth(item.display)),
|
||||
)
|
||||
return width === 0 ? 0 : width + 2
|
||||
})
|
||||
const descriptionPad = (item: RunFooterMenuItem) => {
|
||||
if (!item.description) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return " ".repeat(Math.max(1, descriptionColumn() - Bun.stringWidth(item.display)))
|
||||
}
|
||||
const descriptionText = (item: RunFooterMenuItem) => {
|
||||
if (!item.description) {
|
||||
return
|
||||
}
|
||||
|
||||
const footerWidth = item.footer ? Bun.stringWidth(item.footer) + 1 : 0
|
||||
const available =
|
||||
term().width -
|
||||
(border() ? 1 : 0) -
|
||||
(props.paddingLeft ?? 1) -
|
||||
(props.paddingRight ?? 0) -
|
||||
descriptionColumn() -
|
||||
footerWidth -
|
||||
4
|
||||
return Locale.truncate(item.description, Math.max(12, available))
|
||||
}
|
||||
return (
|
||||
<box
|
||||
id={props.id ?? "run-direct-footer-menu"}
|
||||
width="100%"
|
||||
height={props.rows()}
|
||||
backgroundColor={props.background ? props.theme().shade : transparent}
|
||||
flexDirection="column"
|
||||
>
|
||||
{rows().length === 0 ? (
|
||||
<box
|
||||
paddingRight={0}
|
||||
flexDirection="row"
|
||||
backgroundColor={props.background ? props.theme().shade : transparent}
|
||||
>
|
||||
{border() ? (
|
||||
<text fg={props.theme().border} wrapMode="none">
|
||||
┃
|
||||
</text>
|
||||
) : undefined}
|
||||
<box
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
paddingLeft={props.paddingLeft ?? 1}
|
||||
paddingRight={props.paddingRight ?? 0}
|
||||
backgroundColor={props.background ? props.theme().shade : transparent}
|
||||
>
|
||||
<text fg={props.theme().muted} wrapMode="none" truncate>
|
||||
{props.empty ?? "No matching items"}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
) : (
|
||||
rows().map((row) => {
|
||||
if (row.type === "spacer") {
|
||||
return <box height={1} flexShrink={0} />
|
||||
}
|
||||
|
||||
if (row.type === "header") {
|
||||
return (
|
||||
<box paddingLeft={props.paddingLeft ?? 1} paddingRight={props.paddingRight ?? 1}>
|
||||
<text
|
||||
fg={props.headerColor ?? props.theme().highlight}
|
||||
attributes={TextAttributes.BOLD}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
>
|
||||
{row.label}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const active = () => row.index === props.selected()
|
||||
const background = () =>
|
||||
active()
|
||||
? props.background
|
||||
? props.theme().selected
|
||||
: props.theme().shade
|
||||
: props.background
|
||||
? props.theme().shade
|
||||
: transparent
|
||||
return (
|
||||
<box paddingRight={0} flexDirection="row" backgroundColor={background()}>
|
||||
{border() ? (
|
||||
<text fg={props.theme().highlight} bg={background()} wrapMode="none">
|
||||
{active() ? "▌" : " "}
|
||||
</text>
|
||||
) : undefined}
|
||||
<box
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
paddingLeft={props.paddingLeft ?? 1}
|
||||
paddingRight={props.paddingRight ?? 0}
|
||||
backgroundColor={background()}
|
||||
>
|
||||
<box width="100%" flexDirection="row" justifyContent="space-between" gap={1}>
|
||||
<box flexDirection="row" gap={0} flexGrow={1} flexShrink={1}>
|
||||
<text
|
||||
fg={active() ? props.theme().selectedText : props.theme().text}
|
||||
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={0}
|
||||
>
|
||||
{row.item.display}
|
||||
</text>
|
||||
{row.item.description ? (
|
||||
<>
|
||||
<text
|
||||
fg={active() ? props.theme().selectedText : props.theme().muted}
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
>
|
||||
{descriptionPad(row.item)}
|
||||
</text>
|
||||
<text
|
||||
fg={active() ? props.theme().selectedText : props.theme().muted}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
>
|
||||
{descriptionText(row.item)}
|
||||
</text>
|
||||
</>
|
||||
) : undefined}
|
||||
</box>
|
||||
{row.item.footer ? (
|
||||
<text
|
||||
fg={active() ? props.theme().selectedText : props.theme().muted}
|
||||
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={0}
|
||||
>
|
||||
{row.item.footer}
|
||||
</text>
|
||||
) : undefined}
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
482
packages/opencode/src/cli/cmd/run/footer.permission.tsx
Normal file
482
packages/opencode/src/cli/cmd/run/footer.permission.tsx
Normal file
@@ -0,0 +1,482 @@
|
||||
// Permission UI body for the direct-mode footer.
|
||||
//
|
||||
// Renders inside the footer when the reducer pushes a FooterView of type
|
||||
// "permission". Uses a three-stage state machine (permission.shared.ts):
|
||||
//
|
||||
// permission → shows the request with Allow once / Always / Reject buttons
|
||||
// always → confirmation step before granting permanent access
|
||||
// reject → text field for the rejection message
|
||||
//
|
||||
// Keyboard: left/right to select, enter to confirm, esc to reject.
|
||||
// The diff view (when available) uses the same diff component as scrollback
|
||||
// tool snapshots.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
||||
import {
|
||||
createPermissionBodyState,
|
||||
permissionAlwaysLines,
|
||||
permissionCancel,
|
||||
permissionEscape,
|
||||
permissionHover,
|
||||
permissionInfo,
|
||||
permissionLabel,
|
||||
permissionOptions,
|
||||
permissionReject,
|
||||
permissionRun,
|
||||
permissionShift,
|
||||
type PermissionOption,
|
||||
} from "./permission.shared"
|
||||
import { footerWidthPolicy } from "./footer.width"
|
||||
import { toolFiletype } from "./tool"
|
||||
import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
|
||||
import type { PermissionReply, RunDiffStyle } from "./types"
|
||||
|
||||
function buttons(
|
||||
list: PermissionOption[],
|
||||
selected: PermissionOption,
|
||||
theme: RunFooterTheme,
|
||||
disabled: boolean,
|
||||
onHover: (option: PermissionOption) => void,
|
||||
onSelect: (option: PermissionOption) => void,
|
||||
) {
|
||||
return (
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<For each={list}>
|
||||
{(option) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={option === selected ? theme.highlight : transparent}
|
||||
onMouseOver={() => {
|
||||
if (!disabled) onHover(option)
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (!disabled) onSelect(option)
|
||||
}}
|
||||
>
|
||||
<text fg={option === selected ? theme.surface : theme.muted}>{permissionLabel(option)}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
/** @internal Exported to test managed textarea submission without permission navigation. */
|
||||
export function RejectField(props: {
|
||||
theme: RunFooterTheme
|
||||
text: string
|
||||
disabled: boolean
|
||||
onChange: (text: string) => void
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
let area: TextareaRenderable | undefined
|
||||
|
||||
createEffect(() => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (area.plainText !== props.text) {
|
||||
area.setText(props.text)
|
||||
area.cursorOffset = props.text.length
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (!area || area.isDestroyed || props.disabled) {
|
||||
return
|
||||
}
|
||||
area.focus()
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<textarea
|
||||
id="run-direct-footer-permission-reject"
|
||||
width="100%"
|
||||
minHeight={1}
|
||||
maxHeight={3}
|
||||
wrapMode="word"
|
||||
placeholder="Tell OpenCode what to do differently"
|
||||
placeholderColor={props.theme.muted}
|
||||
textColor={props.theme.text}
|
||||
focusedTextColor={props.theme.text}
|
||||
backgroundColor={props.theme.surface}
|
||||
focusedBackgroundColor={props.theme.surface}
|
||||
cursorColor={props.theme.text}
|
||||
focused={!props.disabled}
|
||||
onSubmit={props.onConfirm}
|
||||
onContentChange={() => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
props.onChange(area.plainText)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.name === "escape") {
|
||||
event.preventDefault()
|
||||
props.onCancel()
|
||||
return
|
||||
}
|
||||
}}
|
||||
ref={(item) => {
|
||||
area = item
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function RunPermissionBody(props: {
|
||||
request: PermissionRequest
|
||||
theme: RunFooterTheme
|
||||
block: RunBlockTheme
|
||||
diffStyle?: RunDiffStyle
|
||||
onReply: (input: PermissionReply) => void | Promise<void>
|
||||
}) {
|
||||
const dims = useTerminalDimensions()
|
||||
const [state, setState] = createSignal(createPermissionBodyState(props.request.id))
|
||||
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 busy = createMemo(() => state().submitting)
|
||||
const title = createMemo(() => {
|
||||
if (state().stage === "always") {
|
||||
return "Always allow"
|
||||
}
|
||||
|
||||
if (state().stage === "reject") {
|
||||
return "Reject permission"
|
||||
}
|
||||
|
||||
return "Permission required"
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const id = props.request.id
|
||||
if (state().requestID === id) {
|
||||
return
|
||||
}
|
||||
|
||||
setState(createPermissionBodyState(id))
|
||||
})
|
||||
|
||||
const shift = (dir: -1 | 1) => {
|
||||
setState((prev) => permissionShift(prev, dir))
|
||||
}
|
||||
|
||||
const submit = async (next: PermissionReply) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
submitting: true,
|
||||
}))
|
||||
|
||||
try {
|
||||
await props.onReply(next)
|
||||
} catch {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
submitting: false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const run = (option: PermissionOption) => {
|
||||
const cur = state()
|
||||
const next = permissionRun(cur, props.request.id, option)
|
||||
if (next.state !== cur) {
|
||||
setState(next.state)
|
||||
}
|
||||
|
||||
if (!next.reply) {
|
||||
return
|
||||
}
|
||||
|
||||
void submit(next.reply)
|
||||
}
|
||||
|
||||
const reject = () => {
|
||||
const next = permissionReject(state(), props.request.id)
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
|
||||
void submit(next)
|
||||
}
|
||||
|
||||
const cancelReject = () => {
|
||||
setState((prev) => permissionCancel(prev))
|
||||
}
|
||||
|
||||
useKeyboard((event) => {
|
||||
const cur = state()
|
||||
if (cur.stage === "reject") {
|
||||
return
|
||||
}
|
||||
|
||||
if (cur.submitting) {
|
||||
if (["left", "right", "h", "l", "tab", "return", "escape"].includes(event.name)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "tab") {
|
||||
shift(event.shift ? -1 : 1)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "left" || event.name === "h") {
|
||||
shift(-1)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "right" || event.name === "l") {
|
||||
shift(1)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "return") {
|
||||
run(state().selected)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name !== "escape") {
|
||||
return
|
||||
}
|
||||
|
||||
setState((prev) => permissionEscape(prev))
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
id="run-direct-footer-permission-body"
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
backgroundColor={props.theme.surface}
|
||||
>
|
||||
<box
|
||||
id="run-direct-footer-permission-head"
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<text fg={state().stage === "reject" ? props.theme.error : props.theme.warning}>△</text>
|
||||
<text fg={props.theme.text}>{title()}</text>
|
||||
</box>
|
||||
<Switch>
|
||||
<Match when={state().stage === "permission"}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={2}>
|
||||
<text fg={props.theme.muted} flexShrink={0}>
|
||||
{info().icon}
|
||||
</text>
|
||||
<text fg={props.theme.text} wrapMode="word">
|
||||
{info().title}
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={state().stage === "reject"}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={props.theme.muted}>Tell OpenCode what to do differently</text>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
|
||||
<Show
|
||||
when={state().stage !== "reject"}
|
||||
fallback={
|
||||
<box width="100%" flexGrow={1} flexShrink={1} justifyContent="flex-end">
|
||||
<box
|
||||
id="run-direct-footer-permission-reject-bar"
|
||||
flexDirection={narrow() ? "column" : "row"}
|
||||
flexShrink={0}
|
||||
backgroundColor={props.theme.line}
|
||||
paddingTop={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
gap={1}
|
||||
>
|
||||
<box width={narrow() ? "100%" : undefined} flexGrow={1} flexShrink={1}>
|
||||
<RejectField
|
||||
theme={props.theme}
|
||||
text={state().message}
|
||||
disabled={busy()}
|
||||
onChange={(text) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
message: text,
|
||||
}))
|
||||
}}
|
||||
onConfirm={reject}
|
||||
onCancel={cancelReject}
|
||||
/>
|
||||
</box>
|
||||
<Show
|
||||
when={!busy()}
|
||||
fallback={
|
||||
<text fg={props.theme.muted} wrapMode="word" flexShrink={0}>
|
||||
Waiting for permission event...
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<box flexDirection="row" gap={2} flexShrink={0}>
|
||||
<text fg={props.theme.text}>
|
||||
enter <span style={{ fg: props.theme.muted }}>confirm</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
esc <span style={{ fg: props.theme.muted }}>cancel</span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1} paddingRight={3} paddingBottom={1}>
|
||||
<Switch>
|
||||
<Match when={state().stage === "permission"}>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
height="100%"
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: props.theme.surface,
|
||||
foregroundColor: props.theme.line,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<Show
|
||||
when={info().diff}
|
||||
fallback={
|
||||
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
|
||||
<For each={info().lines}>
|
||||
{(line) => (
|
||||
<text fg={props.theme.text} wrapMode="word">
|
||||
{line}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<diff
|
||||
diff={info().diff!}
|
||||
view="unified"
|
||||
filetype={ft()}
|
||||
syntaxStyle={props.block.syntax}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
fg={props.theme.text}
|
||||
addedBg={props.block.diffAddedBg}
|
||||
removedBg={props.block.diffRemovedBg}
|
||||
contextBg={props.block.diffContextBg}
|
||||
addedSignColor={props.block.diffHighlightAdded}
|
||||
removedSignColor={props.block.diffHighlightRemoved}
|
||||
lineNumberFg={props.block.diffLineNumber}
|
||||
lineNumberBg={props.block.diffContextBg}
|
||||
addedLineNumberBg={props.block.diffAddedLineNumberBg}
|
||||
removedLineNumberBg={props.block.diffRemovedLineNumberBg}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!info().diff && info().lines.length === 0}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={props.theme.muted}>No diff provided</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
height="100%"
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: props.theme.surface,
|
||||
foregroundColor: props.theme.line,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
|
||||
<For each={permissionAlwaysLines(props.request)}>
|
||||
{(line) => (
|
||||
<text fg={props.theme.text} wrapMode="word">
|
||||
{line}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
|
||||
<box
|
||||
id="run-direct-footer-permission-actions"
|
||||
flexDirection={narrow() ? "column" : "row"}
|
||||
flexShrink={0}
|
||||
backgroundColor={props.theme.pane}
|
||||
gap={1}
|
||||
paddingTop={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
>
|
||||
{buttons(
|
||||
opts(),
|
||||
state().selected,
|
||||
props.theme,
|
||||
busy(),
|
||||
(option) => {
|
||||
setState((prev) => permissionHover(prev, option))
|
||||
},
|
||||
run,
|
||||
)}
|
||||
<Show
|
||||
when={!busy()}
|
||||
fallback={
|
||||
<text fg={props.theme.muted} wrapMode="word" flexShrink={0}>
|
||||
Waiting for permission event...
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<box flexDirection="row" gap={2} flexShrink={0}>
|
||||
<text fg={props.theme.text}>
|
||||
{"⇆"} <span style={{ fg: props.theme.muted }}>select</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
enter <span style={{ fg: props.theme.muted }}>confirm</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
esc <span style={{ fg: props.theme.muted }}>{state().stage === "always" ? "cancel" : "reject"}</span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
1307
packages/opencode/src/cli/cmd/run/footer.prompt.tsx
Normal file
1307
packages/opencode/src/cli/cmd/run/footer.prompt.tsx
Normal file
File diff suppressed because it is too large
Load Diff
580
packages/opencode/src/cli/cmd/run/footer.question.tsx
Normal file
580
packages/opencode/src/cli/cmd/run/footer.question.tsx
Normal file
@@ -0,0 +1,580 @@
|
||||
// Question UI body for the direct-mode footer.
|
||||
//
|
||||
// Renders inside the footer when the reducer pushes a FooterView of type
|
||||
// "question". Supports single-question and multi-question flows:
|
||||
//
|
||||
// Single question: options list with up/down selection, digit shortcuts,
|
||||
// and optional custom text input.
|
||||
//
|
||||
// Multi-question: tabbed interface where each question is a tab, plus a
|
||||
// final "Confirm" tab that shows all answers for review. Tab/shift-tab
|
||||
// or left/right to navigate between questions.
|
||||
//
|
||||
// All state logic lives in question.shared.ts as a pure state machine.
|
||||
// This component just renders it and dispatches keyboard events.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import {
|
||||
createQuestionBodyState,
|
||||
questionConfirm,
|
||||
questionCustom,
|
||||
questionInfo,
|
||||
questionInput,
|
||||
questionMove,
|
||||
questionOther,
|
||||
questionPicked,
|
||||
questionReject,
|
||||
questionSave,
|
||||
questionSelect,
|
||||
questionSetEditing,
|
||||
questionSetSelected,
|
||||
questionSetSubmitting,
|
||||
questionSetTab,
|
||||
questionSingle,
|
||||
questionStoreCustom,
|
||||
questionSubmit,
|
||||
questionSync,
|
||||
questionTabs,
|
||||
questionTotal,
|
||||
} from "./question.shared"
|
||||
import { footerWidthPolicy } from "./footer.width"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
import type { QuestionReject, QuestionReply } from "./types"
|
||||
|
||||
export function RunQuestionBody(props: {
|
||||
request: QuestionRequest
|
||||
theme: RunFooterTheme
|
||||
onReply: (input: QuestionReply) => void | Promise<void>
|
||||
onReject: (input: QuestionReject) => void | Promise<void>
|
||||
}) {
|
||||
const dims = useTerminalDimensions()
|
||||
const [state, setState] = createSignal(createQuestionBodyState(props.request.id))
|
||||
const single = createMemo(() => questionSingle(props.request))
|
||||
const confirm = createMemo(() => questionConfirm(props.request, state()))
|
||||
const info = createMemo(() => questionInfo(props.request, state()))
|
||||
const input = createMemo(() => questionInput(state()))
|
||||
const other = createMemo(() => questionOther(props.request, state()))
|
||||
const picked = createMemo(() => questionPicked(state()))
|
||||
const disabled = createMemo(() => state().submitting)
|
||||
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
|
||||
const verb = createMemo(() => {
|
||||
if (confirm()) {
|
||||
return "submit"
|
||||
}
|
||||
|
||||
if (info()?.multiple) {
|
||||
return "toggle"
|
||||
}
|
||||
|
||||
if (single()) {
|
||||
return "submit"
|
||||
}
|
||||
|
||||
return "confirm"
|
||||
})
|
||||
let area: TextareaRenderable | undefined
|
||||
|
||||
createEffect(() => {
|
||||
setState((prev) => questionSync(prev, props.request.id))
|
||||
})
|
||||
|
||||
const setTab = (tab: number) => {
|
||||
setState((prev) => questionSetTab(prev, tab))
|
||||
}
|
||||
|
||||
const move = (dir: -1 | 1) => {
|
||||
setState((prev) => questionMove(prev, props.request, dir))
|
||||
}
|
||||
|
||||
const beginReply = async (input: QuestionReply) => {
|
||||
setState((prev) => questionSetSubmitting(prev, true))
|
||||
|
||||
try {
|
||||
await props.onReply(input)
|
||||
} catch {
|
||||
setState((prev) => questionSetSubmitting(prev, false))
|
||||
}
|
||||
}
|
||||
|
||||
const beginReject = async (input: QuestionReject) => {
|
||||
setState((prev) => questionSetSubmitting(prev, true))
|
||||
|
||||
try {
|
||||
await props.onReject(input)
|
||||
} catch {
|
||||
setState((prev) => questionSetSubmitting(prev, false))
|
||||
}
|
||||
}
|
||||
|
||||
const saveCustom = () => {
|
||||
const cur = state()
|
||||
const next = questionSave(cur, props.request)
|
||||
if (next.state !== cur) {
|
||||
setState(next.state)
|
||||
}
|
||||
|
||||
if (!next.reply) {
|
||||
return
|
||||
}
|
||||
|
||||
void beginReply(next.reply)
|
||||
}
|
||||
|
||||
const choose = (selected: number) => {
|
||||
const base = state()
|
||||
const cur = questionSetSelected(base, selected)
|
||||
const next = questionSelect(cur, props.request)
|
||||
if (next.state !== base) {
|
||||
setState(next.state)
|
||||
}
|
||||
|
||||
if (!next.reply) {
|
||||
return
|
||||
}
|
||||
|
||||
void beginReply(next.reply)
|
||||
}
|
||||
|
||||
const mark = (selected: number) => {
|
||||
setState((prev) => questionSetSelected(prev, selected))
|
||||
}
|
||||
|
||||
const select = () => {
|
||||
const cur = state()
|
||||
const next = questionSelect(cur, props.request)
|
||||
if (next.state !== cur) {
|
||||
setState(next.state)
|
||||
}
|
||||
|
||||
if (!next.reply) {
|
||||
return
|
||||
}
|
||||
|
||||
void beginReply(next.reply)
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
void beginReply(questionSubmit(props.request, state()))
|
||||
}
|
||||
|
||||
const reject = () => {
|
||||
void beginReject(questionReject(props.request))
|
||||
}
|
||||
|
||||
useKeyboard((event) => {
|
||||
const cur = state()
|
||||
if (cur.submitting) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (cur.editing) {
|
||||
if (event.name === "escape") {
|
||||
setState((prev) => questionSetEditing(prev, false))
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!single() && (event.name === "left" || event.name === "h")) {
|
||||
setTab((cur.tab - 1 + questionTabs(props.request)) % questionTabs(props.request))
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (!single() && (event.name === "right" || event.name === "l")) {
|
||||
setTab((cur.tab + 1) % questionTabs(props.request))
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (!single() && event.name === "tab") {
|
||||
const dir = event.shift ? -1 : 1
|
||||
setTab((cur.tab + dir + questionTabs(props.request)) % questionTabs(props.request))
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (questionConfirm(props.request, cur)) {
|
||||
if (event.name === "return") {
|
||||
submit()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "escape") {
|
||||
reject()
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const total = questionTotal(props.request, cur)
|
||||
const max = Math.min(total, 9)
|
||||
const digit = Number(event.name)
|
||||
if (!Number.isNaN(digit) && digit >= 1 && digit <= max) {
|
||||
choose(digit - 1)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "up" || event.name === "k") {
|
||||
move(-1)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "down" || event.name === "j") {
|
||||
move(1)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "return") {
|
||||
select()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "escape") {
|
||||
reject()
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!state().editing || !area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (area.plainText !== input()) {
|
||||
area.setText(input())
|
||||
area.cursorOffset = input().length
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (!area || area.isDestroyed || !state().editing) {
|
||||
return
|
||||
}
|
||||
|
||||
area.focus()
|
||||
area.cursorOffset = area.plainText.length
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<box id="run-direct-footer-question-body" width="100%" height="100%" flexDirection="column">
|
||||
<box
|
||||
id="run-direct-footer-question-panel"
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={3}
|
||||
paddingTop={1}
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
backgroundColor={props.theme.surface}
|
||||
>
|
||||
<Show when={!single()}>
|
||||
<box id="run-direct-footer-question-tabs" flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
|
||||
<For each={props.request.questions}>
|
||||
{(item, index) => {
|
||||
const active = () => state().tab === index()
|
||||
const answered = () => (state().answers[index()]?.length ?? 0) > 0
|
||||
return (
|
||||
<box
|
||||
id={`run-direct-footer-question-tab-${index()}`}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={active() ? props.theme.highlight : props.theme.surface}
|
||||
onMouseUp={() => {
|
||||
if (!disabled()) setTab(index())
|
||||
}}
|
||||
>
|
||||
<text fg={active() ? props.theme.surface : answered() ? props.theme.text : props.theme.muted}>
|
||||
{item.header}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<box
|
||||
id="run-direct-footer-question-tab-confirm"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={confirm() ? props.theme.highlight : props.theme.surface}
|
||||
onMouseUp={() => {
|
||||
if (!disabled()) setTab(props.request.questions.length)
|
||||
}}
|
||||
>
|
||||
<text fg={confirm() ? props.theme.surface : props.theme.muted}>Confirm</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={!confirm()}
|
||||
fallback={
|
||||
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1}>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
height="100%"
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: props.theme.surface,
|
||||
foregroundColor: props.theme.line,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={props.theme.text}>Review</text>
|
||||
</box>
|
||||
<For each={props.request.questions}>
|
||||
{(item, index) => {
|
||||
const value = () => state().answers[index()]?.join(", ") ?? ""
|
||||
const answered = () => Boolean(value())
|
||||
return (
|
||||
<box paddingLeft={1}>
|
||||
<text wrapMode="word">
|
||||
<span style={{ fg: props.theme.muted }}>{item.header}:</span>{" "}
|
||||
<span style={{ fg: answered() ? props.theme.text : props.theme.error }}>
|
||||
{answered() ? value() : "(not answered)"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1} gap={1}>
|
||||
<box>
|
||||
<text fg={props.theme.text} wrapMode="word">
|
||||
{info()?.question}
|
||||
{info()?.multiple ? " (select all that apply)" : ""}
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box flexGrow={1} flexShrink={1}>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
height="100%"
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: props.theme.surface,
|
||||
foregroundColor: props.theme.line,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<box width="100%" flexDirection="column">
|
||||
<For each={info()?.options ?? []}>
|
||||
{(item, index) => {
|
||||
const active = () => state().selected === index()
|
||||
const hit = () => state().answers[state().tab]?.includes(item.label) ?? false
|
||||
return (
|
||||
<box
|
||||
id={`run-direct-footer-question-option-${index()}`}
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
onMouseOver={() => {
|
||||
if (!disabled()) {
|
||||
mark(index())
|
||||
}
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
if (!disabled()) {
|
||||
mark(index())
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (!disabled()) {
|
||||
choose(index())
|
||||
}
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box backgroundColor={active() ? props.theme.line : undefined} paddingRight={1}>
|
||||
<text fg={active() ? props.theme.highlight : props.theme.muted}>{`${index() + 1}.`}</text>
|
||||
</box>
|
||||
<box backgroundColor={active() ? props.theme.line : undefined}>
|
||||
<text
|
||||
fg={active() ? props.theme.highlight : hit() ? props.theme.success : props.theme.text}
|
||||
>
|
||||
{info()?.multiple ? `[${hit() ? "✓" : " "}] ${item.label}` : item.label}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={!info()?.multiple}>
|
||||
<text fg={props.theme.success}>{hit() ? " ✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={props.theme.muted} wrapMode="word">
|
||||
{item.description}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show when={questionCustom(props.request, state())}>
|
||||
<box
|
||||
id="run-direct-footer-question-option-custom"
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
onMouseOver={() => {
|
||||
if (!disabled()) {
|
||||
mark(info()?.options.length ?? 0)
|
||||
}
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
if (!disabled()) {
|
||||
mark(info()?.options.length ?? 0)
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (!disabled()) {
|
||||
choose(info()?.options.length ?? 0)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box backgroundColor={other() ? props.theme.line : undefined} paddingRight={1}>
|
||||
<text
|
||||
fg={other() ? props.theme.highlight : props.theme.muted}
|
||||
>{`${(info()?.options.length ?? 0) + 1}.`}</text>
|
||||
</box>
|
||||
<box backgroundColor={other() ? props.theme.line : undefined}>
|
||||
<text
|
||||
fg={other() ? props.theme.highlight : picked() ? props.theme.success : props.theme.text}
|
||||
>
|
||||
{info()?.multiple
|
||||
? `[${picked() ? "✓" : " "}] Type your own answer`
|
||||
: "Type your own answer"}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={!info()?.multiple}>
|
||||
<text fg={props.theme.success}>{picked() ? " ✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show
|
||||
when={state().editing}
|
||||
fallback={
|
||||
<Show when={input()}>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={props.theme.muted} wrapMode="word">
|
||||
{input()}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<box paddingLeft={3}>
|
||||
<textarea
|
||||
id="run-direct-footer-question-custom"
|
||||
width="100%"
|
||||
minHeight={1}
|
||||
maxHeight={4}
|
||||
wrapMode="word"
|
||||
placeholder="Type your own answer"
|
||||
placeholderColor={props.theme.muted}
|
||||
textColor={props.theme.text}
|
||||
focusedTextColor={props.theme.text}
|
||||
backgroundColor={props.theme.surface}
|
||||
focusedBackgroundColor={props.theme.surface}
|
||||
cursorColor={props.theme.text}
|
||||
focused={!disabled()}
|
||||
onSubmit={saveCustom}
|
||||
onContentChange={() => {
|
||||
if (!area || area.isDestroyed || disabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = area.plainText
|
||||
setState((prev) => questionStoreCustom(prev, prev.tab, text))
|
||||
}}
|
||||
ref={(item) => {
|
||||
area = item
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<box
|
||||
id="run-direct-footer-question-actions"
|
||||
flexDirection={narrow() ? "column" : "row"}
|
||||
flexShrink={0}
|
||||
gap={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
>
|
||||
<Show
|
||||
when={!disabled()}
|
||||
fallback={
|
||||
<text fg={props.theme.muted} wrapMode="word">
|
||||
Waiting for question event...
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<box
|
||||
flexDirection={narrow() ? "column" : "row"}
|
||||
gap={narrow() ? 1 : 2}
|
||||
flexShrink={0}
|
||||
width={narrow() ? "100%" : undefined}
|
||||
>
|
||||
<Show
|
||||
when={!state().editing}
|
||||
fallback={
|
||||
<>
|
||||
<text fg={props.theme.text}>
|
||||
enter <span style={{ fg: props.theme.muted }}>save</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
esc <span style={{ fg: props.theme.muted }}>cancel</span>
|
||||
</text>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Show when={!single()}>
|
||||
<text fg={props.theme.text}>
|
||||
{"⇆"} <span style={{ fg: props.theme.muted }}>tab</span>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={!confirm()}>
|
||||
<text fg={props.theme.text}>
|
||||
{"↑↓"} <span style={{ fg: props.theme.muted }}>select</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={props.theme.text}>
|
||||
enter <span style={{ fg: props.theme.muted }}>{verb()}</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
esc <span style={{ fg: props.theme.muted }}>dismiss</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
179
packages/opencode/src/cli/cmd/run/footer.subagent.tsx
Normal file
179
packages/opencode/src/cli/cmd/run/footer.subagent.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import "opentui-spinner/solid"
|
||||
import { Show, createMemo, indexArray } from "solid-js"
|
||||
import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
|
||||
import { RunEntryContent, separatorRows } from "./scrollback.writer"
|
||||
import type { FooterSubagentDetail, FooterSubagentTab, RunDiffStyle } from "./types"
|
||||
import type { RunFooterTheme, RunTheme } from "./theme"
|
||||
|
||||
export const SUBAGENT_INSPECTOR_ROWS = 14
|
||||
|
||||
function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"]) {
|
||||
if (status === "completed") {
|
||||
return theme.highlight
|
||||
}
|
||||
|
||||
if (status === "cancelled") {
|
||||
return theme.muted
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return theme.error
|
||||
}
|
||||
|
||||
return theme.highlight
|
||||
}
|
||||
|
||||
function statusIcon(status: FooterSubagentTab["status"]) {
|
||||
if (status === "completed") {
|
||||
return "●"
|
||||
}
|
||||
|
||||
if (status === "cancelled") {
|
||||
return "○"
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return "◍"
|
||||
}
|
||||
|
||||
return "◔"
|
||||
}
|
||||
|
||||
export function RunFooterSubagentBody(props: {
|
||||
active: () => boolean
|
||||
theme: () => RunTheme
|
||||
tab: () => FooterSubagentTab | undefined
|
||||
index: () => number
|
||||
total: () => number
|
||||
detail: () => FooterSubagentDetail | undefined
|
||||
width: () => number
|
||||
diffStyle?: RunDiffStyle
|
||||
onCycle: (dir: -1 | 1) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const theme = createMemo(() => props.theme())
|
||||
const footer = createMemo(() => theme().footer)
|
||||
const tab = createMemo(() => props.tab())
|
||||
const commits = createMemo(() => props.detail()?.commits ?? [])
|
||||
const opts = createMemo(() => ({ diffStyle: props.diffStyle }))
|
||||
const scrollbar = createMemo(() => ({
|
||||
trackOptions: {
|
||||
backgroundColor: footer().surface,
|
||||
foregroundColor: footer().line,
|
||||
},
|
||||
}))
|
||||
const title = createMemo(() => {
|
||||
const current = tab()
|
||||
if (!current) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return current.description || current.title || current.label
|
||||
})
|
||||
const subtitle = createMemo(() => {
|
||||
const current = tab()
|
||||
if (!current || title() === current.label) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return current.label
|
||||
})
|
||||
const rows = indexArray(commits, (commit, index) => (
|
||||
<box flexDirection="column" gap={0} flexShrink={0}>
|
||||
{index > 0 && separatorRows(commits()[index - 1], commit()) > 0 ? <box height={1} flexShrink={0} /> : null}
|
||||
<RunEntryContent commit={commit()} theme={theme()} opts={opts()} width={props.width()} />
|
||||
</box>
|
||||
))
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (!props.active()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "escape") {
|
||||
event.preventDefault()
|
||||
props.onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "tab" && !event.shift) {
|
||||
event.preventDefault()
|
||||
props.onCycle(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "up" || event.name === "k") {
|
||||
event.preventDefault()
|
||||
scroll?.scrollBy(-1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "down" || event.name === "j") {
|
||||
event.preventDefault()
|
||||
scroll?.scrollBy(1)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
id="run-direct-footer-subagent"
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
backgroundColor={footer().surface}
|
||||
>
|
||||
<box paddingTop={1} paddingLeft={1} paddingRight={3} paddingBottom={1} flexDirection="column" flexGrow={1}>
|
||||
<Show when={tab()}>
|
||||
{(current) => (
|
||||
<box width="100%" flexDirection="row" gap={1} paddingBottom={1} flexShrink={0}>
|
||||
{current().status === "running" ? (
|
||||
<box flexShrink={0}>
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={statusColor(footer(), current().status)} />
|
||||
</box>
|
||||
) : (
|
||||
<text fg={statusColor(footer(), current().status)} wrapMode="none" truncate flexShrink={0}>
|
||||
{statusIcon(current().status)}
|
||||
</text>
|
||||
)}
|
||||
<text fg={footer().text} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
{title()}
|
||||
<Show when={subtitle().length > 0}>
|
||||
<span style={{ fg: footer().muted }}>{" " + subtitle()}</span>
|
||||
</Show>
|
||||
</text>
|
||||
<Show when={props.total() > 1 && props.index() > 0}>
|
||||
<text fg={footer().muted} wrapMode="none" truncate flexShrink={0}>
|
||||
{props.index()} of {props.total()}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
height="100%"
|
||||
stickyScroll={true}
|
||||
stickyStart="bottom"
|
||||
verticalScrollbarOptions={scrollbar()}
|
||||
ref={(item) => {
|
||||
scroll = item
|
||||
}}
|
||||
>
|
||||
<box width="100%" flexDirection="column" gap={0}>
|
||||
{commits().length > 0 ? (
|
||||
rows()
|
||||
) : (
|
||||
<text fg={footer().muted} wrapMode="word">
|
||||
No subagent activity yet
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
1129
packages/opencode/src/cli/cmd/run/footer.ts
Normal file
1129
packages/opencode/src/cli/cmd/run/footer.ts
Normal file
File diff suppressed because it is too large
Load Diff
985
packages/opencode/src/cli/cmd/run/footer.view.tsx
Normal file
985
packages/opencode/src/cli/cmd/run/footer.view.tsx
Normal file
@@ -0,0 +1,985 @@
|
||||
// Footer layout
|
||||
//
|
||||
// Renders the footer region as a compact vertical stack:
|
||||
// 1. Single-line composer or active footer body
|
||||
// 2. Optional autocomplete/menu panels below the composer
|
||||
// 3. A statusline-style footer row carrying state, hints, and model info
|
||||
//
|
||||
// All state comes from the parent RunFooter through SolidJS signals.
|
||||
// The view itself is stateless except for derived memos.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import "opentui-spinner/solid"
|
||||
import { createColors, createFrames } from "@opencode-ai/tui/ui/spinner"
|
||||
import {
|
||||
RUN_SUBAGENT_PANEL_ROWS,
|
||||
RunCommandMenuBody,
|
||||
RunModelSelectBody,
|
||||
RunQueuedPromptSelectBody,
|
||||
RunSkillSelectBody,
|
||||
RunSubagentSelectBody,
|
||||
RunVariantSelectBody,
|
||||
} from "./footer.command"
|
||||
import { FOOTER_MENU_ROWS, RunFooterMenu } from "./footer.menu"
|
||||
import { RunFooterSubagentBody } from "./footer.subagent"
|
||||
import { RunPromptBody, createPromptState } from "./footer.prompt"
|
||||
import { RunPermissionBody } from "./footer.permission"
|
||||
import { RunQuestionBody } from "./footer.question"
|
||||
import { footerWidthPolicy } from "./footer.width"
|
||||
import {
|
||||
OPENCODE_BASE_MODE,
|
||||
formatKeyBindings,
|
||||
formatKeySequence,
|
||||
useBindings,
|
||||
useKeymapSelector,
|
||||
type OpenTuiKeymap,
|
||||
} from "@opencode-ai/tui/keymap"
|
||||
import type {
|
||||
FooterPromptRoute,
|
||||
FooterQueuedPrompt,
|
||||
FooterState,
|
||||
FooterSubagentState,
|
||||
FooterView,
|
||||
PermissionReply,
|
||||
QuestionReject,
|
||||
QuestionReply,
|
||||
RunAgent,
|
||||
RunCommand,
|
||||
RunDiffStyle,
|
||||
RunInput,
|
||||
RunPrompt,
|
||||
RunProvider,
|
||||
RunResource,
|
||||
RunTuiConfig,
|
||||
} from "./types"
|
||||
import type { RunTheme } from "./theme"
|
||||
import { modelInfo } from "./variant.shared"
|
||||
|
||||
const EMPTY_BORDER = {
|
||||
topLeft: "",
|
||||
bottomLeft: "",
|
||||
vertical: "",
|
||||
topRight: "",
|
||||
bottomRight: "",
|
||||
horizontal: " ",
|
||||
bottomT: "",
|
||||
topT: "",
|
||||
cross: "",
|
||||
leftT: "",
|
||||
rightT: "",
|
||||
}
|
||||
|
||||
type RunFooterViewProps = {
|
||||
directory: string
|
||||
findFiles: (query: string) => Promise<string[]>
|
||||
agents: () => RunAgent[]
|
||||
resources: () => RunResource[]
|
||||
commands: () => RunCommand[] | undefined
|
||||
providers: () => RunProvider[] | undefined
|
||||
currentModel: () => RunInput["model"]
|
||||
variants: () => string[]
|
||||
currentVariant: () => string | undefined
|
||||
state: () => FooterState
|
||||
view?: () => FooterView
|
||||
subagent?: () => FooterSubagentState
|
||||
queuedPrompts?: () => FooterQueuedPrompt[]
|
||||
theme: () => RunTheme
|
||||
diffStyle?: RunDiffStyle
|
||||
tuiConfig: RunTuiConfig
|
||||
backgroundSubagents: boolean
|
||||
history?: RunPrompt[]
|
||||
agent: string
|
||||
onSubmit: (input: RunPrompt) => boolean
|
||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
||||
onQuestionReject: (input: QuestionReject) => void | Promise<void>
|
||||
onCycle: () => void
|
||||
onInterrupt: () => boolean
|
||||
onBackground?: () => void
|
||||
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
|
||||
onInputClear: () => void
|
||||
onExitRequest?: () => boolean
|
||||
onRequestExit?: (fn: (() => boolean) | undefined) => void
|
||||
onExit: () => void
|
||||
onModelSelect: (model: NonNullable<RunInput["model"]>) => void
|
||||
onVariantSelect: (variant: string | undefined) => void
|
||||
onRows: (rows: number) => void
|
||||
onLayout: (input: { route: FooterPromptRoute; autocomplete: boolean; subagentRows: number }) => void
|
||||
onStatus: (text: string) => void
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
onQueuedRemove: (messageID: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt"
|
||||
|
||||
export function RunFooterView(props: RunFooterViewProps) {
|
||||
const term = useTerminalDimensions()
|
||||
const width = createMemo(() => term().width)
|
||||
const responsive = createMemo(() => footerWidthPolicy(width()))
|
||||
const active = createMemo<FooterView>(() => props.view?.() ?? { type: "prompt" })
|
||||
const subagent = createMemo<FooterSubagentState>(() => {
|
||||
return (
|
||||
props.subagent?.() ?? {
|
||||
tabs: [],
|
||||
details: {},
|
||||
permissions: [],
|
||||
questions: [],
|
||||
}
|
||||
)
|
||||
})
|
||||
const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
|
||||
const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS)
|
||||
const queuedPrompts = createMemo(() => props.queuedPrompts?.() ?? [])
|
||||
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
|
||||
const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
|
||||
const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu")
|
||||
const selectingQueued = createMemo(() => active().type === "prompt" && route().type === "queued-menu")
|
||||
const inspecting = createMemo(() => active().type === "prompt" && route().type === "subagent")
|
||||
const commanding = createMemo(() => active().type === "prompt" && route().type === "command")
|
||||
const skilling = createMemo(() => active().type === "prompt" && route().type === "skill")
|
||||
const modeling = createMemo(() => active().type === "prompt" && route().type === "model")
|
||||
const varianting = createMemo(() => active().type === "prompt" && route().type === "variant")
|
||||
const panel = createMemo(
|
||||
() =>
|
||||
active().type === "permission" ||
|
||||
active().type === "question" ||
|
||||
selectingQueued() ||
|
||||
selectingSubagent() ||
|
||||
commanding() ||
|
||||
skilling() ||
|
||||
modeling() ||
|
||||
varianting(),
|
||||
)
|
||||
const selected = createMemo(() => {
|
||||
const current = route()
|
||||
return current.type === "subagent" ? current.sessionID : undefined
|
||||
})
|
||||
const tabs = createMemo(() => subagent().tabs)
|
||||
const activeTabs = createMemo(() => tabs().filter((item) => item.status === "running"))
|
||||
const selectedTab = createMemo(() => tabs().find((item) => item.sessionID === selected()))
|
||||
const selectedIndex = createMemo(() => {
|
||||
const sessionID = selected()
|
||||
if (!sessionID) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return tabs().findIndex((item) => item.sessionID === sessionID) + 1
|
||||
})
|
||||
const foregroundSubagents = createMemo(
|
||||
() => props.backgroundSubagents && activeTabs().some((item) => !item.background),
|
||||
)
|
||||
const model = createMemo(() => {
|
||||
const current = props.currentModel()
|
||||
return current ? modelInfo(props.providers(), current) : { model: props.state().model, provider: undefined }
|
||||
})
|
||||
const detail = createMemo(() => {
|
||||
const current = route()
|
||||
return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
|
||||
})
|
||||
const command = useKeymapSelector(
|
||||
(keymap: OpenTuiKeymap) =>
|
||||
formatKeySequence(
|
||||
keymap
|
||||
.getCommandBindings({ visibility: "registered", commands: ["command.palette.show"] })
|
||||
.get("command.palette.show")?.[0]?.sequence,
|
||||
props.tuiConfig,
|
||||
) ?? "",
|
||||
)
|
||||
const subagentShortcut = useKeymapSelector(
|
||||
(keymap: OpenTuiKeymap) =>
|
||||
formatKeySequence(
|
||||
keymap
|
||||
.getCommandBindings({ visibility: "registered", commands: ["session.child.first"] })
|
||||
.get("session.child.first")?.[0]?.sequence,
|
||||
props.tuiConfig,
|
||||
) ?? "",
|
||||
)
|
||||
const queuedShortcut = useKeymapSelector(
|
||||
(keymap: OpenTuiKeymap) =>
|
||||
formatKeySequence(
|
||||
keymap
|
||||
.getCommandBindings({ visibility: "registered", commands: ["session.queued_prompts"] })
|
||||
.get("session.queued_prompts")?.[0]?.sequence,
|
||||
props.tuiConfig,
|
||||
) ?? "",
|
||||
)
|
||||
const backgroundShortcut = useKeymapSelector(
|
||||
(keymap: OpenTuiKeymap) =>
|
||||
formatKeySequence(
|
||||
keymap
|
||||
.getCommandBindings({ visibility: "registered", commands: ["session.background"] })
|
||||
.get("session.background")?.[0]?.sequence,
|
||||
props.tuiConfig,
|
||||
) ?? "",
|
||||
)
|
||||
const interrupt = useKeymapSelector(
|
||||
(keymap: OpenTuiKeymap) =>
|
||||
formatKeySequence(
|
||||
keymap
|
||||
.getCommandBindings({ visibility: "registered", commands: ["session.interrupt"] })
|
||||
.get("session.interrupt")?.[0]?.sequence,
|
||||
props.tuiConfig,
|
||||
) ?? "",
|
||||
)
|
||||
const variantCycle = useKeymapSelector(
|
||||
(keymap: OpenTuiKeymap) =>
|
||||
formatKeyBindings(
|
||||
keymap.getCommandBindings({ visibility: "registered", commands: ["variant.cycle"] }).get("variant.cycle"),
|
||||
props.tuiConfig,
|
||||
) ?? "",
|
||||
)
|
||||
const clearShortcut = useKeymapSelector(
|
||||
(keymap: OpenTuiKeymap) =>
|
||||
formatKeySequence(
|
||||
keymap.getCommandBindings({ visibility: "registered", commands: ["prompt.clear"] }).get("prompt.clear")?.[0]
|
||||
?.sequence,
|
||||
props.tuiConfig,
|
||||
) ?? "",
|
||||
)
|
||||
const busy = createMemo(() => props.state().phase === "running")
|
||||
const armed = createMemo(() => props.state().interrupt > 0)
|
||||
const exiting = createMemo(() => props.state().exit > 0)
|
||||
const queue = createMemo(() => props.state().queue)
|
||||
const usage = createMemo(() => props.state().usage)
|
||||
const interruptLabel = createMemo(() => {
|
||||
if (!interrupt()) {
|
||||
return
|
||||
}
|
||||
|
||||
return interrupt() === "escape" ? "esc" : interrupt()
|
||||
})
|
||||
const runTheme = createMemo(() => props.theme())
|
||||
const theme = createMemo(() => runTheme().footer)
|
||||
const block = createMemo(() => runTheme().block)
|
||||
const spin = createMemo(() => {
|
||||
return {
|
||||
frames: createFrames({
|
||||
color: theme().highlight,
|
||||
style: "blocks",
|
||||
inactiveFactor: 0.6,
|
||||
minAlpha: 0.3,
|
||||
}),
|
||||
color: createColors({
|
||||
color: theme().highlight,
|
||||
style: "blocks",
|
||||
inactiveFactor: 0.6,
|
||||
minAlpha: 0.3,
|
||||
}),
|
||||
}
|
||||
})
|
||||
const permission = createMemo<Extract<FooterView, { type: "permission" }> | undefined>(() => {
|
||||
const view = active()
|
||||
return view.type === "permission" ? view : undefined
|
||||
})
|
||||
const question = createMemo<Extract<FooterView, { type: "question" }> | undefined>(() => {
|
||||
const view = active()
|
||||
return view.type === "question" ? view : undefined
|
||||
})
|
||||
const promptView = createMemo(() => {
|
||||
if (active().type !== "prompt") {
|
||||
return active().type
|
||||
}
|
||||
|
||||
const current = route()
|
||||
return current.type === "composer" ? "prompt" : current.type
|
||||
})
|
||||
|
||||
const openCommand = () => {
|
||||
setRoute({ type: "command" })
|
||||
props.onSubagentSelect?.(undefined)
|
||||
}
|
||||
|
||||
const openModel = () => {
|
||||
setRoute({ type: "model" })
|
||||
props.onSubagentSelect?.(undefined)
|
||||
}
|
||||
|
||||
const openSkillMenu = () => {
|
||||
if (props.commands() && skills().length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setRoute({ type: "skill" })
|
||||
props.onSubagentSelect?.(undefined)
|
||||
}
|
||||
|
||||
const openVariant = () => {
|
||||
setRoute({ type: "variant" })
|
||||
props.onSubagentSelect?.(undefined)
|
||||
}
|
||||
|
||||
const openSubagentMenu = () => {
|
||||
if (tabs().length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setRoute({ type: "subagent-menu" })
|
||||
props.onSubagentSelect?.(undefined)
|
||||
}
|
||||
|
||||
const openQueuedMenu = () => {
|
||||
if (queuedPrompts().length === 0) return
|
||||
setRoute({ type: "queued-menu" })
|
||||
props.onSubagentSelect?.(undefined)
|
||||
}
|
||||
|
||||
const closePanel = () => {
|
||||
setRoute({ type: "composer" })
|
||||
}
|
||||
|
||||
const openTab = (sessionID: string) => {
|
||||
setRoute({ type: "subagent", sessionID })
|
||||
props.onSubagentSelect?.(sessionID)
|
||||
}
|
||||
|
||||
const closeTab = () => {
|
||||
setRoute({ type: "composer" })
|
||||
props.onSubagentSelect?.(undefined)
|
||||
}
|
||||
|
||||
const cycleTab = (dir: -1 | 1) => {
|
||||
if (tabs().length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const routeState = route()
|
||||
const current =
|
||||
routeState.type === "subagent" ? tabs().findIndex((item) => item.sessionID === routeState.sessionID) : -1
|
||||
const index = current === -1 ? 0 : (current + dir + tabs().length) % tabs().length
|
||||
const next = tabs()[index]
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
|
||||
openTab(next.sessionID)
|
||||
}
|
||||
const composer = createPromptState({
|
||||
directory: props.directory,
|
||||
findFiles: props.findFiles,
|
||||
agents: props.agents,
|
||||
resources: props.resources,
|
||||
commands: props.commands,
|
||||
tuiConfig: props.tuiConfig,
|
||||
state: props.state,
|
||||
view: promptView,
|
||||
prompt,
|
||||
width,
|
||||
theme,
|
||||
history: props.history,
|
||||
onSubmit: props.onSubmit,
|
||||
onCycle: props.onCycle,
|
||||
onInterrupt: props.onInterrupt,
|
||||
onEditorOpen: props.onEditorOpen,
|
||||
onInputClear: props.onInputClear,
|
||||
onExitRequest: props.onExitRequest,
|
||||
onExit: props.onExit,
|
||||
onSkillMenu: openSkillMenu,
|
||||
onRows: props.onRows,
|
||||
onStatus: props.onStatus,
|
||||
})
|
||||
const shell = createMemo(() => prompt() && composer.shell())
|
||||
const menu = createMemo(() => prompt() && composer.visible())
|
||||
const stateStatus = createMemo(() => props.state().status.trim())
|
||||
const modeLabel = createMemo(() => {
|
||||
if (exiting()) {
|
||||
return "EXIT"
|
||||
}
|
||||
|
||||
return shell() ? "SHELL" : "BUILD"
|
||||
})
|
||||
const modeColor = createMemo(() => {
|
||||
if (exiting()) {
|
||||
return theme().error
|
||||
}
|
||||
|
||||
if (shell()) {
|
||||
return theme().warning
|
||||
}
|
||||
|
||||
return theme().highlight
|
||||
})
|
||||
const statusText = createMemo(() => {
|
||||
if (exiting()) {
|
||||
return `Press ${clearShortcut() || "ctrl+c"} again to exit`
|
||||
}
|
||||
|
||||
if (busy()) {
|
||||
return armed() ? "again to interrupt" : "interrupt"
|
||||
}
|
||||
|
||||
if (stateStatus().length > 0) {
|
||||
return stateStatus()
|
||||
}
|
||||
|
||||
return shell() ? "Shell mode" : ""
|
||||
})
|
||||
const activityMeta = createMemo(() => {
|
||||
if (!responsive().statusline.showActivityMeta || usage().length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return usage()
|
||||
})
|
||||
const modelStatus = createMemo(() => {
|
||||
const current = props.currentModel()
|
||||
if (!prompt() || shell() || !current) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
model: model().model,
|
||||
variant: props.currentVariant(),
|
||||
provider: undefined,
|
||||
// Prefer without provider, but keep it on the shared width policy if we add it back.
|
||||
}
|
||||
})
|
||||
const statusColor = createMemo(() => {
|
||||
if (exiting()) {
|
||||
return theme().error
|
||||
}
|
||||
|
||||
if (armed()) {
|
||||
return theme().highlight
|
||||
}
|
||||
|
||||
if (busy() || stateStatus().length > 0) {
|
||||
return theme().text
|
||||
}
|
||||
|
||||
return theme().muted
|
||||
})
|
||||
const statuslineBackground = createMemo(() => theme().status)
|
||||
const hasActivityMeta = createMemo(() => activityMeta().length > 0)
|
||||
const hasModelStatus = createMemo(() => responsive().statusline.showModel && Boolean(modelStatus()))
|
||||
const contextHints = createMemo(() => {
|
||||
if (!prompt() || shell() || !responsive().statusline.showContextHints) {
|
||||
return []
|
||||
}
|
||||
|
||||
const items: Array<{ kind: string; key: string; label: string }> = []
|
||||
if (foregroundSubagents() && backgroundShortcut()) {
|
||||
items.push({ kind: "background", key: backgroundShortcut(), label: "background" })
|
||||
}
|
||||
if (queuedPrompts().length > 0 && queuedShortcut()) {
|
||||
items.push({ kind: "queued", key: queuedShortcut(), label: `${queue()} queued` })
|
||||
}
|
||||
if (activeTabs().length > 0 && subagentShortcut()) {
|
||||
items.push({ kind: "subagents", key: subagentShortcut(), label: "subagents" })
|
||||
}
|
||||
|
||||
const limit = responsive().statusline.contextHintLimit
|
||||
return limit === undefined ? items : items.slice(0, limit)
|
||||
})
|
||||
const hasContextHints = createMemo(() => contextHints().length > 0)
|
||||
const commandHint = createMemo(() => {
|
||||
if (!prompt() || !responsive().statusline.showCommandHint) {
|
||||
return
|
||||
}
|
||||
|
||||
if (shell()) {
|
||||
return { key: "esc", label: "normal" }
|
||||
}
|
||||
|
||||
if (command()) {
|
||||
return { key: command(), label: "cmd" }
|
||||
}
|
||||
})
|
||||
const sectionSeparator = () => <span style={{ fg: theme().muted }}>· </span>
|
||||
|
||||
createEffect(() => {
|
||||
props.onRequestExit?.(composer.requestExit)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
props.onRequestExit?.(undefined)
|
||||
})
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
enabled: active().type === "prompt" && route().type === "composer" && !composer.visible(),
|
||||
commands: [
|
||||
{
|
||||
name: "command.palette.show",
|
||||
title: "Open command palette",
|
||||
category: "Prompt",
|
||||
run: openCommand,
|
||||
},
|
||||
{
|
||||
name: "variant.cycle",
|
||||
title: "Cycle model variant",
|
||||
category: "Model",
|
||||
run: props.onCycle,
|
||||
},
|
||||
],
|
||||
bindings: [
|
||||
...props.tuiConfig.keybinds.get("command.palette.show"),
|
||||
...props.tuiConfig.keybinds.get("variant.cycle"),
|
||||
],
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents(),
|
||||
priority: 1,
|
||||
commands: [
|
||||
{
|
||||
name: "session.background",
|
||||
title: "Background subagents",
|
||||
category: "Session",
|
||||
run: () => props.onBackground?.(),
|
||||
},
|
||||
],
|
||||
bindings: props.tuiConfig.keybinds.get("session.background"),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
enabled: active().type === "prompt" && route().type === "composer" && tabs().length > 0,
|
||||
commands: [
|
||||
{
|
||||
name: "session.child.first",
|
||||
title: "View subagents",
|
||||
category: "Session",
|
||||
run: openSubagentMenu,
|
||||
},
|
||||
],
|
||||
bindings: props.tuiConfig.keybinds.get("session.child.first"),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0,
|
||||
commands: [
|
||||
{
|
||||
name: "session.queued_prompts",
|
||||
title: "Manage queued prompts",
|
||||
category: "Session",
|
||||
run: openQueuedMenu,
|
||||
},
|
||||
],
|
||||
bindings: props.tuiConfig.keybinds.get("session.queued_prompts"),
|
||||
}))
|
||||
|
||||
createEffect(() => {
|
||||
const current = route()
|
||||
if (current.type !== "subagent") {
|
||||
return
|
||||
}
|
||||
|
||||
if (tabs().some((item) => item.sessionID === current.sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
closeTab()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (route().type !== "subagent-menu") {
|
||||
return
|
||||
}
|
||||
|
||||
if (tabs().length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
closePanel()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (route().type !== "queued-menu" || queuedPrompts().length > 0) return
|
||||
closePanel()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (active().type === "prompt") {
|
||||
return
|
||||
}
|
||||
|
||||
const current = route()
|
||||
if (
|
||||
current.type !== "command" &&
|
||||
current.type !== "skill" &&
|
||||
current.type !== "model" &&
|
||||
current.type !== "variant" &&
|
||||
current.type !== "queued-menu" &&
|
||||
current.type !== "subagent-menu"
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
closePanel()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.onLayout({
|
||||
route: route(),
|
||||
autocomplete: menu(),
|
||||
subagentRows: subagentMenuRows(),
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
id="run-direct-footer-shell"
|
||||
width="100%"
|
||||
height="100%"
|
||||
border={false}
|
||||
backgroundColor="transparent"
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
padding={0}
|
||||
>
|
||||
<Show when={panel() || inspecting()}>
|
||||
<box id="run-direct-footer-panel-spacer" width="100%" height={1} flexShrink={0} backgroundColor="transparent" />
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={inspecting()}
|
||||
fallback={
|
||||
<box width="100%" flexDirection="column" gap={0}>
|
||||
<For each={[promptView()]}>
|
||||
{() => (
|
||||
<box
|
||||
id="run-direct-footer-composer-frame"
|
||||
width="100%"
|
||||
flexShrink={0}
|
||||
border={panel() || prompt() ? false : ["left"]}
|
||||
borderColor={panel() || prompt() ? undefined : theme().highlight}
|
||||
customBorderChars={
|
||||
panel() || prompt()
|
||||
? undefined
|
||||
: {
|
||||
...EMPTY_BORDER,
|
||||
vertical: "█",
|
||||
}
|
||||
}
|
||||
>
|
||||
<box
|
||||
id="run-direct-footer-composer-area"
|
||||
width="100%"
|
||||
flexGrow={1}
|
||||
paddingLeft={0}
|
||||
paddingRight={0}
|
||||
paddingTop={0}
|
||||
flexDirection="column"
|
||||
backgroundColor={panel() || prompt() ? "transparent" : theme().surface}
|
||||
gap={0}
|
||||
>
|
||||
<box id="run-direct-footer-body" width="100%" flexGrow={1} flexShrink={1} flexDirection="column">
|
||||
<Switch>
|
||||
<Match when={active().type === "prompt" && route().type === "composer"}>
|
||||
<RunPromptBody
|
||||
theme={theme}
|
||||
background={() => runTheme().background}
|
||||
placeholder={composer.placeholder}
|
||||
onSubmit={composer.onSubmit}
|
||||
onKeyDown={composer.onKeyDown}
|
||||
onContentChange={composer.onContentChange}
|
||||
bind={composer.bind}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={selectingSubagent()}>
|
||||
<RunSubagentSelectBody
|
||||
theme={theme}
|
||||
tabs={tabs}
|
||||
current={selected}
|
||||
onClose={closePanel}
|
||||
onSelect={openTab}
|
||||
onRows={setSubagentMenuRows}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={selectingQueued()}>
|
||||
<RunQueuedPromptSelectBody
|
||||
theme={theme}
|
||||
prompts={queuedPrompts}
|
||||
onClose={closePanel}
|
||||
onDelete={(item) => void props.onQueuedRemove(item.messageID)}
|
||||
onEdit={async (item) => {
|
||||
if (!(await props.onQueuedRemove(item.messageID))) return
|
||||
closePanel()
|
||||
queueMicrotask(() => composer.replacePrompt(item.prompt))
|
||||
}}
|
||||
onRows={setSubagentMenuRows}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={commanding()}>
|
||||
<RunCommandMenuBody
|
||||
theme={theme}
|
||||
commands={props.commands}
|
||||
subagents={tabs}
|
||||
queued={queuedPrompts}
|
||||
variants={props.variants}
|
||||
variantCycle={variantCycle()}
|
||||
onClose={closePanel}
|
||||
onModel={openModel}
|
||||
onEditor={() => {
|
||||
closePanel()
|
||||
void composer.openEditor()
|
||||
}}
|
||||
onSkill={openSkillMenu}
|
||||
onSubagent={openSubagentMenu}
|
||||
onQueued={openQueuedMenu}
|
||||
onVariant={openVariant}
|
||||
onVariantCycle={() => {
|
||||
props.onCycle()
|
||||
closePanel()
|
||||
}}
|
||||
onCommand={(name) => {
|
||||
composer.submitText(`/${name}`)
|
||||
closePanel()
|
||||
}}
|
||||
onNew={() => {
|
||||
composer.submitText("/new")
|
||||
closePanel()
|
||||
}}
|
||||
onExit={props.onExit}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={skilling()}>
|
||||
<RunSkillSelectBody
|
||||
theme={theme}
|
||||
commands={props.commands}
|
||||
onClose={closePanel}
|
||||
onSelect={(name) => {
|
||||
composer.replacePrompt({
|
||||
text: `/${name} `,
|
||||
parts: [],
|
||||
command: {
|
||||
name,
|
||||
arguments: "",
|
||||
},
|
||||
})
|
||||
closePanel()
|
||||
}}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={modeling()}>
|
||||
<RunModelSelectBody
|
||||
theme={theme}
|
||||
providers={props.providers}
|
||||
current={props.currentModel}
|
||||
onClose={closePanel}
|
||||
onSelect={(model) => {
|
||||
props.onModelSelect(model)
|
||||
closePanel()
|
||||
}}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={varianting()}>
|
||||
<RunVariantSelectBody
|
||||
theme={theme}
|
||||
variants={props.variants}
|
||||
current={props.currentVariant}
|
||||
onClose={closePanel}
|
||||
onSelect={(variant) => {
|
||||
props.onVariantSelect(variant)
|
||||
closePanel()
|
||||
}}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={active().type === "permission"}>
|
||||
<RunPermissionBody
|
||||
request={permission()!.request}
|
||||
theme={theme()}
|
||||
block={block()}
|
||||
diffStyle={props.diffStyle}
|
||||
onReply={props.onPermissionReply}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={active().type === "question"}>
|
||||
<RunQuestionBody
|
||||
request={question()!.request}
|
||||
theme={theme()}
|
||||
onReply={props.onQuestionReply}
|
||||
onReject={props.onQuestionReject}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={!panel() && menu()}>
|
||||
<RunFooterMenu
|
||||
id="run-direct-footer-complete"
|
||||
theme={theme}
|
||||
items={composer.options}
|
||||
selected={composer.selected}
|
||||
offset={composer.offset}
|
||||
rows={composer.rows}
|
||||
limit={FOOTER_MENU_ROWS}
|
||||
border={false}
|
||||
paddingLeft={0}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={!panel() && !menu()}>
|
||||
<box
|
||||
id="run-direct-footer-statusline"
|
||||
width="100%"
|
||||
height={1}
|
||||
flexDirection="row"
|
||||
gap={0}
|
||||
flexShrink={0}
|
||||
backgroundColor={statuslineBackground()}
|
||||
>
|
||||
<box
|
||||
id="run-direct-footer-statusline-mode"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={theme().statusAccent}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text wrapMode="none" truncate>
|
||||
<span style={{ fg: modeColor(), bold: true }}>{modeLabel()}</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box
|
||||
id="run-direct-footer-statusline-main"
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
minWidth={12}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor="transparent"
|
||||
>
|
||||
<Show when={busy() && !exiting()}>
|
||||
<box id="run-direct-footer-status-spinner" flexShrink={0}>
|
||||
<spinner color={spin().color} frames={spin().frames} interval={40} />
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<text
|
||||
id="run-direct-footer-statusline-text"
|
||||
fg={statusColor()}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
>
|
||||
<Show when={busy() && !exiting()} fallback={statusText()}>
|
||||
<Show when={interruptLabel()}>
|
||||
{(label) => <span style={{ fg: armed() ? statusColor() : theme().muted }}>{label()} </span>}
|
||||
</Show>
|
||||
{statusText()}
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<Show when={activityMeta().length > 0}>
|
||||
<box
|
||||
id="run-direct-footer-statusline-meta"
|
||||
paddingRight={1}
|
||||
backgroundColor="transparent"
|
||||
flexShrink={1}
|
||||
>
|
||||
<text fg={theme().muted} wrapMode="none" truncate>
|
||||
{activityMeta()}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={responsive().statusline.showModel && modelStatus()}>
|
||||
{(info) => (
|
||||
<box
|
||||
id="run-direct-footer-statusline-model"
|
||||
paddingRight={1}
|
||||
backgroundColor="transparent"
|
||||
flexShrink={0}
|
||||
>
|
||||
<text fg={theme().text} wrapMode="none">
|
||||
{info().model}
|
||||
<Show when={info().provider}>
|
||||
{(provider) => <span style={{ fg: theme().muted }}> {provider()}</span>}
|
||||
</Show>
|
||||
<Show when={info().variant}>
|
||||
{(variant) => (
|
||||
<>
|
||||
<span style={{ fg: theme().warning, bold: true }}> {variant()}</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<For each={contextHints()}>
|
||||
{(hint, index) => (
|
||||
<box
|
||||
id={`run-direct-footer-statusline-${hint.kind}`}
|
||||
paddingRight={1}
|
||||
backgroundColor="transparent"
|
||||
flexShrink={0}
|
||||
maxWidth={24}
|
||||
>
|
||||
<text fg={theme().text} wrapMode="none" truncate>
|
||||
<Show when={index() > 0 || ((hasActivityMeta() || hasModelStatus()) && index() === 0)}>
|
||||
{sectionSeparator()}
|
||||
</Show>
|
||||
<span style={{ fg: theme().text }}>{hint.key}</span>{" "}
|
||||
<span style={{ fg: theme().muted }}>{hint.label}</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={commandHint()}>
|
||||
{(hint) => (
|
||||
<box
|
||||
id="run-direct-footer-statusline-hint"
|
||||
paddingRight={1}
|
||||
backgroundColor="transparent"
|
||||
flexShrink={0}
|
||||
maxWidth={18}
|
||||
>
|
||||
<text fg={theme().text} wrapMode="none" truncate>
|
||||
<Show when={hasActivityMeta() || hasModelStatus() || hasContextHints()}>
|
||||
{sectionSeparator()}
|
||||
</Show>
|
||||
<span style={{ fg: theme().text }}>{hint().key}</span>{" "}
|
||||
<span style={{ fg: theme().muted }}>{hint().label}</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<box
|
||||
id="run-direct-footer-subagent-frame"
|
||||
width="100%"
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
border={["left"]}
|
||||
borderColor={theme().highlight}
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
vertical: "┃",
|
||||
}}
|
||||
>
|
||||
<RunFooterSubagentBody
|
||||
active={inspecting}
|
||||
theme={runTheme}
|
||||
tab={selectedTab}
|
||||
index={selectedIndex}
|
||||
total={() => tabs().length}
|
||||
detail={detail}
|
||||
width={width}
|
||||
diffStyle={props.diffStyle}
|
||||
onCycle={cycleTab}
|
||||
onClose={closeTab}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
27
packages/opencode/src/cli/cmd/run/footer.width.ts
Normal file
27
packages/opencode/src/cli/cmd/run/footer.width.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Shared responsive width policy
|
||||
|
||||
const FOOTER_WIDTH_BREAKPOINTS = {
|
||||
compact: 80,
|
||||
commandHint: 66,
|
||||
model: 120,
|
||||
spacious: 150,
|
||||
} as const
|
||||
|
||||
export function footerWidthPolicy(width: number) {
|
||||
const compact = width >= FOOTER_WIDTH_BREAKPOINTS.compact
|
||||
const model = width >= FOOTER_WIDTH_BREAKPOINTS.model
|
||||
const spacious = width >= FOOTER_WIDTH_BREAKPOINTS.spacious
|
||||
|
||||
return {
|
||||
dialog: {
|
||||
narrow: !compact,
|
||||
},
|
||||
statusline: {
|
||||
showActivityMeta: compact,
|
||||
showCommandHint: width >= FOOTER_WIDTH_BREAKPOINTS.commandHint,
|
||||
showContextHints: compact,
|
||||
contextHintLimit: !compact ? 0 : spacious ? undefined : model ? 2 : 1,
|
||||
showModel: model,
|
||||
},
|
||||
}
|
||||
}
|
||||
256
packages/opencode/src/cli/cmd/run/permission.shared.ts
Normal file
256
packages/opencode/src/cli/cmd/run/permission.shared.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
// Pure state machine for the permission UI.
|
||||
//
|
||||
// Lives outside the JSX component so it can be tested independently. The
|
||||
// machine has three stages:
|
||||
//
|
||||
// permission → initial view with Allow once / Always / Reject options
|
||||
// always → confirmation step (Confirm / Cancel)
|
||||
// reject → text input for rejection message
|
||||
//
|
||||
// permissionRun() is the main transition: given the current state and the
|
||||
// selected option, it returns a new state and optionally a PermissionReply
|
||||
// to send to the SDK. The component calls this on enter/click.
|
||||
//
|
||||
// permissionInfo() extracts display info (icon, title, lines, diff) from
|
||||
// the request, delegating to tool.ts for tool-specific formatting.
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { PermissionReply } from "./types"
|
||||
import { toolPath, toolPermissionInfo } from "./tool"
|
||||
|
||||
type Dict = Record<string, unknown>
|
||||
|
||||
export type PermissionStage = "permission" | "always" | "reject"
|
||||
export type PermissionOption = "once" | "always" | "reject" | "confirm" | "cancel"
|
||||
|
||||
export type PermissionBodyState = {
|
||||
requestID: string
|
||||
stage: PermissionStage
|
||||
selected: PermissionOption
|
||||
message: string
|
||||
submitting: boolean
|
||||
}
|
||||
|
||||
export type PermissionInfo = {
|
||||
icon: string
|
||||
title: string
|
||||
lines: string[]
|
||||
diff?: string
|
||||
file?: string
|
||||
}
|
||||
|
||||
export type PermissionStep = {
|
||||
state: PermissionBodyState
|
||||
reply?: PermissionReply
|
||||
}
|
||||
|
||||
function dict(v: unknown): Dict {
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return { ...v }
|
||||
}
|
||||
|
||||
function text(v: unknown): string {
|
||||
return typeof v === "string" ? v : ""
|
||||
}
|
||||
|
||||
function data(request: PermissionRequest): Dict {
|
||||
const meta = dict(request.metadata)
|
||||
return {
|
||||
...meta,
|
||||
...dict(meta.input),
|
||||
}
|
||||
}
|
||||
|
||||
function patterns(request: PermissionRequest): string[] {
|
||||
return request.patterns.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
export function createPermissionBodyState(requestID: string): PermissionBodyState {
|
||||
return {
|
||||
requestID,
|
||||
stage: "permission",
|
||||
selected: "once",
|
||||
message: "",
|
||||
submitting: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionOptions(stage: PermissionStage): PermissionOption[] {
|
||||
if (stage === "permission") {
|
||||
return ["once", "always", "reject"]
|
||||
}
|
||||
|
||||
if (stage === "always") {
|
||||
return ["confirm", "cancel"]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export function permissionInfo(request: PermissionRequest): PermissionInfo {
|
||||
const pats = patterns(request)
|
||||
const input = data(request)
|
||||
const info = toolPermissionInfo(request.permission, input, dict(request.metadata), pats)
|
||||
if (info) {
|
||||
return info
|
||||
}
|
||||
|
||||
if (request.permission === "external_directory") {
|
||||
const meta = dict(request.metadata)
|
||||
const raw = text(meta.parentDir) || text(meta.filepath) || pats[0] || ""
|
||||
const dir = raw.includes("*") ? raw.slice(0, raw.indexOf("*")).replace(/[\\/]+$/, "") : raw
|
||||
return {
|
||||
icon: "←",
|
||||
title: `Access external directory ${toolPath(dir, { home: true })}`,
|
||||
lines: pats.map((item) => `- ${item}`),
|
||||
}
|
||||
}
|
||||
|
||||
if (request.permission === "doom_loop") {
|
||||
return {
|
||||
icon: "⟳",
|
||||
title: "Continue after repeated failures",
|
||||
lines: ["This keeps the session running despite repeated failures."],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
icon: "⚙",
|
||||
title: `Call tool ${request.permission}`,
|
||||
lines: [`Tool: ${request.permission}`],
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionAlwaysLines(request: PermissionRequest): string[] {
|
||||
if (request.always.length === 1 && request.always[0] === "*") {
|
||||
return [`This will allow ${request.permission} until AirCoding is restarted.`]
|
||||
}
|
||||
|
||||
return [
|
||||
"This will allow the following patterns until AirCoding is restarted.",
|
||||
...request.always.map((item) => `- ${item}`),
|
||||
]
|
||||
}
|
||||
|
||||
export function permissionLabel(option: PermissionOption): string {
|
||||
if (option === "once") return "Allow once"
|
||||
if (option === "always") return "Allow always"
|
||||
if (option === "reject") return "Reject"
|
||||
if (option === "confirm") return "Confirm"
|
||||
return "Cancel"
|
||||
}
|
||||
|
||||
export function permissionReply(requestID: string, reply: PermissionReply["reply"], message?: string): PermissionReply {
|
||||
return {
|
||||
requestID,
|
||||
reply,
|
||||
...(message && message.trim() ? { message: message.trim() } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionShift(state: PermissionBodyState, dir: -1 | 1): PermissionBodyState {
|
||||
const list = permissionOptions(state.stage)
|
||||
if (list.length === 0) {
|
||||
return state
|
||||
}
|
||||
|
||||
const idx = Math.max(0, list.indexOf(state.selected))
|
||||
const selected = list[(idx + dir + list.length) % list.length]
|
||||
return {
|
||||
...state,
|
||||
selected,
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionHover(state: PermissionBodyState, option: PermissionOption): PermissionBodyState {
|
||||
return {
|
||||
...state,
|
||||
selected: option,
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionRun(state: PermissionBodyState, requestID: string, option: PermissionOption): PermissionStep {
|
||||
if (state.submitting) {
|
||||
return { state }
|
||||
}
|
||||
|
||||
if (state.stage === "permission") {
|
||||
if (option === "always") {
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
stage: "always",
|
||||
selected: "confirm",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (option === "reject") {
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
stage: "reject",
|
||||
selected: "reject",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
reply: permissionReply(requestID, "once"),
|
||||
}
|
||||
}
|
||||
|
||||
if (state.stage !== "always") {
|
||||
return { state }
|
||||
}
|
||||
|
||||
if (option === "cancel") {
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
stage: "permission",
|
||||
selected: "always",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
reply: permissionReply(requestID, "always"),
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionReject(state: PermissionBodyState, requestID: string): PermissionReply | undefined {
|
||||
if (state.submitting) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return permissionReply(requestID, "reject", state.message)
|
||||
}
|
||||
|
||||
export function permissionCancel(state: PermissionBodyState): PermissionBodyState {
|
||||
return {
|
||||
...state,
|
||||
stage: "permission",
|
||||
selected: "reject",
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionEscape(state: PermissionBodyState): PermissionBodyState {
|
||||
if (state.stage === "always") {
|
||||
return {
|
||||
...state,
|
||||
stage: "permission",
|
||||
selected: "always",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
stage: "reject",
|
||||
selected: "reject",
|
||||
}
|
||||
}
|
||||
157
packages/opencode/src/cli/cmd/run/prompt.editor.ts
Normal file
157
packages/opencode/src/cli/cmd/run/prompt.editor.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { RunPromptPart } from "./types"
|
||||
|
||||
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
|
||||
|
||||
export function resolveEditorSlashValue(text: string) {
|
||||
const head = slashHead(text)
|
||||
if (!head || head.name.toLowerCase() !== "editor") {
|
||||
return text
|
||||
}
|
||||
|
||||
return head.arguments
|
||||
}
|
||||
|
||||
export function realignEditorPromptParts(content: string, parts: RunPromptPart[]): RunPromptPart[] {
|
||||
const matches = new Map<number, Mention | undefined>()
|
||||
const used: Array<{ start: number; end: number }> = []
|
||||
|
||||
for (const [index, part] of parts.entries()) {
|
||||
if (part.type !== "file" && part.type !== "agent") {
|
||||
continue
|
||||
}
|
||||
|
||||
const text = promptPartText(part)
|
||||
if (!text) {
|
||||
continue
|
||||
}
|
||||
|
||||
const start = findPromptPartIndex(content, text, used, promptPartStart(part))
|
||||
if (start === -1) {
|
||||
matches.set(index, undefined)
|
||||
continue
|
||||
}
|
||||
|
||||
const end = start + text.length
|
||||
used.push({ start, end })
|
||||
matches.set(index, updatePromptPart(part, start, end, text))
|
||||
}
|
||||
|
||||
const next: RunPromptPart[] = []
|
||||
for (const [index, part] of parts.entries()) {
|
||||
if (part.type !== "file" && part.type !== "agent") {
|
||||
next.push(part)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!promptPartText(part)) {
|
||||
next.push(part)
|
||||
continue
|
||||
}
|
||||
|
||||
const match = matches.get(index)
|
||||
if (match) {
|
||||
next.push(match)
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function slashHead(text: string) {
|
||||
if (!text.startsWith("/")) {
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 1; i < text.length; i++) {
|
||||
switch (text[i]) {
|
||||
case " ":
|
||||
case "\t":
|
||||
case "\n":
|
||||
return {
|
||||
name: text.slice(1, i),
|
||||
arguments: text.slice(i + 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: text.slice(1),
|
||||
arguments: "",
|
||||
}
|
||||
}
|
||||
|
||||
function promptPartText(part: Mention) {
|
||||
if (part.type === "agent") {
|
||||
return part.source?.value
|
||||
}
|
||||
|
||||
return part.source?.text.value
|
||||
}
|
||||
|
||||
function promptPartStart(part: Mention) {
|
||||
if (part.type === "agent") {
|
||||
return part.source?.start ?? Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
return part.source?.text.start ?? Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
function findPromptPartIndex(content: string, text: string, used: Array<{ start: number; end: number }>, hint: number) {
|
||||
let searchFrom = 0
|
||||
let best = -1
|
||||
let distance = Number.POSITIVE_INFINITY
|
||||
const hinted = Number.isFinite(hint)
|
||||
|
||||
while (true) {
|
||||
const start = content.indexOf(text, searchFrom)
|
||||
if (start === -1) {
|
||||
return best
|
||||
}
|
||||
|
||||
const end = start + text.length
|
||||
searchFrom = start + 1
|
||||
if (used.some((range) => start < range.end && end > range.start)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!hinted) {
|
||||
return start
|
||||
}
|
||||
|
||||
const nextDistance = Math.abs(start - hint)
|
||||
if (nextDistance < distance) {
|
||||
best = start
|
||||
distance = nextDistance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updatePromptPart(part: Mention, start: number, end: number, text: string): Mention {
|
||||
if (part.type === "agent") {
|
||||
return {
|
||||
...part,
|
||||
source: {
|
||||
start,
|
||||
end,
|
||||
value: text,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (!part.source?.text) {
|
||||
return part
|
||||
}
|
||||
|
||||
return {
|
||||
...part,
|
||||
source: {
|
||||
...part.source,
|
||||
text: {
|
||||
...part.source.text,
|
||||
start,
|
||||
end,
|
||||
value: text,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
153
packages/opencode/src/cli/cmd/run/prompt.shared.ts
Normal file
153
packages/opencode/src/cli/cmd/run/prompt.shared.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
// Pure state machine for the prompt input.
|
||||
//
|
||||
// Handles history ring navigation and prompt text helpers. All functions are
|
||||
// pure -- they take state in and return new state out, with no side effects.
|
||||
//
|
||||
// The history ring (PromptHistoryState) stores past prompts and tracks
|
||||
// the current browse position. When the user arrows up at cursor offset 0,
|
||||
// the current draft is saved and history begins. Arrowing past the end
|
||||
// restores the draft.
|
||||
export { displayCharAt, displaySlice, mentionTriggerIndex } from "../prompt-display"
|
||||
import type { RunPrompt } from "./types"
|
||||
|
||||
const HISTORY_LIMIT = 200
|
||||
|
||||
export type PromptHistoryState = {
|
||||
items: RunPrompt[]
|
||||
index: number | null
|
||||
draft: string
|
||||
}
|
||||
|
||||
export type PromptMove = {
|
||||
state: PromptHistoryState
|
||||
text?: string
|
||||
cursor?: number
|
||||
apply: boolean
|
||||
}
|
||||
|
||||
export function promptCopy(prompt: RunPrompt): RunPrompt {
|
||||
return {
|
||||
text: prompt.text,
|
||||
parts: structuredClone(prompt.parts),
|
||||
...(prompt.mode ? { mode: prompt.mode } : {}),
|
||||
...(prompt.command ? { command: prompt.command } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function promptSame(a: RunPrompt, b: RunPrompt): boolean {
|
||||
return (
|
||||
a.mode === b.mode &&
|
||||
a.text === b.text &&
|
||||
JSON.stringify(a.parts) === JSON.stringify(b.parts) &&
|
||||
JSON.stringify(a.command) === JSON.stringify(b.command)
|
||||
)
|
||||
}
|
||||
|
||||
export function isExitCommand(input: string): boolean {
|
||||
const text = input.trim().toLowerCase()
|
||||
return text === "/exit" || text === "/quit" || text === ":q"
|
||||
}
|
||||
|
||||
export function isNewCommand(input: string): boolean {
|
||||
return input.trim().toLowerCase() === "/new"
|
||||
}
|
||||
|
||||
export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
|
||||
const list = (items ?? []).filter((item) => item.text.trim().length > 0).map(promptCopy)
|
||||
const next: RunPrompt[] = []
|
||||
for (const item of list) {
|
||||
if (next.length > 0 && promptSame(next[next.length - 1], item)) {
|
||||
continue
|
||||
}
|
||||
|
||||
next.push(item)
|
||||
}
|
||||
|
||||
return {
|
||||
items: next.slice(-HISTORY_LIMIT),
|
||||
index: null,
|
||||
draft: "",
|
||||
}
|
||||
}
|
||||
|
||||
export function pushPromptHistory(state: PromptHistoryState, prompt: RunPrompt): PromptHistoryState {
|
||||
if (!prompt.text.trim()) {
|
||||
return state
|
||||
}
|
||||
|
||||
const next = promptCopy(prompt)
|
||||
if (state.items[state.items.length - 1] && promptSame(state.items[state.items.length - 1], next)) {
|
||||
return {
|
||||
...state,
|
||||
index: null,
|
||||
draft: "",
|
||||
}
|
||||
}
|
||||
|
||||
const items = [...state.items, next].slice(-HISTORY_LIMIT)
|
||||
return {
|
||||
...state,
|
||||
items,
|
||||
index: null,
|
||||
draft: "",
|
||||
}
|
||||
}
|
||||
|
||||
export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text: string, cursor: number): PromptMove {
|
||||
if (state.items.length === 0) {
|
||||
return { state, apply: false }
|
||||
}
|
||||
|
||||
if (dir === -1 && cursor !== 0) {
|
||||
return { state, apply: false }
|
||||
}
|
||||
|
||||
if (dir === 1 && cursor !== Bun.stringWidth(text)) {
|
||||
return { state, apply: false }
|
||||
}
|
||||
|
||||
if (state.index === null) {
|
||||
if (dir === 1) {
|
||||
return { state, apply: false }
|
||||
}
|
||||
|
||||
const idx = state.items.length - 1
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
index: idx,
|
||||
draft: text,
|
||||
},
|
||||
text: state.items[idx].text,
|
||||
cursor: 0,
|
||||
apply: true,
|
||||
}
|
||||
}
|
||||
|
||||
const idx = state.index + dir
|
||||
if (idx < 0) {
|
||||
return { state, apply: false }
|
||||
}
|
||||
|
||||
if (idx >= state.items.length) {
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
index: null,
|
||||
},
|
||||
text: state.draft,
|
||||
cursor: Bun.stringWidth(state.draft),
|
||||
apply: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
index: idx,
|
||||
},
|
||||
text: state.items[idx].text,
|
||||
cursor: dir === -1 ? 0 : Bun.stringWidth(state.items[idx].text),
|
||||
apply: true,
|
||||
}
|
||||
}
|
||||
340
packages/opencode/src/cli/cmd/run/question.shared.ts
Normal file
340
packages/opencode/src/cli/cmd/run/question.shared.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
// Pure state machine for the question UI.
|
||||
//
|
||||
// Supports both single-question and multi-question flows. Single questions
|
||||
// submit immediately on selection. Multi-question flows use tabs and a
|
||||
// final confirmation step.
|
||||
//
|
||||
// State transitions:
|
||||
// questionSelect → picks an option (single: submits, multi: toggles/advances)
|
||||
// questionSave → saves custom text input
|
||||
// questionMove → arrow key navigation through options
|
||||
// questionSetTab → tab navigation between questions
|
||||
// questionSubmit → builds the final QuestionReply with all answers
|
||||
//
|
||||
// Custom answers: if a question has custom=true, an extra "Type your own
|
||||
// answer" option appears. Selecting it enters editing mode with a text field.
|
||||
import type { QuestionInfo, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { QuestionReject, QuestionReply } from "./types"
|
||||
|
||||
export type QuestionBodyState = {
|
||||
requestID: string
|
||||
tab: number
|
||||
answers: string[][]
|
||||
custom: string[]
|
||||
selected: number
|
||||
editing: boolean
|
||||
submitting: boolean
|
||||
}
|
||||
|
||||
export type QuestionStep = {
|
||||
state: QuestionBodyState
|
||||
reply?: QuestionReply
|
||||
}
|
||||
|
||||
export function createQuestionBodyState(requestID: string): QuestionBodyState {
|
||||
return {
|
||||
requestID,
|
||||
tab: 0,
|
||||
answers: [],
|
||||
custom: [],
|
||||
selected: 0,
|
||||
editing: false,
|
||||
submitting: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionSync(state: QuestionBodyState, requestID: string): QuestionBodyState {
|
||||
if (state.requestID === requestID) {
|
||||
return state
|
||||
}
|
||||
|
||||
return createQuestionBodyState(requestID)
|
||||
}
|
||||
|
||||
export function questionSingle(request: QuestionRequest): boolean {
|
||||
return request.questions.length === 1 && request.questions[0]?.multiple !== true
|
||||
}
|
||||
|
||||
export function questionTabs(request: QuestionRequest): number {
|
||||
return questionSingle(request) ? 1 : request.questions.length + 1
|
||||
}
|
||||
|
||||
export function questionConfirm(request: QuestionRequest, state: QuestionBodyState): boolean {
|
||||
return !questionSingle(request) && state.tab === request.questions.length
|
||||
}
|
||||
|
||||
export function questionInfo(request: QuestionRequest, state: QuestionBodyState): QuestionInfo | undefined {
|
||||
return request.questions[state.tab]
|
||||
}
|
||||
|
||||
export function questionCustom(request: QuestionRequest, state: QuestionBodyState): boolean {
|
||||
return questionInfo(request, state)?.custom !== false
|
||||
}
|
||||
|
||||
export function questionInput(state: QuestionBodyState): string {
|
||||
return state.custom[state.tab] ?? ""
|
||||
}
|
||||
|
||||
export function questionPicked(state: QuestionBodyState): boolean {
|
||||
const value = questionInput(state)
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
|
||||
return state.answers[state.tab]?.includes(value) ?? false
|
||||
}
|
||||
|
||||
export function questionOther(request: QuestionRequest, state: QuestionBodyState): boolean {
|
||||
const info = questionInfo(request, state)
|
||||
if (!info || info.custom === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
return state.selected === info.options.length
|
||||
}
|
||||
|
||||
export function questionTotal(request: QuestionRequest, state: QuestionBodyState): number {
|
||||
const info = questionInfo(request, state)
|
||||
if (!info) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return info.options.length + (questionCustom(request, state) ? 1 : 0)
|
||||
}
|
||||
|
||||
export function questionAnswers(state: QuestionBodyState, count: number): string[][] {
|
||||
return Array.from({ length: count }, (_, idx) => state.answers[idx] ?? [])
|
||||
}
|
||||
|
||||
export function questionSetTab(state: QuestionBodyState, tab: number): QuestionBodyState {
|
||||
return {
|
||||
...state,
|
||||
tab,
|
||||
selected: 0,
|
||||
editing: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionSetSelected(state: QuestionBodyState, selected: number): QuestionBodyState {
|
||||
return {
|
||||
...state,
|
||||
selected,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionSetEditing(state: QuestionBodyState, editing: boolean): QuestionBodyState {
|
||||
return {
|
||||
...state,
|
||||
editing,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionSetSubmitting(state: QuestionBodyState, submitting: boolean): QuestionBodyState {
|
||||
return {
|
||||
...state,
|
||||
submitting,
|
||||
}
|
||||
}
|
||||
|
||||
function storeAnswers(state: QuestionBodyState, tab: number, list: string[]): QuestionBodyState {
|
||||
const answers = [...state.answers]
|
||||
answers[tab] = list
|
||||
return {
|
||||
...state,
|
||||
answers,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionStoreCustom(state: QuestionBodyState, tab: number, text: string): QuestionBodyState {
|
||||
const custom = [...state.custom]
|
||||
custom[tab] = text
|
||||
return {
|
||||
...state,
|
||||
custom,
|
||||
}
|
||||
}
|
||||
|
||||
function questionPick(
|
||||
state: QuestionBodyState,
|
||||
request: QuestionRequest,
|
||||
answer: string,
|
||||
custom = false,
|
||||
): QuestionStep {
|
||||
const answers = [...state.answers]
|
||||
answers[state.tab] = [answer]
|
||||
let next: QuestionBodyState = {
|
||||
...state,
|
||||
answers,
|
||||
editing: false,
|
||||
}
|
||||
|
||||
if (custom) {
|
||||
const list = [...state.custom]
|
||||
list[state.tab] = answer
|
||||
next = {
|
||||
...next,
|
||||
custom: list,
|
||||
}
|
||||
}
|
||||
|
||||
if (questionSingle(request)) {
|
||||
return {
|
||||
state: next,
|
||||
reply: {
|
||||
requestID: request.id,
|
||||
answers: [[answer]],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: questionSetTab(next, state.tab + 1),
|
||||
}
|
||||
}
|
||||
|
||||
function questionToggle(state: QuestionBodyState, answer: string): QuestionBodyState {
|
||||
const list = [...(state.answers[state.tab] ?? [])]
|
||||
const idx = list.indexOf(answer)
|
||||
if (idx === -1) {
|
||||
list.push(answer)
|
||||
} else {
|
||||
list.splice(idx, 1)
|
||||
}
|
||||
|
||||
return storeAnswers(state, state.tab, list)
|
||||
}
|
||||
|
||||
export function questionMove(state: QuestionBodyState, request: QuestionRequest, dir: -1 | 1): QuestionBodyState {
|
||||
const total = questionTotal(request, state)
|
||||
if (total === 0) {
|
||||
return state
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
selected: (state.selected + dir + total) % total,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionSelect(state: QuestionBodyState, request: QuestionRequest): QuestionStep {
|
||||
const info = questionInfo(request, state)
|
||||
if (!info) {
|
||||
return { state }
|
||||
}
|
||||
|
||||
if (questionOther(request, state)) {
|
||||
if (!info.multiple) {
|
||||
return {
|
||||
state: questionSetEditing(state, true),
|
||||
}
|
||||
}
|
||||
|
||||
const value = questionInput(state)
|
||||
if (value && questionPicked(state)) {
|
||||
return {
|
||||
state: questionToggle(state, value),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: questionSetEditing(state, true),
|
||||
}
|
||||
}
|
||||
|
||||
const option = info.options[state.selected]
|
||||
if (!option) {
|
||||
return { state }
|
||||
}
|
||||
|
||||
if (info.multiple) {
|
||||
return {
|
||||
state: questionToggle(state, option.label),
|
||||
}
|
||||
}
|
||||
|
||||
return questionPick(state, request, option.label)
|
||||
}
|
||||
|
||||
export function questionSave(state: QuestionBodyState, request: QuestionRequest): QuestionStep {
|
||||
const info = questionInfo(request, state)
|
||||
if (!info) {
|
||||
return { state }
|
||||
}
|
||||
|
||||
const value = questionInput(state).trim()
|
||||
const prev = state.custom[state.tab]
|
||||
if (!value) {
|
||||
if (!prev) {
|
||||
return {
|
||||
state: questionSetEditing(state, false),
|
||||
}
|
||||
}
|
||||
|
||||
const next = questionStoreCustom(state, state.tab, "")
|
||||
return {
|
||||
state: questionSetEditing(
|
||||
storeAnswers(
|
||||
next,
|
||||
state.tab,
|
||||
(state.answers[state.tab] ?? []).filter((item) => item !== prev),
|
||||
),
|
||||
false,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (info.multiple) {
|
||||
const answers = [...(state.answers[state.tab] ?? [])]
|
||||
if (prev) {
|
||||
const idx = answers.indexOf(prev)
|
||||
if (idx !== -1) {
|
||||
answers.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (!answers.includes(value)) {
|
||||
answers.push(value)
|
||||
}
|
||||
|
||||
const next = questionStoreCustom(state, state.tab, value)
|
||||
return {
|
||||
state: questionSetEditing(storeAnswers(next, state.tab, answers), false),
|
||||
}
|
||||
}
|
||||
|
||||
return questionPick(state, request, value, true)
|
||||
}
|
||||
|
||||
export function questionSubmit(request: QuestionRequest, state: QuestionBodyState): QuestionReply {
|
||||
return {
|
||||
requestID: request.id,
|
||||
answers: questionAnswers(state, request.questions.length),
|
||||
}
|
||||
}
|
||||
|
||||
export function questionReject(request: QuestionRequest): QuestionReject {
|
||||
return {
|
||||
requestID: request.id,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionHint(request: QuestionRequest, state: QuestionBodyState): string {
|
||||
if (state.submitting) {
|
||||
return "Waiting for question event..."
|
||||
}
|
||||
|
||||
if (questionConfirm(request, state)) {
|
||||
return "enter submit esc dismiss"
|
||||
}
|
||||
|
||||
if (state.editing) {
|
||||
return "enter save esc cancel"
|
||||
}
|
||||
|
||||
const info = questionInfo(request, state)
|
||||
if (questionSingle(request)) {
|
||||
return `↑↓ select enter ${info?.multiple ? "toggle" : "submit"} esc dismiss`
|
||||
}
|
||||
|
||||
return `⇆ tab ↑↓ select enter ${info?.multiple ? "toggle" : "confirm"} esc dismiss`
|
||||
}
|
||||
202
packages/opencode/src/cli/cmd/run/runtime.boot.ts
Normal file
202
packages/opencode/src/cli/cmd/run/runtime.boot.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
// Boot-time resolution for direct interactive mode.
|
||||
//
|
||||
// These functions run concurrently at startup to gather everything the runtime
|
||||
// needs before the first frame: TUI keymap config, diff display style,
|
||||
// model variant list with context limits, and session history for the prompt
|
||||
// history ring. All are async because they read config or hit the SDK, but
|
||||
// none block each other.
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { resolve } from "@opencode-ai/tui/config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { reusePendingTask } from "./runtime.shared"
|
||||
import { resolveSession, sessionHistory } from "./session.shared"
|
||||
import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
|
||||
import { pickVariant } from "./variant.shared"
|
||||
|
||||
export type ModelInfo = {
|
||||
providers: RunProvider[]
|
||||
variants: string[]
|
||||
limits: Record<string, number>
|
||||
}
|
||||
|
||||
export type SessionInfo = {
|
||||
first: boolean
|
||||
history: RunPrompt[]
|
||||
variant: string | undefined
|
||||
}
|
||||
|
||||
type Config = Awaited<ReturnType<typeof TuiConfig.get>>
|
||||
type BootService = {
|
||||
readonly resolveModelInfo: (
|
||||
sdk: RunInput["sdk"],
|
||||
directory: string,
|
||||
model: RunInput["model"],
|
||||
) => Effect.Effect<ModelInfo>
|
||||
readonly resolveSessionInfo: (
|
||||
sdk: RunInput["sdk"],
|
||||
sessionID: string,
|
||||
model: RunInput["model"],
|
||||
) => Effect.Effect<SessionInfo>
|
||||
readonly resolveRunTuiConfig: () => Effect.Effect<RunTuiConfig>
|
||||
readonly resolveDiffStyle: () => Effect.Effect<RunDiffStyle>
|
||||
}
|
||||
|
||||
const configTask: { current?: Promise<Config> } = {}
|
||||
|
||||
class Service extends Context.Service<Service, BootService>()("@opencode/RunBoot") {}
|
||||
|
||||
function loadConfig() {
|
||||
return reusePendingTask(configTask, () => TuiConfig.get())
|
||||
}
|
||||
|
||||
function emptyModelInfo(): ModelInfo {
|
||||
return {
|
||||
providers: [],
|
||||
variants: [],
|
||||
limits: {},
|
||||
}
|
||||
}
|
||||
|
||||
function emptySessionInfo(): SessionInfo {
|
||||
return {
|
||||
first: true,
|
||||
history: [],
|
||||
variant: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function defaultRunTuiConfig(): RunTuiConfig {
|
||||
return {
|
||||
...resolve({}, { terminalSuspend: process.platform !== "win32" }),
|
||||
diff_style: "auto",
|
||||
}
|
||||
}
|
||||
|
||||
function runTuiConfig(config: Config | undefined): RunTuiConfig {
|
||||
if (!config) {
|
||||
return defaultRunTuiConfig()
|
||||
}
|
||||
|
||||
return {
|
||||
keybinds: config.keybinds,
|
||||
leader_timeout: config.leader_timeout,
|
||||
diff_style: config.diff_style ?? "auto",
|
||||
}
|
||||
}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = Effect.fn("RunBoot.config")(() => Effect.promise(() => loadConfig().catch(() => undefined)))
|
||||
|
||||
const resolveModelInfo = Effect.fn("RunBoot.resolveModelInfo")(function* (
|
||||
sdk: RunInput["sdk"],
|
||||
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 limits = Object.fromEntries(
|
||||
providers.flatMap((provider) =>
|
||||
Object.entries(provider.models ?? {}).flatMap(([modelID, info]) => {
|
||||
const limit = info?.limit?.context
|
||||
if (typeof limit !== "number" || limit <= 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [[`${provider.id}/${modelID}`, limit] as const]
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
if (!model) {
|
||||
return {
|
||||
providers,
|
||||
variants: [],
|
||||
limits,
|
||||
}
|
||||
}
|
||||
|
||||
const info = providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]
|
||||
return {
|
||||
providers,
|
||||
variants: Object.keys(info?.variants ?? {}),
|
||||
limits,
|
||||
}
|
||||
})
|
||||
|
||||
const resolveSessionInfo = Effect.fn("RunBoot.resolveSessionInfo")(function* (
|
||||
sdk: RunInput["sdk"],
|
||||
sessionID: string,
|
||||
model: RunInput["model"],
|
||||
) {
|
||||
const session = yield* Effect.promise(() => resolveSession(sdk, sessionID).catch(() => undefined))
|
||||
if (!session) {
|
||||
return emptySessionInfo()
|
||||
}
|
||||
|
||||
return {
|
||||
first: session.first,
|
||||
history: sessionHistory(session),
|
||||
variant: pickVariant(model, session),
|
||||
}
|
||||
})
|
||||
|
||||
const resolveRunTuiConfig = Effect.fn("RunBoot.resolveRunTuiConfig")(function* () {
|
||||
return runTuiConfig(yield* config())
|
||||
})
|
||||
|
||||
const resolveDiffStyle = Effect.fn("RunBoot.resolveDiffStyle")(function* () {
|
||||
return runTuiConfig(yield* config()).diff_style ?? "auto"
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
resolveModelInfo,
|
||||
resolveSessionInfo,
|
||||
resolveRunTuiConfig,
|
||||
resolveDiffStyle,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const runtime = makeRuntime(Service, layer)
|
||||
|
||||
// Fetches available variants and context limits for every provider/model pair.
|
||||
export async function resolveModelInfo(
|
||||
sdk: RunInput["sdk"],
|
||||
directory: string,
|
||||
model: RunInput["model"],
|
||||
): Promise<ModelInfo> {
|
||||
return runtime.runPromise((svc) => svc.resolveModelInfo(sdk, directory, model)).catch(() => emptyModelInfo())
|
||||
}
|
||||
|
||||
// Fetches session messages to determine if this is the first turn and build prompt history.
|
||||
export async function resolveSessionInfo(
|
||||
sdk: RunInput["sdk"],
|
||||
sessionID: string,
|
||||
model: RunInput["model"],
|
||||
): Promise<SessionInfo> {
|
||||
return runtime.runPromise((svc) => svc.resolveSessionInfo(sdk, sessionID, model)).catch(() => emptySessionInfo())
|
||||
}
|
||||
|
||||
// Reads TUI config once for direct mode keymap setup and display preferences.
|
||||
export async function resolveRunTuiConfig(): Promise<RunTuiConfig> {
|
||||
return runtime.runPromise((svc) => svc.resolveRunTuiConfig()).catch(() => defaultRunTuiConfig())
|
||||
}
|
||||
|
||||
export async function resolveDiffStyle(): Promise<RunDiffStyle> {
|
||||
return runtime.runPromise((svc) => svc.resolveDiffStyle()).catch(() => "auto")
|
||||
}
|
||||
406
packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts
Normal file
406
packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
// Lifecycle management for the split-footer renderer.
|
||||
//
|
||||
// Creates the OpenTUI CliRenderer in split-footer mode, resolves the theme
|
||||
// from the terminal palette, writes the entry splash to scrollback, and
|
||||
// constructs the RunFooter. Returns a Lifecycle handle whose close() writes
|
||||
// the exit splash and tears everything down in the right order:
|
||||
// footer.close → footer.destroy → renderer shutdown.
|
||||
//
|
||||
// Also wires SIGINT so Ctrl-c clears a live prompt draft first, then falls
|
||||
// back to the usual two-press exit sequence through RunFooter.requestExit().
|
||||
import path from "path"
|
||||
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { openEditor } from "@opencode-ai/tui/editor"
|
||||
import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
|
||||
import { Session as SessionApi } from "@/session/session"
|
||||
import * as Locale from "@/util/locale"
|
||||
import { resolveInteractiveStdin } from "./runtime.stdin"
|
||||
import { entrySplash, exitSplash, splashMeta } from "./splash"
|
||||
import { resolveRunTheme } from "./theme"
|
||||
import type {
|
||||
FooterApi,
|
||||
PermissionReply,
|
||||
QuestionReject,
|
||||
QuestionReply,
|
||||
RunAgent,
|
||||
RunInput,
|
||||
RunPrompt,
|
||||
RunResource,
|
||||
RunTuiConfig,
|
||||
} from "./types"
|
||||
import { formatModelLabel } from "./variant.shared"
|
||||
|
||||
const FOOTER_HEIGHT = 4
|
||||
|
||||
type SplashState = {
|
||||
entry: boolean
|
||||
exit: boolean
|
||||
}
|
||||
|
||||
type CycleResult = {
|
||||
modelLabel?: string
|
||||
status?: string
|
||||
variant?: string | undefined
|
||||
variants?: string[]
|
||||
}
|
||||
|
||||
type FooterLabels = {
|
||||
agentLabel: string
|
||||
modelLabel: string
|
||||
}
|
||||
|
||||
export type LifecycleInput = {
|
||||
directory: string
|
||||
findFiles: (query: string) => Promise<string[]>
|
||||
agents: RunAgent[]
|
||||
resources: RunResource[]
|
||||
sessionID: string
|
||||
sessionTitle?: string
|
||||
getSessionID?: () => string | undefined
|
||||
first: boolean
|
||||
history: RunPrompt[]
|
||||
agent: string | undefined
|
||||
model: RunInput["model"]
|
||||
variant: string | undefined
|
||||
tuiConfig: RunTuiConfig
|
||||
backgroundSubagents: boolean
|
||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
||||
onQuestionReject: (input: QuestionReject) => void | Promise<void>
|
||||
onCycleVariant?: () => CycleResult | void
|
||||
onModelSelect?: (model: NonNullable<RunInput["model"]>) => CycleResult | void | Promise<CycleResult | void>
|
||||
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
|
||||
onInterrupt?: () => void
|
||||
onBackground?: () => void
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
}
|
||||
|
||||
export type Lifecycle = {
|
||||
footer: FooterApi
|
||||
onResize(fn: () => void): () => void
|
||||
refreshTheme(): void
|
||||
resetForReplay(input: { sessionTitle?: string; sessionID?: string; history: RunPrompt[] }): Promise<void>
|
||||
close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise<void>
|
||||
}
|
||||
|
||||
// Gracefully tears down the renderer. Order matters: switch external output
|
||||
// back to passthrough before leaving split-footer mode, so pending stdout
|
||||
// doesn't get captured into the now-dead scrollback pipeline.
|
||||
function shutdown(renderer: CliRenderer): void {
|
||||
if (renderer.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (renderer.externalOutputMode === "capture-stdout") {
|
||||
renderer.externalOutputMode = "passthrough"
|
||||
}
|
||||
|
||||
if (renderer.screenMode === "split-footer") {
|
||||
renderer.screenMode = "main-screen"
|
||||
}
|
||||
|
||||
if (!renderer.isDestroyed) {
|
||||
renderer.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
function splashInfo(title: string | undefined, history: RunPrompt[]) {
|
||||
if (title && !SessionApi.isDefaultTitle(title)) {
|
||||
return {
|
||||
title,
|
||||
showSession: true,
|
||||
}
|
||||
}
|
||||
|
||||
const next = history.find((item) => item.text.trim().length > 0)
|
||||
return {
|
||||
title: next?.text ?? title,
|
||||
showSession: !!next,
|
||||
}
|
||||
}
|
||||
|
||||
function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): FooterLabels {
|
||||
const agentLabel = Locale.titlecase(input.agent ?? "aircoding")
|
||||
|
||||
if (!input.model) {
|
||||
return {
|
||||
agentLabel,
|
||||
modelLabel: "Model default",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agentLabel,
|
||||
modelLabel: formatModelLabel(input.model, input.variant),
|
||||
}
|
||||
}
|
||||
|
||||
function directoryLabel(directory: string) {
|
||||
const resolved = path.resolve(directory)
|
||||
const display =
|
||||
resolved === Global.Path.home
|
||||
? "~"
|
||||
: resolved.startsWith(`${Global.Path.home}${path.sep}`)
|
||||
? resolved.replace(Global.Path.home, "~")
|
||||
: resolved
|
||||
return display.replaceAll("\\", "/")
|
||||
}
|
||||
|
||||
function queueSplash(
|
||||
renderer: Pick<CliRenderer, "writeToScrollback" | "requestRender">,
|
||||
state: SplashState,
|
||||
phase: keyof SplashState,
|
||||
write: ScrollbackWriter | undefined,
|
||||
): boolean {
|
||||
if (state[phase]) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!write) {
|
||||
return false
|
||||
}
|
||||
|
||||
state[phase] = true
|
||||
renderer.writeToScrollback(write)
|
||||
renderer.requestRender()
|
||||
return true
|
||||
}
|
||||
|
||||
// Boots the split-footer renderer and constructs the RunFooter.
|
||||
//
|
||||
// The renderer starts in split-footer mode with captured stdout so that
|
||||
// scrollback commits and footer repaints happen in the same frame. After
|
||||
// the entry splash, RunFooter takes over the footer region.
|
||||
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
|
||||
const source = resolveInteractiveStdin()
|
||||
let unregisterKeymap: (() => void) | undefined
|
||||
|
||||
try {
|
||||
const renderer = await createCliRenderer({
|
||||
stdin: source.stdin,
|
||||
targetFps: 30,
|
||||
maxFps: 60,
|
||||
useMouse: false,
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: { events: process.platform === "win32" },
|
||||
screenMode: "split-footer",
|
||||
footerHeight: FOOTER_HEIGHT,
|
||||
externalOutputMode: "capture-stdout",
|
||||
consoleMode: "disabled",
|
||||
clearOnShutdown: false,
|
||||
})
|
||||
const theme = await resolveRunTheme(renderer)
|
||||
renderer.setBackgroundColor(theme.background)
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
unregisterKeymap = registerOpencodeKeymap(keymap, renderer, input.tuiConfig)
|
||||
const state: SplashState = {
|
||||
entry: false,
|
||||
exit: false,
|
||||
}
|
||||
const splash = splashInfo(input.sessionTitle, input.history)
|
||||
const meta = splashMeta({
|
||||
title: splash.title,
|
||||
session_id: input.sessionID,
|
||||
})
|
||||
const labels = footerLabels({
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
variant: input.variant,
|
||||
})
|
||||
const footerTask = import("./footer")
|
||||
const wrote = queueSplash(
|
||||
renderer,
|
||||
state,
|
||||
"entry",
|
||||
entrySplash({
|
||||
...meta,
|
||||
theme: theme.splash,
|
||||
showSession: splash.showSession,
|
||||
detail: directoryLabel(input.directory),
|
||||
}),
|
||||
)
|
||||
await renderer.idle().catch(() => {})
|
||||
|
||||
const { RunFooter } = await footerTask
|
||||
let closed = false
|
||||
let sigintRegistered = false
|
||||
|
||||
const footer = new RunFooter(renderer, {
|
||||
directory: input.directory,
|
||||
findFiles: input.findFiles,
|
||||
agents: input.agents,
|
||||
resources: input.resources,
|
||||
sessionID: input.getSessionID ?? (() => input.sessionID),
|
||||
...labels,
|
||||
model: input.model,
|
||||
variant: input.variant,
|
||||
first: input.first,
|
||||
history: input.history,
|
||||
theme,
|
||||
wrote,
|
||||
keymap,
|
||||
tuiConfig: input.tuiConfig,
|
||||
backgroundSubagents: input.backgroundSubagents,
|
||||
diffStyle: input.tuiConfig.diff_style ?? "auto",
|
||||
onPermissionReply: input.onPermissionReply,
|
||||
onQuestionReply: input.onQuestionReply,
|
||||
onQuestionReject: input.onQuestionReject,
|
||||
onCycleVariant: input.onCycleVariant,
|
||||
onModelSelect: input.onModelSelect,
|
||||
onVariantSelect: input.onVariantSelect,
|
||||
onInterrupt: input.onInterrupt,
|
||||
onBackground: input.onBackground,
|
||||
onEditorOpen: async ({ value }) => {
|
||||
if (closed || renderer.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
await renderer.idle().catch(() => {})
|
||||
const ignore = () => {}
|
||||
detachSigint()
|
||||
process.on("SIGINT", ignore)
|
||||
try {
|
||||
return await openEditor({
|
||||
value,
|
||||
cwd: input.directory,
|
||||
renderer,
|
||||
stdin: source.stdin,
|
||||
})
|
||||
} finally {
|
||||
process.off("SIGINT", ignore)
|
||||
attachSigint()
|
||||
}
|
||||
},
|
||||
onSubagentSelect: input.onSubagentSelect,
|
||||
})
|
||||
|
||||
const sigint = () => {
|
||||
footer.requestExit()
|
||||
}
|
||||
|
||||
const attachSigint = () => {
|
||||
if (closed || sigintRegistered) {
|
||||
return
|
||||
}
|
||||
|
||||
process.on("SIGINT", sigint)
|
||||
sigintRegistered = true
|
||||
}
|
||||
|
||||
const detachSigint = () => {
|
||||
if (!sigintRegistered) {
|
||||
return
|
||||
}
|
||||
|
||||
process.off("SIGINT", sigint)
|
||||
sigintRegistered = false
|
||||
}
|
||||
|
||||
attachSigint()
|
||||
|
||||
const close = async (next: {
|
||||
showExit: boolean
|
||||
sessionTitle?: string
|
||||
sessionID?: string
|
||||
history?: RunPrompt[]
|
||||
}) => {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
|
||||
closed = true
|
||||
detachSigint()
|
||||
let wroteExit = false
|
||||
|
||||
try {
|
||||
await footer.idle().catch(() => {})
|
||||
|
||||
const show = renderer.isDestroyed ? false : next.showExit
|
||||
if (!renderer.isDestroyed && show) {
|
||||
const sessionID = next.sessionID || input.getSessionID?.() || input.sessionID
|
||||
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history ?? input.history)
|
||||
wroteExit = queueSplash(
|
||||
renderer,
|
||||
state,
|
||||
"exit",
|
||||
exitSplash({
|
||||
...splashMeta({
|
||||
title: splash.title,
|
||||
session_id: sessionID,
|
||||
}),
|
||||
theme: footer.currentTheme().splash,
|
||||
}),
|
||||
)
|
||||
await renderer.idle().catch(() => {})
|
||||
}
|
||||
} finally {
|
||||
footer.close()
|
||||
await footer.idle().catch(() => {})
|
||||
footer.destroy()
|
||||
unregisterKeymap?.()
|
||||
shutdown(renderer)
|
||||
if (!wroteExit) {
|
||||
process.stdout.write("\n")
|
||||
}
|
||||
source.cleanup?.()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
footer,
|
||||
refreshTheme() {
|
||||
footer.refreshTheme()
|
||||
},
|
||||
onResize(fn) {
|
||||
let width = renderer.terminalWidth
|
||||
let height = renderer.terminalHeight
|
||||
const resize = () => {
|
||||
if (width === renderer.terminalWidth && height === renderer.terminalHeight) {
|
||||
return
|
||||
}
|
||||
|
||||
width = renderer.terminalWidth
|
||||
height = renderer.terminalHeight
|
||||
fn()
|
||||
}
|
||||
renderer.on(CliRenderEvents.RESIZE, resize)
|
||||
return () => renderer.off(CliRenderEvents.RESIZE, resize)
|
||||
},
|
||||
async resetForReplay(next) {
|
||||
if (closed || renderer.isDestroyed || footer.isClosed) {
|
||||
throw new Error("runtime closed")
|
||||
}
|
||||
|
||||
await footer.idle()
|
||||
if (closed || renderer.isDestroyed || footer.isClosed) {
|
||||
throw new Error("runtime closed")
|
||||
}
|
||||
|
||||
footer.resetForReplay(true)
|
||||
renderer.resetSplitFooterForReplay({ clearSavedLines: true })
|
||||
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history)
|
||||
renderer.writeToScrollback(
|
||||
entrySplash({
|
||||
...splashMeta({
|
||||
title: splash.title,
|
||||
session_id: next.sessionID ?? input.getSessionID?.() ?? input.sessionID,
|
||||
}),
|
||||
theme: footer.currentTheme().splash,
|
||||
showSession: splash.showSession,
|
||||
detail: directoryLabel(input.directory),
|
||||
}),
|
||||
)
|
||||
renderer.requestRender()
|
||||
},
|
||||
close,
|
||||
}
|
||||
} catch (error) {
|
||||
unregisterKeymap?.()
|
||||
source.cleanup?.()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
349
packages/opencode/src/cli/cmd/run/runtime.queue.ts
Normal file
349
packages/opencode/src/cli/cmd/run/runtime.queue.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
// Serial prompt queue for direct interactive mode.
|
||||
//
|
||||
// Prompts arrive from the footer (user types and hits enter) and queue up
|
||||
// here. The queue drains one turn at a time; ordinary prompts waiting behind
|
||||
// an active ordinary turn are exposed for edit/removal until they begin.
|
||||
//
|
||||
// The queue also handles /exit, /quit, and /new commands, empty-prompt rejection,
|
||||
// and tracks per-turn wall-clock duration for the footer status line.
|
||||
//
|
||||
// Resolves when the footer closes and all in-flight work finishes.
|
||||
import * as Locale from "@/util/locale"
|
||||
import { MessageID, PartID } from "@/session/schema"
|
||||
import { isExitCommand, isNewCommand } from "./prompt.shared"
|
||||
import type { FooterApi, FooterEvent, FooterQueuedPrompt, RunPrompt } from "./types"
|
||||
|
||||
type Trace = {
|
||||
write(type: string, data?: unknown): void
|
||||
}
|
||||
|
||||
type Deferred<T = void> = {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T | PromiseLike<T>) => void
|
||||
reject: (error?: unknown) => void
|
||||
}
|
||||
|
||||
export type QueueInput = {
|
||||
footer: FooterApi
|
||||
initialInput?: string
|
||||
trace?: Trace
|
||||
onSend?: (prompt: RunPrompt) => void
|
||||
onNewSession?: () => void | Promise<void>
|
||||
run: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
|
||||
}
|
||||
|
||||
type State = {
|
||||
queue: RunPrompt[]
|
||||
queued: FooterQueuedPrompt[]
|
||||
active?: RunPrompt
|
||||
ctrl?: AbortController
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
function defer<T = void>(): Deferred<T> {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
let reject!: (error?: unknown) => void
|
||||
const promise = new Promise<T>((next, fail) => {
|
||||
resolve = next
|
||||
reject = fail
|
||||
})
|
||||
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
// Runs the prompt queue until the footer closes.
|
||||
//
|
||||
// Subscribes to footer prompt events and drains operations through input.run().
|
||||
// Ordinary prompts submitted during an ordinary active turn remain local and
|
||||
// are exposed by the footer for edit/removal until their turn begins.
|
||||
export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
const stop = defer<{ type: "closed" }>()
|
||||
const done = defer()
|
||||
const state: State = {
|
||||
queue: [],
|
||||
queued: [],
|
||||
closed: input.footer.isClosed,
|
||||
}
|
||||
let draining: Promise<void> | undefined
|
||||
|
||||
const emit = (next: FooterEvent, row: Record<string, unknown>) => {
|
||||
input.trace?.write("ui.patch", row)
|
||||
input.footer.event(next)
|
||||
}
|
||||
|
||||
const syncQueue = () => {
|
||||
const queue = state.queue.length
|
||||
emit({ type: "queue", queue }, { queue })
|
||||
emit(
|
||||
{
|
||||
type: "queued.prompts",
|
||||
prompts: [...state.queued],
|
||||
},
|
||||
{ queued: state.queued.length },
|
||||
)
|
||||
}
|
||||
|
||||
const removeLocalQueued = (queued: FooterQueuedPrompt) => {
|
||||
if (!state.queued.includes(queued)) return
|
||||
state.queued = state.queued.filter((item) => item !== queued)
|
||||
syncQueue()
|
||||
}
|
||||
|
||||
const finish = () => {
|
||||
if (!state.closed || draining) {
|
||||
return
|
||||
}
|
||||
|
||||
done.resolve()
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
if (state.closed) {
|
||||
return
|
||||
}
|
||||
|
||||
state.closed = true
|
||||
state.queue.length = 0
|
||||
state.queued.length = 0
|
||||
state.ctrl?.abort()
|
||||
stop.resolve({ type: "closed" })
|
||||
finish()
|
||||
}
|
||||
|
||||
const drain = () => {
|
||||
if (draining || state.closed || state.queue.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
draining = (async () => {
|
||||
try {
|
||||
while (!state.closed && state.queue.length > 0) {
|
||||
const prompt = state.queue.shift()
|
||||
if (!prompt) {
|
||||
continue
|
||||
}
|
||||
|
||||
const queued = state.queued.find((item) => item.prompt === prompt)
|
||||
if (queued) removeLocalQueued(queued)
|
||||
|
||||
if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
|
||||
syncQueue()
|
||||
if (!input.onNewSession) {
|
||||
emit(
|
||||
{
|
||||
type: "stream.patch",
|
||||
patch: {
|
||||
status: "new sessions unavailable",
|
||||
},
|
||||
},
|
||||
{
|
||||
status: "new sessions unavailable",
|
||||
},
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
emit(
|
||||
{
|
||||
type: "stream.patch",
|
||||
patch: {
|
||||
phase: "running",
|
||||
status: "starting new session",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
},
|
||||
{
|
||||
phase: "running",
|
||||
status: "starting new session",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
)
|
||||
await input.onNewSession()
|
||||
continue
|
||||
}
|
||||
|
||||
const sent =
|
||||
prompt.mode === "shell"
|
||||
? prompt
|
||||
: {
|
||||
...prompt,
|
||||
messageID: prompt.messageID ?? queued?.messageID ?? MessageID.ascending(),
|
||||
}
|
||||
state.active = sent
|
||||
|
||||
emit(
|
||||
{
|
||||
type: "turn.send",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
{
|
||||
phase: "running",
|
||||
status: "sending prompt",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
)
|
||||
const start = Date.now()
|
||||
const ctrl = new AbortController()
|
||||
state.ctrl = ctrl
|
||||
|
||||
try {
|
||||
await input.footer.idle()
|
||||
if (state.closed) {
|
||||
break
|
||||
}
|
||||
|
||||
if (sent.mode !== "shell") {
|
||||
const commit = {
|
||||
kind: "user",
|
||||
text: sent.text,
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: sent.messageID,
|
||||
} as const
|
||||
input.trace?.write("ui.commit", commit)
|
||||
input.footer.append(commit)
|
||||
}
|
||||
input.onSend?.(sent)
|
||||
|
||||
if (state.closed) {
|
||||
break
|
||||
}
|
||||
|
||||
const task = input.run(sent, ctrl.signal).then(
|
||||
() => ({ type: "done" as const }),
|
||||
(error) => ({ type: "error" as const, error }),
|
||||
)
|
||||
|
||||
const next = await Promise.race([task, stop.promise])
|
||||
if (next.type === "closed") {
|
||||
ctrl.abort()
|
||||
break
|
||||
}
|
||||
|
||||
if (next.type === "error") {
|
||||
throw next.error
|
||||
}
|
||||
} finally {
|
||||
if (state.ctrl === ctrl) {
|
||||
state.ctrl = undefined
|
||||
}
|
||||
|
||||
if (sent.mode !== "shell") {
|
||||
const duration = Locale.duration(Math.max(0, Date.now() - start))
|
||||
emit(
|
||||
{
|
||||
type: "turn.duration",
|
||||
duration,
|
||||
},
|
||||
{
|
||||
duration,
|
||||
},
|
||||
)
|
||||
}
|
||||
state.active = undefined
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
done.reject(error)
|
||||
return
|
||||
} finally {
|
||||
draining = undefined
|
||||
emit(
|
||||
{
|
||||
type: "turn.idle",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
{
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
finish()
|
||||
})()
|
||||
}
|
||||
|
||||
const submit = (prompt: RunPrompt) => {
|
||||
if (!prompt.text.trim() || state.closed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (prompt.mode !== "shell" && isExitCommand(prompt.text)) {
|
||||
input.footer.close()
|
||||
return
|
||||
}
|
||||
|
||||
const active = state.active
|
||||
if (
|
||||
active &&
|
||||
active.mode !== "shell" &&
|
||||
!active.command &&
|
||||
prompt.mode !== "shell" &&
|
||||
!prompt.command &&
|
||||
!isNewCommand(prompt.text)
|
||||
) {
|
||||
const queued: FooterQueuedPrompt = {
|
||||
messageID: MessageID.ascending(),
|
||||
partID: PartID.ascending(),
|
||||
prompt,
|
||||
}
|
||||
state.queued = [...state.queued, queued]
|
||||
state.queue.push(prompt)
|
||||
syncQueue()
|
||||
return
|
||||
}
|
||||
|
||||
state.queue.push(prompt)
|
||||
syncQueue()
|
||||
if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
|
||||
drain()
|
||||
return
|
||||
}
|
||||
|
||||
emit(
|
||||
{
|
||||
type: "first",
|
||||
first: false,
|
||||
},
|
||||
{
|
||||
first: false,
|
||||
},
|
||||
)
|
||||
drain()
|
||||
}
|
||||
|
||||
const offPrompt = input.footer.onPrompt((prompt) => {
|
||||
submit(prompt)
|
||||
})
|
||||
const offClose = input.footer.onClose(() => {
|
||||
close()
|
||||
})
|
||||
const offRemoveQueued = input.footer.onQueuedRemove((messageID) => {
|
||||
const queued = state.queued.find((item) => item.messageID === messageID)
|
||||
if (!queued) return false
|
||||
state.queue = state.queue.filter((prompt) => prompt !== queued.prompt)
|
||||
removeLocalQueued(queued)
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
if (state.closed) {
|
||||
return
|
||||
}
|
||||
|
||||
submit({
|
||||
text: input.initialInput ?? "",
|
||||
parts: [],
|
||||
})
|
||||
finish()
|
||||
await done.promise
|
||||
} finally {
|
||||
offPrompt()
|
||||
offClose()
|
||||
offRemoveQueued()
|
||||
close()
|
||||
await draining?.catch(() => {})
|
||||
}
|
||||
}
|
||||
17
packages/opencode/src/cli/cmd/run/runtime.shared.ts
Normal file
17
packages/opencode/src/cli/cmd/run/runtime.shared.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
type PendingTask<T> = {
|
||||
current?: Promise<T>
|
||||
}
|
||||
|
||||
export function reusePendingTask<T>(slot: PendingTask<T>, run: () => Promise<T>) {
|
||||
if (slot.current) {
|
||||
return slot.current
|
||||
}
|
||||
|
||||
const task = run().finally(() => {
|
||||
if (slot.current === task) {
|
||||
slot.current = undefined
|
||||
}
|
||||
})
|
||||
slot.current = task
|
||||
return task
|
||||
}
|
||||
37
packages/opencode/src/cli/cmd/run/runtime.stdin.ts
Normal file
37
packages/opencode/src/cli/cmd/run/runtime.stdin.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import fs from "fs"
|
||||
import * as tty from "node:tty"
|
||||
|
||||
export const INTERACTIVE_INPUT_ERROR = "--interactive requires a controlling terminal for input"
|
||||
|
||||
type InteractiveStdin = {
|
||||
stdin: NodeJS.ReadStream
|
||||
cleanup?: () => void
|
||||
}
|
||||
|
||||
function openTerminalStdin(path: string): NodeJS.ReadStream {
|
||||
return new tty.ReadStream(fs.openSync(path, "r"))
|
||||
}
|
||||
|
||||
export function resolveInteractiveStdin(
|
||||
stdin: NodeJS.ReadStream = process.stdin,
|
||||
open: (path: string) => NodeJS.ReadStream = openTerminalStdin,
|
||||
platform = process.platform,
|
||||
): InteractiveStdin {
|
||||
if (stdin.isTTY) {
|
||||
return { stdin }
|
||||
}
|
||||
|
||||
const file = platform === "win32" ? "CONIN$" : "/dev/tty"
|
||||
|
||||
try {
|
||||
const stream = open(file)
|
||||
return {
|
||||
stdin: stream,
|
||||
cleanup: () => {
|
||||
stream.destroy()
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(INTERACTIVE_INPUT_ERROR, { cause: error })
|
||||
}
|
||||
}
|
||||
814
packages/opencode/src/cli/cmd/run/runtime.ts
Normal file
814
packages/opencode/src/cli/cmd/run/runtime.ts
Normal file
@@ -0,0 +1,814 @@
|
||||
// Top-level orchestrator for `run --interactive`.
|
||||
//
|
||||
// Wires the boot sequence, lifecycle (renderer + footer), stream transport,
|
||||
// and prompt queue together into a single session loop. Two entry points:
|
||||
//
|
||||
// runInteractiveMode -- used when an SDK client already exists (attach mode)
|
||||
// runInteractiveLocalMode -- used for local in-process mode (no server)
|
||||
//
|
||||
// Both delegate to runInteractiveRuntime, which:
|
||||
// 1. resolves TUI config, model info, and session history,
|
||||
// 2. creates the split-footer lifecycle (renderer + RunFooter),
|
||||
// 3. starts the stream transport (SDK event subscription), lazily for fresh
|
||||
// local sessions,
|
||||
// 4. runs the prompt queue until the footer closes.
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { MessageID } from "@/session/schema"
|
||||
import { createRunDemo } from "./demo"
|
||||
import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
|
||||
import { createRuntimeLifecycle } from "./runtime.lifecycle"
|
||||
import { trace } from "./trace"
|
||||
import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared"
|
||||
import type { LocalReplayAnchor, LocalReplayRow, RunInput, RunPrompt, RunProvider, StreamCommit } from "./types"
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export { pickVariant, resolveVariant } from "./variant.shared"
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export { runPromptQueue } from "./runtime.queue"
|
||||
|
||||
type BootContext = Pick<
|
||||
RunInput,
|
||||
"sdk" | "directory" | "sessionID" | "sessionTitle" | "resume" | "agent" | "model" | "variant"
|
||||
>
|
||||
|
||||
type CreateSessionInput = {
|
||||
agent: string | undefined
|
||||
model: RunInput["model"]
|
||||
variant: string | undefined
|
||||
}
|
||||
|
||||
type CreateSession = (sdk: RunInput["sdk"], input: CreateSessionInput) => Promise<{ id: string; title?: string }>
|
||||
|
||||
type RunRuntimeInput = {
|
||||
boot: () => Promise<BootContext>
|
||||
afterPaint?: (ctx: BootContext) => Promise<void> | void
|
||||
resolveSession?: (
|
||||
ctx: BootContext,
|
||||
) => Promise<{ sessionID: string; sessionTitle?: string; agent?: string | undefined }>
|
||||
createSession?: (ctx: BootContext, input: CreateSessionInput) => Promise<ResolvedSession>
|
||||
files: RunInput["files"]
|
||||
initialInput?: string
|
||||
thinking: boolean
|
||||
backgroundSubagents: boolean
|
||||
replay?: boolean
|
||||
replayLimit?: number
|
||||
demo?: RunInput["demo"]
|
||||
}
|
||||
|
||||
type RunLocalInput = {
|
||||
directory: string
|
||||
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"]
|
||||
variant: RunInput["variant"]
|
||||
files: RunInput["files"]
|
||||
initialInput?: string
|
||||
thinking: boolean
|
||||
backgroundSubagents: boolean
|
||||
replay?: boolean
|
||||
replayLimit?: number
|
||||
demo?: RunInput["demo"]
|
||||
}
|
||||
|
||||
type StreamTransportModule = Pick<
|
||||
Awaited<typeof import("./stream.transport")>,
|
||||
"createSessionTransport" | "formatUnknownError"
|
||||
>
|
||||
|
||||
export type RunRuntimeDeps = {
|
||||
createRuntimeLifecycle?: typeof createRuntimeLifecycle
|
||||
streamTransport?: Promise<StreamTransportModule>
|
||||
}
|
||||
|
||||
type StreamState = {
|
||||
mod: StreamTransportModule
|
||||
handle: Awaited<ReturnType<StreamTransportModule["createSessionTransport"]>>
|
||||
}
|
||||
|
||||
type ResolvedSession = {
|
||||
sessionID: string
|
||||
sessionTitle?: string
|
||||
agent?: string | undefined
|
||||
}
|
||||
|
||||
function createSessionResolver(fn?: CreateSession) {
|
||||
if (!fn) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return async (ctx: BootContext, input: CreateSessionInput): Promise<ResolvedSession> => {
|
||||
const created = await fn(ctx.sdk, input)
|
||||
if (!created.id) {
|
||||
throw new Error("Failed to create session")
|
||||
}
|
||||
|
||||
return {
|
||||
sessionID: created.id,
|
||||
sessionTitle: created.title,
|
||||
agent: input.agent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type RuntimeState = {
|
||||
shown: boolean
|
||||
aborting: boolean
|
||||
model: RunInput["model"]
|
||||
providers: RunProvider[]
|
||||
variants: string[]
|
||||
limits: Record<string, number>
|
||||
activeVariant: string | undefined
|
||||
sessionID: string
|
||||
history: RunPrompt[]
|
||||
localRows: LocalReplayRow[]
|
||||
sessionTitle?: string
|
||||
agent: string | undefined
|
||||
switching?: Promise<void>
|
||||
demo?: ReturnType<typeof createRunDemo>
|
||||
selectSubagent?: (sessionID: string | undefined) => void
|
||||
session?: Promise<void>
|
||||
stream?: Promise<StreamState>
|
||||
}
|
||||
|
||||
function hasSession(input: RunRuntimeInput, state: RuntimeState) {
|
||||
return !input.resolveSession || !!state.sessionID
|
||||
}
|
||||
|
||||
function eagerStream(input: RunRuntimeInput, ctx: BootContext) {
|
||||
return ctx.resume === true || !input.resolveSession || !!input.demo
|
||||
}
|
||||
|
||||
function variantsFor(providers: RunProvider[], model: RunInput["model"]) {
|
||||
if (!model) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Object.keys(providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]?.variants ?? {})
|
||||
}
|
||||
|
||||
const RESIZE_DELAY = 250
|
||||
const LOCAL_REPLAY_ROW_LIMIT = 100
|
||||
|
||||
async function resolveExitTitle(
|
||||
ctx: BootContext,
|
||||
input: RunRuntimeInput,
|
||||
state: RuntimeState,
|
||||
): Promise<string | undefined> {
|
||||
if (!state.shown || !hasSession(input, state)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return ctx.sdk.session
|
||||
.get({
|
||||
sessionID: state.sessionID,
|
||||
})
|
||||
.then((x) => x.data?.title)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
// Core runtime loop. Boot resolves the SDK context, then we set up the
|
||||
// lifecycle (renderer + footer), wire the stream transport for SDK events,
|
||||
// and feed prompts through the queue until the user exits.
|
||||
//
|
||||
// Files only attach on the first prompt turn -- after that, includeFiles
|
||||
// flips to false so subsequent turns don't re-send attachments.
|
||||
async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDeps = {}): Promise<void> {
|
||||
const start = performance.now()
|
||||
const log = trace()
|
||||
const tuiConfigTask = resolveRunTuiConfig()
|
||||
const ctx = await input.boot()
|
||||
const modelTask = resolveModelInfo(ctx.sdk, ctx.directory, ctx.model)
|
||||
const sessionTask =
|
||||
ctx.resume === true
|
||||
? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)
|
||||
: Promise.resolve({
|
||||
first: true,
|
||||
history: [],
|
||||
variant: undefined,
|
||||
})
|
||||
const savedTask = resolveSavedVariant(ctx.model)
|
||||
const [tuiConfig, session, savedVariant] = await Promise.all([tuiConfigTask, sessionTask, savedTask])
|
||||
const state: RuntimeState = {
|
||||
shown: !session.first,
|
||||
aborting: false,
|
||||
model: ctx.model,
|
||||
providers: [],
|
||||
variants: [],
|
||||
limits: {},
|
||||
activeVariant: resolveVariant(ctx.variant, session.variant, savedVariant, []),
|
||||
sessionID: ctx.sessionID,
|
||||
history: [...session.history],
|
||||
localRows: [],
|
||||
sessionTitle: ctx.sessionTitle,
|
||||
agent: ctx.agent,
|
||||
}
|
||||
const ensureSession = () => {
|
||||
if (!input.resolveSession || state.sessionID) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
if (state.session) {
|
||||
return state.session
|
||||
}
|
||||
|
||||
state.session = input.resolveSession(ctx).then((next) => {
|
||||
state.sessionID = next.sessionID
|
||||
state.sessionTitle = next.sessionTitle ?? state.sessionTitle
|
||||
state.agent = next.agent
|
||||
})
|
||||
return state.session
|
||||
}
|
||||
|
||||
const shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({
|
||||
directory: ctx.directory,
|
||||
findFiles: (query) =>
|
||||
ctx.sdk.find
|
||||
.files({ query, directory: ctx.directory })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => []),
|
||||
agents: [],
|
||||
resources: [],
|
||||
sessionID: state.sessionID,
|
||||
sessionTitle: state.sessionTitle,
|
||||
getSessionID: () => state.sessionID,
|
||||
first: session.first,
|
||||
history: session.history,
|
||||
agent: state.agent,
|
||||
model: state.model,
|
||||
variant: state.activeVariant,
|
||||
tuiConfig,
|
||||
backgroundSubagents: input.backgroundSubagents,
|
||||
onPermissionReply: async (next) => {
|
||||
if (state.demo?.permission(next)) {
|
||||
return
|
||||
}
|
||||
|
||||
log?.write("send.permission.reply", next)
|
||||
await ctx.sdk.permission.reply(next)
|
||||
},
|
||||
onQuestionReply: async (next) => {
|
||||
if (state.demo?.questionReply(next)) {
|
||||
return
|
||||
}
|
||||
|
||||
await ctx.sdk.question.reply(next)
|
||||
},
|
||||
onQuestionReject: async (next) => {
|
||||
if (state.demo?.questionReject(next)) {
|
||||
return
|
||||
}
|
||||
|
||||
await ctx.sdk.question.reject(next)
|
||||
},
|
||||
onCycleVariant: () => {
|
||||
if (!state.model || state.variants.length === 0) {
|
||||
return {
|
||||
status: "no variants available",
|
||||
}
|
||||
}
|
||||
|
||||
state.activeVariant = cycleVariant(state.activeVariant, state.variants)
|
||||
saveVariant(state.model, state.activeVariant)
|
||||
return {
|
||||
status: state.activeVariant ? `variant ${state.activeVariant}` : "variant default",
|
||||
modelLabel: formatModelLabel(state.model, state.activeVariant, state.providers),
|
||||
variant: state.activeVariant,
|
||||
}
|
||||
},
|
||||
onModelSelect: async (model) => {
|
||||
if (state.model?.providerID === model.providerID && state.model.modelID === model.modelID) {
|
||||
return
|
||||
}
|
||||
|
||||
state.model = model
|
||||
state.activeVariant = undefined
|
||||
state.variants = variantsFor(state.providers, model)
|
||||
const switching = resolveSavedVariant(model).then((saved) => {
|
||||
const current = state.model
|
||||
if (!current || current.providerID !== model.providerID || current.modelID !== model.modelID) {
|
||||
return
|
||||
}
|
||||
|
||||
state.activeVariant = resolveVariant(ctx.variant, undefined, saved, state.variants)
|
||||
})
|
||||
state.switching = switching
|
||||
await switching
|
||||
if (state.switching === switching) {
|
||||
state.switching = undefined
|
||||
}
|
||||
|
||||
const current = state.model
|
||||
if (!current || current.providerID !== model.providerID || current.modelID !== model.modelID) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
modelLabel: formatModelLabel(model, state.activeVariant, state.providers),
|
||||
status: `model ${model.modelID}`,
|
||||
variant: state.activeVariant,
|
||||
variants: state.variants,
|
||||
}
|
||||
},
|
||||
onVariantSelect: async (variant) => {
|
||||
if (!state.model || state.variants.length === 0) {
|
||||
return {
|
||||
status: "no variants available",
|
||||
}
|
||||
}
|
||||
|
||||
if (variant && !state.variants.includes(variant)) {
|
||||
return {
|
||||
status: `variant ${variant} unavailable`,
|
||||
}
|
||||
}
|
||||
|
||||
state.activeVariant = variant
|
||||
saveVariant(state.model, state.activeVariant)
|
||||
return {
|
||||
status: state.activeVariant ? `variant ${state.activeVariant}` : "variant default",
|
||||
modelLabel: formatModelLabel(state.model, state.activeVariant, state.providers),
|
||||
variant: state.activeVariant,
|
||||
variants: state.variants,
|
||||
}
|
||||
},
|
||||
onInterrupt: () => {
|
||||
if (!hasSession(input, state) || state.aborting) {
|
||||
return
|
||||
}
|
||||
|
||||
state.aborting = true
|
||||
void ctx.sdk.session
|
||||
.abort({
|
||||
sessionID: state.sessionID,
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
state.aborting = false
|
||||
})
|
||||
},
|
||||
onBackground: () => {
|
||||
if (!hasSession(input, state)) return
|
||||
void ctx.sdk.experimental.session.background({ sessionID: state.sessionID }).catch(() => {})
|
||||
},
|
||||
onSubagentSelect: (sessionID) => {
|
||||
state.selectSubagent?.(sessionID)
|
||||
log?.write("subagent.select", {
|
||||
sessionID,
|
||||
})
|
||||
},
|
||||
})
|
||||
const footer = shell.footer
|
||||
const rememberLocal = (commit: StreamCommit, after?: LocalReplayAnchor) => {
|
||||
state.localRows = [...state.localRows, { commit, after }].slice(-LOCAL_REPLAY_ROW_LIMIT)
|
||||
}
|
||||
|
||||
const loadCatalog = async (): Promise<void> => {
|
||||
if (footer.isClosed) {
|
||||
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(() => []),
|
||||
])
|
||||
if (footer.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
footer.event({
|
||||
type: "catalog",
|
||||
agents,
|
||||
resources,
|
||||
commands,
|
||||
})
|
||||
}
|
||||
|
||||
void footer
|
||||
.idle()
|
||||
.then(loadCatalog)
|
||||
.catch(() => {})
|
||||
|
||||
if (Flag.OPENCODE_SHOW_TTFD) {
|
||||
footer.append({
|
||||
kind: "system",
|
||||
text: `startup ${Math.max(0, Math.round(performance.now() - start))}ms`,
|
||||
phase: "final",
|
||||
source: "system",
|
||||
})
|
||||
}
|
||||
|
||||
if (input.demo) {
|
||||
await ensureSession()
|
||||
state.demo = createRunDemo({
|
||||
footer,
|
||||
sessionID: state.sessionID,
|
||||
thinking: input.thinking,
|
||||
limits: () => state.limits,
|
||||
})
|
||||
}
|
||||
|
||||
if (input.afterPaint) {
|
||||
void Promise.resolve(input.afterPaint(ctx)).catch(() => {})
|
||||
}
|
||||
|
||||
void modelTask.then((info) => {
|
||||
state.providers = info.providers
|
||||
state.variants = variantsFor(state.providers, state.model)
|
||||
state.limits = info.limits
|
||||
|
||||
const next = resolveVariant(ctx.variant, session.variant, savedVariant, state.variants)
|
||||
if (next !== state.activeVariant) {
|
||||
state.activeVariant = next
|
||||
}
|
||||
|
||||
if (footer.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
footer.event({ type: "models", providers: info.providers })
|
||||
footer.event({ type: "variants", variants: state.variants, current: state.activeVariant })
|
||||
if (!state.model) {
|
||||
return
|
||||
}
|
||||
|
||||
footer.event({
|
||||
type: "model",
|
||||
model: formatModelLabel(state.model, state.activeVariant, state.providers),
|
||||
})
|
||||
})
|
||||
|
||||
const streamTask = deps.streamTransport ?? import("./stream.transport")
|
||||
const ensureStream = () => {
|
||||
if (state.stream) {
|
||||
return state.stream
|
||||
}
|
||||
|
||||
// Share eager prewarm and first-turn boot through one in-flight promise,
|
||||
// but clear it if transport creation fails so a later prompt can retry.
|
||||
const next = (async () => {
|
||||
await ensureSession()
|
||||
if (footer.isClosed) {
|
||||
throw new Error("runtime closed")
|
||||
}
|
||||
|
||||
const mod = await streamTask
|
||||
if (footer.isClosed) {
|
||||
throw new Error("runtime closed")
|
||||
}
|
||||
|
||||
const handle = await mod.createSessionTransport({
|
||||
sdk: ctx.sdk,
|
||||
directory: ctx.directory,
|
||||
sessionID: state.sessionID,
|
||||
thinking: input.thinking,
|
||||
replay: input.replay,
|
||||
replayLimit: input.replayLimit,
|
||||
limits: () => state.limits,
|
||||
providers: () => state.providers,
|
||||
footer,
|
||||
trace: log,
|
||||
})
|
||||
if (footer.isClosed) {
|
||||
await handle.close()
|
||||
throw new Error("runtime closed")
|
||||
}
|
||||
|
||||
state.selectSubagent = (sessionID) => handle.selectSubagent(sessionID)
|
||||
return { mod, handle }
|
||||
})()
|
||||
state.stream = next
|
||||
void next.catch(() => {
|
||||
if (state.stream === next) {
|
||||
state.stream = undefined
|
||||
}
|
||||
})
|
||||
return next
|
||||
}
|
||||
|
||||
let resizeTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const offResize = shell.onResize(() => {
|
||||
if (resizeTimer) {
|
||||
clearTimeout(resizeTimer)
|
||||
}
|
||||
|
||||
resizeTimer = setTimeout(() => {
|
||||
resizeTimer = undefined
|
||||
if (footer.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
shell.refreshTheme()
|
||||
if (!input.replay || !state.stream) {
|
||||
return
|
||||
}
|
||||
|
||||
void state.stream
|
||||
.then((item) =>
|
||||
item.handle.replayOnResize({
|
||||
localRows: () => state.localRows,
|
||||
reset: () =>
|
||||
shell.resetForReplay({
|
||||
sessionTitle: state.sessionTitle,
|
||||
sessionID: state.sessionID,
|
||||
history: state.history,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.catch(() => {})
|
||||
}, RESIZE_DELAY)
|
||||
})
|
||||
|
||||
const runQueue = async () => {
|
||||
let includeFiles = true
|
||||
if (state.demo) {
|
||||
await state.demo.start()
|
||||
}
|
||||
|
||||
const mod = await import("./runtime.queue")
|
||||
const createSession = input.createSession
|
||||
await mod.runPromptQueue({
|
||||
footer,
|
||||
initialInput: input.initialInput,
|
||||
trace: log,
|
||||
onSend: (prompt) => {
|
||||
state.shown = true
|
||||
state.history.push(prompt)
|
||||
if (prompt.mode !== "shell") {
|
||||
rememberLocal({
|
||||
kind: "user",
|
||||
text: prompt.text,
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: prompt.messageID,
|
||||
})
|
||||
}
|
||||
},
|
||||
onNewSession: createSession
|
||||
? async () => {
|
||||
try {
|
||||
await state.switching?.catch(() => {})
|
||||
const created = await createSession(ctx, {
|
||||
agent: state.agent,
|
||||
model: state.model,
|
||||
variant: state.activeVariant,
|
||||
})
|
||||
await footer.idle().catch(() => {})
|
||||
await state.stream?.then((item) => item.handle.close()).catch(() => {})
|
||||
state.stream = undefined
|
||||
state.session = undefined
|
||||
state.selectSubagent = undefined
|
||||
state.shown = false
|
||||
state.sessionID = created.sessionID
|
||||
state.sessionTitle = created.sessionTitle
|
||||
state.agent = created.agent ?? state.agent
|
||||
state.history = []
|
||||
state.localRows = []
|
||||
includeFiles = true
|
||||
state.demo = input.demo
|
||||
? createRunDemo({
|
||||
footer,
|
||||
sessionID: state.sessionID,
|
||||
thinking: input.thinking,
|
||||
limits: () => state.limits,
|
||||
})
|
||||
: undefined
|
||||
log?.write("session.new", {
|
||||
sessionID: state.sessionID,
|
||||
})
|
||||
footer.event({
|
||||
type: "stream.subagent",
|
||||
state: {
|
||||
tabs: [],
|
||||
details: {},
|
||||
permissions: [],
|
||||
questions: [],
|
||||
},
|
||||
})
|
||||
footer.event({ type: "stream.view", view: { type: "prompt" } })
|
||||
footer.event({
|
||||
type: "stream.patch",
|
||||
patch: {
|
||||
phase: "idle",
|
||||
duration: "",
|
||||
usage: "",
|
||||
first: true,
|
||||
},
|
||||
})
|
||||
footer.append({
|
||||
kind: "system",
|
||||
text: `new session ${state.sessionID}`,
|
||||
phase: "final",
|
||||
source: "system",
|
||||
})
|
||||
await state.demo?.start()
|
||||
} catch (error) {
|
||||
footer.event({
|
||||
type: "stream.patch",
|
||||
patch: {
|
||||
phase: "idle",
|
||||
status: "failed to start new session",
|
||||
},
|
||||
})
|
||||
const commit = {
|
||||
kind: "error",
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: MessageID.ascending(),
|
||||
} as const
|
||||
rememberLocal(commit)
|
||||
footer.append(commit)
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
run: async (prompt, signal) => {
|
||||
if (state.demo && (await state.demo.prompt(prompt, signal))) {
|
||||
return
|
||||
}
|
||||
|
||||
await state.switching?.catch(() => {})
|
||||
|
||||
let outputAnchor: LocalReplayAnchor | undefined
|
||||
try {
|
||||
const next = await ensureStream()
|
||||
await next.handle.runPromptTurn({
|
||||
agent: state.agent,
|
||||
model: state.model,
|
||||
variant: state.activeVariant,
|
||||
prompt,
|
||||
files: input.files,
|
||||
includeFiles,
|
||||
onVisibleOutput: (anchor) => {
|
||||
outputAnchor = anchor
|
||||
},
|
||||
signal,
|
||||
})
|
||||
if (prompt.messageID) {
|
||||
state.localRows = state.localRows.filter(
|
||||
(row) => row.commit.kind !== "user" || row.commit.messageID !== prompt.messageID,
|
||||
)
|
||||
}
|
||||
includeFiles = false
|
||||
} catch (error) {
|
||||
if (signal.aborted || footer.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
const text =
|
||||
(await state.stream?.then((item) => item.mod).catch(() => undefined))?.formatUnknownError(error) ??
|
||||
(error instanceof Error ? error.message : String(error))
|
||||
const commit = {
|
||||
kind: "error",
|
||||
text,
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: prompt.messageID,
|
||||
} as const
|
||||
rememberLocal(commit, outputAnchor)
|
||||
footer.append(commit)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const eager = eagerStream(input, ctx)
|
||||
if (eager) {
|
||||
if (input.replay && state.shown) {
|
||||
// Replay commits immutable scrollback rows, so wait for provider names
|
||||
// before bootstrapping existing session history.
|
||||
await modelTask
|
||||
}
|
||||
|
||||
await ensureStream()
|
||||
}
|
||||
|
||||
if (!eager && input.resolveSession) {
|
||||
queueMicrotask(() => {
|
||||
if (footer.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
void ensureStream().catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await runQueue()
|
||||
} finally {
|
||||
if (resizeTimer) {
|
||||
clearTimeout(resizeTimer)
|
||||
}
|
||||
offResize()
|
||||
await state.stream?.then((item) => item.handle.close()).catch(() => {})
|
||||
}
|
||||
} finally {
|
||||
const title = await resolveExitTitle(ctx, input, state)
|
||||
|
||||
await shell.close({
|
||||
showExit: state.shown && hasSession(input, state),
|
||||
sessionTitle: title,
|
||||
sessionID: state.sessionID,
|
||||
history: state.history,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Local in-process mode. Creates an SDK client backed by a direct fetch to
|
||||
// the in-process server, so no external HTTP server is needed.
|
||||
export async function runInteractiveLocalMode(input: RunLocalInput): Promise<void> {
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: "http://opencode.internal",
|
||||
fetch: input.fetch,
|
||||
directory: input.directory,
|
||||
})
|
||||
let session: Promise<ResolvedSession> | undefined
|
||||
|
||||
return runInteractiveRuntime({
|
||||
files: input.files,
|
||||
initialInput: input.initialInput,
|
||||
thinking: input.thinking,
|
||||
backgroundSubagents: input.backgroundSubagents,
|
||||
replay: input.replay,
|
||||
replayLimit: input.replayLimit,
|
||||
demo: input.demo,
|
||||
resolveSession: () => {
|
||||
if (session) {
|
||||
return session
|
||||
}
|
||||
|
||||
session = Promise.all([input.resolveAgent(), input.session(sdk)]).then(([agent, next]) => {
|
||||
if (!next?.id) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
void input.share(sdk, next.id).catch(() => {})
|
||||
return {
|
||||
sessionID: next.id,
|
||||
sessionTitle: next.title,
|
||||
agent,
|
||||
}
|
||||
})
|
||||
return session
|
||||
},
|
||||
createSession: createSessionResolver(input.createSession),
|
||||
boot: async () => {
|
||||
return {
|
||||
sdk,
|
||||
directory: input.directory,
|
||||
sessionID: "",
|
||||
sessionTitle: undefined,
|
||||
resume: false,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
variant: input.variant,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Attach mode. Uses the caller-provided SDK client directly.
|
||||
export async function runInteractiveMode(
|
||||
input: RunInput & { createSession?: CreateSession },
|
||||
deps?: RunRuntimeDeps,
|
||||
): Promise<void> {
|
||||
return runInteractiveRuntime(
|
||||
{
|
||||
files: input.files,
|
||||
initialInput: input.initialInput,
|
||||
thinking: input.thinking,
|
||||
backgroundSubagents: input.backgroundSubagents,
|
||||
replay: input.replay,
|
||||
replayLimit: input.replayLimit,
|
||||
demo: input.demo,
|
||||
boot: async () => ({
|
||||
sdk: input.sdk,
|
||||
directory: input.directory,
|
||||
sessionID: input.sessionID,
|
||||
sessionTitle: input.sessionTitle,
|
||||
resume: input.resume,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
variant: input.variant,
|
||||
}),
|
||||
createSession: createSessionResolver(input.createSession),
|
||||
},
|
||||
deps,
|
||||
)
|
||||
}
|
||||
92
packages/opencode/src/cli/cmd/run/scrollback.shared.ts
Normal file
92
packages/opencode/src/cli/cmd/run/scrollback.shared.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { SyntaxStyle, TextAttributes, type ColorInput } from "@opentui/core"
|
||||
import { type RunEntryTheme, type RunTheme } from "./theme"
|
||||
import type { StreamCommit } from "./types"
|
||||
|
||||
function syntax(style?: SyntaxStyle): SyntaxStyle {
|
||||
return style ?? SyntaxStyle.fromTheme([])
|
||||
}
|
||||
|
||||
export function entrySyntax(commit: StreamCommit, theme: RunTheme): SyntaxStyle {
|
||||
if (commit.kind === "reasoning") {
|
||||
return syntax(theme.block.subtleSyntax ?? theme.block.syntax)
|
||||
}
|
||||
|
||||
return syntax(theme.block.syntax)
|
||||
}
|
||||
|
||||
export function entryFailed(commit: StreamCommit): boolean {
|
||||
return commit.kind === "tool" && (commit.toolState === "error" || commit.part?.state.status === "error")
|
||||
}
|
||||
|
||||
export function entryLook(commit: StreamCommit, theme: RunEntryTheme): { fg: ColorInput; attrs?: number } {
|
||||
if (commit.kind === "user") {
|
||||
return {
|
||||
fg: theme.user.body,
|
||||
//attrs: TextAttributes.BOLD,
|
||||
}
|
||||
}
|
||||
|
||||
if (entryFailed(commit)) {
|
||||
return {
|
||||
fg: theme.error.body,
|
||||
attrs: TextAttributes.BOLD,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.phase === "final") {
|
||||
return {
|
||||
fg: theme.system.body,
|
||||
attrs: TextAttributes.DIM,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.kind === "tool" && commit.phase === "start") {
|
||||
return {
|
||||
fg: theme.tool.start ?? theme.tool.body,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.kind === "assistant") {
|
||||
return { fg: theme.assistant.body }
|
||||
}
|
||||
|
||||
if (commit.kind === "reasoning") {
|
||||
return {
|
||||
fg: theme.reasoning.body,
|
||||
attrs: TextAttributes.DIM,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.kind === "error") {
|
||||
return {
|
||||
fg: theme.error.body,
|
||||
attrs: TextAttributes.BOLD,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.kind === "tool") {
|
||||
return { fg: theme.tool.body }
|
||||
}
|
||||
|
||||
return { fg: theme.system.body }
|
||||
}
|
||||
|
||||
export function entryColor(commit: StreamCommit, theme: RunTheme): ColorInput {
|
||||
if (commit.kind === "assistant") {
|
||||
return theme.entry.assistant.body
|
||||
}
|
||||
|
||||
if (commit.kind === "reasoning") {
|
||||
return theme.entry.reasoning.body
|
||||
}
|
||||
|
||||
if (entryFailed(commit)) {
|
||||
return theme.entry.error.body
|
||||
}
|
||||
|
||||
if (commit.kind === "tool") {
|
||||
return theme.block.text
|
||||
}
|
||||
|
||||
return entryLook(commit, theme.entry).fg
|
||||
}
|
||||
437
packages/opencode/src/cli/cmd/run/scrollback.surface.ts
Normal file
437
packages/opencode/src/cli/cmd/run/scrollback.surface.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
// Retained streaming append logic for direct-mode scrollback.
|
||||
//
|
||||
// Static entries are rendered through `scrollback.writer.tsx`. This file only
|
||||
// keeps the retained-surface machinery needed for streaming assistant,
|
||||
// reasoning, and tool progress entries that need stable markdown/code layout
|
||||
// while content is still arriving.
|
||||
import {
|
||||
CodeRenderable,
|
||||
MarkdownRenderable,
|
||||
TextRenderable,
|
||||
getTreeSitterClient,
|
||||
type TreeSitterClient,
|
||||
type CliRenderer,
|
||||
type ScrollbackSurface,
|
||||
} from "@opentui/core"
|
||||
import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body"
|
||||
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
|
||||
import { turnSummaryCommit } from "./turn-summary"
|
||||
import { entryWriter, sameEntryGroup, separatorRows, spacerWriter, turnSummaryWriter } from "./scrollback.writer"
|
||||
import { type RunTheme } from "./theme"
|
||||
import type { RunDiffStyle, RunEntryBody, StreamCommit } from "./types"
|
||||
|
||||
type ActiveBody = Exclude<RunEntryBody, { type: "none" | "structured" }>
|
||||
|
||||
type ActiveEntry = {
|
||||
body: ActiveBody
|
||||
commit: StreamCommit
|
||||
surface: ScrollbackSurface
|
||||
renderable: TextRenderable | CodeRenderable | MarkdownRenderable
|
||||
content: string
|
||||
committedRows: number
|
||||
committedBlocks: number
|
||||
pendingSpacerRows: number
|
||||
rendered: boolean
|
||||
}
|
||||
|
||||
let nextId = 0
|
||||
|
||||
function commitMarkdownBlocks(input: {
|
||||
surface: ScrollbackSurface
|
||||
renderable: MarkdownRenderable
|
||||
startBlock: number
|
||||
endBlockExclusive: number
|
||||
trailingNewline: boolean
|
||||
beforeCommit?: () => void
|
||||
}) {
|
||||
if (input.endBlockExclusive <= input.startBlock) {
|
||||
return false
|
||||
}
|
||||
|
||||
const first = input.renderable._blockStates[input.startBlock]
|
||||
const last = input.renderable._blockStates[input.endBlockExclusive - 1]
|
||||
if (!first || !last) {
|
||||
return false
|
||||
}
|
||||
|
||||
const next = input.renderable._blockStates[input.endBlockExclusive]
|
||||
const start = first.renderable.y
|
||||
const end = next ? next.renderable.y : last.renderable.y + last.renderable.height
|
||||
|
||||
input.beforeCommit?.()
|
||||
input.surface.commitRows(start, end, {
|
||||
trailingNewline: input.trailingNewline,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
function staticBody(commit: StreamCommit, body: RunEntryBody, spaced: number): RunEntryBody {
|
||||
if (spaced === 0 || body.type !== "text") {
|
||||
return body
|
||||
}
|
||||
|
||||
if (commit.kind !== "tool" || commit.phase !== "progress" || commit.toolState !== "completed") {
|
||||
return body
|
||||
}
|
||||
|
||||
if (!body.content.startsWith("\n")) {
|
||||
return body
|
||||
}
|
||||
|
||||
return {
|
||||
...body,
|
||||
content: body.content.replace(/^\n/, ""),
|
||||
}
|
||||
}
|
||||
|
||||
export class RunScrollbackStream {
|
||||
private tail: StreamCommit | undefined
|
||||
private rendered: StreamCommit | undefined
|
||||
private active: ActiveEntry | undefined
|
||||
private diffStyle: RunDiffStyle | undefined
|
||||
private sessionID?: () => string | undefined
|
||||
private treeSitterClient: TreeSitterClient | undefined
|
||||
private wrote: boolean
|
||||
private pendingThemes: RunTheme[] = []
|
||||
|
||||
constructor(
|
||||
private renderer: CliRenderer,
|
||||
private theme: RunTheme,
|
||||
options: {
|
||||
wrote?: boolean
|
||||
diffStyle?: RunDiffStyle
|
||||
sessionID?: () => string | undefined
|
||||
treeSitterClient?: TreeSitterClient
|
||||
onThemeRelease?: (theme: RunTheme) => void
|
||||
} = {},
|
||||
) {
|
||||
this.diffStyle = options.diffStyle
|
||||
this.sessionID = options.sessionID
|
||||
this.treeSitterClient = options.treeSitterClient ?? getTreeSitterClient()
|
||||
this.wrote = options.wrote ?? false
|
||||
this.onThemeRelease = options.onThemeRelease
|
||||
}
|
||||
|
||||
private onThemeRelease: ((theme: RunTheme) => void) | undefined
|
||||
|
||||
private releasePendingThemes(): void {
|
||||
if (this.pendingThemes.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const theme of this.pendingThemes.splice(0)) this.onThemeRelease?.(theme)
|
||||
}
|
||||
|
||||
public setTheme(theme: RunTheme): void {
|
||||
if (this.theme === theme) {
|
||||
return
|
||||
}
|
||||
|
||||
const previous = this.theme
|
||||
this.theme = theme
|
||||
const active = this.active
|
||||
if (!active) {
|
||||
this.onThemeRelease?.(previous)
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingThemes.push(previous)
|
||||
|
||||
const style = entryLook(active.commit, theme.entry)
|
||||
if (active.renderable instanceof TextRenderable) {
|
||||
active.renderable.fg = style.fg
|
||||
active.renderable.attributes = style.attrs ?? 0
|
||||
return
|
||||
}
|
||||
|
||||
active.renderable.fg = entryColor(active.commit, theme)
|
||||
active.renderable.syntaxStyle = entrySyntax(active.commit, theme)
|
||||
}
|
||||
|
||||
private createEntry(commit: StreamCommit, body: ActiveBody): ActiveEntry {
|
||||
const surface = this.renderer.createScrollbackSurface({
|
||||
startOnNewLine: entryFlags(commit).startOnNewLine,
|
||||
})
|
||||
const id = `run-scrollback-entry-${nextId++}`
|
||||
const style = entryLook(commit, this.theme.entry)
|
||||
const renderable =
|
||||
body.type === "text"
|
||||
? new TextRenderable(surface.renderContext, {
|
||||
id,
|
||||
content: "",
|
||||
width: "100%",
|
||||
wrapMode: "word",
|
||||
fg: style.fg,
|
||||
attributes: style.attrs,
|
||||
})
|
||||
: body.type === "code"
|
||||
? new CodeRenderable(surface.renderContext, {
|
||||
id,
|
||||
content: "",
|
||||
filetype: body.filetype,
|
||||
syntaxStyle: entrySyntax(commit, this.theme),
|
||||
width: "100%",
|
||||
wrapMode: "word",
|
||||
drawUnstyledText: false,
|
||||
streaming: true,
|
||||
fg: entryColor(commit, this.theme),
|
||||
treeSitterClient: this.treeSitterClient,
|
||||
})
|
||||
: new MarkdownRenderable(surface.renderContext, {
|
||||
id,
|
||||
content: "",
|
||||
syntaxStyle: entrySyntax(commit, this.theme),
|
||||
width: "100%",
|
||||
streaming: true,
|
||||
internalBlockMode: "top-level",
|
||||
tableOptions: { widthMode: "content" },
|
||||
fg: entryColor(commit, this.theme),
|
||||
treeSitterClient: this.treeSitterClient,
|
||||
})
|
||||
|
||||
surface.root.add(renderable)
|
||||
|
||||
const rows = separatorRows(this.rendered, commit, body)
|
||||
|
||||
return {
|
||||
body,
|
||||
commit,
|
||||
surface,
|
||||
renderable,
|
||||
content: "",
|
||||
committedRows: 0,
|
||||
committedBlocks: 0,
|
||||
pendingSpacerRows: rows || (!this.rendered && this.wrote ? 1 : 0),
|
||||
rendered: false,
|
||||
}
|
||||
}
|
||||
|
||||
private markRendered(commit: StreamCommit | undefined): void {
|
||||
if (!commit) {
|
||||
return
|
||||
}
|
||||
|
||||
this.rendered = commit
|
||||
}
|
||||
|
||||
private writeSpacer(rows: number): void {
|
||||
if (rows === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.renderer.writeToScrollback(spacerWriter())
|
||||
this.wrote = false
|
||||
}
|
||||
|
||||
private flushPendingSpacer(active: ActiveEntry): void {
|
||||
this.writeSpacer(active.pendingSpacerRows)
|
||||
active.pendingSpacerRows = 0
|
||||
}
|
||||
|
||||
private async flushActive(done: boolean, trailingNewline: boolean): Promise<boolean> {
|
||||
const active = this.active
|
||||
if (!active) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (active.body.type === "text") {
|
||||
if (!(active.renderable instanceof TextRenderable)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const renderable = active.renderable
|
||||
renderable.content = active.content
|
||||
active.surface.render()
|
||||
this.releasePendingThemes()
|
||||
const targetRows = done ? active.surface.height : Math.max(active.committedRows, active.surface.height - 1)
|
||||
if (targetRows <= active.committedRows) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.flushPendingSpacer(active)
|
||||
active.surface.commitRows(active.committedRows, targetRows, {
|
||||
trailingNewline: done && targetRows === active.surface.height ? trailingNewline : false,
|
||||
})
|
||||
active.committedRows = targetRows
|
||||
active.rendered = true
|
||||
return true
|
||||
}
|
||||
|
||||
if (active.body.type === "code") {
|
||||
if (!(active.renderable instanceof CodeRenderable)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const renderable = active.renderable
|
||||
renderable.content = active.content
|
||||
renderable.streaming = !done
|
||||
await active.surface.settle()
|
||||
this.releasePendingThemes()
|
||||
const targetRows = done ? active.surface.height : Math.max(active.committedRows, active.surface.height - 1)
|
||||
if (targetRows <= active.committedRows) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.flushPendingSpacer(active)
|
||||
active.surface.commitRows(active.committedRows, targetRows, {
|
||||
trailingNewline: done && targetRows === active.surface.height ? trailingNewline : false,
|
||||
})
|
||||
active.committedRows = targetRows
|
||||
active.rendered = true
|
||||
return true
|
||||
}
|
||||
|
||||
if (!(active.renderable instanceof MarkdownRenderable)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const renderable = active.renderable
|
||||
renderable.content = active.content
|
||||
renderable.streaming = !done
|
||||
await active.surface.settle()
|
||||
this.releasePendingThemes()
|
||||
const targetBlockCount = done ? renderable._blockStates.length : renderable._stableBlockCount
|
||||
if (targetBlockCount <= active.committedBlocks) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
commitMarkdownBlocks({
|
||||
surface: active.surface,
|
||||
renderable,
|
||||
startBlock: active.committedBlocks,
|
||||
endBlockExclusive: targetBlockCount,
|
||||
trailingNewline: done && targetBlockCount === renderable._blockStates.length ? trailingNewline : false,
|
||||
beforeCommit: () => this.flushPendingSpacer(active),
|
||||
})
|
||||
) {
|
||||
active.committedBlocks = targetBlockCount
|
||||
active.rendered = true
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private async finishActive(trailingNewline: boolean): Promise<StreamCommit | undefined> {
|
||||
if (!this.active) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const active = this.active
|
||||
|
||||
try {
|
||||
await this.flushActive(true, trailingNewline)
|
||||
} finally {
|
||||
if (this.active === active) {
|
||||
this.active = undefined
|
||||
}
|
||||
|
||||
if (!active.surface.isDestroyed) {
|
||||
active.surface.destroy()
|
||||
}
|
||||
this.releasePendingThemes()
|
||||
}
|
||||
|
||||
return active.rendered ? active.commit : undefined
|
||||
}
|
||||
|
||||
private async writeStreaming(commit: StreamCommit, body: ActiveBody): Promise<void> {
|
||||
if (!this.active || !sameEntryGroup(this.active.commit, commit) || this.active.body.type !== body.type) {
|
||||
this.markRendered(await this.finishActive(false))
|
||||
this.active = this.createEntry(commit, body)
|
||||
}
|
||||
|
||||
this.active.body = body
|
||||
this.active.commit = commit
|
||||
this.active.content += body.content
|
||||
await this.flushActive(false, false)
|
||||
if (this.active.rendered) {
|
||||
this.markRendered(this.active.commit)
|
||||
}
|
||||
}
|
||||
|
||||
public async append(commit: StreamCommit): Promise<void> {
|
||||
const same = sameEntryGroup(this.tail, commit)
|
||||
if (!same) {
|
||||
this.markRendered(await this.finishActive(false))
|
||||
}
|
||||
|
||||
if (commit.summary) {
|
||||
this.writeSpacer(1)
|
||||
this.renderer.writeToScrollback(turnSummaryWriter({ ...commit.summary, theme: this.theme }))
|
||||
this.markRendered(commit)
|
||||
this.tail = commit
|
||||
return
|
||||
}
|
||||
|
||||
const body = entryBody(commit)
|
||||
if (body.type === "none") {
|
||||
if (entryDone(commit)) {
|
||||
this.markRendered(await this.finishActive(false))
|
||||
}
|
||||
|
||||
this.tail = commit
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
body.type !== "structured" &&
|
||||
(entryCanStream(commit, body) || (commit.kind === "tool" && commit.phase === "final" && body.type === "markdown"))
|
||||
) {
|
||||
await this.writeStreaming(commit, body)
|
||||
if (entryDone(commit)) {
|
||||
this.markRendered(await this.finishActive(false))
|
||||
}
|
||||
this.tail = commit
|
||||
return
|
||||
}
|
||||
|
||||
if (same) {
|
||||
this.markRendered(await this.finishActive(false))
|
||||
}
|
||||
|
||||
const rows = separatorRows(this.rendered, commit, body)
|
||||
const spaced = rows || (!this.rendered && this.wrote ? 1 : 0)
|
||||
this.writeSpacer(spaced)
|
||||
|
||||
this.renderer.writeToScrollback(
|
||||
entryWriter({
|
||||
commit,
|
||||
body: staticBody(commit, body, spaced),
|
||||
theme: this.theme,
|
||||
opts: {
|
||||
diffStyle: this.diffStyle,
|
||||
},
|
||||
}),
|
||||
)
|
||||
this.markRendered(commit)
|
||||
this.tail = commit
|
||||
}
|
||||
|
||||
private resetActive(): void {
|
||||
if (!this.active) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.active.surface.isDestroyed) {
|
||||
this.active.surface.destroy()
|
||||
}
|
||||
|
||||
this.active = undefined
|
||||
this.releasePendingThemes()
|
||||
}
|
||||
|
||||
public async complete(trailingNewline = false): Promise<void> {
|
||||
this.markRendered(await this.finishActive(trailingNewline))
|
||||
}
|
||||
|
||||
public async writeTurnSummary(input: { agent: string; model: string; duration: string }): Promise<void> {
|
||||
await this.append(turnSummaryCommit(input))
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.resetActive()
|
||||
this.releasePendingThemes()
|
||||
}
|
||||
}
|
||||
353
packages/opencode/src/cli/cmd/run/scrollback.writer.tsx
Normal file
353
packages/opencode/src/cli/cmd/run/scrollback.writer.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
import { createScrollbackWriter } from "@opentui/solid"
|
||||
import { TextRenderable, type ColorInput, type ScrollbackRenderContext, type ScrollbackWriter } from "@opentui/core"
|
||||
import { Match, Switch, createMemo } from "solid-js"
|
||||
import { entryBody, entryFlags } from "./entry.body"
|
||||
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
|
||||
import { toolFiletype, toolStructuredFinal } from "./tool"
|
||||
import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme"
|
||||
import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit } from "./types"
|
||||
|
||||
function todoText(item: { status: string; content: string }): string {
|
||||
if (item.status === "completed") {
|
||||
return `[✓] ${item.content}`
|
||||
}
|
||||
|
||||
if (item.status === "cancelled") {
|
||||
return `~[ ] ${item.content}~`
|
||||
}
|
||||
|
||||
if (item.status === "in_progress") {
|
||||
return `[•] ${item.content}`
|
||||
}
|
||||
|
||||
return `[ ] ${item.content}`
|
||||
}
|
||||
|
||||
function todoColor(theme: RunTheme, status: string) {
|
||||
return status === "in_progress" ? theme.block.warning : theme.block.muted
|
||||
}
|
||||
|
||||
export function entryGroupKey(commit: StreamCommit): string | undefined {
|
||||
if (!commit.partID) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (toolStructuredFinal(commit)) {
|
||||
return `tool:${commit.partID}:final`
|
||||
}
|
||||
|
||||
return `${commit.kind}:${commit.partID}`
|
||||
}
|
||||
|
||||
export function sameEntryGroup(left: StreamCommit | undefined, right: StreamCommit): boolean {
|
||||
if (!left) {
|
||||
return false
|
||||
}
|
||||
|
||||
const current = entryGroupKey(left)
|
||||
const next = entryGroupKey(right)
|
||||
return Boolean(current && next && current === next)
|
||||
}
|
||||
|
||||
export function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody(commit)): EntryLayout {
|
||||
if (commit.kind === "tool") {
|
||||
if (body.type === "structured" || body.type === "markdown") {
|
||||
return "block"
|
||||
}
|
||||
|
||||
if (
|
||||
commit.phase === "progress" &&
|
||||
commit.toolState === "completed" &&
|
||||
body.type === "text" &&
|
||||
body.content.includes("\n")
|
||||
) {
|
||||
return "block"
|
||||
}
|
||||
|
||||
return "inline"
|
||||
}
|
||||
|
||||
if (commit.kind === "reasoning") {
|
||||
return "block"
|
||||
}
|
||||
|
||||
if (commit.kind === "error") {
|
||||
return "block"
|
||||
}
|
||||
|
||||
return "block"
|
||||
}
|
||||
|
||||
export function separatorRows(
|
||||
prev: StreamCommit | undefined,
|
||||
next: StreamCommit,
|
||||
body: RunEntryBody = entryBody(next),
|
||||
): number {
|
||||
if (!prev || sameEntryGroup(prev, next)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (entryLayout(prev) === "inline" && entryLayout(next, body) === "inline") {
|
||||
return 0
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
export function RunEntryContent(props: {
|
||||
commit: StreamCommit
|
||||
body?: RunEntryBody
|
||||
theme?: RunTheme
|
||||
opts?: ScrollbackOptions
|
||||
width?: number
|
||||
}) {
|
||||
const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
|
||||
const body = createMemo(() => props.body ?? entryBody(props.commit))
|
||||
const style = createMemo(() => entryLook(props.commit, theme().entry))
|
||||
const syntax = createMemo(() => entrySyntax(props.commit, theme()))
|
||||
const color = createMemo(() => entryColor(props.commit, theme()))
|
||||
const suppressBackgrounds = createMemo(() => props.opts?.suppressBackgrounds === true)
|
||||
const diffBg = (color: ColorInput) => (suppressBackgrounds() ? transparent : color)
|
||||
const streaming = createMemo(() => props.commit.phase === "progress")
|
||||
const text = createMemo(() => {
|
||||
const next = body()
|
||||
return next.type === "text" ? next : undefined
|
||||
})
|
||||
const code = createMemo(() => {
|
||||
const next = body()
|
||||
return next.type === "code" ? next : undefined
|
||||
})
|
||||
const structured = createMemo(() => {
|
||||
const next = body()
|
||||
return next.type === "structured" ? next.snapshot : undefined
|
||||
})
|
||||
const markdown = createMemo(() => {
|
||||
const next = body()
|
||||
return next.type === "markdown" ? next : undefined
|
||||
})
|
||||
const code_snapshot = createMemo(() => {
|
||||
const next = structured()
|
||||
return next?.kind === "code" ? next : undefined
|
||||
})
|
||||
const diff_snapshot = createMemo(() => {
|
||||
const next = structured()
|
||||
return next?.kind === "diff" ? next : undefined
|
||||
})
|
||||
const task_snapshot = createMemo(() => {
|
||||
const next = structured()
|
||||
return next?.kind === "task" ? next : undefined
|
||||
})
|
||||
const todo_snapshot = createMemo(() => {
|
||||
const next = structured()
|
||||
return next?.kind === "todo" ? next : undefined
|
||||
})
|
||||
const question_snapshot = createMemo(() => {
|
||||
const next = structured()
|
||||
return next?.kind === "question" ? next : undefined
|
||||
})
|
||||
|
||||
return (
|
||||
<Switch fallback={null}>
|
||||
<Match when={text()}>
|
||||
<text width="100%" wrapMode="word" fg={style().fg} attributes={style().attrs}>
|
||||
{text()!.content}
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={code()}>
|
||||
<code
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
filetype={code()!.filetype}
|
||||
drawUnstyledText={false}
|
||||
streaming={streaming()}
|
||||
syntaxStyle={syntax()}
|
||||
content={code()!.content}
|
||||
fg={color()}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={code_snapshot()}>
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
{code_snapshot()!.title}
|
||||
</text>
|
||||
<box width="100%" paddingLeft={1}>
|
||||
<line_number width="100%" fg={theme().block.muted} minWidth={3} paddingRight={1}>
|
||||
<code
|
||||
width="100%"
|
||||
wrapMode="char"
|
||||
filetype={toolFiletype(code_snapshot()!.file)}
|
||||
streaming={false}
|
||||
syntaxStyle={syntax()}
|
||||
content={code_snapshot()!.content}
|
||||
fg={theme().block.text}
|
||||
/>
|
||||
</line_number>
|
||||
</box>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={diff_snapshot()}>
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
{diff_snapshot()!.items.map((item) => (
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
{item.title}
|
||||
</text>
|
||||
{item.diff.trim() ? (
|
||||
<box width="100%" paddingLeft={1}>
|
||||
<diff
|
||||
diff={item.diff}
|
||||
view="unified"
|
||||
filetype={toolFiletype(item.file)}
|
||||
syntaxStyle={syntax()}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
fg={theme().block.text}
|
||||
addedBg={diffBg(theme().block.diffAddedBg)}
|
||||
removedBg={diffBg(theme().block.diffRemovedBg)}
|
||||
contextBg={diffBg(theme().block.diffContextBg)}
|
||||
addedSignColor={theme().block.diffHighlightAdded}
|
||||
removedSignColor={theme().block.diffHighlightRemoved}
|
||||
lineNumberFg={theme().block.diffLineNumber}
|
||||
lineNumberBg={diffBg(theme().block.diffContextBg)}
|
||||
addedLineNumberBg={diffBg(theme().block.diffAddedLineNumberBg)}
|
||||
removedLineNumberBg={diffBg(theme().block.diffRemovedLineNumberBg)}
|
||||
/>
|
||||
</box>
|
||||
) : (
|
||||
<text width="100%" wrapMode="word" fg={theme().block.diffRemoved}>
|
||||
-{item.deletions ?? 0} line{item.deletions === 1 ? "" : "s"}
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
))}
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={task_snapshot()}>
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
{task_snapshot()!.title}
|
||||
</text>
|
||||
<box width="100%" flexDirection="column" gap={0} paddingLeft={1}>
|
||||
{task_snapshot()!.rows.map((row) => (
|
||||
<text width="100%" wrapMode="word" fg={theme().block.text}>
|
||||
{row}
|
||||
</text>
|
||||
))}
|
||||
{task_snapshot()!.tail ? (
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
{task_snapshot()!.tail}
|
||||
</text>
|
||||
) : null}
|
||||
</box>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={todo_snapshot()}>
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
# Todos
|
||||
</text>
|
||||
<box width="100%" flexDirection="column" gap={0}>
|
||||
{todo_snapshot()!.items.map((item) => (
|
||||
<text width="100%" wrapMode="word" fg={todoColor(theme(), item.status)}>
|
||||
{todoText(item)}
|
||||
</text>
|
||||
))}
|
||||
{todo_snapshot()!.tail ? (
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
{todo_snapshot()!.tail}
|
||||
</text>
|
||||
) : null}
|
||||
</box>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={question_snapshot()}>
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
# Questions
|
||||
</text>
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
{question_snapshot()!.items.map((item) => (
|
||||
<box width="100%" flexDirection="column" gap={0}>
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
{item.question}
|
||||
</text>
|
||||
<text width="100%" wrapMode="word" fg={theme().block.text}>
|
||||
{item.answer}
|
||||
</text>
|
||||
</box>
|
||||
))}
|
||||
{question_snapshot()!.tail ? (
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
{question_snapshot()!.tail}
|
||||
</text>
|
||||
) : null}
|
||||
</box>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={markdown()}>
|
||||
<markdown
|
||||
width="100%"
|
||||
syntaxStyle={syntax()}
|
||||
streaming={streaming()}
|
||||
content={markdown()!.content}
|
||||
fg={color()}
|
||||
tableOptions={{ widthMode: "content" }}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
export function entryWriter(input: {
|
||||
commit: StreamCommit
|
||||
body?: RunEntryBody
|
||||
theme?: RunTheme
|
||||
opts?: ScrollbackOptions
|
||||
}): ScrollbackWriter {
|
||||
return createScrollbackWriter(
|
||||
(ctx) => (
|
||||
<RunEntryContent
|
||||
commit={input.commit}
|
||||
body={input.body}
|
||||
theme={input.theme}
|
||||
opts={{ ...input.opts, suppressBackgrounds: true }}
|
||||
width={ctx.width}
|
||||
/>
|
||||
),
|
||||
entryFlags(input.commit),
|
||||
)
|
||||
}
|
||||
|
||||
export function spacerWriter(): ScrollbackWriter {
|
||||
return (ctx: ScrollbackRenderContext) => ({
|
||||
root: new TextRenderable(ctx.renderContext, {
|
||||
id: "run-scrollback-spacer",
|
||||
width: Math.max(1, Math.trunc(ctx.width)),
|
||||
height: 1,
|
||||
content: "",
|
||||
}),
|
||||
width: Math.max(1, Math.trunc(ctx.width)),
|
||||
height: 1,
|
||||
startOnNewLine: true,
|
||||
trailingNewline: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function turnSummaryWriter(input: { agent: string; model: string; duration: string; theme: RunTheme }) {
|
||||
return createScrollbackWriter(
|
||||
() => (
|
||||
<box width="100%" height={1}>
|
||||
<text wrapMode="none" truncate>
|
||||
<span style={{ fg: input.theme.block.highlight }}>▣ </span>
|
||||
<span style={{ fg: input.theme.block.text }}>{input.agent}</span>
|
||||
<span style={{ fg: input.theme.block.muted }}>
|
||||
{" "}
|
||||
· {input.model} · {input.duration}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
),
|
||||
{ startOnNewLine: true, trailingNewline: false },
|
||||
)
|
||||
}
|
||||
1113
packages/opencode/src/cli/cmd/run/session-data.ts
Normal file
1113
packages/opencode/src/cli/cmd/run/session-data.ts
Normal file
File diff suppressed because it is too large
Load Diff
374
packages/opencode/src/cli/cmd/run/session-replay.ts
Normal file
374
packages/opencode/src/cli/cmd/run/session-replay.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
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[]
|
||||
})
|
||||
}
|
||||
196
packages/opencode/src/cli/cmd/run/session.shared.ts
Normal file
196
packages/opencode/src/cli/cmd/run/session.shared.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
// Session message extraction and prompt history.
|
||||
//
|
||||
// Fetches session messages from the SDK and extracts user turn text for
|
||||
// the prompt history ring. Also finds the most recently used variant for
|
||||
// the current model so the footer can pre-select it.
|
||||
import { promptCopy, promptSame } from "./prompt.shared"
|
||||
import type { RunInput, RunPrompt } from "./types"
|
||||
|
||||
const LIMIT = 200
|
||||
|
||||
export type SessionMessages = NonNullable<Awaited<ReturnType<RunInput["sdk"]["session"]["messages"]>>["data"]>
|
||||
|
||||
type Turn = {
|
||||
prompt: RunPrompt
|
||||
provider: string | undefined
|
||||
model: string | undefined
|
||||
variant: string | undefined
|
||||
}
|
||||
|
||||
export type RunSession = {
|
||||
first: boolean
|
||||
turns: Turn[]
|
||||
}
|
||||
|
||||
function fileName(url: string, filename?: string) {
|
||||
if (filename) {
|
||||
return filename
|
||||
}
|
||||
|
||||
try {
|
||||
const next = new URL(url)
|
||||
if (next.protocol !== "file:") {
|
||||
return url
|
||||
}
|
||||
|
||||
const name = next.pathname.split("/").at(-1)
|
||||
if (name) {
|
||||
return decodeURIComponent(name)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
function fileSource(
|
||||
part: Extract<SessionMessages[number]["parts"][number], { type: "file" }>,
|
||||
text: { start: number; end: number; value: string },
|
||||
) {
|
||||
if (part.source) {
|
||||
return {
|
||||
...structuredClone(part.source),
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: "file" as const,
|
||||
path: part.filename ?? part.url,
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
export function messagePrompt(msg: SessionMessages[number]): RunPrompt {
|
||||
const parts: RunPrompt["parts"] = []
|
||||
let text = msg.parts
|
||||
.filter((part): part is Extract<SessionMessages[number]["parts"][number], { type: "text" }> => {
|
||||
return part.type === "text" && !part.synthetic
|
||||
})
|
||||
.map((part) => part.text)
|
||||
.join("")
|
||||
let cursor = Bun.stringWidth(text)
|
||||
const used: Array<{ start: number; end: number }> = []
|
||||
|
||||
const take = (value: string): { start: number; end: number; value: string } | undefined => {
|
||||
let from = 0
|
||||
while (true) {
|
||||
const idx = text.indexOf(value, from)
|
||||
if (idx === -1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const start = Bun.stringWidth(text.slice(0, idx))
|
||||
const end = start + Bun.stringWidth(value)
|
||||
if (!used.some((item) => item.start < end && start < item.end)) {
|
||||
return { start, end, value }
|
||||
}
|
||||
|
||||
from = idx + value.length
|
||||
}
|
||||
}
|
||||
|
||||
const add = (value: string) => {
|
||||
const gap = text ? " " : ""
|
||||
const start = cursor + Bun.stringWidth(gap)
|
||||
text += gap + value
|
||||
const end = start + Bun.stringWidth(value)
|
||||
cursor = end
|
||||
return { start, end, value }
|
||||
}
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "file") {
|
||||
const next = part.source?.text ? structuredClone(part.source.text) : take("@" + fileName(part.url, part.filename))
|
||||
const span = next ?? add("@" + fileName(part.url, part.filename))
|
||||
used.push({ start: span.start, end: span.end })
|
||||
parts.push({
|
||||
type: "file",
|
||||
mime: part.mime,
|
||||
filename: part.filename,
|
||||
url: part.url,
|
||||
source: fileSource(part, span),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type !== "agent") {
|
||||
continue
|
||||
}
|
||||
|
||||
const span = part.source ? structuredClone(part.source) : (take("@" + part.name) ?? add("@" + part.name))
|
||||
used.push({ start: span.start, end: span.end })
|
||||
parts.push({
|
||||
type: "agent",
|
||||
name: part.name,
|
||||
source: span,
|
||||
})
|
||||
}
|
||||
|
||||
return { text, parts }
|
||||
}
|
||||
|
||||
function turn(msg: SessionMessages[number]): Turn | undefined {
|
||||
if (msg.info.role !== "user") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
prompt: messagePrompt(msg),
|
||||
provider: msg.info.model.providerID,
|
||||
model: msg.info.model.modelID,
|
||||
variant: msg.info.model.variant,
|
||||
}
|
||||
}
|
||||
|
||||
export function createSession(messages: SessionMessages): RunSession {
|
||||
return {
|
||||
first: messages.length === 0,
|
||||
turns: messages.flatMap((msg) => {
|
||||
const item = turn(msg)
|
||||
return item ? [item] : []
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
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 function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] {
|
||||
const out: RunPrompt[] = []
|
||||
|
||||
for (const turn of session.turns) {
|
||||
if (!turn.prompt.text.trim()) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (out[out.length - 1] && promptSame(out[out.length - 1], turn.prompt)) {
|
||||
continue
|
||||
}
|
||||
|
||||
out.push(promptCopy(turn.prompt))
|
||||
}
|
||||
|
||||
return out.slice(-limit)
|
||||
}
|
||||
|
||||
export function sessionVariant(session: RunSession, model: RunInput["model"]): string | undefined {
|
||||
if (!model) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (let idx = session.turns.length - 1; idx >= 0; idx -= 1) {
|
||||
const turn = session.turns[idx]
|
||||
if (turn.provider !== model.providerID || turn.model !== model.modelID) {
|
||||
continue
|
||||
}
|
||||
|
||||
return turn.variant
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
284
packages/opencode/src/cli/cmd/run/splash.ts
Normal file
284
packages/opencode/src/cli/cmd/run/splash.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
// Entry and exit splash banners for direct interactive mode scrollback.
|
||||
//
|
||||
// Renders the full opencode entry logo and a compact [O] exit badge, plus
|
||||
// session metadata and the resume command. These are scrollback snapshots, so
|
||||
// they become immutable terminal history once committed.
|
||||
//
|
||||
// Both variants use a cell-based renderer. cells() classifies each character
|
||||
// in the source template as text, full-block, half-block-mix, or
|
||||
// half-block-top, and draw() renders it with foreground/background shadow
|
||||
// colors from the theme.
|
||||
import {
|
||||
BoxRenderable,
|
||||
type ColorInput,
|
||||
TextAttributes,
|
||||
TextRenderable,
|
||||
type ScrollbackRenderContext,
|
||||
type ScrollbackSnapshot,
|
||||
type ScrollbackWriter,
|
||||
} from "@opentui/core"
|
||||
import * as Locale from "@/util/locale"
|
||||
import { go } from "@/cli/logo"
|
||||
import type { RunSplashTheme } from "./theme"
|
||||
|
||||
export const SPLASH_TITLE_LIMIT = 50
|
||||
export const SPLASH_TITLE_FALLBACK = "Untitled session"
|
||||
|
||||
type SplashInput = {
|
||||
title: string | undefined
|
||||
session_id: string
|
||||
}
|
||||
|
||||
type SplashWriterInput = SplashInput & {
|
||||
theme: RunSplashTheme
|
||||
showSession?: boolean
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export type SplashMeta = {
|
||||
title: string
|
||||
session_id: string
|
||||
}
|
||||
|
||||
type Cell = {
|
||||
char: string
|
||||
mark: "text" | "full" | "mix" | "top"
|
||||
}
|
||||
|
||||
let id = 0
|
||||
|
||||
function cells(line: string): Cell[] {
|
||||
const list: Cell[] = []
|
||||
for (const char of line) {
|
||||
if (char === "_") {
|
||||
list.push({ char: " ", mark: "full" })
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "^") {
|
||||
list.push({ char: "▀", mark: "mix" })
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "~") {
|
||||
list.push({ char: "▀", mark: "top" })
|
||||
continue
|
||||
}
|
||||
|
||||
list.push({ char, mark: "text" })
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
function title(text: string | undefined): string {
|
||||
if (!text) {
|
||||
return SPLASH_TITLE_FALLBACK
|
||||
}
|
||||
|
||||
let value = ""
|
||||
let gap = false
|
||||
for (const char of text.trim()) {
|
||||
if (char === " " || char === "\n" || char === "\r" || char === "\t") {
|
||||
gap = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (gap && value.length > 0) {
|
||||
value += " "
|
||||
}
|
||||
|
||||
value += char
|
||||
gap = false
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return SPLASH_TITLE_FALLBACK
|
||||
}
|
||||
|
||||
return Locale.truncate(value, SPLASH_TITLE_LIMIT)
|
||||
}
|
||||
|
||||
function write(
|
||||
root: BoxRenderable,
|
||||
ctx: ScrollbackRenderContext,
|
||||
line: {
|
||||
left: number
|
||||
top: number
|
||||
text: string
|
||||
fg: ColorInput
|
||||
bg?: ColorInput
|
||||
attrs?: number
|
||||
},
|
||||
): void {
|
||||
if (line.left >= ctx.width) {
|
||||
return
|
||||
}
|
||||
|
||||
root.add(
|
||||
new TextRenderable(ctx.renderContext, {
|
||||
id: `run-direct-splash-line-${id++}`,
|
||||
position: "absolute",
|
||||
left: line.left,
|
||||
top: line.top,
|
||||
width: Math.max(1, ctx.width - line.left),
|
||||
height: 1,
|
||||
wrapMode: "none",
|
||||
content: line.text,
|
||||
fg: line.fg,
|
||||
bg: line.bg,
|
||||
attributes: line.attrs,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function push(
|
||||
lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }>,
|
||||
left: number,
|
||||
top: number,
|
||||
text: string,
|
||||
fg: ColorInput,
|
||||
bg?: ColorInput,
|
||||
attrs?: number,
|
||||
): void {
|
||||
lines.push({ left, top, text, fg, bg, attrs })
|
||||
}
|
||||
|
||||
function draw(
|
||||
lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }>,
|
||||
row: string,
|
||||
input: {
|
||||
left: number
|
||||
top: number
|
||||
fg: ColorInput
|
||||
shadow: ColorInput
|
||||
attrs?: number
|
||||
},
|
||||
) {
|
||||
let x = input.left
|
||||
for (const cell of cells(row)) {
|
||||
if (cell.mark === "full" || cell.mark === "mix") {
|
||||
push(lines, x, input.top, cell.char, input.fg, input.shadow, input.attrs)
|
||||
x += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (cell.mark === "top") {
|
||||
push(lines, x, input.top, cell.char, input.shadow, undefined, input.attrs)
|
||||
x += 1
|
||||
continue
|
||||
}
|
||||
|
||||
push(lines, x, input.top, cell.char, input.fg, undefined, input.attrs)
|
||||
x += 1
|
||||
}
|
||||
}
|
||||
|
||||
function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: ScrollbackRenderContext): ScrollbackSnapshot {
|
||||
const width = Math.max(1, ctx.width)
|
||||
const meta = splashMeta(input)
|
||||
const lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }> = []
|
||||
const left = input.theme.left
|
||||
const right = input.theme.right
|
||||
const leftShadow = input.theme.leftShadow
|
||||
let height = 1
|
||||
|
||||
if (kind === "entry") {
|
||||
const mark = go.right.slice(1)
|
||||
const top = 1
|
||||
const body_left = (mark[0]?.length ?? 0) + 2
|
||||
|
||||
for (let i = 0; i < mark.length; i += 1) {
|
||||
draw(lines, mark[i] ?? "", {
|
||||
left: 0,
|
||||
top: top + i,
|
||||
fg: left,
|
||||
shadow: leftShadow,
|
||||
})
|
||||
}
|
||||
|
||||
push(lines, body_left, top, "AirCoding", right, undefined, TextAttributes.BOLD)
|
||||
if (input.detail) {
|
||||
push(
|
||||
lines,
|
||||
body_left,
|
||||
top + 1,
|
||||
Locale.truncateMiddle(input.detail, Math.max(1, width - body_left)),
|
||||
left,
|
||||
undefined,
|
||||
)
|
||||
}
|
||||
height = top + mark.length
|
||||
}
|
||||
|
||||
if (kind === "exit") {
|
||||
const mark = go.right.slice(1)
|
||||
const top = 1
|
||||
const body_left = (mark[0]?.length ?? 0) + 2
|
||||
const session = "Session "
|
||||
const label = "Continue "
|
||||
|
||||
for (let i = 0; i < mark.length; i += 1) {
|
||||
draw(lines, mark[i] ?? "", {
|
||||
left: 0,
|
||||
top: top + i,
|
||||
fg: left,
|
||||
shadow: leftShadow,
|
||||
})
|
||||
}
|
||||
|
||||
if (input.showSession !== false) {
|
||||
push(lines, body_left, top, session, left, undefined, TextAttributes.DIM)
|
||||
push(lines, body_left + session.length, top, meta.title, right, undefined, TextAttributes.BOLD)
|
||||
}
|
||||
|
||||
push(lines, body_left, top + 1, label, left, undefined, TextAttributes.DIM)
|
||||
push(
|
||||
lines,
|
||||
body_left + label.length,
|
||||
top + 1,
|
||||
`aircoding run -i -s ${meta.session_id}`,
|
||||
right,
|
||||
undefined,
|
||||
TextAttributes.BOLD,
|
||||
)
|
||||
height = top + mark.length
|
||||
}
|
||||
|
||||
const root = new BoxRenderable(ctx.renderContext, {
|
||||
id: `run-direct-splash-${kind}-${id++}`,
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
|
||||
for (const line of lines) {
|
||||
write(root, ctx, line)
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
width,
|
||||
height,
|
||||
rowColumns: width,
|
||||
startOnNewLine: true,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function splashMeta(input: SplashInput): SplashMeta {
|
||||
return {
|
||||
title: title(input.title),
|
||||
session_id: input.session_id,
|
||||
}
|
||||
}
|
||||
|
||||
export function entrySplash(input: SplashWriterInput): ScrollbackWriter {
|
||||
return (ctx) => build(input, "entry", ctx)
|
||||
}
|
||||
|
||||
export function exitSplash(input: SplashWriterInput): ScrollbackWriter {
|
||||
return (ctx) => build(input, "exit", ctx)
|
||||
}
|
||||
1462
packages/opencode/src/cli/cmd/run/stream.transport.ts
Normal file
1462
packages/opencode/src/cli/cmd/run/stream.transport.ts
Normal file
File diff suppressed because it is too large
Load Diff
175
packages/opencode/src/cli/cmd/run/stream.ts
Normal file
175
packages/opencode/src/cli/cmd/run/stream.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
// Thin bridge between reducer output and the footer API.
|
||||
//
|
||||
// The reducers produce StreamCommit[] and an optional FooterOutput (patch +
|
||||
// view + subagent state). This module forwards them to footer.append() and
|
||||
// footer.event() respectively, adding trace writes along the way. It also
|
||||
// defaults status updates to phase "running" if the caller didn't set a
|
||||
// phase -- a convenience so reducer code doesn't have to repeat that.
|
||||
import type { FooterApi, FooterOutput, FooterPatch, FooterSubagentState, StreamCommit } from "./types"
|
||||
|
||||
type Trace = {
|
||||
write(type: string, data?: unknown): void
|
||||
}
|
||||
|
||||
type OutputInput = {
|
||||
footer: FooterApi
|
||||
trace?: Trace
|
||||
}
|
||||
|
||||
type StreamOutput = {
|
||||
commits: StreamCommit[]
|
||||
footer?: FooterOutput
|
||||
}
|
||||
|
||||
// Default to "running" phase when a status string arrives without an explicit phase.
|
||||
function patch(next: FooterPatch): FooterPatch {
|
||||
if (typeof next.status === "string" && next.phase === undefined) {
|
||||
return {
|
||||
phase: "running",
|
||||
...next,
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function summarize(value: unknown): unknown {
|
||||
if (typeof value === "string") {
|
||||
if (value.length <= 160) {
|
||||
return value
|
||||
}
|
||||
|
||||
return {
|
||||
type: "string",
|
||||
length: value.length,
|
||||
preview: `${value.slice(0, 160)}...`,
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return {
|
||||
type: "array",
|
||||
length: value.length,
|
||||
}
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") {
|
||||
return value
|
||||
}
|
||||
|
||||
return {
|
||||
type: "object",
|
||||
keys: Object.keys(value),
|
||||
}
|
||||
}
|
||||
|
||||
function traceCommit(commit: StreamCommit) {
|
||||
return {
|
||||
...commit,
|
||||
text: summarize(commit.text),
|
||||
textLength: commit.text.length,
|
||||
part: commit.part
|
||||
? {
|
||||
id: commit.part.id,
|
||||
sessionID: commit.part.sessionID,
|
||||
messageID: commit.part.messageID,
|
||||
callID: commit.part.callID,
|
||||
tool: commit.part.tool,
|
||||
state: {
|
||||
status: commit.part.state.status,
|
||||
title: "title" in commit.part.state ? summarize(commit.part.state.title) : undefined,
|
||||
error: "error" in commit.part.state ? summarize(commit.part.state.error) : undefined,
|
||||
time: "time" in commit.part.state ? summarize(commit.part.state.time) : undefined,
|
||||
input: summarize(commit.part.state.input),
|
||||
metadata: "metadata" in commit.part.state ? summarize(commit.part.state.metadata) : undefined,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function traceSubagentState(state: FooterSubagentState) {
|
||||
return {
|
||||
tabs: state.tabs,
|
||||
details: Object.fromEntries(
|
||||
Object.entries(state.details).map(([sessionID, detail]) => [
|
||||
sessionID,
|
||||
{
|
||||
sessionID,
|
||||
commits: detail.commits.map(traceCommit),
|
||||
},
|
||||
]),
|
||||
),
|
||||
permissions: state.permissions.map((item) => ({
|
||||
id: item.id,
|
||||
sessionID: item.sessionID,
|
||||
permission: item.permission,
|
||||
patterns: item.patterns,
|
||||
tool: item.tool,
|
||||
metadata: item.metadata
|
||||
? {
|
||||
keys: Object.keys(item.metadata),
|
||||
input: summarize(item.metadata.input),
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
questions: state.questions.map((item) => ({
|
||||
id: item.id,
|
||||
sessionID: item.sessionID,
|
||||
questions: item.questions.map((question) => ({
|
||||
header: question.header,
|
||||
question: question.question,
|
||||
options: question.options.length,
|
||||
multiple: question.multiple,
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function traceFooterOutput(footer?: FooterOutput) {
|
||||
if (!footer?.subagent) {
|
||||
return footer
|
||||
}
|
||||
|
||||
return {
|
||||
...footer,
|
||||
subagent: traceSubagentState(footer.subagent),
|
||||
}
|
||||
}
|
||||
|
||||
// Forwards reducer output to the footer: commits go to scrollback, patches update the status bar.
|
||||
export function writeSessionOutput(input: OutputInput, out: StreamOutput): void {
|
||||
for (const commit of out.commits) {
|
||||
input.trace?.write("ui.commit", commit)
|
||||
input.footer.append(commit)
|
||||
}
|
||||
|
||||
if (out.footer?.patch) {
|
||||
const next = patch(out.footer.patch)
|
||||
input.trace?.write("ui.patch", next)
|
||||
input.footer.event({
|
||||
type: "stream.patch",
|
||||
patch: next,
|
||||
})
|
||||
}
|
||||
|
||||
if (out.footer?.subagent) {
|
||||
input.trace?.write("ui.subagent", traceSubagentState(out.footer.subagent))
|
||||
input.footer.event({
|
||||
type: "stream.subagent",
|
||||
state: out.footer.subagent,
|
||||
})
|
||||
}
|
||||
|
||||
if (!out.footer?.view) {
|
||||
return
|
||||
}
|
||||
|
||||
input.trace?.write("ui.patch", {
|
||||
view: out.footer.view,
|
||||
})
|
||||
input.footer.event({
|
||||
type: "stream.view",
|
||||
view: out.footer.view,
|
||||
})
|
||||
}
|
||||
876
packages/opencode/src/cli/cmd/run/subagent-data.ts
Normal file
876
packages/opencode/src/cli/cmd/run/subagent-data.ts
Normal file
@@ -0,0 +1,876 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
690
packages/opencode/src/cli/cmd/run/theme.ts
Normal file
690
packages/opencode/src/cli/cmd/run/theme.ts
Normal file
@@ -0,0 +1,690 @@
|
||||
// Theme resolution for direct interactive mode.
|
||||
//
|
||||
// Derives scrollback and footer colors from the terminal's actual palette.
|
||||
// resolveRunTheme() queries the renderer for the terminal's palette,
|
||||
// detects dark/light mode, builds a small system theme locally, and maps it to
|
||||
// the run footer + scrollback color model. Falls back to a hardcoded dark-mode
|
||||
// palette if detection fails.
|
||||
import { RGBA, SyntaxStyle, type CliRenderer, type ColorInput, type TerminalColors } from "@opentui/core"
|
||||
import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
|
||||
import type { EntryKind } from "./types"
|
||||
|
||||
type Tone = {
|
||||
body: ColorInput
|
||||
start?: ColorInput
|
||||
}
|
||||
|
||||
export type RunEntryTheme = Record<EntryKind, Tone>
|
||||
|
||||
export type RunSplashTheme = {
|
||||
left: ColorInput
|
||||
right: ColorInput
|
||||
leftShadow: ColorInput
|
||||
rightShadow: ColorInput
|
||||
}
|
||||
|
||||
export type RunFooterTheme = {
|
||||
highlight: ColorInput
|
||||
selected: ColorInput
|
||||
selectedText: ColorInput
|
||||
warning: ColorInput
|
||||
success: ColorInput
|
||||
error: ColorInput
|
||||
muted: ColorInput
|
||||
text: ColorInput
|
||||
status: ColorInput
|
||||
statusAccent: ColorInput
|
||||
shade: ColorInput
|
||||
surface: ColorInput
|
||||
pane: ColorInput
|
||||
border: ColorInput
|
||||
line: ColorInput
|
||||
}
|
||||
|
||||
export type RunBlockTheme = {
|
||||
highlight: ColorInput
|
||||
warning: ColorInput
|
||||
text: ColorInput
|
||||
muted: ColorInput
|
||||
syntax?: SyntaxStyle
|
||||
subtleSyntax?: SyntaxStyle
|
||||
diffAdded: ColorInput
|
||||
diffRemoved: ColorInput
|
||||
diffAddedBg: ColorInput
|
||||
diffRemovedBg: ColorInput
|
||||
diffContextBg: ColorInput
|
||||
diffHighlightAdded: ColorInput
|
||||
diffHighlightRemoved: ColorInput
|
||||
diffLineNumber: ColorInput
|
||||
diffAddedLineNumberBg: ColorInput
|
||||
diffRemovedLineNumberBg: ColorInput
|
||||
}
|
||||
|
||||
export type RunTheme = {
|
||||
background: ColorInput
|
||||
footer: RunFooterTheme
|
||||
entry: RunEntryTheme
|
||||
splash: RunSplashTheme
|
||||
block: RunBlockTheme
|
||||
}
|
||||
|
||||
type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity">
|
||||
type HexColor = `#${string}`
|
||||
type RefName = string
|
||||
type Variant = {
|
||||
dark: HexColor | RefName
|
||||
light: HexColor | RefName
|
||||
}
|
||||
type ColorValue = HexColor | RefName | Variant | RGBA | number
|
||||
type ThemeJson = {
|
||||
defs?: Record<string, HexColor | RefName>
|
||||
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
|
||||
selectedListItemText?: ColorValue
|
||||
backgroundMenu?: ColorValue
|
||||
thinkingOpacity?: number
|
||||
}
|
||||
}
|
||||
|
||||
type SharedSyntaxTheme = TuiThemeCurrent & {
|
||||
_hasSelectedListItemText: boolean
|
||||
}
|
||||
|
||||
export const transparent = RGBA.fromValues(0, 0, 0, 0)
|
||||
|
||||
function alpha(color: RGBA, value: number): RGBA {
|
||||
return RGBA.fromValues(color.r, color.g, color.b, Math.max(0, Math.min(1, value)))
|
||||
}
|
||||
|
||||
function rgba(hex: string, value?: number): RGBA {
|
||||
const color = RGBA.fromHex(hex)
|
||||
return value === undefined ? color : alpha(color, value)
|
||||
}
|
||||
|
||||
function mode(bg: RGBA): "dark" | "light" {
|
||||
return luminance(bg) > 0.5 ? "light" : "dark"
|
||||
}
|
||||
|
||||
function luminance(color: RGBA): number {
|
||||
return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
|
||||
}
|
||||
|
||||
function fade(color: RGBA, base: RGBA, fallback: number, scale: number, limit: number): RGBA {
|
||||
if (color.a === 0) {
|
||||
return RGBA.fromValues(color.r, color.g, color.b, Math.max(0, Math.min(1, fallback)))
|
||||
}
|
||||
|
||||
const target = Math.min(limit, color.a * scale)
|
||||
const mix = Math.min(1, target / color.a)
|
||||
|
||||
return RGBA.fromValues(
|
||||
base.r + (color.r - base.r) * mix,
|
||||
base.g + (color.g - base.g) * mix,
|
||||
base.b + (color.b - base.b) * mix,
|
||||
color.a,
|
||||
)
|
||||
}
|
||||
|
||||
function ansiToRgba(code: number): RGBA {
|
||||
if (code < 16) {
|
||||
const ansi = [
|
||||
"#000000",
|
||||
"#800000",
|
||||
"#008000",
|
||||
"#808000",
|
||||
"#000080",
|
||||
"#800080",
|
||||
"#008080",
|
||||
"#c0c0c0",
|
||||
"#808080",
|
||||
"#ff0000",
|
||||
"#00ff00",
|
||||
"#ffff00",
|
||||
"#0000ff",
|
||||
"#ff00ff",
|
||||
"#00ffff",
|
||||
"#ffffff",
|
||||
]
|
||||
return RGBA.fromHex(ansi[code] ?? "#000000")
|
||||
}
|
||||
|
||||
if (code < 232) {
|
||||
const index = code - 16
|
||||
const b = index % 6
|
||||
const g = Math.floor(index / 6) % 6
|
||||
const r = Math.floor(index / 36)
|
||||
const value = (x: number) => (x === 0 ? 0 : x * 40 + 55)
|
||||
return RGBA.fromInts(value(r), value(g), value(b))
|
||||
}
|
||||
|
||||
if (code < 256) {
|
||||
const gray = (code - 232) * 10 + 8
|
||||
return RGBA.fromInts(gray, gray, gray)
|
||||
}
|
||||
|
||||
return RGBA.fromInts(0, 0, 0)
|
||||
}
|
||||
|
||||
function tint(base: RGBA, overlay: RGBA, value: number): RGBA {
|
||||
return RGBA.fromInts(
|
||||
Math.round((base.r + (overlay.r - base.r) * value) * 255),
|
||||
Math.round((base.g + (overlay.g - base.g) * value) * 255),
|
||||
Math.round((base.b + (overlay.b - base.b) * value) * 255),
|
||||
)
|
||||
}
|
||||
|
||||
function blend(color: RGBA, bg: RGBA): RGBA {
|
||||
if (color.a >= 1) {
|
||||
return color
|
||||
}
|
||||
|
||||
return RGBA.fromValues(
|
||||
bg.r + (color.r - bg.r) * color.a,
|
||||
bg.g + (color.g - bg.g) * color.a,
|
||||
bg.b + (color.b - bg.b) * color.a,
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
function chroma(color: RGBA) {
|
||||
return Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b)
|
||||
}
|
||||
|
||||
function opaqueSyntaxStyle(style: SyntaxStyle | undefined, bg: RGBA): SyntaxStyle | undefined {
|
||||
if (!style) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return SyntaxStyle.fromStyles(
|
||||
Object.fromEntries(
|
||||
[...style.getAllStyles()].map(([name, value]) => [
|
||||
name,
|
||||
{
|
||||
...value,
|
||||
fg: value.fg ? blend(value.fg, bg) : value.fg,
|
||||
bg: value.bg ? blend(value.bg, bg) : value.bg,
|
||||
},
|
||||
]),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function indexedPalette(colors: TerminalColors, size: number = Math.max(colors.palette.length, 16)): RGBA[] {
|
||||
return Array.from({ length: size }, (_, index) => {
|
||||
const value = colors.palette[index]
|
||||
return RGBA.fromIndex(index, value ? RGBA.fromHex(value) : ansiToRgba(index))
|
||||
})
|
||||
}
|
||||
|
||||
function srgbToLinear(value: number): number {
|
||||
if (value <= 0.04045) {
|
||||
return value / 12.92
|
||||
}
|
||||
|
||||
return ((value + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
|
||||
function oklab(color: RGBA) {
|
||||
const r = srgbToLinear(color.r)
|
||||
const g = srgbToLinear(color.g)
|
||||
const b = srgbToLinear(color.b)
|
||||
|
||||
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
|
||||
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
|
||||
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
|
||||
|
||||
return {
|
||||
l: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
||||
a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
||||
b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
||||
}
|
||||
}
|
||||
|
||||
function nearestIndexed(indexed: RGBA[], rgba: RGBA): RGBA {
|
||||
const target = oklab(rgba)
|
||||
const hit = indexed.reduce(
|
||||
(best, item) => {
|
||||
const sample = oklab(item)
|
||||
const dl = sample.l - target.l
|
||||
const da = sample.a - target.a
|
||||
const db = sample.b - target.b
|
||||
const dist = dl * dl * 2 + da * da + db * db
|
||||
if (dist >= best.dist) return best
|
||||
return {
|
||||
dist,
|
||||
item,
|
||||
}
|
||||
},
|
||||
{
|
||||
dist: Number.POSITIVE_INFINITY,
|
||||
item: indexed[0]!,
|
||||
},
|
||||
)
|
||||
|
||||
return RGBA.clone(hit.item)
|
||||
}
|
||||
|
||||
function paletteColor(colors: TerminalColors, index: number): RGBA {
|
||||
const value = colors.palette[index]
|
||||
return value ? RGBA.fromHex(value) : ansiToRgba(index)
|
||||
}
|
||||
|
||||
function splashShadow(indexed: RGBA[], base: RGBA, overlay: RGBA, value: number): RGBA {
|
||||
const mixed = tint(base, overlay, value)
|
||||
return nearestIndexed(indexed, mixed)
|
||||
}
|
||||
|
||||
export function resolveTheme(theme: ThemeJson, pick: "dark" | "light"): TuiThemeCurrent {
|
||||
const defs = theme.defs ?? {}
|
||||
|
||||
const resolveColor = (value: ColorValue, chain: string[] = []): RGBA => {
|
||||
if (value instanceof RGBA) return value
|
||||
|
||||
if (typeof value === "number") {
|
||||
return RGBA.fromIndex(value, ansiToRgba(value))
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return resolveColor(value[pick], chain)
|
||||
}
|
||||
|
||||
if (value === "transparent" || value === "none") {
|
||||
return RGBA.fromInts(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
if (value.startsWith("#")) {
|
||||
return RGBA.fromHex(value)
|
||||
}
|
||||
|
||||
if (chain.includes(value)) {
|
||||
throw new Error(`Circular color reference: ${[...chain, value].join(" -> ")}`)
|
||||
}
|
||||
|
||||
const next = defs[value] ?? theme.theme[value as ThemeColor]
|
||||
if (next === undefined) {
|
||||
throw new Error(`Color reference "${value}" not found in defs or theme`)
|
||||
}
|
||||
|
||||
return resolveColor(next, [...chain, value])
|
||||
}
|
||||
|
||||
const resolved = Object.fromEntries(
|
||||
Object.entries(theme.theme)
|
||||
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity")
|
||||
.map(([key, value]) => [key, resolveColor(value as ColorValue)]),
|
||||
) as Partial<Record<ThemeColor, RGBA>>
|
||||
|
||||
return {
|
||||
...(resolved as Record<ThemeColor, RGBA>),
|
||||
selectedListItemText:
|
||||
theme.theme.selectedListItemText === undefined
|
||||
? resolved.background!
|
||||
: resolveColor(theme.theme.selectedListItemText),
|
||||
backgroundMenu:
|
||||
theme.theme.backgroundMenu === undefined ? resolved.backgroundElement! : resolveColor(theme.theme.backgroundMenu),
|
||||
thinkingOpacity: theme.theme.thinkingOpacity ?? 0.6,
|
||||
}
|
||||
}
|
||||
|
||||
function generateGrayScale(bg: RGBA, isDark: boolean, map: (rgba: RGBA) => RGBA): Record<number, RGBA> {
|
||||
const r = bg.r * 255
|
||||
const g = bg.g * 255
|
||||
const b = bg.b * 255
|
||||
const lum = 0.299 * r + 0.587 * g + 0.114 * b
|
||||
const cast = 0.25 * (1 - chroma(bg)) ** 2
|
||||
|
||||
const gray = (level: number) => {
|
||||
const factor = level / 12
|
||||
|
||||
if (isDark && lum < 10) {
|
||||
const value = Math.floor(factor * 0.4 * 255)
|
||||
return map(RGBA.fromInts(value, value, value))
|
||||
}
|
||||
|
||||
if (!isDark && lum > 245) {
|
||||
const value = Math.floor(255 - factor * 0.4 * 255)
|
||||
return map(RGBA.fromInts(value, value, value))
|
||||
}
|
||||
|
||||
const value = isDark ? lum + (255 - lum) * factor * 0.4 : lum * (1 - factor * 0.4)
|
||||
const tone = RGBA.fromInts(Math.floor(value), Math.floor(value), Math.floor(value))
|
||||
if (cast === 0) return map(tone)
|
||||
|
||||
const ratio = lum === 0 ? 0 : value / lum
|
||||
return map(
|
||||
tint(
|
||||
tone,
|
||||
RGBA.fromInts(
|
||||
Math.floor(Math.max(0, Math.min(r * ratio, 255))),
|
||||
Math.floor(Math.max(0, Math.min(g * ratio, 255))),
|
||||
Math.floor(Math.max(0, Math.min(b * ratio, 255))),
|
||||
),
|
||||
cast,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return Object.fromEntries(Array.from({ length: 12 }, (_, index) => [index + 1, gray(index + 1)]))
|
||||
}
|
||||
|
||||
function generateMutedTextColor(bg: RGBA, isDark: boolean, map: (rgba: RGBA) => RGBA): RGBA {
|
||||
const lum = 0.299 * bg.r * 255 + 0.587 * bg.g * 255 + 0.114 * bg.b * 255
|
||||
const gray = isDark
|
||||
? lum < 10
|
||||
? 180
|
||||
: Math.min(Math.floor(160 + lum * 0.3), 200)
|
||||
: lum > 245
|
||||
? 75
|
||||
: Math.max(Math.floor(100 - (255 - lum) * 0.2), 60)
|
||||
|
||||
return map(RGBA.fromInts(gray, gray, gray))
|
||||
}
|
||||
|
||||
export function generateSystem(colors: TerminalColors, pick: "dark" | "light"): ThemeJson {
|
||||
const bg_snapshot = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
|
||||
const fg_snapshot = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
|
||||
const bg = RGBA.defaultBackground(bg_snapshot)
|
||||
const fg = RGBA.defaultForeground(fg_snapshot)
|
||||
const isDark = pick === "dark"
|
||||
|
||||
const color = (index: number) => paletteColor(colors, index)
|
||||
|
||||
const grays = generateGrayScale(bg_snapshot, isDark, (rgba) => rgba)
|
||||
const textMuted = generateMutedTextColor(bg_snapshot, isDark, (rgba) => rgba)
|
||||
|
||||
const ansi = {
|
||||
red: color(1),
|
||||
green: color(2),
|
||||
yellow: color(3),
|
||||
blue: color(4),
|
||||
magenta: color(5),
|
||||
cyan: color(6),
|
||||
red_bright: color(9),
|
||||
green_bright: color(10),
|
||||
}
|
||||
|
||||
const diff_alpha = isDark ? 0.22 : 0.14
|
||||
const diff_context_bg = grays[2]
|
||||
const primary = ansi.cyan
|
||||
const secondary = ansi.magenta
|
||||
|
||||
return {
|
||||
theme: {
|
||||
primary,
|
||||
secondary,
|
||||
accent: primary,
|
||||
error: ansi.red,
|
||||
warning: ansi.yellow,
|
||||
success: ansi.green,
|
||||
info: ansi.cyan,
|
||||
text: fg,
|
||||
textMuted,
|
||||
selectedListItemText: bg,
|
||||
background: alpha(bg, 0),
|
||||
backgroundPanel: grays[2],
|
||||
backgroundElement: grays[3],
|
||||
backgroundMenu: grays[3],
|
||||
borderSubtle: grays[6],
|
||||
border: grays[7],
|
||||
borderActive: grays[8],
|
||||
diffAdded: ansi.green,
|
||||
diffRemoved: ansi.red,
|
||||
diffContext: grays[7],
|
||||
diffHunkHeader: grays[7],
|
||||
diffHighlightAdded: ansi.green_bright,
|
||||
diffHighlightRemoved: ansi.red_bright,
|
||||
diffAddedBg: tint(bg_snapshot, ansi.green, diff_alpha),
|
||||
diffRemovedBg: tint(bg_snapshot, ansi.red, diff_alpha),
|
||||
diffContextBg: diff_context_bg,
|
||||
diffLineNumber: textMuted,
|
||||
diffAddedLineNumberBg: tint(diff_context_bg, ansi.green, diff_alpha),
|
||||
diffRemovedLineNumberBg: tint(diff_context_bg, ansi.red, diff_alpha),
|
||||
markdownText: fg,
|
||||
markdownHeading: fg,
|
||||
markdownLink: ansi.blue,
|
||||
markdownLinkText: ansi.cyan,
|
||||
markdownCode: ansi.green,
|
||||
markdownBlockQuote: ansi.yellow,
|
||||
markdownEmph: ansi.yellow,
|
||||
markdownStrong: fg,
|
||||
markdownHorizontalRule: grays[7],
|
||||
markdownListItem: ansi.blue,
|
||||
markdownListEnumeration: ansi.cyan,
|
||||
markdownImage: ansi.blue,
|
||||
markdownImageText: ansi.cyan,
|
||||
markdownCodeBlock: fg,
|
||||
syntaxComment: textMuted,
|
||||
syntaxKeyword: ansi.magenta,
|
||||
syntaxFunction: ansi.blue,
|
||||
syntaxVariable: fg,
|
||||
syntaxString: ansi.green,
|
||||
syntaxNumber: ansi.yellow,
|
||||
syntaxType: ansi.cyan,
|
||||
syntaxOperator: ansi.cyan,
|
||||
syntaxPunctuation: fg,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function quantizeColor(indexed: RGBA[], rgba: RGBA): RGBA {
|
||||
if (rgba.a === 0 || rgba.intent === "default" || rgba.intent === "indexed") {
|
||||
return RGBA.clone(rgba)
|
||||
}
|
||||
|
||||
return nearestIndexed(indexed, rgba)
|
||||
}
|
||||
|
||||
function quantizeTheme(theme: TuiThemeCurrent, indexed: RGBA[]): TuiThemeCurrent {
|
||||
const resolved = Object.fromEntries(
|
||||
Object.entries(theme)
|
||||
.filter(([key]) => key !== "thinkingOpacity")
|
||||
.map(([key, value]) => [key, quantizeColor(indexed, value as RGBA)]),
|
||||
) as Partial<Record<ThemeColor, RGBA>>
|
||||
|
||||
return {
|
||||
...(resolved as Record<ThemeColor, RGBA>),
|
||||
thinkingOpacity: theme.thinkingOpacity,
|
||||
}
|
||||
}
|
||||
|
||||
function splashTheme(theme: TuiThemeCurrent, indexed: RGBA[]): RunSplashTheme {
|
||||
const left = nearestIndexed(indexed, theme.textMuted)
|
||||
const right = nearestIndexed(indexed, theme.text)
|
||||
return {
|
||||
left,
|
||||
right,
|
||||
leftShadow: splashShadow(indexed, theme.background, left, 0.14),
|
||||
rightShadow: splashShadow(indexed, theme.background, right, 0.14),
|
||||
}
|
||||
}
|
||||
|
||||
function map(
|
||||
footerTheme: TuiThemeCurrent,
|
||||
scrollbackTheme: TuiThemeCurrent,
|
||||
splash: RunSplashTheme,
|
||||
syntax?: SyntaxStyle,
|
||||
subtleSyntax?: SyntaxStyle,
|
||||
): RunTheme {
|
||||
const opaqueSubtleSyntax = opaqueSyntaxStyle(subtleSyntax, scrollbackTheme.background)
|
||||
subtleSyntax?.destroy()
|
||||
const footerBackground = alpha(footerTheme.background, 1)
|
||||
const footerMode = mode(footerBackground)
|
||||
const shade = fade(footerTheme.backgroundMenu, footerTheme.background, 0.12, 0.56, 0.72)
|
||||
const surface = fade(footerTheme.backgroundMenu, footerTheme.background, 0.18, 0.76, 0.9)
|
||||
const line = fade(footerTheme.backgroundMenu, footerTheme.background, 0.24, 0.9, 0.98)
|
||||
const statusBase = tint(footerBackground, rgba("#000000"), footerMode === "dark" ? 0.12 : 0.06)
|
||||
const statusAccentBase =
|
||||
footerMode === "dark" ? tint(footerBackground, rgba("#ffffff"), 0.06) : tint(statusBase, rgba("#000000"), 0.04)
|
||||
const collapsedStatus = footerMode === "dark" && luminance(statusBase) <= 0.04
|
||||
// Pure-black backgrounds need a slight lift or the row disappears into the terminal background.
|
||||
const status = collapsedStatus ? tint(statusBase, statusAccentBase, 0.7) : statusBase
|
||||
const statusAccent = collapsedStatus ? tint(status, rgba("#ffffff"), 0.06) : statusAccentBase
|
||||
|
||||
return {
|
||||
background: footerTheme.background,
|
||||
footer: {
|
||||
highlight: footerTheme.primary,
|
||||
selected: footerTheme.backgroundElement,
|
||||
selectedText: footerTheme.selectedListItemText,
|
||||
warning: footerTheme.warning,
|
||||
success: footerTheme.success,
|
||||
error: footerTheme.error,
|
||||
muted: footerTheme.textMuted,
|
||||
text: footerTheme.text,
|
||||
status,
|
||||
statusAccent,
|
||||
shade,
|
||||
surface,
|
||||
pane: footerTheme.backgroundMenu,
|
||||
border: footerTheme.border,
|
||||
line,
|
||||
},
|
||||
entry: {
|
||||
system: {
|
||||
body: scrollbackTheme.textMuted,
|
||||
},
|
||||
user: {
|
||||
body: scrollbackTheme.primary,
|
||||
},
|
||||
assistant: {
|
||||
body: scrollbackTheme.text,
|
||||
},
|
||||
reasoning: {
|
||||
body: scrollbackTheme.textMuted,
|
||||
},
|
||||
tool: {
|
||||
body: scrollbackTheme.text,
|
||||
start: scrollbackTheme.textMuted,
|
||||
},
|
||||
error: {
|
||||
body: scrollbackTheme.error,
|
||||
},
|
||||
},
|
||||
splash,
|
||||
block: {
|
||||
highlight: scrollbackTheme.primary,
|
||||
warning: scrollbackTheme.warning,
|
||||
text: scrollbackTheme.text,
|
||||
muted: scrollbackTheme.textMuted,
|
||||
syntax,
|
||||
subtleSyntax: opaqueSubtleSyntax,
|
||||
diffAdded: scrollbackTheme.diffAdded,
|
||||
diffRemoved: scrollbackTheme.diffRemoved,
|
||||
diffAddedBg: transparent,
|
||||
diffRemovedBg: transparent,
|
||||
diffContextBg: transparent,
|
||||
diffHighlightAdded: scrollbackTheme.diffHighlightAdded,
|
||||
diffHighlightRemoved: scrollbackTheme.diffHighlightRemoved,
|
||||
diffLineNumber: scrollbackTheme.diffLineNumber,
|
||||
diffAddedLineNumberBg: scrollbackTheme.diffAddedLineNumberBg,
|
||||
diffRemovedLineNumberBg: scrollbackTheme.diffRemovedLineNumberBg,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const seed = {
|
||||
highlight: RGBA.fromIndex(6, rgba("#38bdf8")),
|
||||
muted: RGBA.fromIndex(8, rgba("#64748b")),
|
||||
text: RGBA.defaultForeground(rgba("#f8fafc")),
|
||||
panel: rgba("#0f172a"),
|
||||
success: RGBA.fromIndex(2, rgba("#22c55e")),
|
||||
warning: RGBA.fromIndex(3, rgba("#f59e0b")),
|
||||
error: RGBA.fromIndex(1, rgba("#ef4444")),
|
||||
}
|
||||
|
||||
function tone(body: ColorInput, start?: ColorInput): Tone {
|
||||
return {
|
||||
body,
|
||||
start,
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackSplashIndexed = Array.from({ length: 256 }, (_, index) => RGBA.fromIndex(index))
|
||||
const fallbackSplashLeft = RGBA.fromIndex(67)
|
||||
const fallbackSplashRight = RGBA.fromIndex(110)
|
||||
|
||||
export const RUN_THEME_FALLBACK: RunTheme = {
|
||||
background: RGBA.fromValues(0, 0, 0, 0),
|
||||
footer: {
|
||||
highlight: seed.highlight,
|
||||
selected: seed.text,
|
||||
selectedText: seed.panel,
|
||||
warning: seed.warning,
|
||||
success: seed.success,
|
||||
error: seed.error,
|
||||
muted: seed.muted,
|
||||
text: seed.text,
|
||||
status: tint(seed.panel, rgba("#000000"), 0.12),
|
||||
statusAccent: tint(seed.panel, rgba("#ffffff"), 0.06),
|
||||
shade: alpha(seed.panel, 0.68),
|
||||
surface: alpha(seed.panel, 0.86),
|
||||
pane: seed.panel,
|
||||
border: seed.muted,
|
||||
line: alpha(seed.panel, 0.96),
|
||||
},
|
||||
entry: {
|
||||
system: tone(seed.muted),
|
||||
user: tone(seed.highlight),
|
||||
assistant: tone(seed.text),
|
||||
reasoning: tone(seed.muted),
|
||||
tool: tone(seed.text, seed.muted),
|
||||
error: tone(seed.error),
|
||||
},
|
||||
splash: {
|
||||
left: fallbackSplashLeft,
|
||||
right: fallbackSplashRight,
|
||||
leftShadow: splashShadow(fallbackSplashIndexed, RGBA.fromValues(0, 0, 0, 0), fallbackSplashLeft, 0.14),
|
||||
rightShadow: splashShadow(fallbackSplashIndexed, RGBA.fromValues(0, 0, 0, 0), fallbackSplashRight, 0.14),
|
||||
},
|
||||
block: {
|
||||
highlight: seed.highlight,
|
||||
warning: seed.warning,
|
||||
text: seed.text,
|
||||
muted: seed.muted,
|
||||
diffAdded: seed.success,
|
||||
diffRemoved: seed.error,
|
||||
diffAddedBg: alpha(seed.success, 0.18),
|
||||
diffRemovedBg: alpha(seed.error, 0.18),
|
||||
diffContextBg: alpha(seed.panel, 0.72),
|
||||
diffHighlightAdded: seed.success,
|
||||
diffHighlightRemoved: seed.error,
|
||||
diffLineNumber: seed.muted,
|
||||
diffAddedLineNumberBg: alpha(seed.success, 0.12),
|
||||
diffRemovedLineNumberBg: alpha(seed.error, 0.12),
|
||||
},
|
||||
}
|
||||
|
||||
export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme> {
|
||||
try {
|
||||
const colors = await renderer.getPalette({
|
||||
size: 256,
|
||||
})
|
||||
const bg = colors.defaultBackground ?? colors.palette[0]
|
||||
if (!bg) {
|
||||
return RUN_THEME_FALLBACK
|
||||
}
|
||||
|
||||
// Palette-only terminal reloads can leave renderer.themeMode stale, but
|
||||
// ANSI slot zero is not the terminal background when OSC 11 is absent.
|
||||
const pick = colors.defaultBackground
|
||||
? mode(RGBA.fromHex(colors.defaultBackground))
|
||||
: (renderer.themeMode ?? mode(RGBA.fromHex(bg)))
|
||||
const footerTheme = resolveTheme(generateSystem(colors, pick), pick)
|
||||
const indexed = indexedPalette(colors, 256)
|
||||
const scrollbackTheme = quantizeTheme(footerTheme, indexed)
|
||||
const shared = await import("@opencode-ai/tui/context/theme")
|
||||
const syntaxTheme: SharedSyntaxTheme = {
|
||||
...scrollbackTheme,
|
||||
_hasSelectedListItemText: true,
|
||||
}
|
||||
const syntax = shared.generateSyntax(syntaxTheme)
|
||||
return map(
|
||||
footerTheme,
|
||||
scrollbackTheme,
|
||||
splashTheme(scrollbackTheme, indexed),
|
||||
syntax,
|
||||
shared.generateSubtleSyntax(syntaxTheme),
|
||||
)
|
||||
} catch {
|
||||
return RUN_THEME_FALLBACK
|
||||
}
|
||||
}
|
||||
1489
packages/opencode/src/cli/cmd/run/tool.ts
Normal file
1489
packages/opencode/src/cli/cmd/run/tool.ts
Normal file
File diff suppressed because it is too large
Load Diff
94
packages/opencode/src/cli/cmd/run/trace.ts
Normal file
94
packages/opencode/src/cli/cmd/run/trace.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// Dev-only JSONL event trace for direct interactive mode.
|
||||
//
|
||||
// Enable with OPENCODE_DIRECT_TRACE=1. Writes one JSON line per event to
|
||||
// ~/.local/share/opencode/log/direct/<timestamp>-<pid>.jsonl. Also writes
|
||||
// a latest.json pointer so you can quickly find the most recent trace.
|
||||
//
|
||||
// The trace captures the full closed loop: outbound prompts, inbound SDK
|
||||
// events, reducer output, footer commits, and turn lifecycle markers.
|
||||
// Useful for debugging stream ordering, permission behavior, and
|
||||
// footer/transcript mismatches.
|
||||
//
|
||||
// Lazy-initialized: the first call to trace() decides whether tracing is
|
||||
// active based on the env var, and subsequent calls return the cached result.
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
||||
export type Trace = {
|
||||
write(type: string, data?: unknown): void
|
||||
}
|
||||
|
||||
let state: Trace | false | undefined
|
||||
|
||||
function stamp() {
|
||||
return new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, "")
|
||||
.replace(/\.\d+Z$/, "Z")
|
||||
}
|
||||
|
||||
function file() {
|
||||
return path.join(Global.Path.log, "direct", `${stamp()}-${process.pid}.jsonl`)
|
||||
}
|
||||
|
||||
function latest() {
|
||||
return path.join(Global.Path.log, "direct", "latest.json")
|
||||
}
|
||||
|
||||
function text(data: unknown) {
|
||||
return JSON.stringify(
|
||||
data,
|
||||
(_key, value) => {
|
||||
if (typeof value === "bigint") {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
return value
|
||||
},
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
export function trace(): Trace | undefined {
|
||||
if (state !== undefined) {
|
||||
return state || undefined
|
||||
}
|
||||
|
||||
if (!process.env.OPENCODE_DIRECT_TRACE) {
|
||||
state = false
|
||||
return undefined
|
||||
}
|
||||
|
||||
const target = file()
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||
fs.writeFileSync(
|
||||
latest(),
|
||||
text({
|
||||
time: new Date().toISOString(),
|
||||
pid: process.pid,
|
||||
cwd: process.cwd(),
|
||||
argv: process.argv.slice(2),
|
||||
path: target,
|
||||
}) + "\n",
|
||||
)
|
||||
state = {
|
||||
write(type: string, data?: unknown) {
|
||||
fs.appendFileSync(
|
||||
target,
|
||||
text({
|
||||
time: new Date().toISOString(),
|
||||
pid: process.pid,
|
||||
type,
|
||||
data,
|
||||
}) + "\n",
|
||||
)
|
||||
},
|
||||
}
|
||||
state.write("trace.start", {
|
||||
argv: process.argv.slice(2),
|
||||
cwd: process.cwd(),
|
||||
path: target,
|
||||
})
|
||||
return state
|
||||
}
|
||||
47
packages/opencode/src/cli/cmd/run/turn-summary.ts
Normal file
47
packages/opencode/src/cli/cmd/run/turn-summary.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as Locale from "@/util/locale"
|
||||
import type { SessionMessages } from "./session.shared"
|
||||
import type { RunProvider, StreamCommit } from "./types"
|
||||
|
||||
export function turnSummaryCommit(input: {
|
||||
agent: string
|
||||
model: string
|
||||
duration: string
|
||||
messageID?: string
|
||||
}): StreamCommit {
|
||||
return {
|
||||
kind: "system",
|
||||
text: `▣ ${input.agent} · ${input.model} · ${input.duration}`,
|
||||
phase: "final",
|
||||
source: "system",
|
||||
summary: {
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
duration: input.duration,
|
||||
},
|
||||
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,
|
||||
})
|
||||
}
|
||||
350
packages/opencode/src/cli/cmd/run/types.ts
Normal file
350
packages/opencode/src/cli/cmd/run/types.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
// Shared type vocabulary for the direct interactive mode (`run --interactive`).
|
||||
//
|
||||
// Direct mode uses a split-footer terminal layout: immutable scrollback for the
|
||||
// session transcript, and a mutable footer for prompt input, status, and
|
||||
// permission/question UI. Every module in run/* shares these types to stay
|
||||
// aligned on that two-lane model.
|
||||
//
|
||||
// Data flow through the system:
|
||||
//
|
||||
// SDK events → session-data reducer → StreamCommit[] + FooterOutput
|
||||
// → stream.ts bridges to footer API
|
||||
// → footer.ts queues commits and patches the footer view
|
||||
// → OpenTUI split-footer renderer writes to terminal
|
||||
import type { OpencodeClient, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import type { TuiConfig } from "@opencode-ai/tui/config"
|
||||
|
||||
export type RunFilePart = {
|
||||
type: "file"
|
||||
url: string
|
||||
filename: string
|
||||
mime: string
|
||||
}
|
||||
|
||||
type PromptModel = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
|
||||
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 RunProvider = NonNullable<Awaited<ReturnType<OpencodeClient["provider"]["list"]>>["data"]>["all"][number]
|
||||
|
||||
export type RunPrompt = {
|
||||
messageID?: string
|
||||
partID?: string
|
||||
text: string
|
||||
parts: RunPromptPart[]
|
||||
mode?: "shell"
|
||||
command?: {
|
||||
name: string
|
||||
arguments: string
|
||||
}
|
||||
}
|
||||
|
||||
export type FooterQueuedPrompt = {
|
||||
messageID: string
|
||||
partID: string
|
||||
prompt: RunPrompt
|
||||
}
|
||||
|
||||
export type RunAgent = NonNullable<Awaited<ReturnType<OpencodeClient["app"]["agents"]>>["data"]>[number]
|
||||
|
||||
type RunResourceMap = NonNullable<Awaited<ReturnType<OpencodeClient["experimental"]["resource"]["list"]>>["data"]>
|
||||
|
||||
export type RunResource = RunResourceMap[string]
|
||||
|
||||
export type RunInput = {
|
||||
sdk: OpencodeClient
|
||||
directory: string
|
||||
sessionID: string
|
||||
sessionTitle?: string
|
||||
resume?: boolean
|
||||
replay?: boolean
|
||||
replayLimit?: number
|
||||
agent: string | undefined
|
||||
model: PromptModel | undefined
|
||||
variant: string | undefined
|
||||
files: RunFilePart[]
|
||||
initialInput?: string
|
||||
thinking: boolean
|
||||
backgroundSubagents: boolean
|
||||
demo?: boolean
|
||||
}
|
||||
|
||||
// The semantic role of a scrollback entry. Maps 1:1 to theme colors.
|
||||
export type EntryKind = "system" | "user" | "assistant" | "reasoning" | "tool" | "error"
|
||||
|
||||
// Whether the assistant is actively processing a turn.
|
||||
export type FooterPhase = "idle" | "running"
|
||||
|
||||
// Full snapshot of footer status bar state. Every update replaces the whole
|
||||
// object in the SolidJS signal so the view re-renders atomically.
|
||||
export type FooterState = {
|
||||
phase: FooterPhase
|
||||
status: string
|
||||
queue: number
|
||||
model: string
|
||||
duration: string
|
||||
usage: string
|
||||
first: boolean
|
||||
interrupt: number
|
||||
exit: number
|
||||
}
|
||||
|
||||
// A partial update to FooterState. The footer merges this onto the current state.
|
||||
export type FooterPatch = Partial<FooterState>
|
||||
|
||||
export type RunDiffStyle = "auto" | "stacked"
|
||||
|
||||
export type TurnSummary = {
|
||||
agent: string
|
||||
model: string
|
||||
duration: string
|
||||
}
|
||||
|
||||
export type ScrollbackOptions = {
|
||||
diffStyle?: RunDiffStyle
|
||||
suppressBackgrounds?: boolean
|
||||
}
|
||||
|
||||
export type ToolCodeSnapshot = {
|
||||
kind: "code"
|
||||
title: string
|
||||
content: string
|
||||
file?: string
|
||||
}
|
||||
|
||||
export type ToolDiffSnapshot = {
|
||||
kind: "diff"
|
||||
items: Array<{
|
||||
title: string
|
||||
diff: string
|
||||
file?: string
|
||||
deletions?: number
|
||||
}>
|
||||
}
|
||||
|
||||
export type ToolTaskSnapshot = {
|
||||
kind: "task"
|
||||
title: string
|
||||
rows: string[]
|
||||
tail: string
|
||||
}
|
||||
|
||||
export type ToolTodoSnapshot = {
|
||||
kind: "todo"
|
||||
items: Array<{
|
||||
status: string
|
||||
content: string
|
||||
}>
|
||||
tail: string
|
||||
}
|
||||
|
||||
export type ToolQuestionSnapshot = {
|
||||
kind: "question"
|
||||
items: Array<{
|
||||
question: string
|
||||
answer: string
|
||||
}>
|
||||
tail: string
|
||||
}
|
||||
|
||||
export type ToolSnapshot =
|
||||
| ToolCodeSnapshot
|
||||
| ToolDiffSnapshot
|
||||
| ToolTaskSnapshot
|
||||
| ToolTodoSnapshot
|
||||
| ToolQuestionSnapshot
|
||||
|
||||
export type EntryLayout = "inline" | "block"
|
||||
|
||||
export type RunEntryBody =
|
||||
| { type: "none" }
|
||||
| { type: "text"; content: string }
|
||||
| { type: "code"; content: string; filetype?: string }
|
||||
| { type: "markdown"; content: string }
|
||||
| { type: "structured"; snapshot: ToolSnapshot }
|
||||
|
||||
// Which interactive surface the footer is showing. Only one view is active at
|
||||
// a time. The reducer drives transitions: when a permission arrives the view
|
||||
// switches to "permission", and when the permission resolves it falls back to
|
||||
// "prompt".
|
||||
export type FooterView =
|
||||
| { type: "prompt" }
|
||||
| { type: "permission"; request: PermissionRequest }
|
||||
| { type: "question"; request: QuestionRequest }
|
||||
|
||||
export type FooterPromptRoute =
|
||||
| { type: "composer" }
|
||||
| { type: "queued-menu" }
|
||||
| { type: "subagent-menu" }
|
||||
| { type: "subagent"; sessionID: string }
|
||||
| { type: "command" }
|
||||
| { type: "skill" }
|
||||
| { type: "model" }
|
||||
| { type: "variant" }
|
||||
|
||||
export type FooterSubagentTab = {
|
||||
sessionID: string
|
||||
partID: string
|
||||
callID: string
|
||||
label: string
|
||||
description: string
|
||||
status: "running" | "completed" | "cancelled" | "error"
|
||||
background?: boolean
|
||||
title?: string
|
||||
toolCalls?: number
|
||||
lastUpdatedAt: number
|
||||
}
|
||||
|
||||
export type FooterSubagentDetail = {
|
||||
sessionID: string
|
||||
commits: StreamCommit[]
|
||||
}
|
||||
|
||||
export type FooterSubagentState = {
|
||||
tabs: FooterSubagentTab[]
|
||||
details: Record<string, FooterSubagentDetail>
|
||||
permissions: PermissionRequest[]
|
||||
questions: QuestionRequest[]
|
||||
}
|
||||
|
||||
// The reducer emits this alongside scrollback commits so the footer can update in the same frame.
|
||||
export type FooterOutput = {
|
||||
patch?: FooterPatch
|
||||
view?: FooterView
|
||||
subagent?: FooterSubagentState
|
||||
}
|
||||
|
||||
// Typed messages sent to RunFooter.event(). The prompt queue and stream
|
||||
// transport both emit these to update footer state without reaching into
|
||||
// internal signals directly.
|
||||
export type FooterEvent =
|
||||
| {
|
||||
type: "catalog"
|
||||
agents: RunAgent[]
|
||||
resources: RunResource[]
|
||||
commands?: RunCommand[]
|
||||
}
|
||||
| {
|
||||
type: "models"
|
||||
providers: RunProvider[]
|
||||
}
|
||||
| {
|
||||
type: "variants"
|
||||
variants: string[]
|
||||
current: string | undefined
|
||||
}
|
||||
| {
|
||||
type: "queue"
|
||||
queue: number
|
||||
}
|
||||
| {
|
||||
type: "queued.prompts"
|
||||
prompts: FooterQueuedPrompt[]
|
||||
}
|
||||
| {
|
||||
type: "first"
|
||||
first: boolean
|
||||
}
|
||||
| {
|
||||
type: "model"
|
||||
model: string
|
||||
}
|
||||
| {
|
||||
type: "turn.send"
|
||||
queue: number
|
||||
}
|
||||
| {
|
||||
type: "turn.wait"
|
||||
}
|
||||
| {
|
||||
type: "turn.idle"
|
||||
queue: number
|
||||
}
|
||||
| {
|
||||
type: "turn.duration"
|
||||
duration: string
|
||||
}
|
||||
| {
|
||||
type: "stream.patch"
|
||||
patch: FooterPatch
|
||||
}
|
||||
| {
|
||||
type: "stream.view"
|
||||
view: FooterView
|
||||
}
|
||||
| {
|
||||
type: "stream.subagent"
|
||||
state: FooterSubagentState
|
||||
}
|
||||
|
||||
export type PermissionReply = Parameters<OpencodeClient["permission"]["reply"]>[0]
|
||||
|
||||
export type QuestionReply = Parameters<OpencodeClient["question"]["reply"]>[0]
|
||||
|
||||
export type QuestionReject = Parameters<OpencodeClient["question"]["reject"]>[0]
|
||||
|
||||
export type RunTuiConfig = Pick<TuiConfig.Resolved, "keybinds" | "leader_timeout" | "diff_style">
|
||||
|
||||
// Lifecycle phase of a scrollback entry. "start" opens the entry, "progress"
|
||||
// appends content (coalesced in the footer queue), "final" closes it.
|
||||
export type StreamPhase = "start" | "progress" | "final"
|
||||
|
||||
export type StreamSource = "assistant" | "reasoning" | "tool" | "system"
|
||||
|
||||
export type StreamToolState = "running" | "completed" | "error"
|
||||
|
||||
// A single append-only commit to scrollback. The session-data reducer produces
|
||||
// these from SDK events, and RunFooter.append() queues them for the next
|
||||
// microtask flush. Once flushed, they become immutable terminal scrollback
|
||||
// rows -- they cannot be rewritten.
|
||||
export type StreamCommit = {
|
||||
kind: EntryKind
|
||||
text: string
|
||||
phase: StreamPhase
|
||||
source: StreamSource
|
||||
summary?: TurnSummary
|
||||
messageID?: string
|
||||
partID?: string
|
||||
tool?: string
|
||||
part?: ToolPart
|
||||
interrupted?: boolean
|
||||
toolState?: StreamToolState
|
||||
toolError?: string
|
||||
shell?: {
|
||||
callID: string
|
||||
command: string
|
||||
}
|
||||
}
|
||||
|
||||
export type LocalReplayAnchor = {
|
||||
kind: EntryKind
|
||||
text: string
|
||||
phase: StreamPhase
|
||||
messageID?: string
|
||||
partID?: string
|
||||
toolState?: StreamToolState
|
||||
visible?: string
|
||||
}
|
||||
|
||||
export type LocalReplayRow = {
|
||||
commit: StreamCommit
|
||||
after?: LocalReplayAnchor
|
||||
}
|
||||
|
||||
// The public contract between the stream transport / prompt queue and
|
||||
// the footer. RunFooter implements this. The transport and queue never
|
||||
// touch the renderer directly -- they go through this interface.
|
||||
export type FooterApi = {
|
||||
readonly isClosed: boolean
|
||||
onPrompt(fn: (input: RunPrompt) => void): () => void
|
||||
onQueuedRemove(fn: (messageID: string) => boolean | Promise<boolean>): () => void
|
||||
onClose(fn: () => void): () => void
|
||||
event(next: FooterEvent): void
|
||||
append(commit: StreamCommit): void
|
||||
idle(): Promise<void>
|
||||
close(): void
|
||||
destroy(): void
|
||||
}
|
||||
215
packages/opencode/src/cli/cmd/run/variant.shared.ts
Normal file
215
packages/opencode/src/cli/cmd/run/variant.shared.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
// Model variant resolution and persistence.
|
||||
//
|
||||
// Variants are provider-specific reasoning effort levels (e.g., "high", "max").
|
||||
// Resolution priority: CLI --variant flag > saved preference > session history.
|
||||
//
|
||||
// The saved variant persists across sessions in ~/.local/state/opencode/model.json
|
||||
// so your last-used variant sticks. Cycling (ctrl+t) updates both the active
|
||||
// variant and the persisted file.
|
||||
import path from "path"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { createSession, sessionVariant, type RunSession, type SessionMessages } from "./session.shared"
|
||||
import type { RunInput, RunProvider } from "./types"
|
||||
|
||||
const MODEL_FILE = path.join(Global.Path.state, "model.json")
|
||||
|
||||
type ModelState = Record<string, unknown> & {
|
||||
variant?: Record<string, string | undefined>
|
||||
}
|
||||
type VariantService = {
|
||||
readonly resolveSavedVariant: (model: RunInput["model"]) => Effect.Effect<string | undefined>
|
||||
readonly saveVariant: (model: RunInput["model"], variant: string | undefined) => Effect.Effect<void>
|
||||
}
|
||||
type VariantRuntime = {
|
||||
resolveSavedVariant(model: RunInput["model"]): Promise<string | undefined>
|
||||
saveVariant(model: RunInput["model"], variant: string | undefined): Promise<void>
|
||||
}
|
||||
|
||||
class Service extends Context.Service<Service, VariantService>()("@opencode/RunVariant") {}
|
||||
|
||||
function modelKey(provider: string, model: string): string {
|
||||
return `${provider}/${model}`
|
||||
}
|
||||
|
||||
function variantKey(model: NonNullable<RunInput["model"]>): string {
|
||||
return modelKey(model.providerID, model.modelID)
|
||||
}
|
||||
|
||||
export function modelInfo(providers: RunProvider[] | undefined, model: NonNullable<RunInput["model"]>) {
|
||||
const provider = providers?.find((item) => item.id === model.providerID)
|
||||
return {
|
||||
provider: provider?.name ?? model.providerID,
|
||||
model: provider?.models[model.modelID]?.name ?? model.modelID,
|
||||
}
|
||||
}
|
||||
|
||||
export function formatModelLabel(
|
||||
model: NonNullable<RunInput["model"]>,
|
||||
variant: string | undefined,
|
||||
providers?: RunProvider[],
|
||||
): string {
|
||||
const names = modelInfo(providers, model)
|
||||
const label = variant ? ` · ${variant}` : ""
|
||||
return `${names.model} · ${names.provider}${label}`
|
||||
}
|
||||
|
||||
export function cycleVariant(current: string | undefined, variants: string[]): string | undefined {
|
||||
if (variants.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
return variants[0]
|
||||
}
|
||||
|
||||
const idx = variants.indexOf(current)
|
||||
if (idx === -1 || idx === variants.length - 1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return variants[idx + 1]
|
||||
}
|
||||
|
||||
export function pickVariant(model: RunInput["model"], input: RunSession | SessionMessages): string | undefined {
|
||||
return sessionVariant(Array.isArray(input) ? createSession(input) : input, model)
|
||||
}
|
||||
|
||||
function fitVariant(value: string | undefined, variants: string[]): string | undefined {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (variants.length === 0 || variants.includes(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Picks the active variant. CLI flag wins, then saved preference, then session
|
||||
// history. fitVariant() checks saved and session values against the available
|
||||
// variants list -- if the provider doesn't offer a variant, it drops.
|
||||
export function resolveVariant(
|
||||
input: string | undefined,
|
||||
session: string | undefined,
|
||||
saved: string | undefined,
|
||||
variants: string[],
|
||||
): string | undefined {
|
||||
if (input !== undefined) {
|
||||
return input
|
||||
}
|
||||
|
||||
const fallback = fitVariant(saved, variants)
|
||||
const current = fitVariant(session, variants)
|
||||
if (current !== undefined) {
|
||||
return current
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function state(value: unknown): ModelState {
|
||||
if (!isRecord(value)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const variant = isRecord(value.variant)
|
||||
? Object.fromEntries(
|
||||
Object.entries(value.variant).flatMap(([key, item]) => {
|
||||
if (typeof item !== "string") {
|
||||
return []
|
||||
}
|
||||
|
||||
return [[key, item] as const]
|
||||
}),
|
||||
)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
...value,
|
||||
variant,
|
||||
}
|
||||
}
|
||||
|
||||
function createLayer(fs = FSUtil.defaultLayer) {
|
||||
return 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 resolveSavedVariant = Effect.fn("RunVariant.resolveSavedVariant")(function* (model: RunInput["model"]) {
|
||||
if (!model) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return (yield* read()).variant?.[variantKey(model)]
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if (!variant) {
|
||||
delete next[key]
|
||||
}
|
||||
|
||||
yield* file
|
||||
.writeJson(MODEL_FILE, {
|
||||
...current,
|
||||
variant: next,
|
||||
})
|
||||
.pipe(Effect.orElseSucceed(() => undefined))
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
resolveSavedVariant,
|
||||
saveVariant,
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(fs)),
|
||||
)
|
||||
}
|
||||
|
||||
/** @internal Exported for testing. */
|
||||
export function createVariantRuntime(fs = FSUtil.defaultLayer): VariantRuntime {
|
||||
const runtime = makeRuntime(Service, createLayer(fs))
|
||||
return {
|
||||
resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined),
|
||||
saveVariant: (model, variant) => runtime.runPromise((svc) => svc.saveVariant(model, variant)).catch(() => {}),
|
||||
}
|
||||
}
|
||||
|
||||
const runtime = createVariantRuntime()
|
||||
|
||||
export async function resolveSavedVariant(model: RunInput["model"]): Promise<string | undefined> {
|
||||
return runtime.resolveSavedVariant(model)
|
||||
}
|
||||
|
||||
export function saveVariant(model: RunInput["model"], variant: string | undefined): void {
|
||||
void runtime.saveVariant(model, variant)
|
||||
}
|
||||
24
packages/opencode/src/cli/cmd/serve.ts
Normal file
24
packages/opencode/src/cli/cmd/serve.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Effect } from "effect"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
|
||||
export const ServeCommand = effectCmd({
|
||||
command: "serve",
|
||||
builder: (yargs) => withNetworkOptions(yargs),
|
||||
describe: "starts a headless aircoding server",
|
||||
// Server loads instances per-request via x-opencode-directory header — no
|
||||
// need for an ambient project InstanceContext at startup.
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.serve")(function* (args) {
|
||||
const { Server } = yield* Effect.promise(() => import("../../server/server"))
|
||||
if (!Flag.OPENCODE_SERVER_PASSWORD) {
|
||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = yield* resolveNetworkOptions(args)
|
||||
const server = yield* Effect.promise(() => Server.listen(opts))
|
||||
console.log(`aircoding server listening on http://${server.hostname}:${server.port}`)
|
||||
|
||||
yield* Effect.never
|
||||
}),
|
||||
})
|
||||
147
packages/opencode/src/cli/cmd/session.ts
Normal file
147
packages/opencode/src/cli/cmd/session.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { Effect } from "effect"
|
||||
import { cmd } from "./cmd"
|
||||
import { effectCmd, fail } from "../effect-cmd"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionID } from "../../session/schema"
|
||||
import { UI } from "../ui"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import { EOL } from "os"
|
||||
import path from "path"
|
||||
import { which } from "@opencode-ai/core/util/which"
|
||||
|
||||
function pagerCmd(): string[] {
|
||||
const lessOptions = ["-R", "-S"]
|
||||
if (process.platform !== "win32") {
|
||||
return ["less", ...lessOptions]
|
||||
}
|
||||
|
||||
// user could have less installed via other options
|
||||
const lessOnPath = which("less")
|
||||
if (lessOnPath) {
|
||||
if (Filesystem.stat(lessOnPath)?.size) return [lessOnPath, ...lessOptions]
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_GIT_BASH_PATH) {
|
||||
const less = path.join(Flag.OPENCODE_GIT_BASH_PATH, "..", "..", "usr", "bin", "less.exe")
|
||||
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]
|
||||
}
|
||||
|
||||
const git = which("git")
|
||||
if (git) {
|
||||
const less = path.join(git, "..", "..", "usr", "bin", "less.exe")
|
||||
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]
|
||||
}
|
||||
|
||||
// Fall back to Windows built-in more (via cmd.exe)
|
||||
return ["cmd", "/c", "more"]
|
||||
}
|
||||
|
||||
export const SessionCommand = cmd({
|
||||
command: "session",
|
||||
describe: "manage sessions",
|
||||
builder: (yargs: Argv) => yargs.command(SessionListCommand).command(SessionDeleteCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
export const SessionDeleteCommand = effectCmd({
|
||||
command: "delete <sessionID>",
|
||||
describe: "delete a session",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("sessionID", {
|
||||
describe: "session ID to delete",
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.session.delete")(function* (args) {
|
||||
const svc = yield* Session.Service
|
||||
const sessionID = SessionID.make(args.sessionID)
|
||||
yield* svc
|
||||
.remove(sessionID)
|
||||
.pipe(Effect.catchIf(NotFoundError.isInstance, () => fail(`Session not found: ${args.sessionID}`)))
|
||||
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
|
||||
}),
|
||||
})
|
||||
|
||||
export const SessionListCommand = effectCmd({
|
||||
command: "list",
|
||||
describe: "list sessions",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option("max-count", {
|
||||
alias: "n",
|
||||
describe: "limit to N most recent sessions",
|
||||
type: "number",
|
||||
})
|
||||
.option("format", {
|
||||
describe: "output format",
|
||||
type: "string",
|
||||
choices: ["table", "json"],
|
||||
default: "table",
|
||||
}),
|
||||
handler: Effect.fn("Cli.session.list")(function* (args) {
|
||||
const sessions = yield* Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount }))
|
||||
|
||||
if (sessions.length === 0) return
|
||||
|
||||
const output = args.format === "json" ? formatSessionJSON(sessions) : formatSessionTable(sessions)
|
||||
|
||||
const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
|
||||
|
||||
if (shouldPaginate) {
|
||||
yield* Effect.promise(async () => {
|
||||
const proc = Process.spawn(pagerCmd(), {
|
||||
stdin: "pipe",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
|
||||
if (!proc.stdin) {
|
||||
console.log(output)
|
||||
return
|
||||
}
|
||||
|
||||
proc.stdin.write(output)
|
||||
proc.stdin.end()
|
||||
await proc.exited
|
||||
})
|
||||
} else {
|
||||
console.log(output)
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
function formatSessionTable(sessions: Session.Info[]): string {
|
||||
const lines: string[] = []
|
||||
|
||||
const maxIdWidth = Math.max(20, ...sessions.map((s) => s.id.length))
|
||||
const maxTitleWidth = Math.max(25, ...sessions.map((s) => s.title.length))
|
||||
|
||||
const header = `Session ID${" ".repeat(maxIdWidth - 10)} Title${" ".repeat(maxTitleWidth - 5)} Updated`
|
||||
lines.push(header)
|
||||
lines.push("─".repeat(header.length))
|
||||
for (const session of sessions) {
|
||||
const truncatedTitle = Locale.truncate(session.title, maxTitleWidth)
|
||||
const timeStr = Locale.todayTimeOrDateTime(session.time.updated)
|
||||
const line = `${session.id.padEnd(maxIdWidth)} ${truncatedTitle.padEnd(maxTitleWidth)} ${timeStr}`
|
||||
lines.push(line)
|
||||
}
|
||||
|
||||
return lines.join(EOL)
|
||||
}
|
||||
|
||||
function formatSessionJSON(sessions: Session.Info[]): string {
|
||||
const jsonData = sessions.map((session) => ({
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
updated: session.time.updated,
|
||||
created: session.time.created,
|
||||
projectId: session.projectID,
|
||||
directory: session.directory,
|
||||
}))
|
||||
return JSON.stringify(jsonData, null, 2)
|
||||
}
|
||||
393
packages/opencode/src/cli/cmd/stats.ts
Normal file
393
packages/opencode/src/cli/cmd/stats.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
import { Effect } from "effect"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { Session } from "@/session/session"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { Project } from "@/project/project"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
|
||||
interface SessionStats {
|
||||
totalSessions: number
|
||||
totalMessages: number
|
||||
totalCost: number
|
||||
totalTokens: {
|
||||
input: number
|
||||
output: number
|
||||
reasoning: number
|
||||
cache: {
|
||||
read: number
|
||||
write: number
|
||||
}
|
||||
}
|
||||
toolUsage: Record<string, number>
|
||||
modelUsage: Record<
|
||||
string,
|
||||
{
|
||||
messages: number
|
||||
tokens: {
|
||||
input: number
|
||||
output: number
|
||||
cache: {
|
||||
read: number
|
||||
write: number
|
||||
}
|
||||
}
|
||||
cost: number
|
||||
}
|
||||
>
|
||||
dateRange: {
|
||||
earliest: number
|
||||
latest: number
|
||||
}
|
||||
days: number
|
||||
costPerDay: number
|
||||
tokensPerSession: number
|
||||
medianTokensPerSession: number
|
||||
}
|
||||
|
||||
export const StatsCommand = effectCmd({
|
||||
command: "stats",
|
||||
describe: "show token usage and cost statistics",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option("days", {
|
||||
describe: "show stats for the last N days (default: all time)",
|
||||
type: "number",
|
||||
})
|
||||
.option("tools", {
|
||||
describe: "number of tools to show (default: all)",
|
||||
type: "number",
|
||||
})
|
||||
.option("models", {
|
||||
describe: "show model statistics (default: hidden). Pass a number to show top N, otherwise shows all",
|
||||
})
|
||||
.option("project", {
|
||||
describe: "filter by project (default: all projects, empty string: current project)",
|
||||
type: "string",
|
||||
}),
|
||||
handler: Effect.fn("Cli.stats")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const stats = yield* aggregateSessionStats(args.days, args.project, ctx.project)
|
||||
let modelLimit: number | undefined
|
||||
if (args.models === true) {
|
||||
modelLimit = Infinity
|
||||
} else if (typeof args.models === "number") {
|
||||
modelLimit = args.models
|
||||
}
|
||||
displayStats(stats, args.tools, modelLimit)
|
||||
}),
|
||||
})
|
||||
|
||||
const getAllSessions = Effect.fnUntraced(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
return (yield* db.select().from(SessionTable).all().pipe(Effect.orDie)).map((row) => Session.fromRow(row))
|
||||
})
|
||||
|
||||
const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
|
||||
days?: number,
|
||||
projectFilter?: string,
|
||||
currentProject?: Project.Info,
|
||||
) {
|
||||
const svc = yield* Session.Service
|
||||
const sessions = yield* getAllSessions()
|
||||
const MS_IN_DAY = 24 * 60 * 60 * 1000
|
||||
|
||||
const cutoffTime = (() => {
|
||||
if (days === undefined) return 0
|
||||
if (days === 0) {
|
||||
const now = new Date()
|
||||
now.setHours(0, 0, 0, 0)
|
||||
return now.getTime()
|
||||
}
|
||||
return Date.now() - days * MS_IN_DAY
|
||||
})()
|
||||
|
||||
const windowDays = (() => {
|
||||
if (days === undefined) return
|
||||
if (days === 0) return 1
|
||||
return days
|
||||
})()
|
||||
|
||||
let filteredSessions = cutoffTime > 0 ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions
|
||||
|
||||
if (projectFilter !== undefined) {
|
||||
if (projectFilter === "") {
|
||||
if (!currentProject) throw new Error("currentProject required when projectFilter is empty string")
|
||||
filteredSessions = filteredSessions.filter((session) => session.projectID === currentProject.id)
|
||||
} else {
|
||||
filteredSessions = filteredSessions.filter((session) => session.projectID === projectFilter)
|
||||
}
|
||||
}
|
||||
|
||||
const stats: SessionStats = {
|
||||
totalSessions: filteredSessions.length,
|
||||
totalMessages: 0,
|
||||
totalCost: 0,
|
||||
totalTokens: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: {
|
||||
read: 0,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
toolUsage: {},
|
||||
modelUsage: {},
|
||||
dateRange: {
|
||||
earliest: Date.now(),
|
||||
latest: Date.now(),
|
||||
},
|
||||
days: 0,
|
||||
costPerDay: 0,
|
||||
tokensPerSession: 0,
|
||||
medianTokensPerSession: 0,
|
||||
}
|
||||
|
||||
if (filteredSessions.length > 1000) {
|
||||
console.log(`Large dataset detected (${filteredSessions.length} sessions). This may take a while...`)
|
||||
}
|
||||
|
||||
if (filteredSessions.length === 0) {
|
||||
stats.days = windowDays ?? 0
|
||||
return stats
|
||||
}
|
||||
|
||||
let earliestTime = Date.now()
|
||||
let latestTime = 0
|
||||
|
||||
const sessionTotalTokens: number[] = []
|
||||
|
||||
const results = yield* Effect.forEach(
|
||||
filteredSessions,
|
||||
(session) =>
|
||||
Effect.gen(function* () {
|
||||
const messages = yield* svc
|
||||
.messages({ sessionID: session.id })
|
||||
.pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed([])))
|
||||
|
||||
const sessionCost = session.cost ?? 0
|
||||
const sessionTokens = session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
let sessionToolUsage: Record<string, number> = {}
|
||||
let sessionModelUsage: Record<
|
||||
string,
|
||||
{
|
||||
messages: number
|
||||
tokens: { input: number; output: number; cache: { read: number; write: number } }
|
||||
cost: number
|
||||
}
|
||||
> = {}
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.info.role === "assistant") {
|
||||
const modelKey = `${message.info.providerID}/${message.info.modelID}`
|
||||
if (!sessionModelUsage[modelKey]) {
|
||||
sessionModelUsage[modelKey] = {
|
||||
messages: 0,
|
||||
tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
cost: 0,
|
||||
}
|
||||
}
|
||||
sessionModelUsage[modelKey].messages++
|
||||
sessionModelUsage[modelKey].cost += message.info.cost || 0
|
||||
|
||||
if (message.info.tokens) {
|
||||
sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0
|
||||
sessionModelUsage[modelKey].tokens.output +=
|
||||
(message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0)
|
||||
sessionModelUsage[modelKey].tokens.cache.read += message.info.tokens.cache?.read || 0
|
||||
sessionModelUsage[modelKey].tokens.cache.write += message.info.tokens.cache?.write || 0
|
||||
}
|
||||
}
|
||||
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "tool" && part.tool) {
|
||||
sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messageCount: messages.length,
|
||||
sessionCost,
|
||||
sessionTokens,
|
||||
sessionTotalTokens:
|
||||
sessionTokens.input +
|
||||
sessionTokens.output +
|
||||
sessionTokens.reasoning +
|
||||
sessionTokens.cache.read +
|
||||
sessionTokens.cache.write,
|
||||
sessionToolUsage,
|
||||
sessionModelUsage,
|
||||
earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created,
|
||||
latestTime: session.time.updated,
|
||||
}
|
||||
}),
|
||||
{ concurrency: 20 },
|
||||
)
|
||||
|
||||
for (const result of results) {
|
||||
earliestTime = Math.min(earliestTime, result.earliestTime)
|
||||
latestTime = Math.max(latestTime, result.latestTime)
|
||||
sessionTotalTokens.push(result.sessionTotalTokens)
|
||||
|
||||
stats.totalMessages += result.messageCount
|
||||
stats.totalCost += result.sessionCost
|
||||
stats.totalTokens.input += result.sessionTokens.input
|
||||
stats.totalTokens.output += result.sessionTokens.output
|
||||
stats.totalTokens.reasoning += result.sessionTokens.reasoning
|
||||
stats.totalTokens.cache.read += result.sessionTokens.cache.read
|
||||
stats.totalTokens.cache.write += result.sessionTokens.cache.write
|
||||
|
||||
for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
|
||||
stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
|
||||
}
|
||||
|
||||
for (const [model, usage] of Object.entries(result.sessionModelUsage)) {
|
||||
if (!stats.modelUsage[model]) {
|
||||
stats.modelUsage[model] = {
|
||||
messages: 0,
|
||||
tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
cost: 0,
|
||||
}
|
||||
}
|
||||
stats.modelUsage[model].messages += usage.messages
|
||||
stats.modelUsage[model].tokens.input += usage.tokens.input
|
||||
stats.modelUsage[model].tokens.output += usage.tokens.output
|
||||
stats.modelUsage[model].tokens.cache.read += usage.tokens.cache.read
|
||||
stats.modelUsage[model].tokens.cache.write += usage.tokens.cache.write
|
||||
stats.modelUsage[model].cost += usage.cost
|
||||
}
|
||||
}
|
||||
|
||||
const rangeDays = Math.max(1, Math.ceil((latestTime - earliestTime) / MS_IN_DAY))
|
||||
const effectiveDays = windowDays ?? rangeDays
|
||||
stats.dateRange = {
|
||||
earliest: earliestTime,
|
||||
latest: latestTime,
|
||||
}
|
||||
stats.days = effectiveDays
|
||||
stats.costPerDay = stats.totalCost / effectiveDays
|
||||
const totalTokens =
|
||||
stats.totalTokens.input +
|
||||
stats.totalTokens.output +
|
||||
stats.totalTokens.reasoning +
|
||||
stats.totalTokens.cache.read +
|
||||
stats.totalTokens.cache.write
|
||||
stats.tokensPerSession = filteredSessions.length > 0 ? totalTokens / filteredSessions.length : 0
|
||||
sessionTotalTokens.sort((a, b) => a - b)
|
||||
const mid = Math.floor(sessionTotalTokens.length / 2)
|
||||
stats.medianTokensPerSession =
|
||||
sessionTotalTokens.length === 0
|
||||
? 0
|
||||
: sessionTotalTokens.length % 2 === 0
|
||||
? (sessionTotalTokens[mid - 1] + sessionTotalTokens[mid]) / 2
|
||||
: sessionTotalTokens[mid]
|
||||
|
||||
return stats
|
||||
})
|
||||
|
||||
export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number) {
|
||||
const width = 56
|
||||
|
||||
function renderRow(label: string, value: string): string {
|
||||
const availableWidth = width - 1
|
||||
const paddingNeeded = availableWidth - label.length - value.length
|
||||
const padding = Math.max(0, paddingNeeded)
|
||||
return `│${label}${" ".repeat(padding)}${value} │`
|
||||
}
|
||||
|
||||
// Overview section
|
||||
console.log("┌────────────────────────────────────────────────────────┐")
|
||||
console.log("│ OVERVIEW │")
|
||||
console.log("├────────────────────────────────────────────────────────┤")
|
||||
console.log(renderRow("Sessions", stats.totalSessions.toLocaleString()))
|
||||
console.log(renderRow("Messages", stats.totalMessages.toLocaleString()))
|
||||
console.log(renderRow("Days", stats.days.toString()))
|
||||
console.log("└────────────────────────────────────────────────────────┘")
|
||||
console.log()
|
||||
|
||||
// Cost & Tokens section
|
||||
console.log("┌────────────────────────────────────────────────────────┐")
|
||||
console.log("│ COST & TOKENS │")
|
||||
console.log("├────────────────────────────────────────────────────────┤")
|
||||
const cost = isNaN(stats.totalCost) ? 0 : stats.totalCost
|
||||
const costPerDay = isNaN(stats.costPerDay) ? 0 : stats.costPerDay
|
||||
const tokensPerSession = isNaN(stats.tokensPerSession) ? 0 : stats.tokensPerSession
|
||||
console.log(renderRow("Total Cost", `$${cost.toFixed(2)}`))
|
||||
console.log(renderRow("Avg Cost/Day", `$${costPerDay.toFixed(2)}`))
|
||||
console.log(renderRow("Avg Tokens/Session", formatNumber(Math.round(tokensPerSession))))
|
||||
const medianTokensPerSession = isNaN(stats.medianTokensPerSession) ? 0 : stats.medianTokensPerSession
|
||||
console.log(renderRow("Median Tokens/Session", formatNumber(Math.round(medianTokensPerSession))))
|
||||
console.log(renderRow("Input", formatNumber(stats.totalTokens.input)))
|
||||
console.log(renderRow("Output", formatNumber(stats.totalTokens.output)))
|
||||
console.log(renderRow("Cache Read", formatNumber(stats.totalTokens.cache.read)))
|
||||
console.log(renderRow("Cache Write", formatNumber(stats.totalTokens.cache.write)))
|
||||
console.log("└────────────────────────────────────────────────────────┘")
|
||||
console.log()
|
||||
|
||||
// Model Usage section
|
||||
if (modelLimit !== undefined && Object.keys(stats.modelUsage).length > 0) {
|
||||
const sortedModels = Object.entries(stats.modelUsage).sort(([, a], [, b]) => b.messages - a.messages)
|
||||
const modelsToDisplay = modelLimit === Infinity ? sortedModels : sortedModels.slice(0, modelLimit)
|
||||
|
||||
console.log("┌────────────────────────────────────────────────────────┐")
|
||||
console.log("│ MODEL USAGE │")
|
||||
console.log("├────────────────────────────────────────────────────────┤")
|
||||
|
||||
for (const [model, usage] of modelsToDisplay) {
|
||||
console.log(`│ ${model.padEnd(54)} │`)
|
||||
console.log(renderRow(" Messages", usage.messages.toLocaleString()))
|
||||
console.log(renderRow(" Input Tokens", formatNumber(usage.tokens.input)))
|
||||
console.log(renderRow(" Output Tokens", formatNumber(usage.tokens.output)))
|
||||
console.log(renderRow(" Cache Read", formatNumber(usage.tokens.cache.read)))
|
||||
console.log(renderRow(" Cache Write", formatNumber(usage.tokens.cache.write)))
|
||||
console.log(renderRow(" Cost", `$${usage.cost.toFixed(4)}`))
|
||||
console.log("├────────────────────────────────────────────────────────┤")
|
||||
}
|
||||
// Remove last separator and add bottom border
|
||||
process.stdout.write("\x1B[1A") // Move up one line
|
||||
console.log("└────────────────────────────────────────────────────────┘")
|
||||
}
|
||||
console.log()
|
||||
|
||||
// Tool Usage section
|
||||
if (Object.keys(stats.toolUsage).length > 0) {
|
||||
const sortedTools = Object.entries(stats.toolUsage).sort(([, a], [, b]) => b - a)
|
||||
const toolsToDisplay = toolLimit ? sortedTools.slice(0, toolLimit) : sortedTools
|
||||
|
||||
console.log("┌────────────────────────────────────────────────────────┐")
|
||||
console.log("│ TOOL USAGE │")
|
||||
console.log("├────────────────────────────────────────────────────────┤")
|
||||
|
||||
const maxCount = Math.max(...toolsToDisplay.map(([, count]) => count))
|
||||
const totalToolUsage = Object.values(stats.toolUsage).reduce((a, b) => a + b, 0)
|
||||
|
||||
for (const [tool, count] of toolsToDisplay) {
|
||||
const barLength = Math.max(1, Math.floor((count / maxCount) * 20))
|
||||
const bar = "█".repeat(barLength)
|
||||
const percentage = ((count / totalToolUsage) * 100).toFixed(1)
|
||||
|
||||
const maxToolLength = 18
|
||||
const truncatedTool = tool.length > maxToolLength ? tool.substring(0, maxToolLength - 2) + ".." : tool
|
||||
const toolName = truncatedTool.padEnd(maxToolLength)
|
||||
|
||||
const content = ` ${toolName} ${bar.padEnd(20)} ${count.toString().padStart(3)} (${percentage.padStart(4)}%)`
|
||||
const padding = Math.max(0, width - content.length - 1)
|
||||
console.log(`│${content}${" ".repeat(padding)} │`)
|
||||
}
|
||||
console.log("└────────────────────────────────────────────────────────┘")
|
||||
}
|
||||
console.log()
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + "M"
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + "K"
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
224
packages/opencode/src/cli/cmd/tui.ts
Normal file
224
packages/opencode/src/cli/cmd/tui.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { cmd } from "@/cli/cmd/cmd"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { type rpc } from "../tui/worker"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { UI } from "@/cli/ui"
|
||||
import { errorMessage } from "@opencode-ai/tui/util/error"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import type { EventSource } from "@opencode-ai/tui/context/sdk"
|
||||
import { writeHeapSnapshot } from "v8"
|
||||
import { validateSession } from "../tui/validate-session"
|
||||
import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32"
|
||||
|
||||
declare global {
|
||||
const OPENCODE_WORKER_PATH: string
|
||||
}
|
||||
|
||||
type RpcClient = ReturnType<typeof Rpc.client<typeof rpc>>
|
||||
|
||||
function createWorkerFetch(client: RpcClient): typeof fetch {
|
||||
const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const request = new Request(input, init)
|
||||
const body = request.body ? await request.text() : undefined
|
||||
const result = await client.call("fetch", {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
body,
|
||||
})
|
||||
return new Response(result.body, {
|
||||
status: result.status,
|
||||
headers: result.headers,
|
||||
})
|
||||
}
|
||||
return fn as typeof fetch
|
||||
}
|
||||
|
||||
function createEventSource(client: RpcClient): EventSource {
|
||||
return {
|
||||
subscribe: async (handler) => {
|
||||
return client.on<GlobalEvent>("global.event", (e) => {
|
||||
handler(e)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function target() {
|
||||
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
|
||||
const dist = new URL("./cli/tui/worker.js", import.meta.url)
|
||||
if (await Filesystem.exists(fileURLToPath(dist))) return dist
|
||||
return new URL("../tui/worker.ts", import.meta.url)
|
||||
}
|
||||
|
||||
async function input(value?: string) {
|
||||
const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text()
|
||||
if (!value) return piped
|
||||
if (!piped) return value
|
||||
return piped + "\n" + value
|
||||
}
|
||||
|
||||
export function resolveThreadDirectory(project?: string, envPWD = process.env.PWD, cwd = process.cwd()) {
|
||||
const root = Filesystem.resolve(envPWD ?? cwd)
|
||||
if (project) return Filesystem.resolve(path.isAbsolute(project) ? project : path.join(root, project))
|
||||
return Filesystem.resolve(cwd)
|
||||
}
|
||||
|
||||
export const TuiThreadCommand = cmd({
|
||||
command: "$0 [project]",
|
||||
describe: "start aircoding tui",
|
||||
builder: (yargs) =>
|
||||
withNetworkOptions(yargs)
|
||||
.positional("project", {
|
||||
type: "string",
|
||||
describe: "path to start aircoding in",
|
||||
})
|
||||
.option("model", {
|
||||
type: "string",
|
||||
alias: ["m"],
|
||||
describe: "model to use in the format of provider/model",
|
||||
})
|
||||
.option("continue", {
|
||||
alias: ["c"],
|
||||
describe: "continue the last session",
|
||||
type: "boolean",
|
||||
})
|
||||
.option("session", {
|
||||
alias: ["s"],
|
||||
type: "string",
|
||||
describe: "session id to continue",
|
||||
})
|
||||
.option("fork", {
|
||||
type: "boolean",
|
||||
describe: "fork the session when continuing (use with --continue or --session)",
|
||||
})
|
||||
.option("prompt", {
|
||||
type: "string",
|
||||
describe: "prompt to use",
|
||||
})
|
||||
.option("agent", {
|
||||
type: "string",
|
||||
describe: "agent to use",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const unguard = win32InstallCtrlCGuard()
|
||||
try {
|
||||
const { TuiConfig } = await import("@/config/tui")
|
||||
if (args.fork && !args.continue && !args.session) {
|
||||
UI.error("--fork requires --continue or --session")
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve relative --project paths from PWD, then use the real cwd after
|
||||
// chdir so the thread and worker share the same directory key.
|
||||
const next = resolveThreadDirectory(args.project)
|
||||
const file = await target()
|
||||
try {
|
||||
process.chdir(next)
|
||||
} catch {
|
||||
UI.error("Failed to change directory to " + next)
|
||||
return
|
||||
}
|
||||
const cwd = Filesystem.resolve(process.cwd())
|
||||
|
||||
const worker = new Worker(file)
|
||||
const client = Rpc.client<typeof rpc>(worker)
|
||||
const reload = () => {
|
||||
client.call("reload", undefined).catch(() => {})
|
||||
}
|
||||
process.on("SIGUSR2", reload)
|
||||
|
||||
let stopped = false
|
||||
const stop = async () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
process.off("SIGUSR2", reload)
|
||||
await withTimeout(client.call("shutdown", undefined), 5000).catch(() => {})
|
||||
worker.terminate()
|
||||
}
|
||||
|
||||
const prompt = await input(args.prompt)
|
||||
const config = await TuiConfig.get()
|
||||
|
||||
const network = resolveNetworkOptionsNoConfig(args)
|
||||
const external =
|
||||
process.argv.includes("--port") ||
|
||||
process.argv.includes("--hostname") ||
|
||||
process.argv.includes("--mdns") ||
|
||||
network.mdns ||
|
||||
network.port !== 0 ||
|
||||
network.hostname !== "127.0.0.1"
|
||||
|
||||
const transport = external
|
||||
? {
|
||||
url: (await client.call("server", network)).url,
|
||||
fetch: undefined,
|
||||
events: undefined,
|
||||
}
|
||||
: {
|
||||
url: "http://opencode.internal",
|
||||
fetch: createWorkerFetch(client),
|
||||
events: createEventSource(client),
|
||||
}
|
||||
|
||||
try {
|
||||
await validateSession({
|
||||
url: transport.url,
|
||||
sessionID: args.session,
|
||||
directory: cwd,
|
||||
fetch: transport.fetch,
|
||||
})
|
||||
} catch (error) {
|
||||
UI.error(errorMessage(error))
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
|
||||
}, 1000).unref?.()
|
||||
|
||||
try {
|
||||
const { Effect } = await import("effect")
|
||||
const { run } = await import("../tui/layer")
|
||||
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
|
||||
await Effect.runPromise(
|
||||
run({
|
||||
url: transport.url,
|
||||
async onSnapshot() {
|
||||
const tui = writeHeapSnapshot("tui.heapsnapshot")
|
||||
const server = await client.call("snapshot", undefined)
|
||||
return [tui, server]
|
||||
},
|
||||
config,
|
||||
pluginHost: createLegacyTuiPluginHost(),
|
||||
directory: cwd,
|
||||
fetch: transport.fetch,
|
||||
events: transport.events,
|
||||
args: {
|
||||
continue: args.continue,
|
||||
sessionID: args.session,
|
||||
agent: args.agent,
|
||||
model: args.model,
|
||||
prompt,
|
||||
fork: args.fork,
|
||||
},
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
unguard?.()
|
||||
} catch {}
|
||||
}
|
||||
process.exit(0)
|
||||
},
|
||||
})
|
||||
// scratch
|
||||
353
packages/opencode/src/cli/cmd/uninstall.ts
Normal file
353
packages/opencode/src/cli/cmd/uninstall.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { UI } from "../ui"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { Installation } from "../../installation"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
|
||||
interface UninstallArgs {
|
||||
keepConfig: boolean
|
||||
keepData: boolean
|
||||
dryRun: boolean
|
||||
force: boolean
|
||||
}
|
||||
|
||||
interface RemovalTargets {
|
||||
directories: Array<{ path: string; label: string; keep: boolean }>
|
||||
shellConfig: string | null
|
||||
binary: string | null
|
||||
}
|
||||
|
||||
export const UninstallCommand = {
|
||||
command: "uninstall",
|
||||
describe: "uninstall opencode and remove all related files",
|
||||
builder: (yargs: Argv) =>
|
||||
yargs
|
||||
.option("keep-config", {
|
||||
alias: "c",
|
||||
type: "boolean",
|
||||
describe: "keep configuration files",
|
||||
default: false,
|
||||
})
|
||||
.option("keep-data", {
|
||||
alias: "d",
|
||||
type: "boolean",
|
||||
describe: "keep session data and snapshots",
|
||||
default: false,
|
||||
})
|
||||
.option("dry-run", {
|
||||
type: "boolean",
|
||||
describe: "show what would be removed without removing",
|
||||
default: false,
|
||||
})
|
||||
.option("force", {
|
||||
alias: "f",
|
||||
type: "boolean",
|
||||
describe: "skip confirmation prompts",
|
||||
default: false,
|
||||
}),
|
||||
|
||||
handler: async (args: UninstallArgs) => {
|
||||
UI.empty()
|
||||
UI.println(UI.logo(" "))
|
||||
UI.empty()
|
||||
prompts.intro("Uninstall OpenCode")
|
||||
|
||||
const method = await Installation.method()
|
||||
prompts.log.info(`Installation method: ${method}`)
|
||||
|
||||
const targets = await collectRemovalTargets(args, method)
|
||||
|
||||
await showRemovalSummary(targets, method)
|
||||
|
||||
if (!args.force && !args.dryRun) {
|
||||
const confirm = await prompts.confirm({
|
||||
message: "Are you sure you want to uninstall?",
|
||||
initialValue: false,
|
||||
})
|
||||
if (!confirm || prompts.isCancel(confirm)) {
|
||||
prompts.outro("Cancelled")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (args.dryRun) {
|
||||
prompts.log.warn("Dry run - no changes made")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
await executeUninstall(method, targets)
|
||||
|
||||
prompts.outro("Done")
|
||||
},
|
||||
}
|
||||
|
||||
async function collectRemovalTargets(args: UninstallArgs, method: Installation.Method): Promise<RemovalTargets> {
|
||||
const directories: RemovalTargets["directories"] = [
|
||||
{ path: Global.Path.data, label: "Data", keep: args.keepData },
|
||||
{ path: Global.Path.cache, label: "Cache", keep: false },
|
||||
{ path: Global.Path.config, label: "Config", keep: args.keepConfig },
|
||||
{ path: Global.Path.state, label: "State", keep: false },
|
||||
]
|
||||
|
||||
const shellConfig = method === "curl" ? await getShellConfigFile() : null
|
||||
const binary = method === "curl" ? process.execPath : null
|
||||
|
||||
return { directories, shellConfig, binary }
|
||||
}
|
||||
|
||||
async function showRemovalSummary(targets: RemovalTargets, method: Installation.Method) {
|
||||
prompts.log.message("The following will be removed:")
|
||||
|
||||
for (const dir of targets.directories) {
|
||||
const exists = await fs
|
||||
.access(dir.path)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!exists) continue
|
||||
|
||||
const size = await getDirectorySize(dir.path)
|
||||
const sizeStr = formatSize(size)
|
||||
const status = dir.keep ? UI.Style.TEXT_DIM + "(keeping)" : ""
|
||||
const prefix = dir.keep ? "○" : "✓"
|
||||
|
||||
prompts.log.info(` ${prefix} ${dir.label}: ${shortenPath(dir.path)} ${UI.Style.TEXT_DIM}(${sizeStr})${status}`)
|
||||
}
|
||||
|
||||
if (targets.binary) {
|
||||
prompts.log.info(` ✓ Binary: ${shortenPath(targets.binary)}`)
|
||||
}
|
||||
|
||||
if (targets.shellConfig) {
|
||||
prompts.log.info(` ✓ Shell PATH in ${shortenPath(targets.shellConfig)}`)
|
||||
}
|
||||
|
||||
if (method !== "curl" && method !== "unknown") {
|
||||
const cmds: Record<string, string> = {
|
||||
npm: "npm uninstall -g opencode-ai",
|
||||
pnpm: "pnpm uninstall -g opencode-ai",
|
||||
bun: "bun remove -g opencode-ai",
|
||||
yarn: "yarn global remove opencode-ai",
|
||||
brew: "brew uninstall opencode",
|
||||
choco: "choco uninstall opencode",
|
||||
scoop: "scoop uninstall opencode",
|
||||
}
|
||||
prompts.log.info(` ✓ Package: ${cmds[method] || method}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function executeUninstall(method: Installation.Method, targets: RemovalTargets) {
|
||||
const spinner = prompts.spinner()
|
||||
const errors: string[] = []
|
||||
|
||||
for (const dir of targets.directories) {
|
||||
if (dir.keep) {
|
||||
prompts.log.step(`Skipping ${dir.label} (--keep-${dir.label.toLowerCase()})`)
|
||||
continue
|
||||
}
|
||||
|
||||
const exists = await fs
|
||||
.access(dir.path)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!exists) continue
|
||||
|
||||
spinner.start(`Removing ${dir.label}...`)
|
||||
const err = await fs.rm(dir.path, { recursive: true, force: true }).catch((e) => e)
|
||||
if (err) {
|
||||
spinner.stop(`Failed to remove ${dir.label}`, 1)
|
||||
errors.push(`${dir.label}: ${err.message}`)
|
||||
continue
|
||||
}
|
||||
spinner.stop(`Removed ${dir.label}`)
|
||||
}
|
||||
|
||||
if (targets.shellConfig) {
|
||||
spinner.start("Cleaning shell config...")
|
||||
const err = await cleanShellConfig(targets.shellConfig).catch((e) => e)
|
||||
if (err) {
|
||||
spinner.stop("Failed to clean shell config", 1)
|
||||
errors.push(`Shell config: ${err.message}`)
|
||||
} else {
|
||||
spinner.stop("Cleaned shell config")
|
||||
}
|
||||
}
|
||||
|
||||
if (method !== "curl" && method !== "unknown") {
|
||||
const cmds: Record<string, string[]> = {
|
||||
npm: ["npm", "uninstall", "-g", "opencode-ai"],
|
||||
pnpm: ["pnpm", "uninstall", "-g", "opencode-ai"],
|
||||
bun: ["bun", "remove", "-g", "opencode-ai"],
|
||||
yarn: ["yarn", "global", "remove", "opencode-ai"],
|
||||
brew: ["brew", "uninstall", "opencode"],
|
||||
choco: ["choco", "uninstall", "opencode"],
|
||||
scoop: ["scoop", "uninstall", "opencode"],
|
||||
}
|
||||
|
||||
const cmd = cmds[method]
|
||||
if (cmd) {
|
||||
spinner.start(`Running ${cmd.join(" ")}...`)
|
||||
const result = await Process.run(method === "choco" ? ["choco", "uninstall", "opencode", "-y", "-r"] : cmd, {
|
||||
nothrow: true,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
spinner.stop(`Package manager uninstall failed: exit code ${result.code}`, 1)
|
||||
const text = `${result.stdout.toString("utf8")}\n${result.stderr.toString("utf8")}`
|
||||
if (method === "choco" && text.includes("not running from an elevated command shell")) {
|
||||
prompts.log.warn(`You may need to run '${cmd.join(" ")}' from an elevated command shell`)
|
||||
} else {
|
||||
prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`)
|
||||
}
|
||||
} else {
|
||||
spinner.stop("Package removed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (method === "curl" && targets.binary) {
|
||||
UI.empty()
|
||||
prompts.log.message("To finish removing the binary, run:")
|
||||
prompts.log.info(` rm "${targets.binary}"`)
|
||||
|
||||
const binDir = path.dirname(targets.binary)
|
||||
if (binDir.includes(".opencode")) {
|
||||
prompts.log.info(` rmdir "${binDir}" 2>/dev/null`)
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
UI.empty()
|
||||
prompts.log.warn("Some operations failed:")
|
||||
for (const err of errors) {
|
||||
prompts.log.error(` ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
UI.empty()
|
||||
prompts.log.success("Thank you for using OpenCode!")
|
||||
}
|
||||
|
||||
async function getShellConfigFile(): Promise<string | null> {
|
||||
const shell = path.basename(process.env.SHELL || "bash")
|
||||
const home = os.homedir()
|
||||
const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(home, ".config")
|
||||
|
||||
const configFiles: Record<string, string[]> = {
|
||||
fish: [path.join(xdgConfig, "fish", "config.fish")],
|
||||
zsh: [
|
||||
path.join(home, ".zshrc"),
|
||||
path.join(home, ".zshenv"),
|
||||
path.join(xdgConfig, "zsh", ".zshrc"),
|
||||
path.join(xdgConfig, "zsh", ".zshenv"),
|
||||
],
|
||||
bash: [
|
||||
path.join(home, ".bashrc"),
|
||||
path.join(home, ".bash_profile"),
|
||||
path.join(home, ".profile"),
|
||||
path.join(xdgConfig, "bash", ".bashrc"),
|
||||
path.join(xdgConfig, "bash", ".bash_profile"),
|
||||
],
|
||||
ash: [path.join(home, ".ashrc"), path.join(home, ".profile")],
|
||||
sh: [path.join(home, ".profile")],
|
||||
}
|
||||
|
||||
const candidates = configFiles[shell] || configFiles.bash
|
||||
|
||||
for (const file of candidates) {
|
||||
const exists = await fs
|
||||
.access(file)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!exists) continue
|
||||
|
||||
const content = await Filesystem.readText(file).catch(() => "")
|
||||
if (content.includes("# opencode") || content.includes(".opencode/bin")) {
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function cleanShellConfig(file: string) {
|
||||
const content = await Filesystem.readText(file)
|
||||
const lines = content.split("\n")
|
||||
|
||||
const filtered: string[] = []
|
||||
let skip = false
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (trimmed === "# opencode") {
|
||||
skip = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (skip) {
|
||||
skip = false
|
||||
if (trimmed.includes(".opencode/bin") || trimmed.includes("fish_add_path")) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(trimmed.startsWith("export PATH=") && trimmed.includes(".opencode/bin")) ||
|
||||
(trimmed.startsWith("fish_add_path") && trimmed.includes(".opencode"))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
filtered.push(line)
|
||||
}
|
||||
|
||||
while (filtered.length > 0 && filtered[filtered.length - 1].trim() === "") {
|
||||
filtered.pop()
|
||||
}
|
||||
|
||||
const output = filtered.join("\n") + "\n"
|
||||
await Filesystem.write(file, output)
|
||||
}
|
||||
|
||||
async function getDirectorySize(dir: string): Promise<number> {
|
||||
let total = 0
|
||||
|
||||
const walk = async (current: string) => {
|
||||
const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => [])
|
||||
|
||||
for (const entry of entries) {
|
||||
const full = path.join(current, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await walk(full)
|
||||
continue
|
||||
}
|
||||
if (entry.isFile()) {
|
||||
const stat = await fs.stat(full).catch(() => null)
|
||||
if (stat) total += stat.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(dir)
|
||||
return total
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
function shortenPath(p: string): string {
|
||||
const home = os.homedir()
|
||||
if (p.startsWith(home)) {
|
||||
return p.replace(home, "~")
|
||||
}
|
||||
return p
|
||||
}
|
||||
74
packages/opencode/src/cli/cmd/upgrade.ts
Normal file
74
packages/opencode/src/cli/cmd/upgrade.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { UI } from "../ui"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { Installation } from "../../installation"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
|
||||
export const UpgradeCommand = {
|
||||
command: "upgrade [target]",
|
||||
describe: "upgrade aircoding to the latest or a specific version",
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs
|
||||
.positional("target", {
|
||||
describe: "version to upgrade to, for ex '0.1.48' or 'v0.1.48'",
|
||||
type: "string",
|
||||
})
|
||||
.option("method", {
|
||||
alias: "m",
|
||||
describe: "installation method to use",
|
||||
type: "string",
|
||||
choices: ["curl", "npm", "pnpm", "bun", "brew", "choco", "scoop"],
|
||||
})
|
||||
},
|
||||
handler: async (args: { target?: string; method?: string }) => {
|
||||
UI.empty()
|
||||
UI.println(UI.logo(" "))
|
||||
UI.empty()
|
||||
prompts.intro("Upgrade")
|
||||
const detectedMethod = await Installation.method()
|
||||
const method = (args.method as Installation.Method) ?? detectedMethod
|
||||
if (method === "unknown") {
|
||||
prompts.log.error(`aircoding is installed to ${process.execPath} and may be managed by a package manager`)
|
||||
const install = await prompts.select({
|
||||
message: "Install anyways?",
|
||||
options: [
|
||||
{ label: "Yes", value: true },
|
||||
{ label: "No", value: false },
|
||||
],
|
||||
initialValue: false,
|
||||
})
|
||||
if (!install) {
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
}
|
||||
prompts.log.info("Using method: " + method)
|
||||
const target = args.target ? args.target.replace(/^v/, "") : await Installation.latest()
|
||||
|
||||
if (InstallationVersion === target) {
|
||||
prompts.log.warn(`aircoding upgrade skipped: ${target} is already installed`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
prompts.log.info(`From ${InstallationVersion} → ${target}`)
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Upgrading...")
|
||||
const err = await Installation.upgrade(method, target).catch((err) => err)
|
||||
if (err) {
|
||||
spinner.stop("Upgrade failed", 1)
|
||||
if (err instanceof Installation.UpgradeFailedError) {
|
||||
// necessary because choco only allows install/upgrade in elevated terminals
|
||||
if (method === "choco" && err.stderr.includes("not running from an elevated command shell")) {
|
||||
prompts.log.error("Please run the terminal as Administrator and try again")
|
||||
} else {
|
||||
prompts.log.error(err.stderr)
|
||||
}
|
||||
} else if (err instanceof Error) prompts.log.error(err.message)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
spinner.stop("Upgrade complete")
|
||||
prompts.outro("Done")
|
||||
},
|
||||
}
|
||||
84
packages/opencode/src/cli/cmd/web.ts
Normal file
84
packages/opencode/src/cli/cmd/web.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Effect } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import open from "open"
|
||||
import { networkInterfaces } from "os"
|
||||
|
||||
function getNetworkIPs() {
|
||||
const nets = networkInterfaces()
|
||||
const results: string[] = []
|
||||
|
||||
for (const name of Object.keys(nets)) {
|
||||
const net = nets[name]
|
||||
if (!net) continue
|
||||
|
||||
for (const netInfo of net) {
|
||||
// Skip internal and non-IPv4 addresses
|
||||
if (netInfo.internal || netInfo.family !== "IPv4") continue
|
||||
|
||||
// Skip Docker bridge networks (typically 172.x.x.x)
|
||||
if (netInfo.address.startsWith("172.")) continue
|
||||
|
||||
results.push(netInfo.address)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
export const WebCommand = effectCmd({
|
||||
command: "web",
|
||||
builder: (yargs) => withNetworkOptions(yargs),
|
||||
describe: "start opencode server and open web interface",
|
||||
// Server loads instances per-request via x-opencode-directory header — no
|
||||
// ambient project InstanceContext needed at startup.
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.web")(function* (args) {
|
||||
const { Server } = yield* Effect.promise(() => import("../../server/server"))
|
||||
if (!Flag.OPENCODE_SERVER_PASSWORD) {
|
||||
UI.println(UI.Style.TEXT_WARNING_BOLD + "! OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = yield* resolveNetworkOptions(args)
|
||||
const server = yield* Effect.promise(() => Server.listen(opts))
|
||||
UI.empty()
|
||||
UI.println(UI.logo(" "))
|
||||
UI.empty()
|
||||
|
||||
if (opts.hostname === "0.0.0.0") {
|
||||
// Show localhost for local access
|
||||
const localhostUrl = `http://localhost:${server.port}`
|
||||
UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl)
|
||||
|
||||
// Show network IPs for remote access
|
||||
const networkIPs = getNetworkIPs()
|
||||
if (networkIPs.length > 0) {
|
||||
for (const ip of networkIPs) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_INFO_BOLD + " Network access: ",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`http://${ip}:${server.port}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.mdns) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_INFO_BOLD + " mDNS: ",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`${opts.mdnsDomain}:${server.port}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Open localhost in browser
|
||||
open(localhostUrl).catch(() => {})
|
||||
} else {
|
||||
const displayUrl = server.url.toString()
|
||||
UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl)
|
||||
open(displayUrl).catch(() => {})
|
||||
}
|
||||
|
||||
yield* Effect.never
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user