Dax Raad 7 месяцев назад
Родитель
Сommit
4b2ce14ff3

+ 2 - 10
packages/opencode/src/provider/provider.ts

@@ -21,7 +21,7 @@ import { AuthCopilot } from "../auth/copilot"
 import { ModelsDev } from "./models"
 import { NamedError } from "../util/error"
 import { Auth } from "../auth"
-// import { TaskTool } from "../tool/task"
+import { TaskTool } from "../tool/task"
 
 export namespace Provider {
   const log = Log.create({ service: "provider" })
@@ -456,7 +456,7 @@ export namespace Provider {
     WriteTool,
     TodoWriteTool,
     TodoReadTool,
-    // TaskTool,
+    TaskTool,
   ]
 
   const TOOL_MAPPING: Record<string, Tool.Info[]> = {
@@ -531,12 +531,4 @@ export namespace Provider {
       providerID: z.string(),
     }),
   )
-
-  export const AuthError = NamedError.create(
-    "ProviderAuthError",
-    z.object({
-      providerID: z.string(),
-      message: z.string(),
-    }),
-  )
 }

+ 239 - 234
packages/opencode/src/session/index.ts

@@ -443,7 +443,7 @@ export namespace Session {
                 const result = await ReadTool.execute(args, {
                   sessionID: input.sessionID,
                   abort: abort.signal,
-                  messageID: "", // read tool doesn't use message ID
+                  messageID: userMsg.id,
                   metadata: async () => {},
                 })
                 return [
@@ -577,20 +577,22 @@ export namespace Session {
     await updateMessage(assistantMsg)
     const tools: Record<string, AITool> = {}
 
+    const processor = createProcessor(assistantMsg, model.info)
+
     for (const item of await Provider.tools(input.providerID)) {
       if (mode.tools[item.id] === false) continue
+      if (session.parentID && item.id === "task") continue
       tools[item.id] = tool({
         id: item.id as any,
         description: item.description,
         inputSchema: item.parameters as ZodSchema,
-        async execute(args) {
+        async execute(args, options) {
           const result = await item.execute(args, {
             sessionID: input.sessionID,
             abort: abort.signal,
             messageID: assistantMsg.id,
-            metadata: async () => {
-              /*
-              const match = toolCalls[opts.toolCallId]
+            metadata: async (val) => {
+              const match = processor.partFromToolCall(options.toolCallId)
               if (match && match.state.status === "running") {
                 await updatePart({
                   ...match,
@@ -598,14 +600,13 @@ export namespace Session {
                     title: val.title,
                     metadata: val.metadata,
                     status: "running",
-                    input: args.input,
+                    input: args,
                     time: {
                       start: Date.now(),
                     },
                   },
                 })
               }
-              */
             },
           })
           return result
@@ -676,257 +677,260 @@ export namespace Session {
         ],
       }),
     })
-    const result = await processStream(assistantMsg, model.info, stream)
+    const result = await processor.process(stream)
     return result
   }
 
-  async function processStream(
-    assistantMsg: MessageV2.Assistant,
-    model: ModelsDev.Model,
-    stream: StreamTextResult<Record<string, AITool>, never>,
-  ) {
-    try {
-      let currentText: MessageV2.TextPart | undefined
-      const toolCalls: Record<string, MessageV2.ToolPart> = {}
+  function createProcessor(assistantMsg: MessageV2.Assistant, model: ModelsDev.Model) {
+    const toolCalls: Record<string, MessageV2.ToolPart> = {}
+    return {
+      partFromToolCall(toolCallID: string) {
+        return toolCalls[toolCallID]
+      },
+      async process(stream: StreamTextResult<Record<string, AITool>, never>) {
+        try {
+          let currentText: MessageV2.TextPart | undefined
 
-      for await (const value of stream.fullStream) {
-        log.info("part", {
-          type: value.type,
-        })
-        switch (value.type) {
-          case "start":
-            const snapshot = await Snapshot.create(assistantMsg.sessionID)
-            if (snapshot)
-              await updatePart({
-                id: Identifier.ascending("part"),
-                messageID: assistantMsg.id,
-                sessionID: assistantMsg.sessionID,
-                type: "snapshot",
-                snapshot,
-              })
-            break
-
-          case "tool-input-start":
-            const part = await updatePart({
-              id: Identifier.ascending("part"),
-              messageID: assistantMsg.id,
-              sessionID: assistantMsg.sessionID,
-              type: "tool",
-              tool: value.toolName,
-              callID: value.id,
-              state: {
-                status: "pending",
-              },
+          for await (const value of stream.fullStream) {
+            log.info("part", {
+              type: value.type,
             })
-            toolCalls[value.id] = part as MessageV2.ToolPart
-            break
-
-          case "tool-input-delta":
-            break
-
-          case "tool-call": {
-            const match = toolCalls[value.toolCallId]
-            if (match) {
-              const part = await updatePart({
-                ...match,
-                state: {
-                  status: "running",
-                  input: value.input,
-                  time: {
-                    start: Date.now(),
-                  },
-                },
-              })
-              toolCalls[value.toolCallId] = part as MessageV2.ToolPart
-            }
-            break
-          }
-          case "tool-result": {
-            const match = toolCalls[value.toolCallId]
-            if (match && match.state.status === "running") {
-              await updatePart({
-                ...match,
-                state: {
-                  status: "completed",
-                  input: value.input,
-                  output: value.output.output,
-                  metadata: value.output.metadata,
-                  title: value.output.title,
-                  time: {
-                    start: match.state.time.start,
-                    end: Date.now(),
+            switch (value.type) {
+              case "start":
+                const snapshot = await Snapshot.create(assistantMsg.sessionID)
+                if (snapshot)
+                  await updatePart({
+                    id: Identifier.ascending("part"),
+                    messageID: assistantMsg.id,
+                    sessionID: assistantMsg.sessionID,
+                    type: "snapshot",
+                    snapshot,
+                  })
+                break
+
+              case "tool-input-start":
+                const part = await updatePart({
+                  id: Identifier.ascending("part"),
+                  messageID: assistantMsg.id,
+                  sessionID: assistantMsg.sessionID,
+                  type: "tool",
+                  tool: value.toolName,
+                  callID: value.id,
+                  state: {
+                    status: "pending",
                   },
-                },
-              })
-              delete toolCalls[value.toolCallId]
-              const snapshot = await Snapshot.create(assistantMsg.sessionID)
-              if (snapshot)
+                })
+                toolCalls[value.id] = part as MessageV2.ToolPart
+                break
+
+              case "tool-input-delta":
+                break
+
+              case "tool-call": {
+                const match = toolCalls[value.toolCallId]
+                if (match) {
+                  const part = await updatePart({
+                    ...match,
+                    state: {
+                      status: "running",
+                      input: value.input,
+                      time: {
+                        start: Date.now(),
+                      },
+                    },
+                  })
+                  toolCalls[value.toolCallId] = part as MessageV2.ToolPart
+                }
+                break
+              }
+              case "tool-result": {
+                const match = toolCalls[value.toolCallId]
+                if (match && match.state.status === "running") {
+                  await updatePart({
+                    ...match,
+                    state: {
+                      status: "completed",
+                      input: value.input,
+                      output: value.output.output,
+                      metadata: value.output.metadata,
+                      title: value.output.title,
+                      time: {
+                        start: match.state.time.start,
+                        end: Date.now(),
+                      },
+                    },
+                  })
+                  delete toolCalls[value.toolCallId]
+                  const snapshot = await Snapshot.create(assistantMsg.sessionID)
+                  if (snapshot)
+                    await updatePart({
+                      id: Identifier.ascending("part"),
+                      messageID: assistantMsg.id,
+                      sessionID: assistantMsg.sessionID,
+                      type: "snapshot",
+                      snapshot,
+                    })
+                }
+                break
+              }
+
+              case "tool-error": {
+                const match = toolCalls[value.toolCallId]
+                if (match && match.state.status === "running") {
+                  await updatePart({
+                    ...match,
+                    state: {
+                      status: "error",
+                      input: value.input,
+                      error: (value.error as any).toString(),
+                      time: {
+                        start: match.state.time.start,
+                        end: Date.now(),
+                      },
+                    },
+                  })
+                  delete toolCalls[value.toolCallId]
+                  const snapshot = await Snapshot.create(assistantMsg.sessionID)
+                  if (snapshot)
+                    await updatePart({
+                      id: Identifier.ascending("part"),
+                      messageID: assistantMsg.id,
+                      sessionID: assistantMsg.sessionID,
+                      type: "snapshot",
+                      snapshot,
+                    })
+                }
+                break
+              }
+
+              case "error":
+                throw value.error
+
+              case "start-step":
                 await updatePart({
                   id: Identifier.ascending("part"),
                   messageID: assistantMsg.id,
                   sessionID: assistantMsg.sessionID,
-                  type: "snapshot",
-                  snapshot,
+                  type: "step-start",
                 })
-            }
-            break
-          }
+                break
 
-          case "tool-error": {
-            const match = toolCalls[value.toolCallId]
-            if (match && match.state.status === "running") {
-              await updatePart({
-                ...match,
-                state: {
-                  status: "error",
-                  input: value.input,
-                  error: (value.error as any).toString(),
-                  time: {
-                    start: match.state.time.start,
-                    end: Date.now(),
-                  },
-                },
-              })
-              delete toolCalls[value.toolCallId]
-              const snapshot = await Snapshot.create(assistantMsg.sessionID)
-              if (snapshot)
+              case "finish-step":
+                const usage = getUsage(model, value.usage, value.providerMetadata)
+                assistantMsg.cost += usage.cost
+                assistantMsg.tokens = usage.tokens
                 await updatePart({
                   id: Identifier.ascending("part"),
                   messageID: assistantMsg.id,
                   sessionID: assistantMsg.sessionID,
-                  type: "snapshot",
-                  snapshot,
+                  type: "step-finish",
+                  tokens: usage.tokens,
+                  cost: usage.cost,
                 })
-            }
-            break
-          }
+                await updateMessage(assistantMsg)
+                break
 
-          case "error":
-            throw value.error
-
-          case "start-step":
-            await updatePart({
-              id: Identifier.ascending("part"),
-              messageID: assistantMsg.id,
-              sessionID: assistantMsg.sessionID,
-              type: "step-start",
-            })
-            break
-
-          case "finish-step":
-            const usage = getUsage(model, value.usage, value.providerMetadata)
-            assistantMsg.cost += usage.cost
-            assistantMsg.tokens = usage.tokens
-            await updatePart({
-              id: Identifier.ascending("part"),
-              messageID: assistantMsg.id,
-              sessionID: assistantMsg.sessionID,
-              type: "step-finish",
-              tokens: usage.tokens,
-              cost: usage.cost,
-            })
-            await updateMessage(assistantMsg)
-            break
-
-          case "text-start":
-            currentText = {
-              id: Identifier.ascending("part"),
-              messageID: assistantMsg.id,
-              sessionID: assistantMsg.sessionID,
-              type: "text",
-              text: "",
-              time: {
-                start: Date.now(),
-              },
-            }
-            break
+              case "text-start":
+                currentText = {
+                  id: Identifier.ascending("part"),
+                  messageID: assistantMsg.id,
+                  sessionID: assistantMsg.sessionID,
+                  type: "text",
+                  text: "",
+                  time: {
+                    start: Date.now(),
+                  },
+                }
+                break
 
-          case "text":
-            if (currentText) {
-              currentText.text += value.text
-              await updatePart(currentText)
-            }
-            break
+              case "text":
+                if (currentText) {
+                  currentText.text += value.text
+                  await updatePart(currentText)
+                }
+                break
 
-          case "text-end":
-            if (currentText && currentText.text) {
-              currentText.time = {
-                start: Date.now(),
-                end: Date.now(),
-              }
-              await updatePart(currentText)
-            }
-            currentText = undefined
-            break
+              case "text-end":
+                if (currentText && currentText.text) {
+                  currentText.time = {
+                    start: Date.now(),
+                    end: Date.now(),
+                  }
+                  await updatePart(currentText)
+                }
+                currentText = undefined
+                break
 
-          case "finish":
-            assistantMsg.time.completed = Date.now()
-            await updateMessage(assistantMsg)
-            break
+              case "finish":
+                assistantMsg.time.completed = Date.now()
+                await updateMessage(assistantMsg)
+                break
 
-          default:
-            log.info("unhandled", {
-              ...value,
+              default:
+                log.info("unhandled", {
+                  ...value,
+                })
+                continue
+            }
+          }
+        } catch (e) {
+          log.error("", {
+            error: e,
+          })
+          switch (true) {
+            case e instanceof DOMException && e.name === "AbortError":
+              assistantMsg.error = new MessageV2.AbortedError(
+                { message: e.message },
+                {
+                  cause: e,
+                },
+              ).toObject()
+              break
+            case MessageV2.OutputLengthError.isInstance(e):
+              assistantMsg.error = e
+              break
+            case LoadAPIKeyError.isInstance(e):
+              assistantMsg.error = new MessageV2.AuthError(
+                {
+                  providerID: model.id,
+                  message: e.message,
+                },
+                { cause: e },
+              ).toObject()
+              break
+            case e instanceof Error:
+              assistantMsg.error = new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject()
+              break
+            default:
+              assistantMsg.error = new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e })
+          }
+          Bus.publish(Event.Error, {
+            sessionID: assistantMsg.sessionID,
+            error: assistantMsg.error,
+          })
+        }
+        const p = await parts(assistantMsg.sessionID, assistantMsg.id)
+        for (const part of p) {
+          if (part.type === "tool" && part.state.status !== "completed") {
+            updatePart({
+              ...part,
+              state: {
+                status: "error",
+                error: "Tool execution aborted",
+                time: {
+                  start: Date.now(),
+                  end: Date.now(),
+                },
+                input: {},
+              },
             })
-            continue
+          }
         }
-      }
-    } catch (e) {
-      log.error("", {
-        error: e,
-      })
-      switch (true) {
-        case e instanceof DOMException && e.name === "AbortError":
-          assistantMsg.error = new MessageV2.AbortedError(
-            { message: e.message },
-            {
-              cause: e,
-            },
-          ).toObject()
-          break
-        case MessageV2.OutputLengthError.isInstance(e):
-          assistantMsg.error = e
-          break
-        case LoadAPIKeyError.isInstance(e):
-          assistantMsg.error = new Provider.AuthError(
-            {
-              providerID: model.id,
-              message: e.message,
-            },
-            { cause: e },
-          ).toObject()
-          break
-        case e instanceof Error:
-          assistantMsg.error = new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject()
-          break
-        default:
-          assistantMsg.error = new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e })
-      }
-      Bus.publish(Event.Error, {
-        sessionID: assistantMsg.sessionID,
-        error: assistantMsg.error,
-      })
-    }
-    const p = await parts(assistantMsg.sessionID, assistantMsg.id)
-    for (const part of p) {
-      if (part.type === "tool" && part.state.status !== "completed") {
-        updatePart({
-          ...part,
-          state: {
-            status: "error",
-            error: "Tool execution aborted",
-            time: {
-              start: Date.now(),
-              end: Date.now(),
-            },
-            input: {},
-          },
-        })
-      }
+        assistantMsg.time.completed = Date.now()
+        await updateMessage(assistantMsg)
+        return { info: assistantMsg, parts: p }
+      },
     }
-    assistantMsg.time.completed = Date.now()
-    await updateMessage(assistantMsg)
-    return { info: assistantMsg, parts: p }
   }
 
   export async function revert(_input: { sessionID: string; messageID: string; part: number }) {
@@ -1006,6 +1010,7 @@ export namespace Session {
     }
     await updateMessage(next)
 
+    const processor = createProcessor(next, model.info)
     const stream = streamText({
       abortSignal: abort.signal,
       model: model.language,
@@ -1029,7 +1034,7 @@ export namespace Session {
       ],
     })
 
-    const result = await processStream(next, model.info, stream)
+    const result = await processor.process(stream)
     return result
   }
 

+ 8 - 2
packages/opencode/src/session/message-v2.ts

@@ -1,6 +1,5 @@
 import z from "zod"
 import { Bus } from "../bus"
-import { Provider } from "../provider/provider"
 import { NamedError } from "../util/error"
 import { Message } from "./message"
 import { convertToModelMessages, type ModelMessage, type UIMessage } from "ai"
@@ -9,6 +8,13 @@ import { Identifier } from "../id/id"
 export namespace MessageV2 {
   export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
   export const AbortedError = NamedError.create("MessageAbortedError", z.object({}))
+  export const AuthError = NamedError.create(
+    "ProviderAuthError",
+    z.object({
+      providerID: z.string(),
+      message: z.string(),
+    }),
+  )
 
   export const ToolStatePending = z
     .object({
@@ -173,7 +179,7 @@ export namespace MessageV2 {
     }),
     error: z
       .discriminatedUnion("name", [
-        Provider.AuthError.Schema,
+        AuthError.Schema,
         NamedError.Unknown.Schema,
         OutputLengthError.Schema,
         AbortedError.Schema,

+ 8 - 6
packages/opencode/src/session/message.ts

@@ -1,9 +1,15 @@
 import z from "zod"
-import { Provider } from "../provider/provider"
 import { NamedError } from "../util/error"
 
 export namespace Message {
   export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
+  export const AuthError = NamedError.create(
+    "ProviderAuthError",
+    z.object({
+      providerID: z.string(),
+      message: z.string(),
+    }),
+  )
 
   export const ToolCall = z
     .object({
@@ -134,11 +140,7 @@ export namespace Message {
             completed: z.number().optional(),
           }),
           error: z
-            .discriminatedUnion("name", [
-              Provider.AuthError.Schema,
-              NamedError.Unknown.Schema,
-              OutputLengthError.Schema,
-            ])
+            .discriminatedUnion("name", [AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema])
             .optional(),
           sessionID: z.string(),
           tool: z.record(

+ 1 - 1
packages/opencode/src/storage/storage.ts

@@ -129,7 +129,7 @@ export namespace Storage {
           cwd: path.join(dir, prefix),
           onlyFiles: true,
         }),
-      )
+      ).then((items) => items.map((item) => path.join(prefix, item.slice(0, -5))))
       result.sort()
       return result
     } catch {

+ 7 - 14
packages/opencode/src/tool/task.ts

@@ -15,21 +15,15 @@ export const TaskTool = Tool.define({
   }),
   async execute(params, ctx) {
     const session = await Session.create(ctx.sessionID)
-    const msg = (await Session.getMessage(ctx.sessionID, ctx.messageID)) as MessageV2.Assistant
-
-    const parts: Record<string, MessageV2.Part> = {}
-    function summary(input: MessageV2.Part[]) {
-      const result = []
-      for (const part of input) {
-        if (part.type === "tool" && part.state.status === "completed") {
-          result.push(part)
-        }
-      }
-      return result
-    }
+    const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
+    if (msg.role !== "assistant") throw new Error("Not an assistant message")
 
+    const messageID = Identifier.ascending("message")
+    const parts: Record<string, MessageV2.ToolPart> = {}
     const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
       if (evt.properties.part.sessionID !== session.id) return
+      if (evt.properties.part.messageID === messageID) return
+      if (evt.properties.part.type !== "tool") return
       parts[evt.properties.part.id] = evt.properties.part
       ctx.metadata({
         title: params.description,
@@ -42,7 +36,6 @@ export const TaskTool = Tool.define({
     ctx.abort.addEventListener("abort", () => {
       Session.abort(session.id)
     })
-    const messageID = Identifier.ascending("message")
     const result = await Session.chat({
       messageID,
       sessionID: session.id,
@@ -62,7 +55,7 @@ export const TaskTool = Tool.define({
     return {
       title: params.description,
       metadata: {
-        summary: summary(result.parts),
+        summary: result.parts.filter((x) => x.type === "tool"),
       },
       output: result.parts.findLast((x) => x.type === "text")!.text,
     }

+ 111 - 118
packages/tui/internal/components/chat/message.go

@@ -305,10 +305,8 @@ func renderToolDetails(
 		return ""
 	}
 
-	if toolCall.State.Status == opencode.ToolPartStateStatusPending ||
-		toolCall.State.Status == opencode.ToolPartStateStatusRunning {
+	if toolCall.State.Status == opencode.ToolPartStateStatusPending {
 		title := renderToolTitle(toolCall, width)
-		title = styles.NewStyle().Width(width - 6).Render(title)
 		return renderContentBlock(app, title, highlight, width)
 	}
 
@@ -339,128 +337,124 @@ func renderToolDetails(
 		borderColor = t.BorderActive()
 	}
 
-	if toolCall.State.Status == opencode.ToolPartStateStatusCompleted {
-		metadata := toolCall.State.Metadata.(map[string]any)
-		switch toolCall.Tool {
-		case "read":
-			preview := metadata["preview"]
-			if preview != nil && toolInputMap["filePath"] != nil {
-				filename := toolInputMap["filePath"].(string)
-				body = preview.(string)
-				body = util.RenderFile(filename, body, width, util.WithTruncate(6))
-			}
-		case "edit":
-			if filename, ok := toolInputMap["filePath"].(string); ok {
-				diffField := metadata["diff"]
-				if diffField != nil {
-					patch := diffField.(string)
-					var formattedDiff string
-					formattedDiff, _ = diff.FormatUnifiedDiff(
-						filename,
-						patch,
-						diff.WithWidth(width-2),
-					)
-					body = strings.TrimSpace(formattedDiff)
-					style := styles.NewStyle().
-						Background(backgroundColor).
-						Foreground(t.TextMuted()).
-						Padding(1, 2).
-						Width(width - 4)
-					if highlight {
-						style = style.Foreground(t.Text()).Bold(true)
-					}
-
-					if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
-						diagnostics = style.Render(diagnostics)
-						body += "\n" + diagnostics
-					}
-
-					title := renderToolTitle(toolCall, width)
-					title = style.Render(title)
-					content := title + "\n" + body
-					content = renderContentBlock(
-						app,
-						content,
-						highlight,
-						width,
-						WithPadding(0),
-						WithBorderColor(borderColor),
-					)
-					return content
+	metadata := toolCall.State.Metadata.(map[string]any)
+	switch toolCall.Tool {
+	case "read":
+		preview := metadata["preview"]
+		if preview != nil && toolInputMap["filePath"] != nil {
+			filename := toolInputMap["filePath"].(string)
+			body = preview.(string)
+			body = util.RenderFile(filename, body, width, util.WithTruncate(6))
+		}
+	case "edit":
+		if filename, ok := toolInputMap["filePath"].(string); ok {
+			diffField := metadata["diff"]
+			if diffField != nil {
+				patch := diffField.(string)
+				var formattedDiff string
+				formattedDiff, _ = diff.FormatUnifiedDiff(
+					filename,
+					patch,
+					diff.WithWidth(width-2),
+				)
+				body = strings.TrimSpace(formattedDiff)
+				style := styles.NewStyle().
+					Background(backgroundColor).
+					Foreground(t.TextMuted()).
+					Padding(1, 2).
+					Width(width - 4)
+				if highlight {
+					style = style.Foreground(t.Text()).Bold(true)
 				}
-			}
-		case "write":
-			if filename, ok := toolInputMap["filePath"].(string); ok {
-				if content, ok := toolInputMap["content"].(string); ok {
-					body = util.RenderFile(filename, content, width)
-					if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
-						body += "\n\n" + diagnostics
-					}
+
+				if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
+					diagnostics = style.Render(diagnostics)
+					body += "\n" + diagnostics
 				}
+
+				title := renderToolTitle(toolCall, width)
+				title = style.Render(title)
+				content := title + "\n" + body
+				content = renderContentBlock(
+					app,
+					content,
+					highlight,
+					width,
+					WithPadding(0),
+					WithBorderColor(borderColor),
+				)
+				return content
 			}
-		case "bash":
-			stdout := metadata["stdout"]
-			if stdout != nil {
-				command := toolInputMap["command"].(string)
-				body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
-				body = util.ToMarkdown(body, width, backgroundColor)
-			}
-		case "webfetch":
-			if format, ok := toolInputMap["format"].(string); ok && result != nil {
-				body = *result
-				body = util.TruncateHeight(body, 10)
-				if format == "html" || format == "markdown" {
-					body = util.ToMarkdown(body, width, backgroundColor)
+		}
+	case "write":
+		if filename, ok := toolInputMap["filePath"].(string); ok {
+			if content, ok := toolInputMap["content"].(string); ok {
+				body = util.RenderFile(filename, content, width)
+				if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
+					body += "\n\n" + diagnostics
 				}
 			}
-		case "todowrite":
-			todos := metadata["todos"]
-			if todos != nil {
-				for _, item := range todos.([]any) {
-					todo := item.(map[string]any)
-					content := todo["content"].(string)
-					switch todo["status"] {
-					case "completed":
-						body += fmt.Sprintf("- [x] %s\n", content)
-					case "cancelled":
-						// strike through cancelled todo
-						body += fmt.Sprintf("- [~] ~~%s~~\n", content)
-					case "in_progress":
-						// highlight in progress todo
-						body += fmt.Sprintf("- [ ] `%s`\n", content)
-					default:
-						body += fmt.Sprintf("- [ ] %s\n", content)
-					}
-				}
+		}
+	case "bash":
+		stdout := metadata["stdout"]
+		if stdout != nil {
+			command := toolInputMap["command"].(string)
+			body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
+			body = util.ToMarkdown(body, width, backgroundColor)
+		}
+	case "webfetch":
+		if format, ok := toolInputMap["format"].(string); ok && result != nil {
+			body = *result
+			body = util.TruncateHeight(body, 10)
+			if format == "html" || format == "markdown" {
 				body = util.ToMarkdown(body, width, backgroundColor)
 			}
-		case "task":
-			summary := metadata["summary"]
-			if summary != nil {
-				toolcalls := summary.([]any)
-				steps := []string{}
-				for _, toolcall := range toolcalls {
-					call := toolcall.(map[string]any)
-					if toolInvocation, ok := call["toolInvocation"].(map[string]any); ok {
-						data, _ := json.Marshal(toolInvocation)
-						var toolCall opencode.ToolPart
-						_ = json.Unmarshal(data, &toolCall)
-						step := renderToolTitle(toolCall, width)
-						step = "∟ " + step
-						steps = append(steps, step)
-					}
+		}
+	case "todowrite":
+		todos := metadata["todos"]
+		if todos != nil {
+			for _, item := range todos.([]any) {
+				todo := item.(map[string]any)
+				content := todo["content"].(string)
+				switch todo["status"] {
+				case "completed":
+					body += fmt.Sprintf("- [x] %s\n", content)
+				case "cancelled":
+					// strike through cancelled todo
+					body += fmt.Sprintf("- [~] ~~%s~~\n", content)
+				case "in_progress":
+					// highlight in progress todo
+					body += fmt.Sprintf("- [ ] `%s`\n", content)
+				default:
+					body += fmt.Sprintf("- [ ] %s\n", content)
 				}
-				body = strings.Join(steps, "\n")
 			}
-		default:
-			if result == nil {
-				empty := ""
-				result = &empty
+			body = util.ToMarkdown(body, width, backgroundColor)
+		}
+	case "task":
+		summary := metadata["summary"]
+		if summary != nil {
+			toolcalls := summary.([]any)
+			steps := []string{}
+			for _, item := range toolcalls {
+				data, _ := json.Marshal(item)
+				var toolCall opencode.ToolPart
+				_ = json.Unmarshal(data, &toolCall)
+				step := renderToolTitle(toolCall, width)
+				step = "∟ " + step
+				steps = append(steps, step)
 			}
-			body = *result
-			body = util.TruncateHeight(body, 10)
-			body = styles.NewStyle().Width(width - 6).Render(body)
+			body = strings.Join(steps, "\n")
 		}
+		body = styles.NewStyle().Width(width - 6).Render(body)
+	default:
+		if result == nil {
+			empty := ""
+			result = &empty
+		}
+		body = *result
+		body = util.TruncateHeight(body, 10)
+		body = styles.NewStyle().Width(width - 6).Render(body)
 	}
 
 	error := ""
@@ -539,10 +533,9 @@ func renderToolTitle(
 	toolCall opencode.ToolPart,
 	width int,
 ) string {
-	// TODO: handle truncate to width
-
 	if toolCall.State.Status == opencode.ToolPartStateStatusPending {
-		return renderToolAction(toolCall.Tool)
+		title := renderToolAction(toolCall.Tool)
+		return styles.NewStyle().Width(width - 6).Render(title)
 	}
 
 	toolArgs := ""
@@ -596,7 +589,7 @@ func renderToolTitle(
 func renderToolAction(name string) string {
 	switch name {
 	case "task":
-		return "Searching..."
+		return "Planning..."
 	case "bash":
 		return "Writing command..."
 	case "edit":