refactor tool rendering

Kujtim Hoxha created

Change summary

internal/tui/components/anim/anim.go              | 254 +++++++
internal/tui/components/chat/editor.go            |   1 
internal/tui/components/chat/list_v2.go           |  38 
internal/tui/components/chat/message.go           |  57 +
internal/tui/components/chat/messages/messages.go |  41 -
internal/tui/components/chat/messages/renderer.go | 555 +++++++++++++++++
internal/tui/components/chat/messages/tool.go     | 155 ++++
internal/tui/components/chat/tool_message.go      | 365 -----------
internal/tui/components/core/list/list.go         |   2 
internal/tui/page/chat.go                         |   2 
internal/tui/theme/manager.go                     |   6 
internal/tui/tui.go                               |  11 
12 files changed, 1,056 insertions(+), 431 deletions(-)

Detailed changes

internal/tui/components/anim/anim.go πŸ”—

@@ -0,0 +1,254 @@
+package anim
+
+import (
+	"image/color"
+	"math/rand"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/bubbles/v2/spinner"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/lucasb-eyer/go-colorful"
+	"github.com/opencode-ai/opencode/internal/tui/styles"
+	"github.com/opencode-ai/opencode/internal/tui/theme"
+	"github.com/opencode-ai/opencode/internal/tui/util"
+)
+
+const (
+	charCyclingFPS  = time.Second / 22
+	colorCycleFPS   = time.Second / 5
+	maxCyclingChars = 120
+)
+
+var charRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_")
+
+type charState int
+
+const (
+	charInitialState charState = iota
+	charCyclingState
+	charEndOfLifeState
+)
+
+// cyclingChar is a single animated character.
+type cyclingChar struct {
+	finalValue   rune // if < 0 cycle forever
+	currentValue rune
+	initialDelay time.Duration
+	lifetime     time.Duration
+}
+
+func (c cyclingChar) randomRune() rune {
+	return (charRunes)[rand.Intn(len(charRunes))] //nolint:gosec
+}
+
+func (c cyclingChar) state(start time.Time) charState {
+	now := time.Now()
+	if now.Before(start.Add(c.initialDelay)) {
+		return charInitialState
+	}
+	if c.finalValue > 0 && now.After(start.Add(c.initialDelay)) {
+		return charEndOfLifeState
+	}
+	return charCyclingState
+}
+
+type StepCharsMsg struct{}
+
+func stepChars() tea.Cmd {
+	return tea.Tick(charCyclingFPS, func(time.Time) tea.Msg {
+		return StepCharsMsg{}
+	})
+}
+
+type ColorCycleMsg struct{}
+
+func cycleColors() tea.Cmd {
+	return tea.Tick(colorCycleFPS, func(time.Time) tea.Msg {
+		return ColorCycleMsg{}
+	})
+}
+
+// anim is the model that manages the animation that displays while the
+// output is being generated.
+type anim struct {
+	start           time.Time
+	cyclingChars    []cyclingChar
+	labelChars      []cyclingChar
+	ramp            []lipgloss.Style
+	label           []rune
+	ellipsis        spinner.Model
+	ellipsisStarted bool
+}
+
+func New(cyclingCharsSize uint, label string) util.Model {
+	// #nosec G115
+	n := min(int(cyclingCharsSize), maxCyclingChars)
+
+	gap := " "
+	if n == 0 {
+		gap = ""
+	}
+
+	c := anim{
+		start:    time.Now(),
+		label:    []rune(gap + label),
+		ellipsis: spinner.New(spinner.WithSpinner(spinner.Ellipsis)),
+	}
+
+	// If we're in truecolor mode (and there are enough cycling characters)
+	// color the cycling characters with a gradient ramp.
+	const minRampSize = 3
+	if n >= minRampSize {
+		// Note: double capacity for color cycling as we'll need to reverse and
+		// append the ramp for seamless transitions.
+		c.ramp = make([]lipgloss.Style, n, n*2) //nolint:mnd
+		ramp := makeGradientRamp(n)
+		for i, color := range ramp {
+			c.ramp[i] = lipgloss.NewStyle().Foreground(color)
+		}
+		c.ramp = append(c.ramp, reverse(c.ramp)...) // reverse and append for color cycling
+	}
+
+	makeDelay := func(a int32, b time.Duration) time.Duration {
+		return time.Duration(rand.Int31n(a)) * (time.Millisecond * b) //nolint:gosec
+	}
+
+	makeInitialDelay := func() time.Duration {
+		return makeDelay(8, 60) //nolint:mnd
+	}
+
+	// Initial characters that cycle forever.
+	c.cyclingChars = make([]cyclingChar, n)
+
+	for i := range n {
+		c.cyclingChars[i] = cyclingChar{
+			finalValue:   -1, // cycle forever
+			initialDelay: makeInitialDelay(),
+		}
+	}
+
+	// Label text that only cycles for a little while.
+	c.labelChars = make([]cyclingChar, len(c.label))
+
+	for i, r := range c.label {
+		c.labelChars[i] = cyclingChar{
+			finalValue:   r,
+			initialDelay: makeInitialDelay(),
+			lifetime:     makeDelay(5, 180), //nolint:mnd
+		}
+	}
+
+	return c
+}
+
+// Init initializes the animation.
+func (anim) Init() tea.Cmd {
+	return tea.Batch(stepChars(), cycleColors())
+}
+
+// Update handles messages.
+func (a anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmd tea.Cmd
+	switch msg.(type) {
+	case StepCharsMsg:
+		a.updateChars(&a.cyclingChars)
+		a.updateChars(&a.labelChars)
+
+		if !a.ellipsisStarted {
+			var eol int
+			for _, c := range a.labelChars {
+				if c.state(a.start) == charEndOfLifeState {
+					eol++
+				}
+			}
+			if eol == len(a.label) {
+				// If our entire label has reached end of life, start the
+				// ellipsis "spinner" after a short pause.
+				a.ellipsisStarted = true
+				cmd = tea.Tick(time.Millisecond*220, func(time.Time) tea.Msg { //nolint:mnd
+					return a.ellipsis.Tick()
+				})
+			}
+		}
+
+		return a, tea.Batch(stepChars(), cmd)
+	case ColorCycleMsg:
+		const minColorCycleSize = 2
+		if len(a.ramp) < minColorCycleSize {
+			return a, nil
+		}
+		a.ramp = append(a.ramp[1:], a.ramp[0])
+		return a, cycleColors()
+	case spinner.TickMsg:
+		var cmd tea.Cmd
+		a.ellipsis, cmd = a.ellipsis.Update(msg)
+		return a, cmd
+	default:
+		return a, nil
+	}
+}
+
+func (a *anim) updateChars(chars *[]cyclingChar) {
+	for i, c := range *chars {
+		switch c.state(a.start) {
+		case charInitialState:
+			(*chars)[i].currentValue = '.'
+		case charCyclingState:
+			(*chars)[i].currentValue = c.randomRune()
+		case charEndOfLifeState:
+			(*chars)[i].currentValue = c.finalValue
+		}
+	}
+}
+
+// View renders the animation.
+func (a anim) View() string {
+	t := theme.CurrentTheme()
+	var b strings.Builder
+
+	for i, c := range a.cyclingChars {
+		if len(a.ramp) > i {
+			b.WriteString(a.ramp[i].Render(string(c.currentValue)))
+			continue
+		}
+		b.WriteRune(c.currentValue)
+	}
+
+	textStyle := styles.BaseStyle().
+		Foreground(t.Text())
+
+	for _, c := range a.labelChars {
+		b.WriteString(
+			textStyle.Render(string(c.currentValue)),
+		)
+	}
+
+	return b.String() + textStyle.Render(a.ellipsis.View())
+}
+
+func makeGradientRamp(length int) []color.Color {
+	t := theme.CurrentTheme()
+	startColor := theme.GetColor(t.Primary())
+	endColor := theme.GetColor(t.Secondary())
+	var (
+		c        = make([]color.Color, length)
+		start, _ = colorful.Hex(startColor)
+		end, _   = colorful.Hex(endColor)
+	)
+	for i := range length {
+		step := start.BlendLuv(end, float64(i)/float64(length))
+		c[i] = lipgloss.Color(step.Hex())
+	}
+	return c
+}
+
+func reverse[T any](in []T) []T {
+	out := make([]T, len(in))
+	copy(out, in[:])
+	for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
+		out[i], out[j] = out[j], out[i]
+	}
+	return out
+}

