|
|
@@ -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"
|
|
|
-
|
|
|
-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],
|
|
|
+import { resolveNetworkOptions, withNetworkOptions } from "../network"
|
|
|
+import { Locale } from "@/util/locale"
|
|
|
+
|
|
|
+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) {
|