message rendering performance improvements

This commit is contained in:
Dax Raad 2025-07-18 13:11:35 -04:00
parent 6470243095
commit c952e9ae3d
5 changed files with 334 additions and 264 deletions

View file

@ -17,6 +17,9 @@ import { DebugCommand } from "./cli/cmd/debug"
import { StatsCommand } from "./cli/cmd/stats" import { StatsCommand } from "./cli/cmd/stats"
import { McpCommand } from "./cli/cmd/mcp" import { McpCommand } from "./cli/cmd/mcp"
import { InstallGithubCommand } from "./cli/cmd/install-github" import { InstallGithubCommand } from "./cli/cmd/install-github"
import { Trace } from "./trace"
Trace.init()
const cancel = new AbortController() const cancel = new AbortController()
@ -42,7 +45,7 @@ const cli = yargs(hideBin(process.argv))
type: "boolean", type: "boolean",
}) })
.middleware(async () => { .middleware(async () => {
await Log.init({ print: process.argv.includes("--print-logs") }) await Log.init({ print: process.argv.includes("--print-logs"), dev: Installation.isDev() })
try { try {
const { Config } = await import("./config/config") const { Config } = await import("./config/config")

View file

@ -0,0 +1,53 @@
import { Global } from "../global"
import { Installation } from "../installation"
import path from "path"
export namespace Trace {
export function init() {
if (!Installation.isDev()) return
const writer = Bun.file(path.join(Global.Path.data, "log", "fetch.log")).writer()
const originalFetch = globalThis.fetch
// @ts-expect-error
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url
const method = init?.method || "GET"
const urlObj = new URL(url)
writer.write(`\n${method} ${urlObj.pathname}${urlObj.search} HTTP/1.1\n`)
writer.write(`Host: ${urlObj.host}\n`)
if (init?.headers) {
if (init.headers instanceof Headers) {
init.headers.forEach((value, key) => {
writer.write(`${key}: ${value}\n`)
})
} else {
for (const [key, value] of Object.entries(init.headers)) {
writer.write(`${key}: ${value}\n`)
}
}
}
if (init?.body) {
writer.write(`\n${init.body}`)
}
writer.flush()
const response = await originalFetch(input, init)
const clonedResponse = response.clone()
writer.write(`\nHTTP/1.1 ${response.status} ${response.statusText}\n`)
response.headers.forEach((value, key) => {
writer.write(`${key}: ${value}\n`)
})
if (clonedResponse.body) {
clonedResponse.text().then(async (x) => {
writer.write(`\n${x}\n`)
})
}
writer.flush()
return response
}
}
}

View file

@ -50,6 +50,7 @@ export namespace Log {
export interface Options { export interface Options {
print: boolean print: boolean
dev?: boolean
level?: Level level?: Level
} }
@ -63,7 +64,10 @@ export namespace Log {
await fs.mkdir(dir, { recursive: true }) await fs.mkdir(dir, { recursive: true })
cleanup(dir) cleanup(dir)
if (options.print) return if (options.print) return
logpath = path.join(dir, new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log") logpath = path.join(
dir,
options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
)
const logfile = Bun.file(logpath) const logfile = Bun.file(logpath)
await fs.truncate(logpath).catch(() => {}) await fs.truncate(logpath).catch(() => {})
const writer = logfile.writer() const writer = logfile.writer()
@ -75,15 +79,16 @@ export namespace Log {
} }
async function cleanup(dir: string) { async function cleanup(dir: string) {
const entries = await fs.readdir(dir, { withFileTypes: true }) const glob = new Bun.Glob("????-??-??T??????.log")
const files = entries const files = await Array.fromAsync(
.filter((entry) => entry.isFile() && entry.name.endsWith(".log")) glob.scan({
.map((entry) => path.join(dir, entry.name)) cwd: dir,
absolute: true,
}),
)
if (files.length <= 5) return if (files.length <= 5) return
const filesToDelete = files.slice(0, -10) const filesToDelete = files.slice(0, -10)
await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {}))) await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {})))
} }

View file