internal/tui/components/chat/editor.go πŸ”—

@@ -242,7 +242,6 @@ func (m *editorCmp) SetSize(width, height int) tea.Cmd {
 	m.height = height
 	m.textarea.SetWidth(width - 3) // account for the prompt and padding right
 	m.textarea.SetHeight(height)
-	m.textarea.SetWidth(width)
 	return nil
 }
 

internal/tui/components/chat/list_v2.go πŸ”—

@@ -5,9 +5,11 @@ import (
 	"time"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/opencode-ai/opencode/internal/app"
 	"github.com/opencode-ai/opencode/internal/message"
 	"github.com/opencode-ai/opencode/internal/session"
+	"github.com/opencode-ai/opencode/internal/tui/components/chat/messages"
 	"github.com/opencode-ai/opencode/internal/tui/components/core/list"
 	"github.com/opencode-ai/opencode/internal/tui/components/dialog"
 	"github.com/opencode-ai/opencode/internal/tui/layout"
@@ -52,12 +54,17 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return m, cmd
 		}
 		return m, nil
+	default:
+		var cmds []tea.Cmd
+		u, cmd := m.listCmp.Update(msg)
+		m.listCmp = u.(list.ListModel)
+		cmds = append(cmds, cmd)
+		return m, tea.Batch(cmds...)
 	}
-	return m, nil
 }
 
 func (m *messageListCmp) View() string {
-	return m.listCmp.View()
+	return lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View())
 }
 
 // GetSize implements MessageListCmp.
@@ -68,8 +75,8 @@ func (m *messageListCmp) GetSize() (int, int) {
 // SetSize implements MessageListCmp.
 func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
 	m.width = width
-	m.height = height
-	return m.listCmp.SetSize(width, height)
+	m.height = height - 1
+	return m.listCmp.SetSize(width, height-1)
 }
 
 func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
@@ -77,41 +84,38 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
 		return nil
 	}
 	m.session = session
-	messages, err := m.app.Messages.List(context.Background(), session.ID)
+	sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
 	if err != nil {
 		return util.ReportError(err)
 	}
 	m.messages = make([]util.Model, 0)
-	lastUserMessageTime := messages[0].CreatedAt
+	lastUserMessageTime := sessionMessages[0].CreatedAt
 	toolResultMap := make(map[string]message.ToolResult)
 	// first pass to get all tool results
