From d090c08ef0940d974305adc29ea931e046626786 Mon Sep 17 00:00:00 2001 From: Timo Clasen Date: Mon, 30 Jun 2025 18:57:56 +0200 Subject: feat: update user and agent messages width and alignment (#515) Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com> --- packages/opencode/src/provider/provider.ts | 9 +- packages/tui/internal/app/app.go | 2 + packages/tui/internal/components/chat/message.go | 668 ++++++++++------------ packages/tui/internal/components/chat/messages.go | 224 ++++---- packages/tui/internal/util/util.go | 2 +- 5 files changed, 422 insertions(+), 483 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index cae40889a..f65eeae56 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -11,8 +11,6 @@ import { WebFetchTool } from "../tool/webfetch" import { GlobTool } from "../tool/glob" import { GrepTool } from "../tool/grep" import { ListTool } from "../tool/ls" -import { LspDiagnosticTool } from "../tool/lsp-diagnostics" -import { LspHoverTool } from "../tool/lsp-hover" import { PatchTool } from "../tool/patch" import { ReadTool } from "../tool/read" import type { Tool } from "../tool/tool" @@ -23,6 +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" export namespace Provider { const log = Log.create({ service: "provider" }) @@ -447,16 +446,16 @@ export namespace Provider { GlobTool, GrepTool, ListTool, - LspDiagnosticTool, - LspHoverTool, + // LspDiagnosticTool, + // LspHoverTool, PatchTool, ReadTool, EditTool, // MultiEditTool, WriteTool, TodoWriteTool, - // TaskTool, TodoReadTool, + // TaskTool, ] const TOOL_MAPPING: Record = { diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 3bd48f02a..2369b196a 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -21,6 +21,7 @@ import ( ) var RootPath string +var CwdPath string type App struct { Info opencode.App @@ -61,6 +62,7 @@ func New( httpClient *opencode.Client, ) (*App, error) { RootPath = appInfo.Path.Root + CwdPath = appInfo.Path.Cwd configInfo, err := httpClient.Config.Get(ctx) if err != nil { diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go index cf4662d53..cc7dabf19 100644 --- a/packages/tui/internal/components/chat/message.go +++ b/packages/tui/internal/components/chat/message.go @@ -24,7 +24,7 @@ import ( ) func toMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string { - r := styles.GetMarkdownRenderer(width, backgroundColor) + r := styles.GetMarkdownRenderer(width-7, backgroundColor) content = strings.ReplaceAll(content, app.RootPath+"/", "") rendered, _ := r.Render(content) lines := strings.Split(rendered, "\n") @@ -50,9 +50,8 @@ func toMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) } type blockRenderer struct { - align *lipgloss.Position + border bool borderColor *compat.AdaptiveColor - fullWidth bool paddingTop int paddingBottom int paddingLeft int @@ -63,15 +62,9 @@ type blockRenderer struct { type renderingOption func(*blockRenderer) -func WithFullWidth() renderingOption { +func WithNoBorder() renderingOption { return func(c *blockRenderer) { - c.fullWidth = true - } -} - -func WithAlign(align lipgloss.Position) renderingOption { - return func(c *blockRenderer) { - c.align = &align + c.border = false } } @@ -93,6 +86,15 @@ func WithMarginBottom(padding int) renderingOption { } } +func WithPadding(padding int) renderingOption { + return func(c *blockRenderer) { + c.paddingTop = padding + c.paddingBottom = padding + c.paddingLeft = padding + c.paddingRight = padding + } +} + func WithPaddingLeft(padding int) renderingOption { return func(c *blockRenderer) { c.paddingLeft = padding @@ -117,10 +119,15 @@ func WithPaddingBottom(padding int) renderingOption { } } -func renderContentBlock(content string, options ...renderingOption) string { +func renderContentBlock( + content string, + width int, + align lipgloss.Position, + options ...renderingOption, +) string { t := theme.CurrentTheme() renderer := &blockRenderer{ - fullWidth: false, + border: true, paddingTop: 1, paddingBottom: 1, paddingLeft: 2, @@ -130,59 +137,42 @@ func renderContentBlock(content string, options ...renderingOption) string { option(renderer) } - style := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()). - // MarginTop(renderer.marginTop). - // MarginBottom(renderer.marginBottom). - PaddingTop(renderer.paddingTop). - PaddingBottom(renderer.paddingBottom). - PaddingLeft(renderer.paddingLeft). - PaddingRight(renderer.paddingRight). - BorderStyle(lipgloss.ThickBorder()) - - align := lipgloss.Left - if renderer.align != nil { - align = *renderer.align - } - borderColor := t.BackgroundPanel() if renderer.borderColor != nil { borderColor = *renderer.borderColor } - switch align { - case lipgloss.Left: + style := styles.NewStyle(). + Foreground(t.TextMuted()). + Background(t.BackgroundPanel()). + Width(width). + PaddingTop(renderer.paddingTop). + PaddingBottom(renderer.paddingBottom). + PaddingLeft(renderer.paddingLeft). + PaddingRight(renderer.paddingRight). + AlignHorizontal(lipgloss.Left) + + if renderer.border { style = style. + BorderStyle(lipgloss.ThickBorder()). BorderLeft(true). BorderRight(true). - AlignHorizontal(align). BorderLeftForeground(borderColor). BorderLeftBackground(t.Background()). BorderRightForeground(t.BackgroundPanel()). BorderRightBackground(t.Background()) - case lipgloss.Right: - style = style. - BorderRight(true). - BorderLeft(true). - AlignHorizontal(align). - BorderRightForeground(borderColor). - BorderRightBackground(t.Background()). - BorderLeftForeground(t.BackgroundPanel()). - BorderLeftBackground(t.Background()) } - if renderer.fullWidth { - style = style.Width(layout.Current.Container.Width) - } content = style.Render(content) content = lipgloss.PlaceHorizontal( - layout.Current.Container.Width, - align, + width, + lipgloss.Left, content, styles.WhitespaceStyle(t.Background()), ) content = lipgloss.PlaceHorizontal( layout.Current.Viewport.Width, - lipgloss.Center, + align, content, styles.WhitespaceStyle(t.Background()), ) @@ -196,24 +186,19 @@ func renderContentBlock(content string, options ...renderingOption) string { content = content + "\n" } } - return content } -func calculatePadding() int { - if layout.Current.Viewport.Width < 80 { - return 5 - } else if layout.Current.Viewport.Width < 120 { - return 15 - } else { - return 20 - } -} - -func renderText(message opencode.Message, text string, author string) string { +func renderText( + message opencode.Message, + text string, + author string, + showToolDetails bool, + width int, + align lipgloss.Position, + toolCalls ...opencode.ToolInvocationPart, +) string { t := theme.CurrentTheme() - width := layout.Current.Container.Width - padding := calculatePadding() timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM") if time.Now().Format("02 Jan 2006") == timestamp[:11] { @@ -222,175 +207,120 @@ func renderText(message opencode.Message, text string, author string) string { } info := fmt.Sprintf("%s (%s)", author, timestamp) - textWidth := max(lipgloss.Width(text), lipgloss.Width(info)) - markdownWidth := min(textWidth, width-padding-4) // -4 for the border and padding - if message.Role == opencode.MessageRoleAssistant { - markdownWidth = width - padding - 4 - 3 - } - minWidth := max(markdownWidth, (width-4)/2) messageStyle := styles.NewStyle(). - Width(minWidth). Background(t.BackgroundPanel()). Foreground(t.Text()) - if textWidth < minWidth { - messageStyle = messageStyle.AlignHorizontal(lipgloss.Right) + if message.Role == opencode.MessageRoleUser { + messageStyle = messageStyle.Width(width - 6) } + content := messageStyle.Render(text) if message.Role == opencode.MessageRoleAssistant { - content = toMarkdown(text, markdownWidth, t.BackgroundPanel()) + content = toMarkdown(text, width, t.BackgroundPanel()) + } + + 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] + } + style := styles.NewStyle() + if _, ok := metadata.ExtraFields["error"]; ok { + style = style.Foreground(t.Error()) + } + title = style.Render(title) + title = "∟ " + title + "\n" + content = content + title + } } + content = strings.Join([]string{content, info}, "\n") switch message.Role { case opencode.MessageRoleUser: - return renderContentBlock(content, - WithAlign(lipgloss.Right), + return renderContentBlock( + content, + width, + align, WithBorderColor(t.Secondary()), ) case opencode.MessageRoleAssistant: - return renderContentBlock(content, - WithAlign(lipgloss.Left), + return renderContentBlock( + content, + width, + align, WithBorderColor(t.Accent()), ) } return "" } -func renderToolInvocation( +func renderToolDetails( toolCall opencode.ToolInvocationPart, - result *string, - metadata opencode.MessageMetadataTool, - showDetails bool, - isLast bool, - contentOnly bool, messageMetadata opencode.MessageMetadata, + width int, + align lipgloss.Position, ) string { ignoredTools := []string{"todoread"} if slices.Contains(ignoredTools, toolCall.ToolInvocation.ToolName) { return "" } - outerWidth := layout.Current.Container.Width - innerWidth := outerWidth - 6 - paddingTop := 0 - paddingBottom := 0 - if showDetails { - paddingTop = 1 - if result == nil || *result == "" { - paddingBottom = 1 - } + toolCallID := toolCall.ToolInvocation.ToolCallID + metadata := opencode.MessageMetadataTool{} + if _, ok := messageMetadata.Tool[toolCallID]; ok { + metadata = messageMetadata.Tool[toolCallID] } - t := theme.CurrentTheme() - style := styles.NewStyle(). - Foreground(t.TextMuted()). - Background(t.BackgroundPanel()). - Width(outerWidth). - PaddingTop(paddingTop). - PaddingBottom(paddingBottom). - PaddingLeft(2). - PaddingRight(2). - BorderLeft(true). - BorderRight(true). - BorderBackground(t.Background()). - BorderForeground(t.BackgroundPanel()). - BorderStyle(lipgloss.ThickBorder()) + var result *string + if toolCall.ToolInvocation.Result != "" { + result = &toolCall.ToolInvocation.Result + } if toolCall.ToolInvocation.State == "partial-call" { - title := renderToolAction(toolCall.ToolInvocation.ToolName) - if !showDetails { - title = "∟ " + title - padding := calculatePadding() - style := styles.NewStyle(). - Background(t.BackgroundPanel()). - Width(outerWidth - padding - 4 - 3) - return renderContentBlock(style.Render(title), - WithAlign(lipgloss.Left), - WithBorderColor(t.Accent()), - WithPaddingTop(0), - WithPaddingBottom(1), - ) - } - - style = style.Foreground(t.TextMuted()) - return style.Render(title) + title := renderToolTitle(toolCall, messageMetadata, width) + return renderContentBlock(title, width, align) } - toolArgs := "" toolArgsMap := make(map[string]any) if toolCall.ToolInvocation.Args != nil { value := toolCall.ToolInvocation.Args if m, ok := value.(map[string]any); ok { toolArgsMap = m - keys := make([]string, 0, len(toolArgsMap)) for key := range toolArgsMap { keys = append(keys, key) } slices.Sort(keys) - firstKey := "" - if len(keys) > 0 { - firstKey = keys[0] - } - - toolArgs = renderArgs(&toolArgsMap, firstKey) } } body := "" - error := "" finished := result != nil && *result != "" + t := theme.CurrentTheme() - er := messageMetadata.Error.AsUnion() - switch er.(type) { - case nil: - default: - clientError := er.(opencode.UnknownError) - error = clientError.Data.Message - } - - if error != "" { - style = style.BorderLeftForeground(t.Error()) - error = styles.NewStyle(). - Foreground(t.Error()). - Background(t.BackgroundPanel()). - Render(error) - error = renderContentBlock( - error, - WithFullWidth(), - WithBorderColor(t.Error()), - WithMarginBottom(1), - ) - } - - title := "" switch toolCall.ToolInvocation.ToolName { case "read": - toolArgs = renderArgs(&toolArgsMap, "filePath") - title = fmt.Sprintf("READ %s", toolArgs) preview := metadata.ExtraFields["preview"] if preview != nil && toolArgsMap["filePath"] != nil { filename := toolArgsMap["filePath"].(string) body = preview.(string) - body = renderFile(filename, body, WithTruncate(6)) + body = renderFile(filename, body, width, WithTruncate(6)) } case "edit": if filename, ok := toolArgsMap["filePath"].(string); ok { - title = fmt.Sprintf("EDIT %s", relative(filename)) diffField := metadata.ExtraFields["diff"] if diffField != nil { patch := diffField.(string) var formattedDiff string - if layout.Current.Viewport.Width < 80 { - formattedDiff, _ = diff.FormatUnifiedDiff( - filename, - patch, - diff.WithWidth(layout.Current.Container.Width-2), - ) - } else { - diffWidth := min(layout.Current.Viewport.Width-2, 120) - formattedDiff, _ = diff.FormatDiff(filename, patch, diff.WithTotalWidth(diffWidth)) - } + formattedDiff, _ = diff.FormatUnifiedDiff( + filename, + patch, + diff.WithWidth(width-2), + ) formattedDiff = strings.TrimSpace(formattedDiff) formattedDiff = styles.NewStyle(). BorderStyle(lipgloss.ThickBorder()). @@ -400,67 +330,51 @@ func renderToolInvocation( BorderRight(true). Render(formattedDiff) - if showDetails { - style = style.Width(lipgloss.Width(formattedDiff)) - title += "\n" - } - body = strings.TrimSpace(formattedDiff) - body = lipgloss.Place( - layout.Current.Viewport.Width, - lipgloss.Height(body)+1, - lipgloss.Center, - lipgloss.Top, + body = renderContentBlock( body, - styles.WhitespaceStyle(t.Background()), + width, + align, + WithNoBorder(), + WithPadding(0), ) - // Add diagnostics at the bottom if they exist - if diagnostics := renderDiagnostics(messageMetadata, filename); diagnostics != "" { - body += "\n" + renderContentBlock(diagnostics, WithFullWidth(), WithBorderColor(t.Error())) + if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" { + body += "\n" + renderContentBlock(diagnostics, width, align) } + + title := renderToolTitle(toolCall, messageMetadata, width) + title = renderContentBlock(title, width, align) + content := title + "\n" + body + return content } } case "write": if filename, ok := toolArgsMap["filePath"].(string); ok { - title = fmt.Sprintf("WRITE %s", relative(filename)) if content, ok := toolArgsMap["content"].(string); ok { - body = renderFile(filename, content) - - // Add diagnostics at the bottom if they exist - if diagnostics := renderDiagnostics(messageMetadata, filename); diagnostics != "" { - body += "\n" + renderContentBlock(diagnostics, WithFullWidth(), WithBorderColor(t.Error())) + body = renderFile(filename, content, width) + if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" { + body += "\n\n" + diagnostics } } } case "bash": - if description, ok := toolArgsMap["description"].(string); ok { - title = fmt.Sprintf("SHELL %s", description) - } stdout := metadata.JSON.ExtraFields["stdout"] if !stdout.IsNull() { command := toolArgsMap["command"].(string) stdout := stdout.Raw() body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout) - body = toMarkdown(body, innerWidth, t.BackgroundPanel()) - body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1)) + body = toMarkdown(body, width, t.BackgroundPanel()) } case "webfetch": - toolArgs = renderArgs(&toolArgsMap, "url") - title = fmt.Sprintf("FETCH %s", toolArgs) - if format, ok := toolArgsMap["format"].(string); ok { - if result != nil { - body = *result - body = truncateHeight(body, 10) - if format == "html" || format == "markdown" { - body = toMarkdown(body, innerWidth, t.BackgroundPanel()) - } - body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1)) + if format, ok := toolArgsMap["format"].(string); ok && result != nil { + body = *result + body = truncateHeight(body, 10) + if format == "html" || format == "markdown" { + body = toMarkdown(body, width, t.BackgroundPanel()) } } case "todowrite": - title = fmt.Sprintf("PLAN") - todos := metadata.JSON.ExtraFields["todos"] if !todos.IsNull() && finished { strTodos := todos.Raw() @@ -476,120 +390,168 @@ func renderToolInvocation( body += fmt.Sprintf("- [ ] %s\n", content) } } - body = toMarkdown(body, innerWidth, t.BackgroundPanel()) - body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1)) + body = toMarkdown(body, width, t.BackgroundPanel()) } case "task": - if description, ok := toolArgsMap["description"].(string); ok { - title = fmt.Sprintf("TASK %s", description) - 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 := renderToolInvocation( - toolCall, - nil, - toolMetadata, - false, - false, - true, - messageMetadata, - ) - steps = append(steps, step) - } + 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) + step = "∟ " + step + steps = append(steps, step) } } - body = strings.Join(steps, "\n") - body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1)) } + body = strings.Join(steps, "\n") } - default: - toolName := renderToolName(toolCall.ToolInvocation.ToolName) - title = fmt.Sprintf("%s %s", toolName, toolArgs) if result == nil { empty := "" result = &empty } body = *result body = truncateHeight(body, 10) - body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1)) } - if contentOnly { - title = "∟ " + title - return title + error := "" + if err, ok := metadata.ExtraFields["error"].(bool); ok && err { + if message, ok := metadata.ExtraFields["message"].(string); ok { + error = message + } } - if !showDetails { - title = "∟ " + title - padding := calculatePadding() - style := styles.NewStyle().Background(t.BackgroundPanel()).Width(outerWidth - padding - 4 - 3) - paddingBottom := 0 - if isLast { - paddingBottom = 1 - } - return renderContentBlock(style.Render(title), - WithAlign(lipgloss.Left), - WithBorderColor(t.Accent()), - WithPaddingTop(0), - WithPaddingBottom(paddingBottom), - ) + if error != "" { + body = styles.NewStyle(). + Foreground(t.Error()). + Background(t.BackgroundPanel()). + Render(error) } if body == "" && error == "" && result != nil { body = *result body = truncateHeight(body, 10) - body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1)) } - content := style.Render(title) - content = lipgloss.PlaceHorizontal( - layout.Current.Viewport.Width, - lipgloss.Center, - content, - styles.WhitespaceStyle(t.Background()), - ) - if showDetails && body != "" && error == "" { - content += "\n" + body - } - if showDetails && error != "" { - content += "\n" + error - } - return content + title := renderToolTitle(toolCall, messageMetadata, width) + content := title + "\n\n" + body + return renderContentBlock(content, width, align) } func renderToolName(name string) string { switch name { - case "list": - return "LIST" case "webfetch": - return "FETCH" - case "todowrite": - return "PLAN" + return "Fetch" + case "todowrite", "todoread": + return "Plan" default: normalizedName := name if strings.HasPrefix(name, "opencode_") { normalizedName = strings.TrimPrefix(name, "opencode_") } - return cases.Upper(language.Und).String(normalizedName) + return cases.Title(language.Und).String(normalizedName) } } +func renderToolTitle( + toolCall opencode.ToolInvocationPart, + messageMetadata opencode.MessageMetadata, + width int, +) string { + // TODO: handle truncate to width + + if toolCall.ToolInvocation.State == "partial-call" { + return renderToolAction(toolCall.ToolInvocation.ToolName) + } + + toolArgs := "" + toolArgsMap := make(map[string]any) + if toolCall.ToolInvocation.Args != nil { + value := toolCall.ToolInvocation.Args + if m, ok := value.(map[string]any); ok { + toolArgsMap = m + + keys := make([]string, 0, len(toolArgsMap)) + for key := range toolArgsMap { + keys = append(keys, key) + } + slices.Sort(keys) + firstKey := "" + if len(keys) > 0 { + firstKey = keys[0] + } + + toolArgs = renderArgs(&toolArgsMap, firstKey) + } + } + + title := renderToolName(toolCall.ToolInvocation.ToolName) + switch toolCall.ToolInvocation.ToolName { + case "read": + toolArgs = renderArgs(&toolArgsMap, "filePath") + title = fmt.Sprintf("%s %s", title, toolArgs) + case "edit", "write": + if filename, ok := toolArgsMap["filePath"].(string); ok { + title = fmt.Sprintf("%s %s", title, relative(filename)) + } + case "bash", "task": + if description, ok := toolArgsMap["description"].(string); ok { + title = fmt.Sprintf("%s %s", title, description) + } + case "webfetch": + toolArgs = renderArgs(&toolArgsMap, "url") + title = fmt.Sprintf("%s %s", title, toolArgs) + case "todowrite", "todoread": + // title is just the tool name + default: + toolName := renderToolName(toolCall.ToolInvocation.ToolName) + title = fmt.Sprintf("%s %s", toolName, toolArgs) + } + return title +} + +func renderToolAction(name string) string { + switch name { + case "task": + return "Searching..." + case "bash": + return "Writing command..." + case "edit": + return "Preparing edit..." + case "webfetch": + return "Fetching from the web..." + case "glob": + return "Finding files..." + case "grep": + return "Searching content..." + case "list": + return "Listing directory..." + case "read": + return "Reading file..." + case "write": + return "Preparing write..." + case "todowrite", "todoread": + return "Planning..." + case "patch": + return "Preparing patch..." + } + return "Working..." +} + type fileRenderer struct { filename string content string @@ -604,7 +566,11 @@ func WithTruncate(height int) fileRenderingOption { } } -func renderFile(filename string, content string, options ...fileRenderingOption) string { +func renderFile( + filename string, + content string, + width int, + options ...fileRenderingOption) string { t := theme.CurrentTheme() renderer := &fileRenderer{ filename: filename, @@ -622,44 +588,12 @@ func renderFile(filename string, content string, options ...fileRenderingOption) } content = strings.Join(lines, "\n") - width := layout.Current.Container.Width - 8 if renderer.height > 0 { content = truncateHeight(content, renderer.height) } content = fmt.Sprintf("```%s\n%s\n```", extension(renderer.filename), content) content = toMarkdown(content, width, t.BackgroundPanel()) - - return renderContentBlock(content, WithFullWidth(), WithMarginBottom(1)) -} - -func renderToolAction(name string) string { - switch name { - case "task": - return "Searching..." - case "bash": - return "Building command..." - case "edit": - return "Preparing edit..." - case "webfetch": - return "Fetching from the web..." - case "glob": - return "Finding files..." - case "grep": - return "Searching content..." - case "list": - return "Listing directory..." - case "read": - return "Reading file..." - case "write": - return "Preparing write..." - case "todowrite", "todoread": - return "Planning..." - case "patch": - return "Preparing patch..." - case "batch": - return "Running batch operations..." - } - return "Working..." + return content } func renderArgs(args *map[string]any, titleKey string) string { @@ -704,6 +638,7 @@ func truncateHeight(content string, height int) string { } func relative(path string) string { + path = strings.TrimPrefix(path, app.CwdPath+"/") return strings.TrimPrefix(path, app.RootPath+"/") } @@ -730,64 +665,59 @@ type Diagnostic struct { } // renderDiagnostics formats LSP diagnostics for display in the TUI -func renderDiagnostics(metadata opencode.MessageMetadata, filePath string) string { - diagnosticsData := metadata.JSON.ExtraFields["diagnostics"] - if diagnosticsData.IsNull() { - return "" - } - - // diagnosticsData should be a map[string][]Diagnostic - strDiagnosticsData := diagnosticsData.Raw() - diagnosticsMap := gjson.Parse(strDiagnosticsData).Value().(map[string]any) - fileDiagnostics, ok := diagnosticsMap[filePath] - if !ok { - return "" - } - - diagnosticsList, ok := fileDiagnostics.([]any) - if !ok { - return "" - } - - var errorDiagnostics []string - for _, diagInterface := range diagnosticsList { - diagMap, ok := diagInterface.(map[string]any) - if !ok { - continue - } - - // Parse the diagnostic - var diag Diagnostic - diagBytes, err := json.Marshal(diagMap) - if err != nil { - continue - } - if err := json.Unmarshal(diagBytes, &diag); err != nil { - continue - } - - // Only show error diagnostics (severity === 1) - if diag.Severity != 1 { - continue +func renderDiagnostics(metadata opencode.MessageMetadataTool, filePath string) string { + if diagnosticsData, ok := metadata.ExtraFields["diagnostics"].(map[string]any); ok { + if fileDiagnostics, ok := diagnosticsData[filePath].([]any); ok { + var errorDiagnostics []string + for _, diagInterface := range fileDiagnostics { + diagMap, ok := diagInterface.(map[string]any) + if !ok { + continue + } + // Parse the diagnostic + var diag Diagnostic + diagBytes, err := json.Marshal(diagMap) + if err != nil { + continue + } + if err := json.Unmarshal(diagBytes, &diag); err != nil { + continue + } + // Only show error diagnostics (severity === 1) + if diag.Severity != 1 { + continue + } + line := diag.Range.Start.Line + 1 // 1-based + column := diag.Range.Start.Character + 1 // 1-based + errorDiagnostics = append(errorDiagnostics, fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message)) + } + if len(errorDiagnostics) == 0 { + return "" + } + t := theme.CurrentTheme() + var result strings.Builder + for _, diagnostic := range errorDiagnostics { + if result.Len() > 0 { + result.WriteString("\n") + } + result.WriteString(styles.NewStyle().Foreground(t.Error()).Render(diagnostic)) + } + return result.String() } - - line := diag.Range.Start.Line + 1 // 1-based - column := diag.Range.Start.Character + 1 // 1-based - errorDiagnostics = append(errorDiagnostics, fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message)) - } - - if len(errorDiagnostics) == 0 { - return "" } + return "" - t := theme.CurrentTheme() - var result strings.Builder - for _, diagnostic := range errorDiagnostics { - if result.Len() > 0 { - result.WriteString("\n") - } - result.WriteString(styles.NewStyle().Foreground(t.Error()).Render(diagnostic)) - } + // diagnosticsData should be a map[string][]Diagnostic + // strDiagnosticsData := diagnosticsData.Raw() + // diagnosticsMap := gjson.Parse(strDiagnosticsData).Value().(map[string]any) + // fileDiagnostics, ok := diagnosticsMap[filePath] + // if !ok { + // return "" + // } + + // diagnosticsList, ok := fileDiagnostics.([]any) + // if !ok { + // return "" + // } - return result.String() } diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index 356eba9f6..0a1aaa8f8 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -1,7 +1,6 @@ package chat import ( - "slices" "strings" "time" @@ -107,16 +106,6 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -type blockType int - -const ( - none blockType = iota - userTextBlock - assistantTextBlock - toolInvocationBlock - errorBlock -) - func (m *messagesComponent) renderView() { if m.width == 0 { return @@ -127,128 +116,147 @@ func (m *messagesComponent) renderView() { t := theme.CurrentTheme() blocks := make([]string, 0) - previousBlockType := none + + align := lipgloss.Center + width := layout.Current.Container.Width for _, message := range m.app.Messages { var content string var cached bool - lastToolIndex := 0 - lastToolIndices := []int{} - for i, p := range message.Parts { - switch p.Type { - case opencode.MessagePartTypeText: - lastToolIndices = append(lastToolIndices, lastToolIndex) - case opencode.MessagePartTypeToolInvocation: - lastToolIndex = i - } - } - author := "" switch message.Role { case opencode.MessageRoleUser: - author = m.app.Info.User - case opencode.MessageRoleAssistant: - author = message.Metadata.Assistant.ModelID - } - - for i, p := range message.Parts { - switch part := p.AsUnion().(type) { - // case client.MessagePartStepStart: - // messages = append(messages, "") - case opencode.TextPart: - key := m.cache.GenerateKey(message.ID, p.Text, layout.Current.Viewport.Width) - content, cached = m.cache.Get(key) - if !cached { - content = renderText(message, p.Text, author) - m.cache.Set(key, content) - } - if previousBlockType != none { - blocks = append(blocks, "") - } - blocks = append(blocks, content) - if message.Role == opencode.MessageRoleUser { - previousBlockType = userTextBlock - } else if message.Role == opencode.MessageRoleAssistant { - previousBlockType = assistantTextBlock - } - case opencode.ToolInvocationPart: - isLastToolInvocation := slices.Contains(lastToolIndices, i) - metadata := opencode.MessageMetadataTool{} - - toolCallID := part.ToolInvocation.ToolCallID - // var toolCallID string - // var result *string - // switch toolCall := part.ToolInvocation.AsUnion().(type) { - // case opencode.ToolCall: - // toolCallID = toolCall.ToolCallID - // case opencode.ToolPartialCall: - // toolCallID = toolCall.ToolCallID - // case opencode.ToolResult: - // toolCallID = toolCall.ToolCallID - // result = &toolCall.Result - // } - - if _, ok := message.Metadata.Tool[toolCallID]; ok { - metadata = message.Metadata.Tool[toolCallID] - } - - var result *string - if part.ToolInvocation.Result != "" { - result = &part.ToolInvocation.Result - } - - if part.ToolInvocation.State == "result" { - key := m.cache.GenerateKey(message.ID, - part.ToolInvocation.ToolCallID, - m.showToolDetails, - layout.Current.Viewport.Width, - ) + for _, part := range message.Parts { + switch part := part.AsUnion().(type) { + case opencode.TextPart: + key := m.cache.GenerateKey(message.ID, part.Text, layout.Current.Viewport.Width) content, cached = m.cache.Get(key) if !cached { - content = renderToolInvocation( - part, - result, - metadata, + content = renderText( + message, + part.Text, + m.app.Info.User, m.showToolDetails, - isLastToolInvocation, - false, - message.Metadata, + width, + align, ) m.cache.Set(key, content) } - } else { - // if the tool call isn't finished, don't cache - content = renderToolInvocation( - part, - result, - metadata, - m.showToolDetails, - isLastToolInvocation, - false, - message.Metadata, - ) + if content != "" { + blocks = append(blocks, content) + } } + } + + case opencode.MessageRoleAssistant: + for i, p := range message.Parts { + switch part := p.AsUnion().(type) { + case opencode.TextPart: + finished := message.Metadata.Time.Completed > 0 + remainingParts := message.Parts[i+1:] + toolCallParts := make([]opencode.ToolInvocationPart, 0) + for _, part := range remainingParts { + switch part := part.AsUnion().(type) { + case opencode.TextPart: + // we only want tool calls associated with the current text part. + // if we hit another text part, we're done. + break + case opencode.ToolInvocationPart: + toolCallParts = append(toolCallParts, part) + if part.ToolInvocation.State != "result" { + // 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 previousBlockType != toolInvocationBlock && m.showToolDetails { - blocks = append(blocks, "") + if finished { + key := m.cache.GenerateKey(message.ID, p.Text, layout.Current.Viewport.Width, m.showToolDetails) + content, cached = m.cache.Get(key) + if !cached { + content = renderText( + message, + p.Text, + message.Metadata.Assistant.ModelID, + m.showToolDetails, + width, + align, + toolCallParts..., + ) + m.cache.Set(key, content) + } + } else { + content = renderText( + message, + p.Text, + message.Metadata.Assistant.ModelID, + m.showToolDetails, + width, + align, + toolCallParts..., + ) + } + if content != "" { + blocks = append(blocks, content) + } + case opencode.ToolInvocationPart: + if !m.showToolDetails { + continue + } + + if part.ToolInvocation.State == "result" { + key := m.cache.GenerateKey(message.ID, + part.ToolInvocation.ToolCallID, + m.showToolDetails, + layout.Current.Viewport.Width, + ) + content, cached = m.cache.Get(key) + if !cached { + content = renderToolDetails( + part, + message.Metadata, + width, + align, + ) + m.cache.Set(key, content) + } + } else { + // if the tool call isn't finished, don't cache + content = renderToolDetails( + part, + message.Metadata, + width, + align, + ) + } + if content != "" { + blocks = append(blocks, content) + } } - blocks = append(blocks, content) - previousBlockType = toolInvocationBlock } + } error := "" switch err := message.Metadata.Error.AsUnion().(type) { case nil: - default: - clientError := err.(opencode.UnknownError) - error = clientError.Data.Message + case opencode.MessageMetadataErrorMessageOutputLengthError: + error = "Message output length exceeded" + case opencode.ProviderAuthError: + error = err.Data.Message + case opencode.UnknownError: + error = err.Data.Message } if error != "" { - error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithMarginTop(1), WithMarginBottom(1)) + error = renderContentBlock( + error, + width, + align, + WithBorderColor(t.Error()), + ) blocks = append(blocks, error) - previousBlockType = errorBlock } } @@ -257,7 +265,7 @@ func (m *messagesComponent) renderView() { centered = append(centered, lipgloss.PlaceHorizontal( m.width, lipgloss.Center, - block, + block+"\n", styles.WhitespaceStyle(t.Background()), )) } diff --git a/packages/tui/internal/util/util.go b/packages/tui/internal/util/util.go index da12cc5b3..c7898acb5 100644 --- a/packages/tui/internal/util/util.go +++ b/packages/tui/internal/util/util.go @@ -42,6 +42,6 @@ func Measure(tag string) func(...any) { startTime := time.Now() return func(tags ...any) { args := append([]any{"timeTakenMs", time.Since(startTime).Milliseconds()}, tags...) - slog.Info(tag, args...) + slog.Debug(tag, args...) } } -- cgit v1.2.3