diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 45c06206..8e7243ba 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/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 = { @@ -531,12 +531,4 @@ export namespace Provider { providerID: z.string(), }), ) - - export const AuthError = NamedError.create( - "ProviderAuthError", - z.object({ - providerID: z.string(), - message: z.string(), - }), - ) } diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 3a92cbea..71514a29 100644 --- a/packages/opencode/src/session/index.ts +++ b/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 = {} + 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, never>, - ) { - try { - let currentText: MessageV2.TextPart | undefined - const toolCalls: Record = {} + function createProcessor(assistantMsg: MessageV2.Assistant, model: ModelsDev.Model) { + const toolCalls: Record = {} + return { + partFromToolCall(toolCallID: string) { + return toolCalls[toolCallID] + }, + async process(stream: StreamTextResult, 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 + 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-delta": - 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", + }, + }) + toolCalls[value.id] = part as MessageV2.ToolPart + break - case "tool-call": { - const match = toolCalls[value.toolCallId] - if (match) { - const part = await updatePart({ - ...match, - state: { - status: "running", - input: value.input, + 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: "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(), }, - }, - }) - 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, + } + 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(), - }, - }, - }) - 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, + } + await updatePart(currentText) + } + currentText = undefined + break + + case "finish": + assistantMsg.time.completed = Date.now() + await updateMessage(assistantMsg) + break + + default: + log.info("unhandled", { + ...value, }) + continue } - 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(), - }, + } 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, }, - }) - 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 + ).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 }) } - - 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": - 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 "finish": - assistantMsg.time.completed = Date.now() - await updateMessage(assistantMsg) - break - - default: - log.info("unhandled", { - ...value, - }) - continue + Bus.publish(Event.Error, { + sessionID: assistantMsg.sessionID, + error: assistantMsg.error, + }) } - } - } 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 } + }, } - 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 } } 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 } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 0031f6ea..744eaadc 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/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, diff --git a/packages/opencode/src/session/message.ts b/packages/opencode/src/session/message.ts index 34b522fc..e71c35c5 100644 --- a/packages/opencode/src/session/message.ts +++ b/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( diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 442ce82b..97efcef7 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/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 { diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 0d7808a3..49b89495 100644 --- a/packages/opencode/src/tool/task.ts +++ b/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 = {} - 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 = {} 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, } diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go index b2a5d716..f6a4604c 100644 --- a/packages/tui/internal/components/chat/message.go +++ b/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) - } + 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 - } + 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 + 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 "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 "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 "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 "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 = 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) - } - } - body = strings.Join(steps, "\n") - } - default: - if result == nil { - empty := "" - result = &empty - } + } + 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) - body = styles.NewStyle().Width(width - 6).Render(body) + if format == "html" || format == "markdown" { + body = util.ToMarkdown(body, width, backgroundColor) + } } + 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 = 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 = 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":