mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-01 22:48:16 +00:00
cli: improve run output and network handling
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user