This commit is contained in:
Dax Raad 2025-07-05 18:33:07 -04:00
parent 3e2fe176f9
commit 8eaf25b319
13 changed files with 383 additions and 416 deletions

View file

@ -3,7 +3,7 @@ import { Bus } from "../bus"
import { Provider } from "../provider/provider"
import { NamedError } from "../util/error"
import { Message } from "./message"
import type { AssistantContent, ModelMessage, UserContent } from "ai"
import { convertToModelMessages, type ModelMessage, type UIMessage } from "ai"
export namespace MessageV2 {
export const OutputLengthError = NamedError.create(
@ -132,7 +132,6 @@ export namespace MessageV2 {
export type UserPart = z.infer<typeof UserPart>
export const User = Base.extend({
id: z.string(),
role: z.literal("user"),
parts: z.array(UserPart),
time: z.object({
@ -151,7 +150,6 @@ export namespace MessageV2 {
export type AssistantPart = z.infer<typeof AssistantPart>
export const Assistant = Base.extend({
id: z.string(),
role: z.literal("assistant"),
parts: z.array(AssistantPart),
time: z.object({
@ -244,6 +242,13 @@ export namespace MessageV2 {
},
]
}
if (part.type === "step-start") {
return [
{
type: "step-start",
},
]
}
if (part.type === "tool-invocation") {
return [
{
@ -325,13 +330,15 @@ export namespace MessageV2 {
}
export function toModelMessage(input: Info[]): ModelMessage[] {
const result: ModelMessage[] = []
const result: UIMessage[] = []
for (const msg of input) {
if (msg.parts.length === 0) continue
if (msg.role === "user") {
result.push({
id: msg.id,
role: "user",
content: msg.parts.flatMap((part): Exclude<UserContent, string> => {
parts: msg.parts.flatMap((part): UIMessage["parts"] => {
if (part.type === "text")
return [
{
@ -343,7 +350,7 @@ export namespace MessageV2 {
return [
{
type: "file",
data: part.url,
url: part.url,
mediaType: part.mime,
filename: part.filename,
},
@ -355,62 +362,51 @@ export namespace MessageV2 {
if (msg.role === "assistant") {
result.push({
id: msg.id,
role: "assistant",
content: msg.parts.flatMap(
(part): Exclude<AssistantContent, string> => {
if (part.type === "text")
parts: msg.parts.flatMap((part): UIMessage["parts"] => {
if (part.type === "text")
return [
{
type: "text",
text: part.text,
},
]
if (part.type === "step-start")
return [
{
type: "step-start",
},
]
if (part.type === "tool") {
if (part.state.status === "completed")
return [
{
type: "text",
text: part.text,
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-available",
toolCallId: part.id,
input: part.state.input,
output: part.state.output,
},
]
if (part.type === "tool") {
if (part.state.status === "completed")
return [
{
type: "tool-call",
input: part.state.input,
toolName: part.tool,
toolCallId: part.id,
},
{
type: "tool-result",
toolCallId: part.id,
toolName: part.tool,
output: {
type: "text",
value: part.state.output,
},
},
]
if (part.state.status === "error")
return [
{
type: "tool-call",
input: part.state.input,
toolName: part.tool,
toolCallId: part.id,
},
{
type: "tool-result",
toolCallId: part.id,
toolName: part.tool,
output: {
type: "text",
value: part.state.error,
},
},
]
}
if (part.state.status === "error")
return [
{
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-error",
toolCallId: part.id,
input: part.state.input,
errorText: part.state.error,
},
]
}
return []
},
),
return []
}),
})
}
}
return result
return convertToModelMessages(result)
}
}

View file

@ -1,5 +1,4 @@
import z from "zod"
import { Bus } from "../bus"
import { Provider } from "../provider/provider"
import { NamedError } from "../util/error"
@ -197,28 +196,4 @@ export namespace Message {
ref: "Message",
})
export type Info = z.infer<typeof Info>
export const Event = {
Updated: Bus.event(
"message.updated",
z.object({
info: Info,
}),
),
Removed: Bus.event(
"message.removed",
z.object({
sessionID: z.string(),
messageID: z.string(),
}),
),
PartUpdated: Bus.event(
"message.part.updated",
z.object({
part: MessagePart,
sessionID: z.string(),
messageID: z.string(),
}),
),
}
}

View file

@ -10,12 +10,13 @@ require (
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1
github.com/charmbracelet/x/ansi v0.8.0
github.com/google/uuid v1.6.0
github.com/lithammer/fuzzysearch v1.1.8
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.16.0
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
github.com/stainless-sdks/opencode-go dev
github.com/sst/opencode-sdk-go v0.1.0-alpha.8
github.com/tidwall/gjson v1.14.4
rsc.io/qr v0.2.0
)
@ -37,7 +38,6 @@ require (
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/goccy/go-yaml v1.17.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/invopop/yaml v0.3.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect

View file

@ -30,7 +30,7 @@ type App struct {
Provider *opencode.Provider
Model *opencode.Model
Session *opencode.Session
Messages []opencode.Message
Messages []opencode.MessageUnion
Commands commands.CommandRegistry
}
@ -47,7 +47,7 @@ type SendMsg struct {
Attachments []opencode.FilePartParam
}
type OptimisticMessageAddedMsg struct {
Message opencode.Message
Message opencode.MessageUnion
}
type FileRenderedMsg struct {
FilePath string
@ -116,7 +116,7 @@ func New(
State: appState,
Client: httpClient,
Session: &opencode.Session{},
Messages: []opencode.Message{},
Messages: []opencode.MessageUnion{},
Commands: commands.LoadFromConfig(configInfo),
}
@ -223,7 +223,10 @@ func (a *App) IsBusy() bool {
}
lastMessage := a.Messages[len(a.Messages)-1]
return lastMessage.Metadata.Time.Completed == 0
if casted, ok := lastMessage.(opencode.AssistantMessage); ok {
return casted.Time.Completed == 0
}
return false
}
func (a *App) SaveState() {
@ -304,30 +307,28 @@ func (a *App) SendChatMessage(
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
}
optimisticParts := []opencode.MessagePart{{
Type: opencode.MessagePartTypeText,
optimisticParts := []opencode.UserMessagePart{{
Type: opencode.UserMessagePartTypeText,
Text: text,
}}
if len(attachments) > 0 {
for _, attachment := range attachments {
optimisticParts = append(optimisticParts, opencode.MessagePart{
Type: opencode.MessagePartTypeFile,
Filename: attachment.Filename.Value,
MediaType: attachment.MediaType.Value,
URL: attachment.URL.Value,
optimisticParts = append(optimisticParts, opencode.UserMessagePart{
Type: opencode.UserMessagePartTypeFile,
Filename: attachment.Filename.Value,
Mime: attachment.Mime.Value,
URL: attachment.URL.Value,
})
}
}
optimisticMessage := opencode.Message{
ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
Role: opencode.MessageRoleUser,
Parts: optimisticParts,
Metadata: opencode.MessageMetadata{
SessionID: a.Session.ID,
Time: opencode.MessageMetadataTime{
Created: float64(time.Now().Unix()),
},
optimisticMessage := opencode.UserMessage{
ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
Role: opencode.UserMessageRoleUser,
Parts: optimisticParts,
SessionID: a.Session.ID,
Time: opencode.UserMessageTime{
Created: float64(time.Now().Unix()),
},
}
@ -335,7 +336,7 @@ func (a *App) SendChatMessage(
cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage}))
cmds = append(cmds, func() tea.Msg {
parts := []opencode.MessagePartUnionParam{
parts := []opencode.UserMessagePartUnionParam{
opencode.TextPartParam{
Type: opencode.F(opencode.TextPartTypeText),
Text: opencode.F(text),
@ -344,10 +345,10 @@ func (a *App) SendChatMessage(
if len(attachments) > 0 {
for _, attachment := range attachments {
parts = append(parts, opencode.FilePartParam{
MediaType: attachment.MediaType,
Type: attachment.Type,
URL: attachment.URL,
Filename: attachment.Filename,
Mime: attachment.Mime,
Type: attachment.Type,
URL: attachment.URL,
Filename: attachment.Filename,
})
}
}

View file

@ -248,10 +248,10 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
fileParts := make([]opencode.FilePartParam, 0)
for _, attachment := range attachments {
fileParts = append(fileParts, opencode.FilePartParam{
Type: opencode.F(opencode.FilePartTypeFile),
MediaType: opencode.F(attachment.MediaType),
URL: opencode.F(attachment.URL),
Filename: opencode.F(attachment.Filename),
Type: opencode.F(opencode.FilePartTypeFile),
Mime: opencode.F(attachment.MediaType),
URL: opencode.F(attachment.URL),
Filename: opencode.F(attachment.Filename),
})
}

View file

@ -17,7 +17,6 @@ import (
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/tidwall/gjson"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
@ -217,18 +216,35 @@ func renderContentBlock(
func renderText(
app *app.App,
message opencode.Message,
message opencode.MessageUnion,
text string,
author string,
showToolDetails bool,
highlight bool,
width int,
extra string,
toolCalls ...opencode.ToolInvocationPart,
toolCalls ...opencode.ToolPart,
) string {
t := theme.CurrentTheme()
timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)).
var ts time.Time
backgroundColor := t.BackgroundPanel()
if highlight {
backgroundColor = t.BackgroundElement()
}
messageStyle := styles.NewStyle().Background(backgroundColor)
content := messageStyle.Render(text)
switch casted := message.(type) {
case opencode.AssistantMessage:
ts = time.UnixMilli(int64(casted.Time.Created))
content = util.ToMarkdown(text, width, backgroundColor)
case opencode.UserMessage:
ts = time.UnixMilli(int64(casted.Time.Created))
messageStyle = messageStyle.Width(width - 6)
}
timestamp := ts.
Local().
Format("02 Jan 2006 03:04 PM")
if time.Now().Format("02 Jan 2006") == timestamp[:11] {
@ -238,30 +254,12 @@ func renderText(
info := fmt.Sprintf("%s (%s)", author, timestamp)
info = styles.NewStyle().Foreground(t.TextMuted()).Render(info)
backgroundColor := t.BackgroundPanel()
if highlight {
backgroundColor = t.BackgroundElement()
}
messageStyle := styles.NewStyle().Background(backgroundColor)
if message.Role == opencode.MessageRoleUser {
messageStyle = messageStyle.Width(width - 6)
}
content := messageStyle.Render(text)
if message.Role == opencode.MessageRoleAssistant {
content = util.ToMarkdown(text, width, backgroundColor)
}
if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 {
content = content + "\n\n"
for _, toolCall := range toolCalls {
title := renderToolTitle(toolCall, message.Metadata, width)
metadata := opencode.MessageMetadataTool{}
if _, ok := message.Metadata.Tool[toolCall.ToolInvocation.ToolCallID]; ok {
metadata = message.Metadata.Tool[toolCall.ToolInvocation.ToolCallID]
}
title := renderToolTitle(toolCall, width)
style := styles.NewStyle()
if _, ok := metadata.ExtraFields["error"]; ok {
if toolCall.State.Status == opencode.ToolPartStateStatusError {
style = style.Foreground(t.Error())
}
title = style.Render(title)
@ -276,8 +274,8 @@ func renderText(
}
content = strings.Join(sections, "\n")
switch message.Role {
case opencode.MessageRoleUser:
switch message.(type) {
case opencode.UserMessage:
return renderContentBlock(
app,
content,
@ -286,7 +284,7 @@ func renderText(
WithTextColor(t.Text()),
WithBorderColorRight(t.Secondary()),
)
case opencode.MessageRoleAssistant:
case opencode.AssistantMessage:
return renderContentBlock(
app,
content,
@ -300,39 +298,32 @@ func renderText(
func renderToolDetails(
app *app.App,
toolCall opencode.ToolInvocationPart,
messageMetadata opencode.MessageMetadata,
toolCall opencode.ToolPart,
highlight bool,
width int,
) string {
ignoredTools := []string{"todoread"}
if slices.Contains(ignoredTools, toolCall.ToolInvocation.ToolName) {
if slices.Contains(ignoredTools, toolCall.Tool) {
return ""
}
toolCallID := toolCall.ToolInvocation.ToolCallID
metadata := opencode.MessageMetadataTool{}
if _, ok := messageMetadata.Tool[toolCallID]; ok {
metadata = messageMetadata.Tool[toolCallID]
}
var result *string
if toolCall.ToolInvocation.Result != "" {
result = &toolCall.ToolInvocation.Result
}
if toolCall.ToolInvocation.State == "partial-call" {
title := renderToolTitle(toolCall, messageMetadata, width)
if toolCall.State.Status == opencode.ToolPartStateStatusPending || toolCall.State.Status == opencode.ToolPartStateStatusRunning {
title := renderToolTitle(toolCall, width)
return renderContentBlock(app, title, highlight, width)
}
toolArgsMap := make(map[string]any)
if toolCall.ToolInvocation.Args != nil {
value := toolCall.ToolInvocation.Args
var result *string
if toolCall.State.Output != "" {
result = &toolCall.State.Output
}
toolInputMap := make(map[string]any)
if toolCall.State.Input != nil {
value := toolCall.State.Input
if m, ok := value.(map[string]any); ok {
toolArgsMap = m
keys := make([]string, 0, len(toolArgsMap))
for key := range toolArgsMap {
toolInputMap = m
keys := make([]string, 0, len(toolInputMap))
for key := range toolInputMap {
keys = append(keys, key)
}
slices.Sort(keys)
@ -340,7 +331,6 @@ func renderToolDetails(
}
body := ""
finished := result != nil && *result != ""
t := theme.CurrentTheme()
backgroundColor := t.BackgroundPanel()
borderColor := t.BackgroundPanel()
@ -349,137 +339,128 @@ func renderToolDetails(
borderColor = t.BorderActive()
}
switch toolCall.ToolInvocation.ToolName {
case "read":
preview := metadata.ExtraFields["preview"]
if preview != nil && toolArgsMap["filePath"] != nil {
filename := toolArgsMap["filePath"].(string)
body = preview.(string)
body = util.RenderFile(filename, body, width, util.WithTruncate(6))
}
case "edit":
if filename, ok := toolArgsMap["filePath"].(string); ok {
diffField := metadata.ExtraFields["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, messageMetadata, width)
title = style.Render(title)
content := title + "\n" + body
content = renderContentBlock(
app,
content,
highlight,
width,
WithPadding(0),
WithBorderColor(borderColor),
)
return content
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 "write":
if filename, ok := toolArgsMap["filePath"].(string); ok {
if content, ok := toolArgsMap["content"].(string); ok {
body = util.RenderFile(filename, content, width)
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
body += "\n\n" + diagnostics
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
}
}
}
case "bash":
stdout := metadata.ExtraFields["stdout"]
if stdout != nil {
command := toolArgsMap["command"].(string)
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
body = util.ToMarkdown(body, width, backgroundColor)
}
case "webfetch":
if format, ok := toolArgsMap["format"].(string); ok && result != nil {
body = *result
body = util.TruncateHeight(body, 10)
if format == "html" || format == "markdown" {
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 "todowrite":
todos := metadata.JSON.ExtraFields["todos"]
if !todos.IsNull() && finished {
strTodos := todos.Raw()
todos := gjson.Parse(strTodos)
for _, todo := range todos.Array() {
content := todo.Get("content").String()
switch todo.Get("status").String() {
case "completed":
body += fmt.Sprintf("- [x] %s\n", content)
// case "in-progress":
// body += fmt.Sprintf("- [ ] %s\n", content)
default:
body += fmt.Sprintf("- [ ] %s\n", content)
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)
}
}
body = util.ToMarkdown(body, width, backgroundColor)
}
case "task":
summary := metadata.JSON.ExtraFields["summary"]
if !summary.IsNull() {
strValue := summary.Raw()
toolcalls := gjson.Parse(strValue).Array()
steps := []string{}
for _, toolcall := range toolcalls {
call := toolcall.Value().(map[string]any)
if toolInvocation, ok := call["toolInvocation"].(map[string]any); ok {
data, _ := json.Marshal(toolInvocation)
var toolCall opencode.ToolInvocationPart
_ = json.Unmarshal(data, &toolCall)
if metadata, ok := call["metadata"].(map[string]any); ok {
data, _ = json.Marshal(metadata)
var toolMetadata opencode.MessageMetadataTool
_ = json.Unmarshal(data, &toolMetadata)
step := renderToolTitle(toolCall, messageMetadata, width)
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 "in-progress":
// 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")
}
body = strings.Join(steps, "\n")
default:
if result == nil {
empty := ""
result = &empty
}
body = *result
body = util.TruncateHeight(body, 10)
}
default:
if result == nil {
empty := ""
result = &empty
}
body = *result
body = util.TruncateHeight(body, 10)
}
error := ""
if err, ok := metadata.ExtraFields["error"].(bool); ok && err {
if message, ok := metadata.ExtraFields["message"].(string); ok {
error = message
}
if toolCall.State.Status == opencode.ToolPartStateStatusError {
error = toolCall.State.Error
}
if error != "" {
@ -494,7 +475,7 @@ func renderToolDetails(
body = util.TruncateHeight(body, 10)
}
title := renderToolTitle(toolCall, messageMetadata, width)
title := renderToolTitle(toolCall, width)
content := title + "\n\n" + body
return renderContentBlock(app, content, highlight, width, WithBorderColor(borderColor))
}
@ -515,20 +496,19 @@ func renderToolName(name string) string {
}
func renderToolTitle(
toolCall opencode.ToolInvocationPart,
messageMetadata opencode.MessageMetadata,
toolCall opencode.ToolPart,
width int,
) string {
// TODO: handle truncate to width
if toolCall.ToolInvocation.State == "partial-call" {
return renderToolAction(toolCall.ToolInvocation.ToolName)
if toolCall.State.Status == opencode.ToolPartStateStatusPending {
return renderToolAction(toolCall.Tool)
}
toolArgs := ""
toolArgsMap := make(map[string]any)
if toolCall.ToolInvocation.Args != nil {
value := toolCall.ToolInvocation.Args
if toolCall.State.Input != nil {
value := toolCall.State.Input
if m, ok := value.(map[string]any); ok {
toolArgsMap = m
@ -546,8 +526,8 @@ func renderToolTitle(
}
}
title := renderToolName(toolCall.ToolInvocation.ToolName)
switch toolCall.ToolInvocation.ToolName {
title := renderToolName(toolCall.Tool)
switch toolCall.Tool {
case "read":
toolArgs = renderArgs(&toolArgsMap, "filePath")
title = fmt.Sprintf("%s %s", title, toolArgs)
@ -565,7 +545,7 @@ func renderToolTitle(
case "todowrite", "todoread":
// title is just the tool name
default:
toolName := renderToolName(toolCall.ToolInvocation.ToolName)
toolName := renderToolName(toolCall.Tool)
title = fmt.Sprintf("%s %s", toolName, toolArgs)
}
return title
@ -645,8 +625,8 @@ type Diagnostic struct {
}
// renderDiagnostics formats LSP diagnostics for display in the TUI
func renderDiagnostics(metadata opencode.MessageMetadataTool, filePath string) string {
if diagnosticsData, ok := metadata.ExtraFields["diagnostics"].(map[string]any); ok {
func renderDiagnostics(metadata map[string]any, filePath string) string {
if diagnosticsData, ok := metadata["diagnostics"].(map[string]any); ok {
if fileDiagnostics, ok := diagnosticsData[filePath].([]any); ok {
var errorDiagnostics []string
for _, diagInterface := range fileDiagnostics {

View file

@ -99,7 +99,7 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
case opencode.EventListResponseEventMessageUpdated:
if msg.Properties.Info.Metadata.SessionID == m.app.Session.ID {
if msg.Properties.Info.SessionID == m.app.Session.ID {
m.renderView(m.width)
if m.tail {
m.viewport.GotoBottom()
@ -124,19 +124,19 @@ func (m *messagesComponent) renderView(width int) {
m.partCount = 0
m.lineCount = 0
orphanedToolCalls := make([]opencode.ToolInvocationPart, 0)
orphanedToolCalls := make([]opencode.ToolPart, 0)
for _, message := range m.app.Messages {
var content string
var cached bool
switch message.Role {
case opencode.MessageRoleUser:
switch casted := message.(type) {
case opencode.UserMessage:
userLoop:
for partIndex, part := range message.Parts {
for partIndex, part := range casted.Parts {
switch part := part.AsUnion().(type) {
case opencode.TextPart:
remainingParts := message.Parts[partIndex+1:]
remainingParts := casted.Parts[partIndex+1:]
fileParts := make([]opencode.FilePart, 0)
for _, part := range remainingParts {
switch part := part.AsUnion().(type) {
@ -150,7 +150,7 @@ func (m *messagesComponent) renderView(width int) {
mediaTypeStyle := styles.NewStyle().Background(t.Secondary()).Foreground(t.BackgroundPanel()).Padding(0, 1)
for _, filePart := range fileParts {
mediaType := ""
switch filePart.MediaType {
switch filePart.Mime {
case "text/plain":
mediaType = "txt"
case "image/png", "image/jpeg", "image/gif", "image/webp":
@ -175,7 +175,7 @@ func (m *messagesComponent) renderView(width int) {
flexItems...,
)
key := m.cache.GenerateKey(message.ID, part.Text, width, m.selectedPart == m.partCount, files)
key := m.cache.GenerateKey(casted.ID, part.Text, width, m.selectedPart == m.partCount, files)
content, cached = m.cache.Get(key)
if !cached {
content = renderText(
@ -199,21 +199,21 @@ func (m *messagesComponent) renderView(width int) {
}
}
case opencode.MessageRoleAssistant:
case opencode.AssistantMessage:
hasTextPart := false
for partIndex, p := range message.Parts {
for partIndex, p := range casted.Parts {
switch part := p.AsUnion().(type) {
case opencode.TextPart:
hasTextPart = true
finished := message.Metadata.Time.Completed > 0
remainingParts := message.Parts[partIndex+1:]
toolCallParts := make([]opencode.ToolInvocationPart, 0)
finished := casted.Time.Completed > 0
remainingParts := casted.Parts[partIndex+1:]
toolCallParts := make([]opencode.ToolPart, 0)
// sometimes tool calls happen without an assistant message
// these should be included in this assistant message as well
if len(orphanedToolCalls) > 0 {
toolCallParts = append(toolCallParts, orphanedToolCalls...)
orphanedToolCalls = make([]opencode.ToolInvocationPart, 0)
orphanedToolCalls = make([]opencode.ToolPart, 0)
}
remaining := true
@ -226,9 +226,9 @@ func (m *messagesComponent) renderView(width int) {
// we only want tool calls associated with the current text part.
// if we hit another text part, we're done.
remaining = false
case opencode.ToolInvocationPart:
case opencode.ToolPart:
toolCallParts = append(toolCallParts, part)
if part.ToolInvocation.State != "result" {
if part.State.Status != opencode.ToolPartStateStatusCompleted || part.State.Status != opencode.ToolPartStateStatusError {
// i don't think there's a case where a tool call isn't in result state
// and the message time is 0, but just in case
finished = false
@ -237,14 +237,14 @@ func (m *messagesComponent) renderView(width int) {
}
if finished {
key := m.cache.GenerateKey(message.ID, p.Text, width, m.showToolDetails, m.selectedPart == m.partCount)
key := m.cache.GenerateKey(casted.ID, p.Text, width, m.showToolDetails, m.selectedPart == m.partCount)
content, cached = m.cache.Get(key)
if !cached {
content = renderText(
m.app,
message,
p.Text,
message.Metadata.Assistant.ModelID,
casted.ModelID,
m.showToolDetails,
m.partCount == m.selectedPart,
width,
@ -258,7 +258,7 @@ func (m *messagesComponent) renderView(width int) {
m.app,
message,
p.Text,
message.Metadata.Assistant.ModelID,
casted.ModelID,
m.showToolDetails,
m.partCount == m.selectedPart,
width,
@ -270,7 +270,7 @@ func (m *messagesComponent) renderView(width int) {
m = m.updateSelected(content, p.Text)
blocks = append(blocks, content)
}
case opencode.ToolInvocationPart:
case opencode.ToolPart:
if !m.showToolDetails {
if !hasTextPart {
orphanedToolCalls = append(orphanedToolCalls, part)
@ -278,9 +278,9 @@ func (m *messagesComponent) renderView(width int) {
continue
}
if part.ToolInvocation.State == "result" {
key := m.cache.GenerateKey(message.ID,
part.ToolInvocation.ToolCallID,
if part.State.Status == opencode.ToolPartStateStatusCompleted || part.State.Status == opencode.ToolPartStateStatusError {
key := m.cache.GenerateKey(casted.ID,
part.ID,
m.showToolDetails,
width,
m.partCount == m.selectedPart,
@ -290,7 +290,6 @@ func (m *messagesComponent) renderView(width int) {
content = renderToolDetails(
m.app,
part,
message.Metadata,
m.partCount == m.selectedPart,
width,
)
@ -301,7 +300,6 @@ func (m *messagesComponent) renderView(width int) {
content = renderToolDetails(
m.app,
part,
message.Metadata,
m.partCount == m.selectedPart,
width,
)
@ -315,14 +313,16 @@ func (m *messagesComponent) renderView(width int) {
}
error := ""
switch err := message.Metadata.Error.AsUnion().(type) {
case nil:
case opencode.MessageMetadataErrorMessageOutputLengthError:
error = "Message output length exceeded"
case opencode.ProviderAuthError:
error = err.Data.Message
case opencode.UnknownError:
error = err.Data.Message
if assistant, ok := message.(opencode.AssistantMessage); ok {
switch err := assistant.Error.AsUnion().(type) {
case nil:
case opencode.AssistantMessageErrorMessageOutputLengthError:
error = "Message output length exceeded"
case opencode.ProviderAuthError:
error = err.Data.Message
case opencode.UnknownError:
error = err.Data.Message
}
}
if error != "" {

View file

@ -6,6 +6,7 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
@ -101,18 +102,20 @@ func (m statusComponent) View() string {
contextWindow := m.app.Model.Limit.Context
for _, message := range m.app.Messages {
cost += message.Metadata.Assistant.Cost
usage := message.Metadata.Assistant.Tokens
if usage.Output > 0 {
if message.Metadata.Assistant.Summary {
tokens = usage.Output
continue
if assistant, ok := message.(opencode.AssistantMessage); ok {
cost += assistant.Cost
usage := assistant.Tokens
if usage.Output > 0 {
if assistant.Summary {
tokens = usage.Output
continue
}
tokens = (usage.Input +
usage.Cache.Write +
usage.Cache.Read +
usage.Output +
usage.Reasoning)
}
tokens = (usage.Input +
usage.Cache.Write +
usage.Cache.Read +
usage.Output +
usage.Reasoning)
}
}

View file

@ -363,7 +363,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case opencode.EventListResponseEventSessionDeleted:
if a.app.Session != nil && msg.Properties.Info.ID == a.app.Session.ID {
a.app.Session = &opencode.Session{}
a.app.Messages = []opencode.Message{}
a.app.Messages = []opencode.MessageUnion{}
}
return a, toast.NewSuccessToast("Session deleted successfully")
case opencode.EventListResponseEventSessionUpdated:
@ -371,7 +371,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.app.Session = &msg.Properties.Info
}
case opencode.EventListResponseEventMessageUpdated:
if msg.Properties.Info.Metadata.SessionID == a.app.Session.ID {
if msg.Properties.Info.SessionID == a.app.Session.ID {
exists := false
optimisticReplaced := false
@ -379,12 +379,15 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.Properties.Info.Role == opencode.MessageRoleUser {
// Look for optimistic messages to replace
for i, m := range a.app.Messages {
if strings.HasPrefix(m.ID, "optimistic-") && m.Role == opencode.MessageRoleUser {
// Replace the optimistic message with the real one
a.app.Messages[i] = msg.Properties.Info
exists = true
optimisticReplaced = true
break
switch m := m.(type) {
case opencode.UserMessage:
if strings.HasPrefix(m.ID, "optimistic-") && m.Role == opencode.UserMessageRoleUser {
// Replace the optimistic message with the real one
a.app.Messages[i] = msg.Properties.Info.AsUnion()
exists = true
optimisticReplaced = true
break
}
}
}
}
@ -392,8 +395,15 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// If not replacing optimistic, check for existing message with same ID
if !optimisticReplaced {
for i, m := range a.app.Messages {
if m.ID == msg.Properties.Info.ID {
a.app.Messages[i] = msg.Properties.Info
var id string
switch m := m.(type) {
case opencode.UserMessage:
id = m.ID
case opencode.AssistantMessage:
id = m.ID
}
if id == msg.Properties.Info.ID {
a.app.Messages[i] = msg.Properties.Info.AsUnion()
exists = true
break
}
@ -401,7 +411,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if !exists {
a.app.Messages = append(a.app.Messages, msg.Properties.Info)
a.app.Messages = append(a.app.Messages, msg.Properties.Info.AsUnion())
}
}
case opencode.EventListResponseEventSessionError:
@ -462,7 +472,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, toast.NewErrorToast("Failed to open session")
}
a.app.Session = msg
a.app.Messages = messages
a.app.Messages = make([]opencode.MessageUnion, 0)
for _, message := range messages {
a.app.Messages = append(a.app.Messages, message.AsUnion())
}
return a, util.CmdHandler(app.SessionLoadedMsg{})
case app.ModelSelectedMsg:
a.app.Provider = &msg.Provider
@ -813,7 +826,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
return a, nil
}
a.app.Session = &opencode.Session{}
a.app.Messages = []opencode.Message{}
a.app.Messages = []opencode.MessageUnion{}
cmds = append(cmds, util.CmdHandler(app.SessionClearedMsg{}))
case commands.SessionListCommand:
sessionDialog := dialog.NewSessionDialog(a.app)

View file

@ -1,4 +1,4 @@
configured_endpoints: 20
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-15eeb028f79b9a065b4e54a6ea6a58631e9bd5004f97820f0c79d18e3f8bac84.yml
openapi_spec_hash: 38c8bacb6c8e4c46852a3e81e3fb9fda
config_hash: 348a85e725de595ca05a61f4333794ac
config_hash: e03e9d1aad76081fa1163086e89f201b

View file

@ -88,6 +88,7 @@ Response Types:
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ToolStateError">ToolStateError</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ToolStatePending">ToolStatePending</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ToolStateRunning">ToolStateRunning</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#UserMessage">UserMessage</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#UserMessagePart">UserMessagePart</a>
Methods:

View file

@ -577,7 +577,7 @@ type Message struct {
Parts interface{} `json:"parts,required"`
Role MessageRole `json:"role,required"`
SessionID string `json:"sessionID,required"`
// This field can have the runtime type of [MessageUserMessageTime],
// This field can have the runtime type of [UserMessageTime],
// [AssistantMessageTime].
Time interface{} `json:"time,required"`
Cost float64 `json:"cost"`
@ -631,13 +631,12 @@ func (r *Message) UnmarshalJSON(data []byte) (err error) {
// AsUnion returns a [MessageUnion] interface which you can cast to the specific
// types for more type safety.
//
// Possible runtime types of the union are [MessageUserMessage],
// [AssistantMessage].
// Possible runtime types of the union are [UserMessage], [AssistantMessage].
func (r Message) AsUnion() MessageUnion {
return r.union
}
// Union satisfied by [MessageUserMessage] or [AssistantMessage].
// Union satisfied by [UserMessage] or [AssistantMessage].
type MessageUnion interface {
implementsMessage()
}
@ -648,7 +647,7 @@ func init() {
"role",
apijson.UnionVariant{
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(MessageUserMessage{}),
Type: reflect.TypeOf(UserMessage{}),
DiscriminatorValue: "user",
},
apijson.UnionVariant{
@ -659,72 +658,6 @@ func init() {
)
}
type MessageUserMessage struct {
ID string `json:"id,required"`
Parts []UserMessagePart `json:"parts,required"`
Role MessageUserMessageRole `json:"role,required"`
SessionID string `json:"sessionID,required"`
Time MessageUserMessageTime `json:"time,required"`
JSON messageUserMessageJSON `json:"-"`
}
// messageUserMessageJSON contains the JSON metadata for the struct
// [MessageUserMessage]
type messageUserMessageJSON struct {
ID apijson.Field
Parts apijson.Field
Role apijson.Field
SessionID apijson.Field
Time apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *MessageUserMessage) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r messageUserMessageJSON) RawJSON() string {
return r.raw
}
func (r MessageUserMessage) implementsMessage() {}
type MessageUserMessageRole string
const (
MessageUserMessageRoleUser MessageUserMessageRole = "user"
)
func (r MessageUserMessageRole) IsKnown() bool {
switch r {
case MessageUserMessageRoleUser:
return true
}
return false
}
type MessageUserMessageTime struct {
Created float64 `json:"created,required"`
JSON messageUserMessageTimeJSON `json:"-"`
}
// messageUserMessageTimeJSON contains the JSON metadata for the struct
// [MessageUserMessageTime]
type messageUserMessageTimeJSON struct {
Created apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *MessageUserMessageTime) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r messageUserMessageTimeJSON) RawJSON() string {
return r.raw
}
type MessageRole string
const (
@ -1306,6 +1239,70 @@ func (r toolStateRunningTimeJSON) RawJSON() string {
return r.raw
}
type UserMessage struct {
ID string `json:"id,required"`
Parts []UserMessagePart `json:"parts,required"`
Role UserMessageRole `json:"role,required"`
SessionID string `json:"sessionID,required"`
Time UserMessageTime `json:"time,required"`
JSON userMessageJSON `json:"-"`
}
// userMessageJSON contains the JSON metadata for the struct [UserMessage]
type userMessageJSON struct {
ID apijson.Field
Parts apijson.Field
Role apijson.Field
SessionID apijson.Field
Time apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *UserMessage) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r userMessageJSON) RawJSON() string {
return r.raw
}
func (r UserMessage) implementsMessage() {}
type UserMessageRole string
const (
UserMessageRoleUser UserMessageRole = "user"
)
func (r UserMessageRole) IsKnown() bool {
switch r {
case UserMessageRoleUser:
return true
}
return false
}
type UserMessageTime struct {
Created float64 `json:"created,required"`
JSON userMessageTimeJSON `json:"-"`
}
// userMessageTimeJSON contains the JSON metadata for the struct [UserMessageTime]
type userMessageTimeJSON struct {
Created apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *UserMessageTime) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r userMessageTimeJSON) RawJSON() string {
return r.raw
}
type UserMessagePart struct {
Type UserMessagePartType `json:"type,required"`
Filename string `json:"filename"`

View file

@ -83,8 +83,9 @@ resources:
toolPart: ToolPart
stepStartPart: StepStartPart
assistantMessage: AssistantMessage
userMessagePart: UserMessagePart
assistantMessagePart: AssistantMessagePart
userMessage: UserMessage
userMessagePart: UserMessagePart
toolStatePending: ToolStatePending
toolStateRunning: ToolStateRunning
toolStateCompleted: ToolStateCompleted