-	for _, msg := range messages {
+	for _, msg := range sessionMessages {
 		for _, tr := range msg.ToolResults() {
 			toolResultMap[tr.ToolCallID] = tr
 		}
 	}
-	for _, msg := range messages {
-		// TODO: handle tool calls and others here
+	for _, msg := range sessionMessages {
 		switch msg.Role {
 		case message.User:
 			lastUserMessageTime = msg.CreatedAt
-			m.messages = append(m.messages, NewMessageCmp(WithMessage(msg)))
+			m.messages = append(m.messages, messages.NewMessageCmp(msg))
 		case message.Assistant:
 			// Only add assistant messages if they don't have tool calls or there is some content
 			if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
-				m.messages = append(m.messages, NewMessageCmp(WithMessage(msg), WithLastUserMessageTime(time.Unix(lastUserMessageTime, 0))))
+				m.messages = append(m.messages, messages.NewMessageCmp(msg, messages.WithLastUserMessageTime(time.Unix(lastUserMessageTime, 0))))
 			}
 			for _, tc := range msg.ToolCalls() {
-				options := []MessageOption{
-					WithToolCall(tc),
-				}
+				options := []messages.ToolCallOption{}
 				if tr, ok := toolResultMap[tc.ID]; ok {
-					options = append(options, WithToolResult(tr))
+					options = append(options, messages.WithToolCallResult(tr))
 				}
 				if msg.FinishPart().Reason == message.FinishReasonCanceled {
-					options = append(options, WithCancelledToolCall(true))
+					options = append(options, messages.WithToolCallCancelled())
 				}
-				m.messages = append(m.messages, NewMessageCmp(options...))
+				m.messages = append(m.messages, messages.NewToolCallCmp(tc, options...))
 			}
 		}
 	}

internal/tui/components/chat/message.go πŸ”—

@@ -10,6 +10,7 @@ import (
 
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
+	"github.com/opencode-ai/opencode/internal/config"
 	"github.com/opencode-ai/opencode/internal/diff"
 	"github.com/opencode-ai/opencode/internal/llm/agent"
 	"github.com/opencode-ai/opencode/internal/llm/models"
@@ -559,6 +560,62 @@ func renderToolMessage(
 	return toolMsg
 }
 
+func removeWorkingDirPrefix(path string) string {
+	wd := config.WorkingDirectory()
+	path = strings.TrimPrefix(path, wd)
+	return path
+}
+
+func truncateHeight(content string, height int) string {
+	lines := strings.Split(content, "\n")
+	if len(lines) > height {
+		return strings.Join(lines[:height], "\n")
+	}
+	return content
+}
+
+func renderParams(paramsWidth int, params ...string) string {
+	if len(params) == 0 {
+		return ""
+	}
+	mainParam := params[0]
+	if len(mainParam) > paramsWidth {
+		mainParam = mainParam[:paramsWidth-3] + "..."
+	}
+
+	if len(params) == 1 {
+		return mainParam
+	}
+	otherParams := params[1:]
+	// create pairs of key/value
+	// if odd number of params, the last one is a key without value
+	if len(otherParams)%2 != 0 {
+		otherParams = append(otherParams, "")
+	}
+	parts := make([]string, 0, len(otherParams)/2)
+	for i := 0; i < len(otherParams); i += 2 {
+		key := otherParams[i]
+		value := otherParams[i+1]
+		if value == "" {
+			continue
+		}
+		parts = append(parts, fmt.Sprintf("%s=%s", key, value))
+	}
+
+	partsRendered := strings.Join(parts, ", ")
+	remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
+	if remainingWidth < 30 {
+		// No space for the params, just show the main
+		return mainParam
+	}
+
+	if len(parts) > 0 {
+		mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
+	}
+
+	return ansi.Truncate(mainParam, paramsWidth, "...")
+}
+
 // Helper function to format the time difference between two Unix timestamps
 func formatTimestampDiff(start, end int64) string {
 	diffSeconds := float64(end-start) / 1000.0 // Convert to seconds

internal/tui/components/chat/message_v2.go β†’ internal/tui/components/chat/messages/messages.go πŸ”—

@@ -1,4 +1,4 @@
-package chat
+package messages
 
 import (
 	"fmt"
@@ -31,11 +31,6 @@ type messageCmp struct {
 	// Used for agent and user messages
 	message             message.Message
 	lastUserMessageTime time.Time
-
-	// Used for tool calls
-	toolCall          message.ToolCall
-	toolResult        message.ToolResult
-	cancelledToolCall bool
 }
 
 type MessageOption func(*messageCmp)
@@ -46,32 +41,10 @@ func WithLastUserMessageTime(t time.Time) MessageOption {
 	}
 }
 
-func WithToolCall(tc message.ToolCall) MessageOption {
-	return func(m *messageCmp) {
-		m.toolCall = tc
+func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
+	m := &messageCmp{
+		message: msg,
 	}
-}
-
-func WithToolResult(tr message.ToolResult) MessageOption {
-	return func(m *messageCmp) {
-		m.toolResult = tr
-	}
-}
-
-func WithMessage(msg message.Message) MessageOption {
-	return func(m *messageCmp) {
-		m.message = msg
-	}
-}
-
-func WithCancelledToolCall(cancelled bool) MessageOption {
-	return func(m *messageCmp) {
-		m.cancelledToolCall = cancelled
-	}
-}
-
-func NewMessageCmp(opts ...MessageOption) MessageCmp {
-	m := &messageCmp{}
 	for _, opt := range opts {
 		opt(m)
 	}
@@ -95,17 +68,11 @@ func (m *messageCmp) View() string {
 		default:
 			return m.renderAssistantMessage()
 		}
-	} else if m.toolCall.ID != "" {
-		// this is a tool call message
-		return m.renderToolCallMessage()
 	}
 	return "Unknown Message"
 }
 
 func (m *messageCmp) textWidth() int {
-	if m.toolCall.ID != "" {
-		return m.width - 2 // take into account the border and PaddingLeft
-	}
 	return m.width - 1 // take into account the border
 }
 

internal/tui/components/chat/messages/renderer.go πŸ”—

@@ -0,0 +1,555 @@
+package messages
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/opencode-ai/opencode/internal/config"
+	"github.com/opencode-ai/opencode/internal/diff"
+	"github.com/opencode-ai/opencode/internal/highlight"
+	"github.com/opencode-ai/opencode/internal/llm/agent"
+	"github.com/opencode-ai/opencode/internal/llm/tools"
+	"github.com/opencode-ai/opencode/internal/tui/styles"
+	"github.com/opencode-ai/opencode/internal/tui/theme"
+)
+
+const responseContextHeight = 10
+
+type renderer interface {
+	// Render returns the complete (already styled) tool‑call view, not
+	// including the outer border.
+	Render(v *toolCallCmp) string
+}
+
+type rendererFactory func() renderer
+
+type renderRegistry map[string]rendererFactory
+
+func (rr renderRegistry) register(name string, f rendererFactory) { rr[name] = f }
+func (rr renderRegistry) lookup(name string) renderer {
+	if f, ok := rr[name]; ok {
+		return f()
+	}
+	return genericRenderer{} // sensible fallback
+}
+
+var registry = renderRegistry{}
+
+// Registger tool renderers
+func init() {
+	registry.register(tools.BashToolName, func() renderer { return bashRenderer{} })
+	registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} })
+	registry.register(tools.EditToolName, func() renderer { return editRenderer{} })
+	registry.register(tools.WriteToolName, func() renderer { return writeRenderer{} })
+	registry.register(tools.FetchToolName, func() renderer { return fetchRenderer{} })
+	registry.register(tools.GlobToolName, func() renderer { return globRenderer{} })
+	registry.register(tools.GrepToolName, func() renderer { return grepRenderer{} })
+	registry.register(tools.LSToolName, func() renderer { return lsRenderer{} })
+	registry.register(tools.SourcegraphToolName, func() renderer { return sourcegraphRenderer{} })
+	registry.register(tools.PatchToolName, func() renderer { return patchRenderer{} })
+	registry.register(tools.DiagnosticsToolName, func() renderer { return diagnosticsRenderer{} })
+}
+
+// -----------------------------------------------------------------------------
+//  Generic renderer
+// -----------------------------------------------------------------------------
+
+type genericRenderer struct{}
+
+func (genericRenderer) Render(v *toolCallCmp) string {
+	header := makeHeader(prettifyToolName(v.call.Name), v.textWidth(), v.call.Input)
+	if res, done := earlyState(header, v); done {
+		return res
+	}
+	body := renderPlainContent(v, v.result.Content)
+	return joinHeaderBody(header, body)
+}
+
+// -----------------------------------------------------------------------------
+//  Bash renderer
+// -----------------------------------------------------------------------------
+
+type bashRenderer struct{}
+
+func (bashRenderer) Render(v *toolCallCmp) string {
+	var p tools.BashParams
+	_ = json.Unmarshal([]byte(v.call.Input), &p)
+
+	cmd := strings.ReplaceAll(p.Command, "\n", " ")
+	header := makeHeader("Bash", v.textWidth(), cmd)
+	if res, done := earlyState(header, v); done {
+		return res
+	}
+	body := renderPlainContent(v, v.result.Content)
+	return joinHeaderBody(header, body)
+}
+
+// -----------------------------------------------------------------------------
+//  View renderer
+// -----------------------------------------------------------------------------
+
+type viewRenderer struct{}
+
+func (viewRenderer) Render(v *toolCallCmp) string {
+	var params tools.ViewParams
+	_ = json.Unmarshal([]byte(v.call.Input), &params)
+
+	file := removeWorkingDirPrefix(params.FilePath)
+	args := []string{file}
+	if params.Limit != 0 {
+		args = append(args, "limit", fmt.Sprintf("%d", params.Limit))
+	}
+	if params.Offset != 0 {
+		args = append(args, "offset", fmt.Sprintf("%d", params.Offset))
+	}
+
+	header := makeHeader("View", v.textWidth(), args...)
+	if res, done := earlyState(header, v); done {
+		return res
+	}
+
+	var meta tools.ViewResponseMetadata
+	_ = json.Unmarshal([]byte(v.result.Metadata), &meta)
+
+	body := renderCodeContent(v, meta.FilePath, meta.Content, params.Offset)
+	return joinHeaderBody(header, body)
+}
+
+// -----------------------------------------------------------------------------
+//  Edit renderer
+// -----------------------------------------------------------------------------
+
+type editRenderer struct{}
+
+func (editRenderer) Render(v *toolCallCmp) string {
+	var params tools.EditParams
+	_ = json.Unmarshal([]byte(v.call.Input), &params)
+
+	file := removeWorkingDirPrefix(params.FilePath)
+	header := makeHeader("Edit", v.textWidth(), file)
+	if res, done := earlyState(header, v); done {
+		return res
+	}
+
+	var meta tools.EditResponseMetadata
+	_ = json.Unmarshal([]byte(v.result.Metadata), &meta)
+
+	trunc := truncateHeight(meta.Diff, responseContextHeight)
+	diffView, _ := diff.FormatDiff(trunc, diff.WithTotalWidth(v.textWidth()))
+	return joinHeaderBody(header, diffView)
+}
+
+// -----------------------------------------------------------------------------
+//  Write renderer
+// -----------------------------------------------------------------------------
+
+type writeRenderer struct{}
+
+func (writeRenderer) Render(v *toolCallCmp) string {
+	var params tools.WriteParams
+	_ = json.Unmarshal([]byte(v.call.Input), &params)
+
+	file := removeWorkingDirPrefix(params.FilePath)
+	header := makeHeader("Write", v.textWidth(), file)
+	if res, done := earlyState(header, v); done {
+		return res
+	}
+
+	body := renderCodeContent(v, file, params.Content, 0)
+	return joinHeaderBody(header, body)
+}
+
+// -----------------------------------------------------------------------------
+//  Fetch renderer
+// -----------------------------------------------------------------------------
+
+type fetchRenderer struct{}
+
+func (fetchRenderer) Render(v *toolCallCmp) string {
+	var params tools.FetchParams
+	_ = json.Unmarshal([]byte(v.call.Input), &params)
+
+	args := []string{params.URL}
+	if params.Format != "" {
+		args = append(args, "format", params.Format)
+	}
+	if params.Timeout != 0 {
+		args = append(args, "timeout", (time.Duration(params.Timeout) * time.Second).String())
+	}
+
+	header := makeHeader("Fetch", v.textWidth(), args...)
+	if res, done := earlyState(header, v); done {
+		return res
+	}
+
+	file := "fetch.md"
+	switch params.Format {
+	case "text":
+		file = "fetch.txt"
+	case "html":
+		file = "fetch.html"
+	}
+
+	body := renderCodeContent(v, file, v.result.Content, 0)
+	return joinHeaderBody(header, body)
+}
+
+// -----------------------------------------------------------------------------
+//  Glob renderer
+// -----------------------------------------------------------------------------
+
+type globRenderer struct{}
+
+func (globRenderer) Render(v *toolCallCmp) string {
+	var params tools.GlobParams
+	_ = json.Unmarshal([]byte(v.call.Input), &params)
+
+	args := []string{params.Pattern}
+	if params.Path != "" {
+		args = append(args, "path", params.Path)
+	}
+
+	header := makeHeader("Glob", v.textWidth(), args...)
+	if res, done := earlyState(header, v); done {
+		return res
+	}
+
+	body := renderPlainContent(v, v.result.Content)
+	return joinHeaderBody(header, body)
+}
+
+// -----------------------------------------------------------------------------
+//  Grep renderer
+// -----------------------------------------------------------------------------
+
+type grepRenderer struct{}
+
+func (grepRenderer) Render(v *toolCallCmp) string {
+	var params tools.GrepParams
+	_ = json.Unmarshal([]byte(v.call.Input), &params)
+
+	args := []string{params.Pattern}
+	if params.Path != "" {
+		args = append(args, "path", params.Path)
+	}
+	if params.Include != "" {
+		args = append(args, "include", params.Include)
+	}
+	if params.LiteralText {
+		args = append(args, "literal", "true")
+	}
+
+	header := makeHeader("Grep", v.textWidth(), args...)
+	if res, done := earlyState(header, v); done {
+		return res
+	}
+
+	body := renderPlainContent(v, v.result.Content)
+	return joinHeaderBody(header, body)
+}
+
+// -----------------------------------------------------------------------------
+//  LS renderer
+// -----------------------------------------------------------------------------
+
+type lsRenderer struct{}
+
+func (lsRenderer) Render(v *toolCallCmp) string {
+	var params tools.LSParams
+	_ = json.Unmarshal([]byte(v.call.Input), &params)
+
+	path := params.Path
+	if path == "" {
+		path = "."
+	}
+
+	header := makeHeader("List", v.textWidth(), path)
+	if res, done := earlyState(header, v); done {
+		return res
+	}
+
+	body := renderPlainContent(v, v.result.Content)
+	return joinHeaderBody(header, body)
+}
+
+// -----------------------------------------------------------------------------
+//  Sourcegraph renderer
+// -----------------------------------------------------------------------------
+
+type sourcegraphRenderer struct{}
+
+func (sourcegraphRenderer) Render(v *toolCallCmp) string {
+	var params tools.SourcegraphParams
+	_ = json.Unmarshal([]byte(v.call.Input), &params)
+
+	args := []string{params.Query}
+	if params.Count != 0 {
+		args = append(args, "count", fmt.Sprintf("%d", params.Count))
+	}
+	if params.ContextWindow != 0 {
+		args = append(args, "context", fmt.Sprintf("%d", params.ContextWindow))
+	}
+
+	header := makeHeader("Sourcegraph", v.textWidth(), args...)
+	if res, done := earlyState(header, v); done {
+		return res
+	}
+
+	body := renderPlainContent(v, v.result.Content)
+	return joinHeaderBody(header, body)
+}
+
+// -----------------------------------------------------------------------------
+//  Patch renderer
+// -----------------------------------------------------------------------------
+
+type patchRenderer struct{}
+
+func (patchRenderer) Render(v *toolCallCmp) string {
+	var params tools.PatchParams
+	_ = json.Unmarshal([]byte(v.call.Input), &params)
+
+	header := makeHeader("Patch", v.textWidth(), "multiple files")
+	if res, done := earlyState(header, v); done {
+		return res
+	}
+
+	var meta tools.PatchResponseMetadata
+	_ = json.Unmarshal([]byte(v.result.Metadata), &meta)
+
+	// Format the result as a summary of changes
+	summary := fmt.Sprintf("Changed %d files (%d+ %d-)",
+		len(meta.FilesChanged), meta.Additions, meta.Removals)
+
+	// List the changed files
+	filesList := strings.Join(meta.FilesChanged, "\n")
+
+	body := renderPlainContent(v, summary+"\n\n"+filesList)
+	return joinHeaderBody(header, body)
+}
+
+// -----------------------------------------------------------------------------
+//  Diagnostics renderer
+// -----------------------------------------------------------------------------
+
+type diagnosticsRenderer struct{}
+
+func (diagnosticsRenderer) Render(v *toolCallCmp) string {
+	header := makeHeader("Diagnostics", v.textWidth(), "project")
+	if res, done := earlyState(header, v); done {
+		return res
+	}
+
+	body := renderPlainContent(v, v.result.Content)
+	return joinHeaderBody(header, body)
+}
+
+// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
+func makeHeader(tool string, width int, params ...string) string {
+	prefix := tool + ": "
+	return prefix + renderParams(width-lipgloss.Width(prefix), params...)
+}
+
+// renders params, params[0] (params[1]=params[2] ....)
+func renderParams(paramsWidth int, params ...string) string {
+	if len(params) == 0 {
+		return ""
+	}
+	mainParam := params[0]
+	if len(mainParam) > paramsWidth {
+		mainParam = mainParam[:paramsWidth-3] + "..."
+	}
+
+	if len(params) == 1 {
+		return mainParam
+	}
+	otherParams := params[1:]
+	// create pairs of key/value
+	// if odd number of params, the last one is a key without value
+	if len(otherParams)%2 != 0 {
+		otherParams = append(otherParams, "")
+	}
+	parts := make([]string, 0, len(otherParams)/2)
+	for i := 0; i < len(otherParams); i += 2 {
+		key := otherParams[i]
+		value := otherParams[i+1]
+		if value == "" {
+			continue
+		}
+		parts = append(parts, fmt.Sprintf("%s=%s", key, value))
+	}
+
+	partsRendered := strings.Join(parts, ", ")
+	remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
+	if remainingWidth < 30 {
+		// No space for the params, just show the main
+		return mainParam
+	}
+
+	if len(parts) > 0 {
+		mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
+	}
+
+	return ansi.Truncate(mainParam, paramsWidth, "...")
+}
+
+// earlyState returns immediately‑rendered error/cancelled/ongoing states.
+func earlyState(header string, v *toolCallCmp) (string, bool) {
+	switch {
+	case v.result.IsError:
+		return lipgloss.JoinVertical(lipgloss.Left, header, v.renderToolError()), true
+	case v.cancelled:
+		return lipgloss.JoinVertical(lipgloss.Left, header, "Cancelled"), true
+	case v.result.ToolCallID == "":
+		return lipgloss.JoinVertical(lipgloss.Left, header, "Waiting for tool to finish..."), true
+	default:
+		return "", false
+	}
+}
+
+func joinHeaderBody(header, body string) string {
+	return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "")
+}
+
+func renderPlainContent(v *toolCallCmp, content string) string {
+	t := theme.CurrentTheme()
+	content = strings.TrimSpace(content)
+	lines := strings.Split(content, "\n")
+
+	var out []string
+	for i, ln := range lines {
+		if i >= responseContextHeight {
+			break
+		}
+		ln = " " + ln // left padding
+		if len(ln) > v.textWidth() {
+			ln = v.fit(ln, v.textWidth())
+		}
+		out = append(out, lipgloss.NewStyle().
+			Width(v.textWidth()).
+			Background(t.BackgroundSecondary()).
+			Foreground(t.TextMuted()).
+			Render(ln))
+	}
+
+	if len(lines) > responseContextHeight {
+		out = append(out, lipgloss.NewStyle().
+			Background(t.BackgroundSecondary()).
+			Foreground(t.TextMuted()).
+			Render(fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight)))
+	}
+	return strings.Join(out, "\n")
+}
+
+func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
+	t := theme.CurrentTheme()
+	truncated := truncateHeight(content, responseContextHeight)
+
+	highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BackgroundSecondary())
+	lines := strings.Split(highlighted, "\n")
+
+	if len(strings.Split(content, "\n")) > responseContextHeight {
+		lines = append(lines, lipgloss.NewStyle().
+			Background(t.BackgroundSecondary()).
+			Foreground(t.TextMuted()).
+			Render(fmt.Sprintf("... (%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
+	}
+
+	for i, ln := range lines {
+		num := lipgloss.NewStyle().
+			PaddingLeft(4).PaddingRight(2).
+			Background(t.BackgroundSecondary()).
+			Foreground(t.TextMuted()).
+			Render(fmt.Sprintf("%d", i+1+offset))
+		w := v.textWidth() - lipgloss.Width(num)
+		lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
+			num,
+			lipgloss.NewStyle().
+				Width(w).
+				Background(t.BackgroundSecondary()).
+				Render(v.fit(ln, w)))
+	}
+	return lipgloss.JoinVertical(lipgloss.Left, lines...)
+}
+
+func (v *toolCallCmp) renderToolError() string {
+	t := theme.CurrentTheme()
+	err := strings.ReplaceAll(v.result.Content, "\n", " ")
+	err = fmt.Sprintf("Error: %s", err)
+	return styles.BaseStyle().Foreground(t.Error()).Render(v.fit(err, v.textWidth()))
+}
+
+func removeWorkingDirPrefix(path string) string {
+	wd := config.WorkingDirectory()
+	return strings.TrimPrefix(path, wd)
+}
+
+func truncateHeight(s string, h int) string {
+	lines := strings.Split(s, "\n")
+	if len(lines) > h {
+		return strings.Join(lines[:h], "\n")
+	}
+	return s
+}
+
+func prettifyToolName(name string) string {
+	switch name {
+	case agent.AgentToolName:
+		return "Task"
+	case tools.BashToolName:
+		return "Bash"
+	case tools.EditToolName:
+		return "Edit"
+	case tools.FetchToolName:
+		return "Fetch"
+	case tools.GlobToolName:
+		return "Glob"
+	case tools.GrepToolName:
+		return "Grep"
+	case tools.LSToolName:
+		return "List"
+	case tools.SourcegraphToolName:
+		return "Sourcegraph"
+	case tools.ViewToolName:
+		return "View"
+	case tools.WriteToolName:
+		return "Write"
+	case tools.PatchToolName:
+		return "Patch"
+	default:
+		return name
+	}
+}
+
+func toolAction(name string) string {
+	switch name {
+	case agent.AgentToolName:
+		return "Preparing prompt..."
+	case tools.BashToolName:
+		return "Building command..."
+	case tools.EditToolName:
+		return "Preparing edit..."
+	case tools.FetchToolName:
+		return "Writing fetch..."
+	case tools.GlobToolName:
+		return "Finding files..."
+	case tools.GrepToolName:
+		return "Searching content..."
+	case tools.LSToolName:
+		return "Listing directory..."
+	case tools.SourcegraphToolName:
+		return "Searching code..."
+	case tools.ViewToolName:
+		return "Reading file..."
+	case tools.WriteToolName:
+		return "Preparing write..."
+	case tools.PatchToolName:
+		return "Preparing patch..."
+	default:
+		return "Working..."
+	}
+}

internal/tui/components/chat/messages/tool.go πŸ”—

@@ -0,0 +1,155 @@
+package messages
+
+import (
+	"fmt"
+
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/opencode-ai/opencode/internal/llm/agent"
+	"github.com/opencode-ai/opencode/internal/llm/tools"
+	"github.com/opencode-ai/opencode/internal/message"
+	"github.com/opencode-ai/opencode/internal/tui/layout"
+	"github.com/opencode-ai/opencode/internal/tui/styles"
+	"github.com/opencode-ai/opencode/internal/tui/theme"
+	"github.com/opencode-ai/opencode/internal/tui/util"
+)
+
+type ToolCallCmp interface {
+	util.Model
+	layout.Sizeable
+	layout.Focusable
+}
+
+type toolCallCmp struct {
+	width   int
+	focused bool
+
+	call      message.ToolCall
+	result    message.ToolResult
+	cancelled bool
+}
+
+type ToolCallOption func(*toolCallCmp)
+
+func WithToolCallCancelled() ToolCallOption {
+	return func(m *toolCallCmp) {
+		m.cancelled = true
+	}
+}
+
+func WithToolCallResult(result message.ToolResult) ToolCallOption {
+	return func(m *toolCallCmp) {
+		m.result = result
+	}
+}
+
+func NewToolCallCmp(tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
+	m := &toolCallCmp{
+		call: tc,
+	}
+	for _, opt := range opts {
+		opt(m)
+	}
+	return m
+}
+
+func (m *toolCallCmp) Init() tea.Cmd {
+	return nil
+}
+
+func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	return m, nil
+}
+
+func (m *toolCallCmp) View() string {
+	box := m.style()
+
+	if !m.call.Finished && !m.cancelled {
+		return box.PaddingLeft(1).Render(m.renderPending())
+	}
+
+	r := registry.lookup(m.call.Name)
+	return box.PaddingLeft(1).Render(r.Render(m))
+}
+
+func (v *toolCallCmp) renderPending() string {
+	return fmt.Sprintf("%s: %s", prettifyToolName(v.call.Name), toolAction(v.call.Name))
+}
+
+func (msg *toolCallCmp) style() lipgloss.Style {
+	t := theme.CurrentTheme()
+	borderStyle := lipgloss.NormalBorder()
+	if msg.focused {
+		borderStyle = lipgloss.DoubleBorder()
+	}
+	return styles.BaseStyle().
+		BorderLeft(true).
+		Foreground(t.TextMuted()).
+		BorderForeground(t.TextMuted()).
+		BorderStyle(borderStyle)
+}
+
+func (m *toolCallCmp) textWidth() int {
+	return m.width - 2 // take into account the border and PaddingLeft
+}
+
+func (m *toolCallCmp) fit(content string, width int) string {
+	t := theme.CurrentTheme()
+	lineStyle := lipgloss.NewStyle().Background(t.BackgroundSecondary()).Foreground(t.TextMuted())
+	dots := lineStyle.Render("...")
+	return ansi.Truncate(content, width, dots)
+}
+
+func (m *toolCallCmp) toolName() string {
+	switch m.call.Name {
+	case agent.AgentToolName:
+		return "Task"
+	case tools.BashToolName:
+		return "Bash"
+	case tools.EditToolName:
+		return "Edit"
+	case tools.FetchToolName:
+		return "Fetch"
+	case tools.GlobToolName:
+		return "Glob"
+	case tools.GrepToolName:
+		return "Grep"
+	case tools.LSToolName:
+		return "List"
+	case tools.SourcegraphToolName:
+		return "Sourcegraph"
+	case tools.ViewToolName:
+		return "View"
+	case tools.WriteToolName:
+		return "Write"
+	case tools.PatchToolName:
+		return "Patch"
+	default:
+		return m.call.Name
+	}
+}
+
+func (m *toolCallCmp) Blur() tea.Cmd {
+	m.focused = false
+	return nil
+}
+
+func (m *toolCallCmp) Focus() tea.Cmd {
+	m.focused = true
+	return nil
+}
+
+// IsFocused implements MessageModel.
+func (m *toolCallCmp) IsFocused() bool {
+	return m.focused
+}
+
+func (m *toolCallCmp) GetSize() (int, int) {
+	return m.width, 0
+}
+
+func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
+	m.width = width
+	return nil
+}

