Просмотр исходного кода

feat: update user and agent messages width and alignment (#515)

Co-authored-by: adamdottv <[email protected]>
Timo Clasen 7 месяцев назад
Родитель
Сommit
d090c08ef0

+ 4 - 5
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<string, Tool.Info[]> = {

+ 2 - 0
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 {

+ 299 - 369
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()
 }

+ 116 - 108
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()),
 		))
 	}

+ 1 - 1
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...)
 	}
 }