cli: improve run output and network handling

This commit is contained in:
Dax Raad
2026-01-12 12:34:21 -05:00
parent 20399bbdfe
commit cd62829f77

View File

@@ -7,29 +7,36 @@ import { bootstrap } from "../bootstrap"
import { Command } from "../../command"
import { EOL } from "os"
import { select } from "@clack/prompts"
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server"
import { Provider } from "../../provider/provider"
import { Agent } from "../../agent/agent"
import { resolveNetworkOptions, withNetworkOptions } from "../network"
import { Locale } from "@/util/locale"
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
list: ["List", UI.Style.TEXT_INFO_BOLD],
read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD],
write: ["Write", UI.Style.TEXT_SUCCESS_BOLD],
websearch: ["Search", UI.Style.TEXT_DIM_BOLD],
const TOOL_ICON: Record<string, string> = {
bash: "$",
codesearch: "◇",
edit: "←",
glob: "✱",
grep: "✱",
list: "→",
patch: "%",
question: "→",
read: "→",
task: "◉",
todoread: "⚙",
todowrite: "⚙",
webfetch: "%",
websearch: "◈",
write: "←",
}
export const RunCommand = cmd({
command: "run [message..]",
describe: "run opencode with a message",
builder: (yargs: Argv) => {
return yargs
return withNetworkOptions(yargs)
.positional("message", {
describe: "message to send",
type: "string",
@@ -83,10 +90,6 @@ export const RunCommand = cmd({
type: "string",
describe: "attach to a running opencode server (e.g., http://localhost:4096)",
})
.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)",
@@ -134,16 +137,144 @@ export const RunCommand = cmd({
}
const execute = async (sdk: OpencodeClient, sessionID: string) => {
const printEvent = (color: string, type: string, title: string) => {
UI.println(
color + `|`,
UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`,
"",
UI.Style.TEXT_NORMAL + title,
)
const normalizePath = (input?: string) => {
if (!input) return ""
if (path.isAbsolute(input)) return path.relative(process.cwd(), input) || "."
return input
}
const outputJsonEvent = (type: string, data: any) => {
const formatInput = (input: Record<string, unknown>, omit?: string[]) => {
const entries = Object.entries(input).filter(([key, value]) => {
if (omit?.includes(key)) return false
if (typeof value === "string") return true
if (typeof value === "number") return true
if (typeof value === "boolean") return true
return false
})
if (entries.length === 0) return ""
return `[${entries.map(([key, value]) => `${key}=${value}`).join(", ")}]`
}
const toolLine = (part: ToolPart) => {
const state = part.state.status === "completed" ? part.state : undefined
const input = (state?.input ?? {}) as Record<string, unknown>
const meta = (state?.metadata ?? {}) as Record<string, unknown>
if (part.tool === "read") {
const filePath = typeof input.filePath === "string" ? input.filePath : ""
const detail = formatInput(input, ["filePath"])
if (!detail) return `Read ${normalizePath(filePath)}`
return `Read ${normalizePath(filePath)} ${detail}`
}
if (part.tool === "write") {
const filePath = typeof input.filePath === "string" ? input.filePath : ""
return `Write ${normalizePath(filePath)}`
}
if (part.tool === "edit") {
const filePath = typeof input.filePath === "string" ? input.filePath : ""
const detail = formatInput({ replaceAll: input.replaceAll })
if (!detail) return `Edit ${normalizePath(filePath)}`
return `Edit ${normalizePath(filePath)} ${detail}`
}
if (part.tool === "glob") {
const pattern = typeof input.pattern === "string" ? input.pattern : ""
const dir = typeof input.path === "string" ? normalizePath(input.path) : ""
const count = typeof meta.count === "number" ? meta.count : undefined
const parts = [`Glob "${pattern}"`]
if (dir) parts.push(`in ${dir}`)
if (count !== undefined) parts.push(`(${count} matches)`)
return parts.join(" ")
}
if (part.tool === "grep") {
const pattern = typeof input.pattern === "string" ? input.pattern : ""
const dir = typeof input.path === "string" ? normalizePath(input.path) : ""
const matches = typeof meta.matches === "number" ? meta.matches : undefined
const parts = [`Grep "${pattern}"`]
if (dir) parts.push(`in ${dir}`)
if (matches !== undefined) parts.push(`(${matches} matches)`)
return parts.join(" ")
}
if (part.tool === "list") {
const dir = typeof input.path === "string" ? normalizePath(input.path) : ""
if (!dir) return "List"
return `List ${dir}`
}
if (part.tool === "webfetch") {
const url = typeof input.url === "string" ? input.url : ""
if (!url) return "WebFetch"
return `WebFetch ${url}`
}
if (part.tool === "codesearch") {
const query = typeof input.query === "string" ? input.query : ""
const results = typeof meta.results === "number" ? meta.results : undefined
const parts = [`Exa Code Search "${query}"`]
if (results !== undefined) parts.push(`(${results} results)`)
return parts.join(" ")
}
if (part.tool === "websearch") {
const query = typeof input.query === "string" ? input.query : ""
const results = typeof meta.numResults === "number" ? meta.numResults : undefined
const parts = [`Exa Web Search "${query}"`]
if (results !== undefined) parts.push(`(${results} results)`)
return parts.join(" ")
}
if (part.tool === "task") {
const desc = typeof input.description === "string" ? input.description : "Task"
const agent = typeof input.subagent_type === "string" ? input.subagent_type : "Task"
return `${agent} Task "${desc}"`
}
if (part.tool === "todowrite" || part.tool === "todoread") {
const count = Array.isArray(input.todos) ? input.todos.length : 0
if (count) return `Todos (${count})`
return "Todos"
}
if (part.tool === "question") {
const count = Array.isArray(input.questions) ? input.questions.length : 0
return `Asked ${count} question${count === 1 ? "" : "s"}`
}
if (part.tool === "patch") {
return "Patch"
}
const detail = formatInput(input)
if (!detail) return part.tool
return `${part.tool} ${detail}`
}
const printTool = (part: ToolPart) => {
if (part.tool === "bash") {
const state = part.state.status === "completed" ? part.state : undefined
if (!state) return
UI.empty()
const input = (state.input ?? {}) as Record<string, unknown>
const meta = (state.metadata ?? {}) as Record<string, unknown>
const desc = typeof input.description === "string" ? input.description : undefined
const title = desc ?? state.title ?? "Shell"
UI.println(UI.Style.TEXT_DIM + "# " + title)
const command = typeof input.command === "string" ? input.command : ""
if (command) UI.println(UI.Style.TEXT_NORMAL + "$ " + command)
const output = typeof state.output === "string" ? state.output.trimEnd() : undefined
const metaOutput = typeof meta.output === "string" ? meta.output.trimEnd() : undefined
const result = output ?? metaOutput
if (result) UI.println(UI.Style.TEXT_NORMAL + result)
UI.empty()
return
}
const icon = TOOL_ICON[part.tool] ?? "⚙"
const line = toolLine(part)
UI.println(UI.Style.TEXT_NORMAL + icon, UI.Style.TEXT_NORMAL + line)
}
const printUserMessage = () => {
if (args.format === "json") return
const trimmed = message.trim()
if (!trimmed) return
const single = trimmed.replace(/\s+/g, " ")
UI.println(UI.Style.TEXT_NORMAL_BOLD + "▌", UI.Style.TEXT_NORMAL + single)
UI.empty()
userPrinted = true
printHeader()
}
const outputJsonEvent = (type: string, data: Record<string, unknown>) => {
if (args.format === "json") {
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
return true
@@ -152,6 +283,19 @@ export const RunCommand = cmd({
}
const events = await sdk.event.subscribe()
let header: { agent: string; modelID: string } | undefined
let headerPrinted = false
let userPrinted = false
const printHeader = () => {
if (!process.stdout.isTTY) return
if (!header || headerPrinted) return
UI.empty()
UI.println(
UI.Style.TEXT_NORMAL + "▣ " + Locale.titlecase(header.agent) + UI.Style.TEXT_DIM + " · " + header.modelID,
)
UI.empty()
headerPrinted = true
}
let errorMsg: string | undefined
const eventProcessor = (async () => {
@@ -162,15 +306,7 @@ export const RunCommand = cmd({
if (part.type === "tool" && part.state.status === "completed") {
if (outputJsonEvent("tool_use", { part })) continue
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
const title =
part.state.title ||
(Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown")
printEvent(color, tool, title)
if (part.tool === "bash" && part.state.output?.trim()) {
UI.println()
UI.println(part.state.output)
}
printTool(part as ToolPart)
}
if (part.type === "step-start") {
@@ -184,9 +320,19 @@ export const RunCommand = cmd({
if (part.type === "text" && part.time?.end) {
if (outputJsonEvent("text", { part })) continue
const isPiped = !process.stdout.isTTY
if (!isPiped) UI.println()
if (!isPiped) UI.empty()
if (!isPiped) UI.empty()
process.stdout.write((isPiped ? part.text : UI.markdown(part.text)) + EOL)
if (!isPiped) UI.println()
if (!isPiped) UI.empty()
if (!isPiped) UI.empty()
}
}
if (event.type === "message.updated") {
const info = event.properties.info
if (info.sessionID === sessionID && info.role === "assistant") {
header = { agent: info.agent, modelID: info.modelID }
if (userPrinted) printHeader()
}
}
@@ -251,6 +397,8 @@ export const RunCommand = cmd({
return args.agent
})()
printUserMessage()
if (args.command) {
await sdk.session.command({
sessionID,
@@ -339,7 +487,8 @@ export const RunCommand = cmd({
}
await bootstrap(process.cwd(), async () => {
const server = Server.listen({ port: args.port ?? 0, hostname: "127.0.0.1" })
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}` })
if (args.command) {