tool_message.go

  1package chat
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"strings"
  7
  8	"github.com/charmbracelet/lipgloss/v2"
  9	"github.com/charmbracelet/x/ansi"
 10	"github.com/opencode-ai/opencode/internal/config"
 11	"github.com/opencode-ai/opencode/internal/diff"
 12	"github.com/opencode-ai/opencode/internal/highlight"
 13	"github.com/opencode-ai/opencode/internal/llm/agent"
 14	"github.com/opencode-ai/opencode/internal/llm/tools"
 15	"github.com/opencode-ai/opencode/internal/tui/styles"
 16	"github.com/opencode-ai/opencode/internal/tui/theme"
 17)
 18
 19const responseContextHeight = 10
 20
 21func (m *messageCmp) renderUnfinishedToolCall() string {
 22	toolName := m.toolName()
 23	toolAction := m.getToolAction()
 24	return fmt.Sprintf("%s: %s", toolName, toolAction)
 25}
 26
 27func (m *messageCmp) renderToolError() string {
 28	t := theme.CurrentTheme()
 29	baseStyle := styles.BaseStyle()
 30	err := strings.ReplaceAll(m.toolResult.Content, "\n", " ")
 31	err = fmt.Sprintf("Error: %s", err)
 32	return baseStyle.Foreground(t.Error()).Render(m.fit(err))
 33}
 34
 35func (m *messageCmp) renderBashTool() string {
 36	name := m.toolName()
 37	prefix := fmt.Sprintf("%s: ", name)
 38	var params tools.BashParams
 39	json.Unmarshal([]byte(m.toolCall.Input), &params)
 40	command := strings.ReplaceAll(params.Command, "\n", " ")
 41	header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), command)
 42
 43	if result, ok := m.toolResultErrorOrMissing(header); ok {
 44		return result
 45	}
 46	return m.renderTool(header, m.renderPlainContent(m.toolResult.Content))
 47}
 48
 49func (m *messageCmp) renderViewTool() string {
 50	name := m.toolName()
 51	prefix := fmt.Sprintf("%s: ", name)
 52	var params tools.ViewParams
 53	json.Unmarshal([]byte(m.toolCall.Input), &params)
 54	filePath := removeWorkingDirPrefix(params.FilePath)
 55	toolParams := []string{
 56		filePath,
 57	}
 58	if params.Limit != 0 {
 59		toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
 60	}
 61	if params.Offset != 0 {
 62		toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
 63	}
 64	header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), toolParams...)
 65
 66	if result, ok := m.toolResultErrorOrMissing(header); ok {
 67		return result
 68	}
 69
 70	metadata := tools.ViewResponseMetadata{}
 71	json.Unmarshal([]byte(m.toolResult.Metadata), &metadata)
 72
 73	return m.renderTool(header, m.renderCodeContent(metadata.FilePath, metadata.Content, params.Offset))
 74}
 75
 76func (m *messageCmp) renderCodeContent(path, content string, offset int) string {
 77	t := theme.CurrentTheme()
 78	originalHeight := lipgloss.Height(content)
 79	fileContent := truncateHeight(content, responseContextHeight)
 80
 81	highlighted, _ := highlight.SyntaxHighlight(fileContent, path, t.BackgroundSecondary())
 82
 83	lines := strings.Split(highlighted, "\n")
 84
 85	if originalHeight > responseContextHeight {
 86		lines = append(lines,
 87			lipgloss.NewStyle().Background(t.BackgroundSecondary()).
 88				Foreground(t.TextMuted()).
 89				Render(
 90					fmt.Sprintf("... (%d lines)", originalHeight-responseContextHeight),
 91				),
 92		)
 93	}
 94	for i, line := range lines {
 95		lineNumber := lipgloss.NewStyle().
 96			PaddingLeft(4).
 97			PaddingRight(2).
 98			Background(t.BackgroundSecondary()).
 99			Foreground(t.TextMuted()).
100			Render(fmt.Sprintf("%d", i+1+offset))
101		formattedLine := lipgloss.NewStyle().
102			Width(m.textWidth() - lipgloss.Width(lineNumber)).
103			Background(t.BackgroundSecondary()).Render(line)
104		lines[i] = lipgloss.JoinHorizontal(lipgloss.Left, lineNumber, formattedLine)
105	}
106	return lipgloss.NewStyle().Render(
107		lipgloss.JoinVertical(
108			lipgloss.Left,
109			lines...,
110		),
111	)
112}
113
114func (m *messageCmp) renderPlainContent(content string) string {
115	t := theme.CurrentTheme()
116	content = strings.TrimSuffix(content, "\n")
117	content = strings.TrimPrefix(content, "\n")
118	lines := strings.Split(fmt.Sprintf("\n%s\n", content), "\n")
119
120	for i, line := range lines {
121		line = " " + line // add padding
122		if len(line) > m.textWidth() {
123			line = m.fit(line)
124		}
125		lines[i] = lipgloss.NewStyle().
126			Width(m.textWidth()).
127			Background(t.BackgroundSecondary()).
128			Foreground(t.TextMuted()).
129			Render(line)
130	}
131	if len(lines) > responseContextHeight {
132		lines = lines[:responseContextHeight]
133		lines = append(lines,
134			lipgloss.NewStyle().Background(t.BackgroundSecondary()).
135				Foreground(t.TextMuted()).
136				Render(
137					fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight),
138				),
139		)
140	}
141	return strings.Join(lines, "\n")
142}
143
144func (m *messageCmp) renderGenericTool() string {
145	// Tool params
146	name := m.toolName()
147	prefix := fmt.Sprintf("%s: ", name)
148	input := strings.ReplaceAll(m.toolCall.Input, "\n", " ")
149	params := renderParams(m.textWidth()-lipgloss.Width(prefix), input)
150	header := prefix + params
151
152	if result, ok := m.toolResultErrorOrMissing(header); ok {
153		return result
154	}
155	return m.renderTool(header, m.renderPlainContent(m.toolResult.Content))
156}
157
158func (m *messageCmp) renderEditTool() string {
159	// Tool params
160	name := m.toolName()
161	prefix := fmt.Sprintf("%s: ", name)
162	var params tools.EditParams
163	json.Unmarshal([]byte(m.toolCall.Input), &params)
164	filePath := removeWorkingDirPrefix(params.FilePath)
165	header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), filePath)
166
167	if result, ok := m.toolResultErrorOrMissing(header); ok {
168		return result
169	}
170	metadata := tools.EditResponseMetadata{}
171	json.Unmarshal([]byte(m.toolResult.Metadata), &metadata)
172	truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
173	formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(m.textWidth()))
174	return m.renderTool(header, formattedDiff)
175}
176
177func (m *messageCmp) renderWriteTool() string {
178	// Tool params
179	name := m.toolName()
180	prefix := fmt.Sprintf("%s: ", name)
181	var params tools.WriteParams
182	json.Unmarshal([]byte(m.toolCall.Input), &params)
183	filePath := removeWorkingDirPrefix(params.FilePath)
184	header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), filePath)
185	if result, ok := m.toolResultErrorOrMissing(header); ok {
186		return result
187	}
188	return m.renderTool(header, m.renderCodeContent(filePath, params.Content, 0))
189}
190
191func (m *messageCmp) renderToolCallMessage() string {
192	if !m.toolCall.Finished && !m.cancelledToolCall {
193		return m.renderUnfinishedToolCall()
194	}
195	content := ""
196	switch m.toolCall.Name {
197	case tools.ViewToolName:
198		content = m.renderViewTool()
199	case tools.BashToolName:
200		content = m.renderBashTool()
201	case tools.EditToolName:
202		content = m.renderEditTool()
203	case tools.WriteToolName:
204		content = m.renderWriteTool()
205	default:
206		content = m.renderGenericTool()
207	}
208	return m.style().PaddingLeft(1).Render(content)
209}
210
211func (m *messageCmp) toolResultErrorOrMissing(header string) (string, bool) {
212	result := "Waiting for tool to finish..."
213	if m.toolResult.IsError {
214		result = m.renderToolError()
215		return lipgloss.JoinVertical(
216			lipgloss.Left,
217			header,
218			result,
219		), true
220	} else if m.cancelledToolCall {
221		result = "Cancelled"
222		return lipgloss.JoinVertical(
223			lipgloss.Left,
224			header,
225			result,
226		), true
227	} else if m.toolResult.ToolCallID == "" {
228		return lipgloss.JoinVertical(
229			lipgloss.Left,
230			header,
231			result,
232		), true
233	}
234
235	return "", false
236}
237
238func (m *messageCmp) renderTool(header, result string) string {
239	return lipgloss.JoinVertical(
240		lipgloss.Left,
241		header,
242		"",
243		result,
244		"",
245	)
246}
247
248func removeWorkingDirPrefix(path string) string {
249	wd := config.WorkingDirectory()
250	path = strings.TrimPrefix(path, wd)
251	return path
252}
253
254func truncateHeight(content string, height int) string {
255	lines := strings.Split(content, "\n")
256	if len(lines) > height {
257		return strings.Join(lines[:height], "\n")
258	}
259	return content
260}
261
262func (m *messageCmp) fit(content string) string {
263	return ansi.Truncate(content, m.textWidth(), "...")
264}
265
266func (m *messageCmp) toolName() string {
267	switch m.toolCall.Name {
268	case agent.AgentToolName:
269		return "Task"
270	case tools.BashToolName:
271		return "Bash"
272	case tools.EditToolName:
273		return "Edit"
274	case tools.FetchToolName:
275		return "Fetch"
276	case tools.GlobToolName:
277		return "Glob"
278	case tools.GrepToolName:
279		return "Grep"
280	case tools.LSToolName:
281		return "List"
282	case tools.SourcegraphToolName:
283		return "Sourcegraph"
284	case tools.ViewToolName:
285		return "View"
286	case tools.WriteToolName:
287		return "Write"
288	case tools.PatchToolName:
289		return "Patch"
290	default:
291		return m.toolCall.Name
292	}
293}
294
295func (m *messageCmp) getToolAction() string {
296	switch m.toolCall.Name {
297	case agent.AgentToolName:
298		return "Preparing prompt..."
299	case tools.BashToolName:
300		return "Building command..."
301	case tools.EditToolName:
302		return "Preparing edit..."
303	case tools.FetchToolName:
304		return "Writing fetch..."
305	case tools.GlobToolName:
306		return "Finding files..."
307	case tools.GrepToolName:
308		return "Searching content..."
309	case tools.LSToolName:
310		return "Listing directory..."
311	case tools.SourcegraphToolName:
312		return "Searching code..."
313	case tools.ViewToolName:
314		return "Reading file..."
315	case tools.WriteToolName:
316		return "Preparing write..."
317	case tools.PatchToolName:
318		return "Preparing patch..."
319	default:
320		return "Working..."
321	}
322}
323
324// renders params, params[0] (params[1]=params[2] ....)
325func renderParams(paramsWidth int, params ...string) string {
326	if len(params) == 0 {
327		return ""
328	}
329	mainParam := params[0]
330	if len(mainParam) > paramsWidth {
331		mainParam = mainParam[:paramsWidth-3] + "..."
332	}
333
334	if len(params) == 1 {
335		return mainParam
336	}
337	otherParams := params[1:]
338	// create pairs of key/value
339	// if odd number of params, the last one is a key without value
340	if len(otherParams)%2 != 0 {
341		otherParams = append(otherParams, "")
342	}
343	parts := make([]string, 0, len(otherParams)/2)
344	for i := 0; i < len(otherParams); i += 2 {
345		key := otherParams[i]
346		value := otherParams[i+1]
347		if value == "" {
348			continue
349		}
350		parts = append(parts, fmt.Sprintf("%s=%s", key, value))
351	}
352
353	partsRendered := strings.Join(parts, ", ")
354	remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
355	if remainingWidth < 30 {
356		// No space for the params, just show the main
357		return mainParam
358	}
359
360	if len(parts) > 0 {
361		mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
362	}
363
364	return ansi.Truncate(mainParam, paramsWidth, "...")
365}