소스 검색

cli: improve run output and network handling

Dax Raad 3 달 전
부모
커밋
cd62829f77
1개의 변경된 파일187개의 추가작업 그리고 38개의 파일을 삭제
  1. 187 38
      packages/opencode/src/cli/cmd/run.ts

+ 187 - 38
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"
-
-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) {