internal/tui/components/chat/tool_message.go πŸ”—

@@ -1,365 +0,0 @@
-package chat
-
-import (
-	"encoding/json"
-	"fmt"
-	"strings"
-
-	"github.com/charmbracelet/lipgloss/v2"
-	"github.com/charmbracelet/x/ansi"
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/diff"
-	"github.com/opencode-ai/opencode/internal/highlight"
-	"github.com/opencode-ai/opencode/internal/llm/agent"
-	"github.com/opencode-ai/opencode/internal/llm/tools"
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
-)
-
-const responseContextHeight = 10
-
-func (m *messageCmp) renderUnfinishedToolCall() string {
-	toolName := m.toolName()
-	toolAction := m.getToolAction()
-	return fmt.Sprintf("%s: %s", toolName, toolAction)
-}
-
-func (m *messageCmp) renderToolError() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-	err := strings.ReplaceAll(m.toolResult.Content, "\n", " ")
-	err = fmt.Sprintf("Error: %s", err)
-	return baseStyle.Foreground(t.Error()).Render(m.fit(err))
-}
-
-func (m *messageCmp) renderBashTool() string {
-	name := m.toolName()
-	prefix := fmt.Sprintf("%s: ", name)
-	var params tools.BashParams
-	json.Unmarshal([]byte(m.toolCall.Input), &params)
-	command := strings.ReplaceAll(params.Command, "\n", " ")
-	header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), command)
-
-	if result, ok := m.toolResultErrorOrMissing(header); ok {
-		return result
-	}
-	return m.renderTool(header, m.renderPlainContent(m.toolResult.Content))
-}
-
-func (m *messageCmp) renderViewTool() string {
-	name := m.toolName()
-	prefix := fmt.Sprintf("%s: ", name)
-	var params tools.ViewParams
-	json.Unmarshal([]byte(m.toolCall.Input), &params)
-	filePath := removeWorkingDirPrefix(params.FilePath)
-	toolParams := []string{
-		filePath,
-	}
-	if params.Limit != 0 {
-		toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
-	}
-	if params.Offset != 0 {
-		toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
-	}
-	header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), toolParams...)
-
-	if result, ok := m.toolResultErrorOrMissing(header); ok {
-		return result
-	}
-
-	metadata := tools.ViewResponseMetadata{}
-	json.Unmarshal([]byte(m.toolResult.Metadata), &metadata)
-
-	return m.renderTool(header, m.renderCodeContent(metadata.FilePath, metadata.Content, params.Offset))
-}
-
-func (m *messageCmp) renderCodeContent(path, content string, offset int) string {
-	t := theme.CurrentTheme()
-	originalHeight := lipgloss.Height(content)
-	fileContent := truncateHeight(content, responseContextHeight)
-
-	highlighted, _ := highlight.SyntaxHighlight(fileContent, path, t.BackgroundSecondary())
-
-	lines := strings.Split(highlighted, "\n")
-
-	if originalHeight > responseContextHeight {
-		lines = append(lines,
-			lipgloss.NewStyle().Background(t.BackgroundSecondary()).
-				Foreground(t.TextMuted()).
-				Render(
-					fmt.Sprintf("... (%d lines)", originalHeight-responseContextHeight),
-				),
-		)
-	}
-	for i, line := range lines {
-		lineNumber := lipgloss.NewStyle().
-			PaddingLeft(4).
-			PaddingRight(2).
-			Background(t.BackgroundSecondary()).
-			Foreground(t.TextMuted()).
-			Render(fmt.Sprintf("%d", i+1+offset))
-		formattedLine := lipgloss.NewStyle().
-			Width(m.textWidth() - lipgloss.Width(lineNumber)).
-			Background(t.BackgroundSecondary()).Render(line)
-		lines[i] = lipgloss.JoinHorizontal(lipgloss.Left, lineNumber, formattedLine)
-	}
-	return lipgloss.NewStyle().Render(
-		lipgloss.JoinVertical(
-			lipgloss.Left,
-			lines...,
-		),
-	)
-}
-
-func (m *messageCmp) renderPlainContent(content string) string {
-	t := theme.CurrentTheme()
-	content = strings.TrimSuffix(content, "\n")
-	content = strings.TrimPrefix(content, "\n")
-	lines := strings.Split(fmt.Sprintf("\n%s\n", content), "\n")
-
-	for i, line := range lines {
-		line = " " + line // add padding
-		if len(line) > m.textWidth() {
-			line = m.fit(line)
-		}
-		lines[i] = lipgloss.NewStyle().
-			Width(m.textWidth()).
-			Background(t.BackgroundSecondary()).
-			Foreground(t.TextMuted()).
-			Render(line)
-	}
-	if len(lines) > responseContextHeight {
-		lines = lines[:responseContextHeight]
-		lines = append(lines,
-			lipgloss.NewStyle().Background(t.BackgroundSecondary()).
-				Foreground(t.TextMuted()).
-				Render(
-					fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight),
-				),
-		)
-	}
-	return strings.Join(lines, "\n")
-}
-
-func (m *messageCmp) renderGenericTool() string {
-	// Tool params
-	name := m.toolName()
-	prefix := fmt.Sprintf("%s: ", name)
-	input := strings.ReplaceAll(m.toolCall.Input, "\n", " ")
-	params := renderParams(m.textWidth()-lipgloss.Width(prefix), input)
-	header := prefix + params
-
-	if result, ok := m.toolResultErrorOrMissing(header); ok {
-		return result
-	}
-	return m.renderTool(header, m.renderPlainContent(m.toolResult.Content))
-}
-
-func (m *messageCmp) renderEditTool() string {
-	// Tool params
-	name := m.toolName()
-	prefix := fmt.Sprintf("%s: ", name)
-	var params tools.EditParams
-	json.Unmarshal([]byte(m.toolCall.Input), &params)
-	filePath := removeWorkingDirPrefix(params.FilePath)
-	header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), filePath)
-
-	if result, ok := m.toolResultErrorOrMissing(header); ok {
-		return result
-	}
-	metadata := tools.EditResponseMetadata{}
-	json.Unmarshal([]byte(m.toolResult.Metadata), &metadata)
-	truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
-	formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(m.textWidth()))
-	return m.renderTool(header, formattedDiff)
-}
-
-func (m *messageCmp) renderWriteTool() string {
-	// Tool params
-	name := m.toolName()
-	prefix := fmt.Sprintf("%s: ", name)
-	var params tools.WriteParams
-	json.Unmarshal([]byte(m.toolCall.Input), &params)
-	filePath := removeWorkingDirPrefix(params.FilePath)
-	header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), filePath)
-	if result, ok := m.toolResultErrorOrMissing(header); ok {
-		return result
-	}
-	return m.renderTool(header, m.renderCodeContent(filePath, params.Content, 0))
-}
-
-func (m *messageCmp) renderToolCallMessage() string {
-	if !m.toolCall.Finished && !m.cancelledToolCall {
-		return m.renderUnfinishedToolCall()
-	}
-	content := ""
-	switch m.toolCall.Name {
-	case tools.ViewToolName:
-		content = m.renderViewTool()
-	case tools.BashToolName:
-		content = m.renderBashTool()
-	case tools.EditToolName:
-		content = m.renderEditTool()
-	case tools.WriteToolName:
-		content = m.renderWriteTool()
-	default:
-		content = m.renderGenericTool()
-	}
-	return m.style().PaddingLeft(1).Render(content)
-}
-
-func (m *messageCmp) toolResultErrorOrMissing(header string) (string, bool) {
-	result := "Waiting for tool to finish..."
-	if m.toolResult.IsError {
-		result = m.renderToolError()
-		return lipgloss.JoinVertical(
-			lipgloss.Left,
-			header,
-			result,
-		), true
-	} else if m.cancelledToolCall {
-		result = "Cancelled"
-		return lipgloss.JoinVertical(
-			lipgloss.Left,
-			header,
-			result,
-		), true
-	} else if m.toolResult.ToolCallID == "" {
-		return lipgloss.JoinVertical(
-			lipgloss.Left,
-			header,
-			result,
-		), true
-	}
-
-	return "", false
-}
-
-func (m *messageCmp) renderTool(header, result string) string {
-	return lipgloss.JoinVertical(
-		lipgloss.Left,
-		header,
-		"",
-		result,
-		"",
-	)
-}
-
-func removeWorkingDirPrefix(path string) string {
-	wd := config.WorkingDirectory()
-	path = strings.TrimPrefix(path, wd)
-	return path
-}
-
-func truncateHeight(content string, height int) string {
-	lines := strings.Split(content, "\n")
-	if len(lines) > height {
-		return strings.Join(lines[:height], "\n")
-	}
-	return content
-}
-
-func (m *messageCmp) fit(content string) string {
-	return ansi.Truncate(content, m.textWidth(), "...")
-}
-
-func (m *messageCmp) toolName() string {
-	switch m.toolCall.Name {
-	case agent.AgentToolName:
-		return "Task"
-	case tools.BashToolName:
-		return "Bash"
-	case tools.EditToolName:
-		return "Edit"
-	case tools.FetchToolName:
-		return "Fetch"
-	case tools.GlobToolName:
-		return "Glob"
-	case tools.GrepToolName:
-		return "Grep"
-	case tools.LSToolName:
-		return "List"
-	case tools.SourcegraphToolName:
-		return "Sourcegraph"
-	case tools.ViewToolName:
-		return "View"
-	case tools.WriteToolName:
-		return "Write"
-	case tools.PatchToolName:
-		return "Patch"
-	default:
-		return m.toolCall.Name
-	}
-}
-
-func (m *messageCmp) getToolAction() string {
-	switch m.toolCall.Name {
-	case agent.AgentToolName:
-		return "Preparing prompt..."
-	case tools.BashToolName:
-		return "Building command..."
-	case tools.EditToolName:
-		return "Preparing edit..."
-	case tools.FetchToolName:
-		return "Writing fetch..."
-	case tools.GlobToolName:
-		return "Finding files..."
-	case tools.GrepToolName:
-		return "Searching content..."
-	case tools.LSToolName:
-		return "Listing directory..."
-	case tools.SourcegraphToolName:
-		return "Searching code..."
-	case tools.ViewToolName:
-		return "Reading file..."
-	case tools.WriteToolName:
-		return "Preparing write..."
-	case tools.PatchToolName:
-		return "Preparing patch..."
-	default:
-		return "Working..."
-	}
-}
-
-// renders params, params[0] (params[1]=params[2] ....)
-func renderParams(paramsWidth int, params ...string) string {
-	if len(params) == 0 {
-		return ""
-	}
-	mainParam := params[0]
-	if len(mainParam) > paramsWidth {
-		mainParam = mainParam[:paramsWidth-3] + "..."
-	}
-
-	if len(params) == 1 {
-		return mainParam
-	}
-	otherParams := params[1:]
-	// create pairs of key/value
-	// if odd number of params, the last one is a key without value
-	if len(otherParams)%2 != 0 {
-		otherParams = append(otherParams, "")
-	}
-	parts := make([]string, 0, len(otherParams)/2)
-	for i := 0; i < len(otherParams); i += 2 {
-		key := otherParams[i]
-		value := otherParams[i+1]
-		if value == "" {
-			continue
-		}
-		parts = append(parts, fmt.Sprintf("%s=%s", key, value))
-	}
-
-	partsRendered := strings.Join(parts, ", ")
-	remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
-	if remainingWidth < 30 {
-		// No space for the params, just show the main
-		return mainParam
-	}
-
-	if len(parts) > 0 {
-		mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
-	}
-
-	return ansi.Truncate(mainParam, paramsWidth, "...")
-}