@ -68,9 +68,6 @@ type SendMsg struct {
type SetEditorContentMsg struct { type SetEditorContentMsg struct {
Text string Text string
} }
type OptimisticMessageAddedMsg struct {
Message opencode.MessageUnion
}
type FileRenderedMsg struct { type FileRenderedMsg struct {
FilePath string FilePath string
} }
@ -508,7 +505,6 @@ func (a *App) SendChatMessage(
} }
a.Messages = append(a.Messages, Message{Info: message, Parts: parts}) a.Messages = append(a.Messages, Message{Info: message, Parts: parts})
cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: message}))
cmds = append(cmds, func() tea.Msg { cmds = append(cmds, func() tea.Msg {
partsParam := []opencode.SessionChatParamsPartUnion{} partsParam := []opencode.SessionChatParamsPartUnion{}

View file

@ -36,13 +36,14 @@ type messagesComponent struct {
header string header string
viewport viewport.Model viewport viewport.Model
cache *PartCache cache *PartCache
rendering bool loading bool
showToolDetails bool showToolDetails bool
rendering bool
dirty bool
tail bool tail bool
partCount int partCount int
lineCount int lineCount int
} }
type renderFinishedMsg struct{}
type ToggleToolDetailsMsg struct{} type ToggleToolDetailsMsg struct{}
@ -62,34 +63,24 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width = effectiveWidth m.width = effectiveWidth
m.height = msg.Height - 7 m.height = msg.Height - 7
m.viewport.SetWidth(m.width) m.viewport.SetWidth(m.width)
m.header = m.renderHeader() m.loading = true
return m, m.Reload() return m, m.Reload()
case app.SendMsg: case app.SendMsg:
m.viewport.GotoBottom() m.viewport.GotoBottom()
m.tail = true m.tail = true
return m, nil return m, nil
case app.OptimisticMessageAddedMsg:
m.tail = true
m.rendering = true
return m, m.Reload()
case dialog.ThemeSelectedMsg: case dialog.ThemeSelectedMsg:
m.cache.Clear() m.cache.Clear()
m.rendering = true m.loading = true
return m, m.Reload() return m, m.Reload()
case ToggleToolDetailsMsg: case ToggleToolDetailsMsg:
m.showToolDetails = !m.showToolDetails m.showToolDetails = !m.showToolDetails
m.rendering = true
return m, m.Reload() return m, m.Reload()
case app.SessionLoadedMsg, app.SessionClearedMsg: case app.SessionLoadedMsg, app.SessionClearedMsg:
m.cache.Clear() m.cache.Clear()
m.tail = true m.tail = true
m.rendering = true m.loading = true
return m, m.Reload() return m, m.Reload()
case renderFinishedMsg:
m.rendering = false
if m.tail {
m.viewport.GotoBottom()
}
case opencode.EventListResponseEventSessionUpdated: case opencode.EventListResponseEventSessionUpdated:
if msg.Properties.Info.ID == m.app.Session.ID { if msg.Properties.Info.ID == m.app.Session.ID {
@ -97,17 +88,24 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case opencode.EventListResponseEventMessageUpdated: case opencode.EventListResponseEventMessageUpdated:
if msg.Properties.Info.SessionID == m.app.Session.ID { if msg.Properties.Info.SessionID == m.app.Session.ID {
m.renderView() cmds = append(cmds, m.renderView())
if m.tail {
m.viewport.GotoBottom()
}
} }
case opencode.EventListResponseEventMessagePartUpdated: case opencode.EventListResponseEventMessagePartUpdated:
if msg.Properties.Part.SessionID == m.app.Session.ID { if msg.Properties.Part.SessionID == m.app.Session.ID {
m.renderView() cmds = append(cmds, m.renderView())
if m.tail { }
m.viewport.GotoBottom() case renderCompleteMsg:
} m.partCount = msg.partCount
m.lineCount = msg.lineCount
m.rendering = false
m.loading = false
m.viewport.SetHeight(m.height - lipgloss.Height(m.header))
m.viewport.SetContent(msg.content)
if m.tail {
m.viewport.GotoBottom()
}
if m.dirty {
cmds = append(cmds, m.renderView())
} }
} }
@ -119,144 +117,179 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
} }
func (m *messagesComponent) renderView() { type renderCompleteMsg struct {
measure := util.Measure("messages.renderView") content string
defer measure("messageCount", len(m.app.Messages)) partCount int
lineCount int
}
func (m *messagesComponent) renderView() tea.Cmd {
m.header = m.renderHeader() m.header = m.renderHeader()
t := theme.CurrentTheme() if m.rendering {
blocks := make([]string, 0) m.dirty = true
m.partCount = 0 return func() tea.Msg {
m.lineCount = 0 return nil
}
orphanedToolCalls := make([]opencode.ToolPart, 0)
width := min(m.width, app.MAX_CONTAINER_WIDTH)
if m.app.Config.Layout == opencode.LayoutConfigStretch {
width = m.width
} }
m.dirty = false
m.rendering = true
for _, message := range m.app.Messages { return func() tea.Msg {
var content string measure := util.Measure("messages.renderView")
var cached bool defer measure()
switch casted := message.Info.(type) { t := theme.CurrentTheme()
case opencode.UserMessage: blocks := make([]string, 0)
for partIndex, part := range message.Parts { partCount := 0
switch part := part.(type) { lineCount := 0
case opencode.TextPart:
if part.Synthetic { orphanedToolCalls := make([]opencode.ToolPart, 0)
continue
} width := min(m.width, app.MAX_CONTAINER_WIDTH)
remainingParts := message.Parts[partIndex+1:] if m.app.Config.Layout == opencode.LayoutConfigStretch {
fileParts := make([]opencode.FilePart, 0) width = m.width
for _, part := range remainingParts { }
switch part := part.(type) {
case opencode.FilePart: for _, message := range m.app.Messages {
fileParts = append(fileParts, part) var content string
var cached bool
switch casted := message.Info.(type) {
case opencode.UserMessage:
for partIndex, part := range message.Parts {
switch part := part.(type) {
case opencode.TextPart:
if part.Synthetic {
continue
} }
} remainingParts := message.Parts[partIndex+1:]
flexItems := []layout.FlexItem{} fileParts := make([]opencode.FilePart, 0)
if len(fileParts) > 0 { for _, part := range remainingParts {
fileStyle := styles.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Padding(0, 1) switch part := part.(type) {
mediaTypeStyle := styles.NewStyle().Background(t.Secondary()).Foreground(t.BackgroundPanel()).Padding(0, 1) case opencode.FilePart:
for _, filePart := range fileParts { fileParts = append(fileParts, part)
mediaType := ""
switch filePart.Mime {
case "text/plain":
mediaType = "txt"
case "image/png", "image/jpeg", "image/gif", "image/webp":
mediaType = "img"
mediaTypeStyle = mediaTypeStyle.Background(t.Accent())
case "application/pdf":
mediaType = "pdf"
mediaTypeStyle = mediaTypeStyle.Background(t.Primary())
}
flexItems = append(flexItems, layout.FlexItem{
View: mediaTypeStyle.Render(mediaType) + fileStyle.Render(filePart.Filename),
})
}
}
bgColor := t.BackgroundPanel()
files := layout.Render(
layout.FlexOptions{
Background: &bgColor,
Width: width - 6,
Direction: layout.Column,
},
flexItems...,
)
key := m.cache.GenerateKey(casted.ID, part.Text, width, files)
content, cached = m.cache.Get(key)
if !cached {
content = renderText(
m.app,
message.Info,
part.Text,
m.app.Config.Username,
m.showToolDetails,
width,
files,
)
content = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
content,
styles.WhitespaceStyle(t.Background()),
)
m.cache.Set(key, content)
}
if content != "" {
m.partCount++
m.lineCount += lipgloss.Height(content) + 1
blocks = append(blocks, content)
}
}
}
case opencode.AssistantMessage:
hasTextPart := false
for partIndex, p := range message.Parts {
switch part := p.(type) {
case opencode.TextPart:
hasTextPart = true
finished := casted.Time.Completed > 0
remainingParts := message.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.ToolPart, 0)
}
remaining := true
for _, part := range remainingParts {
if !remaining {
break
}
switch part := part.(type) {
case opencode.TextPart:
// we only want tool calls associated with the current text part.
// if we hit another text part, we're done.
remaining = false
case opencode.ToolPart:
toolCallParts = append(toolCallParts, part)
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
} }
} }
} flexItems := []layout.FlexItem{}
if len(fileParts) > 0 {
fileStyle := styles.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Padding(0, 1)
mediaTypeStyle := styles.NewStyle().Background(t.Secondary()).Foreground(t.BackgroundPanel()).Padding(0, 1)
for _, filePart := range fileParts {
mediaType := ""
switch filePart.Mime {
case "text/plain":
mediaType = "txt"
case "image/png", "image/jpeg", "image/gif", "image/webp":
mediaType = "img"
mediaTypeStyle = mediaTypeStyle.Background(t.Accent())
case "application/pdf":
mediaType = "pdf"
mediaTypeStyle = mediaTypeStyle.Background(t.Primary())
}
flexItems = append(flexItems, layout.FlexItem{
View: mediaTypeStyle.Render(mediaType) + fileStyle.Render(filePart.Filename),
})
}
}
bgColor := t.BackgroundPanel()
files := layout.Render(
layout.FlexOptions{
Background: &bgColor,
Width: width - 6,
Direction: layout.Column,
},
flexItems...,
)
if finished { key := m.cache.GenerateKey(casted.ID, part.Text, width, files)
key := m.cache.GenerateKey(casted.ID, part.Text, width, m.showToolDetails)
content, cached = m.cache.Get(key) content, cached = m.cache.Get(key)
if !cached { if !cached {
content = renderText(
m.app,
message.Info,
part.Text,
m.app.Config.Username,
m.showToolDetails,
width,
files,
)
content = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
content,
styles.WhitespaceStyle(t.Background()),
)
m.cache.Set(key, content)
}
if content != "" {
partCount++
lineCount += lipgloss.Height(content) + 1
blocks = append(blocks, content)
}
}
}
case opencode.AssistantMessage:
hasTextPart := false
for partIndex, p := range message.Parts {
switch part := p.(type) {
case opencode.TextPart:
hasTextPart = true
finished := part.Time.End > 0
remainingParts := message.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.ToolPart, 0)
}
remaining := true
for _, part := range remainingParts {
if !remaining {
break
}
switch part := part.(type) {
case opencode.TextPart:
// we only want tool calls associated with the current text part.
// if we hit another text part, we're done.
remaining = false
case opencode.ToolPart:
toolCallParts = append(toolCallParts, part)
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
}
}
}
if finished {
key := m.cache.GenerateKey(casted.ID, part.Text, width, m.showToolDetails)
content, cached = m.cache.Get(key)
if !cached {
content = renderText(
m.app,
message.Info,
part.Text,
casted.ModelID,
m.showToolDetails,
width,
"",
toolCallParts...,
)
content = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
content,
styles.WhitespaceStyle(t.Background()),
)
m.cache.Set(key, content)
}
} else {
content = renderText( content = renderText(
m.app, m.app,
message.Info, message.Info,
@ -273,54 +306,50 @@ func (m *messagesComponent) renderView() {
content, content,
styles.WhitespaceStyle(t.Background()), styles.WhitespaceStyle(t.Background()),
) )
m.cache.Set(key, content)
} }
} else { if content != "" {
content = renderText( partCount++
m.app, lineCount += lipgloss.Height(content) + 1
message.Info, blocks = append(blocks, content)
part.Text, }
casted.ModelID, case opencode.ToolPart:
m.showToolDetails, if !m.showToolDetails {
width, if !hasTextPart {
"", orphanedToolCalls = append(orphanedToolCalls, part)
toolCallParts..., }
) continue
content = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
content,
styles.WhitespaceStyle(t.Background()),
)
}
if content != "" {
m.partCount++
m.lineCount += lipgloss.Height(content) + 1
blocks = append(blocks, content)
}
case opencode.ToolPart:
if !m.showToolDetails {
if !hasTextPart {
orphanedToolCalls = append(orphanedToolCalls, part)
} }
continue
}
width := width width := width
if m.app.Config.Layout == opencode.LayoutConfigAuto && if m.app.Config.Layout == opencode.LayoutConfigAuto &&
part.Tool == "edit" && part.Tool == "edit" &&
part.State.Error == "" { part.State.Error == "" {
width = min(m.width, app.EDIT_DIFF_MAX_WIDTH) width = min(m.width, app.EDIT_DIFF_MAX_WIDTH)
} }
if part.State.Status == opencode.ToolPartStateStatusCompleted || part.State.Status == opencode.ToolPartStateStatusError { if part.State.Status == opencode.ToolPartStateStatusCompleted || part.State.Status == opencode.ToolPartStateStatusError {
key := m.cache.GenerateKey(casted.ID, key := m.cache.GenerateKey(casted.ID,
part.ID, part.ID,
m.showToolDetails, m.showToolDetails,
width, width,
) )
content, cached = m.cache.Get(key) content, cached = m.cache.Get(key)
if !cached { if !cached {
content = renderToolDetails(
m.app,
part,
width,
)
content = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
content,
styles.WhitespaceStyle(t.Background()),
)
m.cache.Set(key, content)
}
} else {
// if the tool call isn't finished, don't cache
content = renderToolDetails( content = renderToolDetails(
m.app, m.app,
part, part,
@ -332,70 +361,57 @@ func (m *messagesComponent) renderView() {
content, content,
styles.WhitespaceStyle(t.Background()), styles.WhitespaceStyle(t.Background()),
) )
m.cache.Set(key, content)
} }
} else { if content != "" {
// if the tool call isn't finished, don't cache partCount++
content = renderToolDetails( lineCount += lipgloss.Height(content) + 1
m.app, blocks = append(blocks, content)
part, }
width,
)
content = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
content,
styles.WhitespaceStyle(t.Background()),
)
}
if content != "" {
m.partCount++
m.lineCount += lipgloss.Height(content) + 1
blocks = append(blocks, content)
} }
} }
} }
}
error := "" error := ""
if assistant, ok := message.Info.(opencode.AssistantMessage); ok { if assistant, ok := message.Info.(opencode.AssistantMessage); ok {
switch err := assistant.Error.AsUnion().(type) { switch err := assistant.Error.AsUnion().(type) {
case nil: case nil:
case opencode.AssistantMessageErrorMessageOutputLengthError: case opencode.AssistantMessageErrorMessageOutputLengthError:
error = "Message output length exceeded" error = "Message output length exceeded"
case opencode.ProviderAuthError: case opencode.ProviderAuthError:
error = err.Data.Message error = err.Data.Message
case opencode.MessageAbortedError: case opencode.MessageAbortedError:
error = "Request was aborted" error = "Request was aborted"
case opencode.UnknownError: case opencode.UnknownError:
error = err.Data.Message error = err.Data.Message
}
}
if error != "" {
error = styles.NewStyle().Width(width - 6).Render(error)
error = renderContentBlock(
m.app,
error,
width,
WithBorderColor(t.Error()),
)
error = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
error,
styles.WhitespaceStyle(t.Background()),
)
blocks = append(blocks, error)
lineCount += lipgloss.Height(error) + 1
} }
} }
if error != "" { content := "\n" + strings.Join(blocks, "\n\n")
error = styles.NewStyle().Width(width - 6).Render(error) return renderCompleteMsg{
error = renderContentBlock( content: content,
m.app, partCount: partCount,
error, lineCount: lineCount,
width,
WithBorderColor(t.Error()),
)
error = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
error,
styles.WhitespaceStyle(t.Background()),
)
blocks = append(blocks, error)
m.lineCount += lipgloss.Height(error) + 1
} }
} }
m.viewport.SetHeight(m.height - lipgloss.Height(m.header))
m.viewport.SetContent("\n" + strings.Join(blocks, "\n\n"))
if m.tail {
m.viewport.GotoBottom()
}
} }
func (m *messagesComponent) renderHeader() string { func (m *messagesComponent) renderHeader() string {
@ -552,7 +568,7 @@ func formatTokensAndCost(
func (m *messagesComponent) View() string { func (m *messagesComponent) View() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
if m.rendering { if m.loading {
return lipgloss.Place( return lipgloss.Place(
m.width, m.width,
m.height, m.height,
@ -569,10 +585,7 @@ func (m *messagesComponent) View() string {
} }
func (m *messagesComponent) Reload() tea.Cmd { func (m *messagesComponent) Reload() tea.Cmd {
return func() tea.Msg { return m.renderView()
m.renderView()
return renderFinishedMsg{}
}
} }
func (m *messagesComponent) PageUp() (tea.Model, tea.Cmd) { func (m *messagesComponent) PageUp() (tea.Model, tea.Cmd) {