From cd62829f778e970124457abc851fe7d81ee9ff06 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 12 Jan 2026 12:34:21 -0500 Subject: [PATCH] cli: improve run output and network handling --- packages/opencode/src/cli/cmd/run.ts | 223 ++++++++++++++++++++++----- 1 file changed, 186 insertions(+), 37 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index a86b435ec3..f2b5c1235d 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -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 = { - 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 = { + 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, 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 + const meta = (state?.metadata ?? {}) as Record + 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 + const meta = (state.metadata ?? {}) as Record + 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) => { 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) {