internal/tui/components/core/list/list.go πŸ”—

@@ -169,7 +169,7 @@ func (m *model) View() string {
 	if m.needsRerender {
 		m.renderVisible()
 	}
-	return lipgloss.NewStyle().Padding(m.padding...).Render(m.content)
+	return lipgloss.NewStyle().Padding(m.padding...).Height(m.height).Render(m.content)
 }
 
 func (m *model) renderVisibleReverse() {

internal/tui/page/chat.go πŸ”—

@@ -9,6 +9,7 @@ import (
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/opencode-ai/opencode/internal/app"
 	"github.com/opencode-ai/opencode/internal/completions"
+	"github.com/opencode-ai/opencode/internal/logging"
 	"github.com/opencode-ai/opencode/internal/message"
 	"github.com/opencode-ai/opencode/internal/session"
 	"github.com/opencode-ai/opencode/internal/tui/components/chat"
@@ -62,6 +63,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
+		logging.Info("Window size changed Chat:", "Width", msg.Width, "Height", msg.Height)
 		cmd := p.layout.SetSize(msg.Width, msg.Height)
 		cmds = append(cmds, cmd)
 	case dialog.CompletionDialogCloseMsg:

internal/tui/theme/manager.go πŸ”—

@@ -2,6 +2,7 @@ package theme
 
 import (
 	"fmt"
+	"image/color"
 	"slices"
 	"strings"
 	"sync"
@@ -74,6 +75,11 @@ func CurrentTheme() Theme {
 	return globalManager.themes[globalManager.currentName]
 }
 
+func GetColor(c color.Color) string {
+	rgba := color.RGBAModel.Convert(c).(color.RGBA)
+	return fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
+}
+
 // CurrentThemeName returns the name of the currently active theme.
 func CurrentThemeName() string {
 	globalManager.mu.RLock()

internal/tui/tui.go πŸ”—

@@ -186,6 +186,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmd tea.Cmd
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
+		logging.Info("Window size changed main: ", "Width", msg.Width, "Height", msg.Height)
 		msg.Height -= 1 // Make space for the status bar
 		a.width, a.height = msg.Width, msg.Height
 
@@ -674,15 +675,6 @@ func (a *appModel) RegisterCommand(cmd dialog.Command) {
 	a.commands = append(a.commands, cmd)
 }
 
-func (a *appModel) findCommand(id string) (dialog.Command, bool) {
-	for _, cmd := range a.commands {
-		if cmd.ID == id {
-			return cmd, true
-		}
-	}
-	return dialog.Command{}, false
-}
-
 func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
 	if a.app.CoderAgent.IsBusy() {
 		// For now we don't move to any page if the agent is busy
@@ -709,7 +701,6 @@ func (a appModel) View() string {
 	components := []string{
 		a.pages[a.currentPage].View(),
 	}
-
 	components = append(components, a.status.View())
 
 	appView := lipgloss.JoinVertical(lipgloss.Top, components...)