on main help sign in
refactor tool rendering
commitabbbc05fd74dec055b31cb7c06dcaccbd21068d0 authorKujtim Hoxha <kujtimii.h@gmail.com> dateThu 22 May 2025, 11:23 (1 year ago) parentc891295
12 files changed+1056−431
internal/tui/components/anim/anim.go+254 internal/tui/components/chat/editor.go−1 internal/tui/components/chat/list_v2.go+21−17 internal/tui/components/chat/message.go+57 internal/tui/components/chat/message_v2.go → internal/tui/components/chat/messages/messages.go+4−37 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+1−1 internal/tui/page/chat.go+2 internal/tui/theme/manager.go+6 internal/tui/tui.go+1−10
internal/tui/components/anim/anim.gonew file+254
@@ -0,0 +1,254 @@
1+package anim
2+
3+import (
4+ "image/color"
5+ "math/rand"
6+ "strings"
7+ "time"
8+
9+ "github.com/charmbracelet/bubbles/v2/spinner"
10+ tea "github.com/charmbracelet/bubbletea/v2"
11+ "github.com/charmbracelet/lipgloss/v2"
12+ "github.com/lucasb-eyer/go-colorful"
13+ "github.com/opencode-ai/opencode/internal/tui/styles"
14+ "github.com/opencode-ai/opencode/internal/tui/theme"
15+ "github.com/opencode-ai/opencode/internal/tui/util"
16+)
17+
18+const (
19+ charCyclingFPS = time.Second / 22
20+ colorCycleFPS = time.Second / 5
21+ maxCyclingChars = 120
22+)
23+
24+var charRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_")
25+
26+type charState int
27+
28+const (
29+ charInitialState charState = iota
30+ charCyclingState
31+ charEndOfLifeState
32+)
33+
34+// cyclingChar is a single animated character.
35+type cyclingChar struct {
36+ finalValue rune // if < 0 cycle forever
37+ currentValue rune
38+ initialDelay time.Duration
39+ lifetime time.Duration
40+}
41+
42+func (c cyclingChar) randomRune() rune {
43+ return (charRunes)[rand.Intn(len(charRunes))] //nolint:gosec
44+}
45+
46+func (c cyclingChar) state(start time.Time) charState {
47+ now := time.Now()
48+ if now.Before(start.Add(c.initialDelay)) {
49+ return charInitialState
50+ }
51+ if c.finalValue > 0 && now.After(start.Add(c.initialDelay)) {
52+ return charEndOfLifeState
53+ }
54+ return charCyclingState
55+}
56+
57+type StepCharsMsg struct{}
58+
59+func stepChars() tea.Cmd {
60+ return tea.Tick(charCyclingFPS, func(time.Time) tea.Msg {
61+ return StepCharsMsg{}
62+ })
63+}
64+
65+type ColorCycleMsg struct{}
66+
67+func cycleColors() tea.Cmd {
68+ return tea.Tick(colorCycleFPS, func(time.Time) tea.Msg {
69+ return ColorCycleMsg{}
70+ })
71+}
72+
73+// anim is the model that manages the animation that displays while the
74+// output is being generated.
75+type anim struct {
76+ start time.Time
77+ cyclingChars []cyclingChar
78+ labelChars []cyclingChar
79+ ramp []lipgloss.Style
80+ label []rune
81+ ellipsis spinner.Model
82+ ellipsisStarted bool
83+}
84+
85+func New(cyclingCharsSize uint, label string) util.Model {
86+ // #nosec G115
87+ n := min(int(cyclingCharsSize), maxCyclingChars)
88+
89+ gap := " "
90+ if n == 0 {
91+ gap = ""
92+ }
93+
94+ c := anim{
95+ start: time.Now(),
96+ label: []rune(gap + label),
97+ ellipsis: spinner.New(spinner.WithSpinner(spinner.Ellipsis)),
98+ }
99+
100+ // If we're in truecolor mode (and there are enough cycling characters)
101+ // color the cycling characters with a gradient ramp.
102+ const minRampSize = 3
103+ if n >= minRampSize {
104+ // Note: double capacity for color cycling as we'll need to reverse and
105+ // append the ramp for seamless transitions.
106+ c.ramp = make([]lipgloss.Style, n, n*2) //nolint:mnd
107+ ramp := makeGradientRamp(n)
108+ for i, color := range ramp {
109+ c.ramp[i] = lipgloss.NewStyle().Foreground(color)
110+ }
111+ c.ramp = append(c.ramp, reverse(c.ramp)...) // reverse and append for color cycling
112+ }
113+
114+ makeDelay := func(a int32, b time.Duration) time.Duration {
115+ return time.Duration(rand.Int31n(a)) * (time.Millisecond * b) //nolint:gosec
116+ }
117+
118+ makeInitialDelay := func() time.Duration {
119+ return makeDelay(8, 60) //nolint:mnd
120+ }
121+
122+ // Initial characters that cycle forever.
123+ c.cyclingChars = make([]cyclingChar, n)
124+
125+ for i := range n {
126+ c.cyclingChars[i] = cyclingChar{
127+ finalValue: -1, // cycle forever
128+ initialDelay: makeInitialDelay(),
129+ }
130+ }
131+
132+ // Label text that only cycles for a little while.
133+ c.labelChars = make([]cyclingChar, len(c.label))
134+
135+ for i, r := range c.label {
136+ c.labelChars[i] = cyclingChar{
137+ finalValue: r,
138+ initialDelay: makeInitialDelay(),
139+ lifetime: makeDelay(5, 180), //nolint:mnd
140+ }
141+ }
142+
143+ return c
144+}
145+
146+// Init initializes the animation.
147+func (anim) Init() tea.Cmd {
148+ return tea.Batch(stepChars(), cycleColors())
149+}
150+
151+// Update handles messages.
152+func (a anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
153+ var cmd tea.Cmd
154+ switch msg.(type) {
155+ case StepCharsMsg:
156+ a.updateChars(&a.cyclingChars)
157+ a.updateChars(&a.labelChars)
158+
159+ if !a.ellipsisStarted {
160+ var eol int
161+ for _, c := range a.labelChars {
162+ if c.state(a.start) == charEndOfLifeState {
163+ eol++
164+ }
165+ }
166+ if eol == len(a.label) {
167+ // If our entire label has reached end of life, start the
168+ // ellipsis "spinner" after a short pause.
169+ a.ellipsisStarted = true
170+ cmd = tea.Tick(time.Millisecond*220, func(time.Time) tea.Msg { //nolint:mnd
171+ return a.ellipsis.Tick()
172+ })
173+ }
174+ }
175+
176+ return a, tea.Batch(stepChars(), cmd)
177+ case ColorCycleMsg:
178+ const minColorCycleSize = 2
179+ if len(a.ramp) < minColorCycleSize {
180+ return a, nil
181+ }
182+ a.ramp = append(a.ramp[1:], a.ramp[0])
183+ return a, cycleColors()
184+ case spinner.TickMsg:
185+ var cmd tea.Cmd
186+ a.ellipsis, cmd = a.ellipsis.Update(msg)
187+ return a, cmd
188+ default:
189+ return a, nil
190+ }
191+}
192+
193+func (a *anim) updateChars(chars *[]cyclingChar) {
194+ for i, c := range *chars {
195+ switch c.state(a.start) {
196+ case charInitialState:
197+ (*chars)[i].currentValue = '.'
198+ case charCyclingState:
199+ (*chars)[i].currentValue = c.randomRune()
200+ case charEndOfLifeState:
201+ (*chars)[i].currentValue = c.finalValue
202+ }
203+ }
204+}
205+
206+// View renders the animation.
207+func (a anim) View() string {
208+ t := theme.CurrentTheme()
209+ var b strings.Builder
210+
211+ for i, c := range a.cyclingChars {
212+ if len(a.ramp) > i {
213+ b.WriteString(a.ramp[i].Render(string(c.currentValue)))
214+ continue
215+ }
216+ b.WriteRune(c.currentValue)
217+ }
218+
219+ textStyle := styles.BaseStyle().
220+ Foreground(t.Text())
221+
222+ for _, c := range a.labelChars {
223+ b.WriteString(
224+ textStyle.Render(string(c.currentValue)),
225+ )
226+ }
227+
228+ return b.String() + textStyle.Render(a.ellipsis.View())
229+}
230+
231+func makeGradientRamp(length int) []color.Color {
232+ t := theme.CurrentTheme()
233+ startColor := theme.GetColor(t.Primary())
234+ endColor := theme.GetColor(t.Secondary())
235+ var (
236+ c = make([]color.Color, length)
237+ start, _ = colorful.Hex(startColor)
238+ end, _ = colorful.Hex(endColor)
239+ )
240+ for i := range length {
241+ step := start.BlendLuv(end, float64(i)/float64(length))
242+ c[i] = lipgloss.Color(step.Hex())
243+ }
244+ return c
245+}
246+
247+func reverse[T any](in []T) []T {
248+ out := make([]T, len(in))
249+ copy(out, in[:])
250+ for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
251+ out[i], out[j] = out[j], out[i]
252+ }
253+ return out
254+}
internal/tui/components/chat/editor.go−1
@@ -242,7 +242,6 @@ func (m *editorCmp) SetSize(width, height int) tea.Cmd {
242242 m.height = height
243243 m.textarea.SetWidth(width - 3) // account for the prompt and padding right
244244 m.textarea.SetHeight(height)
245− m.textarea.SetWidth(width)
246245 return nil
247246}
248247
internal/tui/components/chat/list_v2.go+21−17
@@ -5,9 +5,11 @@ import (
55 "time"
66
77 tea "github.com/charmbracelet/bubbletea/v2"
8+ "github.com/charmbracelet/lipgloss/v2"
89 "github.com/opencode-ai/opencode/internal/app"
910 "github.com/opencode-ai/opencode/internal/message"
1011 "github.com/opencode-ai/opencode/internal/session"
12+ "github.com/opencode-ai/opencode/internal/tui/components/chat/messages"
1113 "github.com/opencode-ai/opencode/internal/tui/components/core/list"
1214 "github.com/opencode-ai/opencode/internal/tui/components/dialog"
1315 "github.com/opencode-ai/opencode/internal/tui/layout"
@@ -52,12 +54,17 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
5254 return m, cmd
5355 }
5456 return m, nil
57+ default:
58+ var cmds []tea.Cmd
59+ u, cmd := m.listCmp.Update(msg)
60+ m.listCmp = u.(list.ListModel)
61+ cmds = append(cmds, cmd)
62+ return m, tea.Batch(cmds...)
5563 }
56− return m, nil
5764}
5865
5966func (m *messageListCmp) View() string {
60− return m.listCmp.View()
67+ return lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View())
6168}
6269
6370// GetSize implements MessageListCmp.
@@ -68,8 +75,8 @@ func (m *messageListCmp) GetSize() (int, int) {
6875// SetSize implements MessageListCmp.
6976func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
7077 m.width = width
71− m.height = height
72− return m.listCmp.SetSize(width, height)
78+ m.height = height - 1
79+ return m.listCmp.SetSize(width, height-1)
7380}
7481
7582func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
@@ -77,41 +84,38 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
7784 return nil
7885 }
7986 m.session = session
80− messages, err := m.app.Messages.List(context.Background(), session.ID)
87+ sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
8188 if err != nil {
8289 return util.ReportError(err)
8390 }
8491 m.messages = make([]util.Model, 0)
85− lastUserMessageTime := messages[0].CreatedAt
92+ lastUserMessageTime := sessionMessages[0].CreatedAt
8693 toolResultMap := make(map[string]message.ToolResult)
8794 // first pass to get all tool results
88− for _, msg := range messages {
95+ for _, msg := range sessionMessages {
8996 for _, tr := range msg.ToolResults() {
9097 toolResultMap[tr.ToolCallID] = tr
9198 }
9299 }
93− for _, msg := range messages {
94− // TODO: handle tool calls and others here
100+ for _, msg := range sessionMessages {
95101 switch msg.Role {
96102 case message.User:
97103 lastUserMessageTime = msg.CreatedAt
98− m.messages = append(m.messages, NewMessageCmp(WithMessage(msg)))
104+ m.messages = append(m.messages, messages.NewMessageCmp(msg))
99105 case message.Assistant:
100106 // Only add assistant messages if they don't have tool calls or there is some content
101107 if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
102− m.messages = append(m.messages, NewMessageCmp(WithMessage(msg), WithLastUserMessageTime(time.Unix(lastUserMessageTime, 0))))
108+ m.messages = append(m.messages, messages.NewMessageCmp(msg, messages.WithLastUserMessageTime(time.Unix(lastUserMessageTime, 0))))
103109 }
104110 for _, tc := range msg.ToolCalls() {
105− options := []MessageOption{
106− WithToolCall(tc),
107− }
111+ options := []messages.ToolCallOption{}
108112 if tr, ok := toolResultMap[tc.ID]; ok {
109− options = append(options, WithToolResult(tr))
113+ options = append(options, messages.WithToolCallResult(tr))
110114 }
111115 if msg.FinishPart().Reason == message.FinishReasonCanceled {
112− options = append(options, WithCancelledToolCall(true))
116+ options = append(options, messages.WithToolCallCancelled())
113117 }
114− m.messages = append(m.messages, NewMessageCmp(options...))
118+ m.messages = append(m.messages, messages.NewToolCallCmp(tc, options...))
115119 }
116120 }
117121 }
internal/tui/components/chat/message.go+57
@@ -10,6 +10,7 @@ import (
1010
1111 "github.com/charmbracelet/lipgloss/v2"
1212 "github.com/charmbracelet/x/ansi"
13+ "github.com/opencode-ai/opencode/internal/config"
1314 "github.com/opencode-ai/opencode/internal/diff"
1415 "github.com/opencode-ai/opencode/internal/llm/agent"
1516 "github.com/opencode-ai/opencode/internal/llm/models"
@@ -559,6 +560,62 @@ func renderToolMessage(
559560 return toolMsg
560561}
561562
563+func removeWorkingDirPrefix(path string) string {
564+ wd := config.WorkingDirectory()
565+ path = strings.TrimPrefix(path, wd)
566+ return path
567+}
568+
569+func truncateHeight(content string, height int) string {
570+ lines := strings.Split(content, "\n")
571+ if len(lines) > height {
572+ return strings.Join(lines[:height], "\n")
573+ }
574+ return content
575+}
576+
577+func renderParams(paramsWidth int, params ...string) string {
578+ if len(params) == 0 {
579+ return ""
580+ }
581+ mainParam := params[0]
582+ if len(mainParam) > paramsWidth {
583+ mainParam = mainParam[:paramsWidth-3] + "..."
584+ }
585+
586+ if len(params) == 1 {
587+ return mainParam
588+ }
589+ otherParams := params[1:]
590+ // create pairs of key/value
591+ // if odd number of params, the last one is a key without value
592+ if len(otherParams)%2 != 0 {
593+ otherParams = append(otherParams, "")
594+ }
595+ parts := make([]string, 0, len(otherParams)/2)
596+ for i := 0; i < len(otherParams); i += 2 {
597+ key := otherParams[i]
598+ value := otherParams[i+1]
599+ if value == "" {
600+ continue
601+ }
602+ parts = append(parts, fmt.Sprintf("%s=%s", key, value))
603+ }
604+
605+ partsRendered := strings.Join(parts, ", ")
606+ remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
607+ if remainingWidth < 30 {
608+ // No space for the params, just show the main
609+ return mainParam
610+ }
611+
612+ if len(parts) > 0 {
613+ mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
614+ }
615+
616+ return ansi.Truncate(mainParam, paramsWidth, "...")
617+}
618+
562619// Helper function to format the time difference between two Unix timestamps
563620func formatTimestampDiff(start, end int64) string {
564621 diffSeconds := float64(end-start) / 1000.0 // Convert to seconds
internal/tui/components/chat/message_v2.go → internal/tui/components/chat/messages/messages.gorenamed+4−37
@@ -1,4 +1,4 @@
1−package chat
1+package messages
22
33import (
44 "fmt"
@@ -31,11 +31,6 @@ type messageCmp struct {
3131 // Used for agent and user messages
3232 message message.Message
3333 lastUserMessageTime time.Time
34−
35− // Used for tool calls
36− toolCall message.ToolCall
37− toolResult message.ToolResult
38− cancelledToolCall bool
3934}
4035
4136type MessageOption func(*messageCmp)
@@ -46,32 +41,10 @@ func WithLastUserMessageTime(t time.Time) MessageOption {
4641 }
4742}
4843
49−func WithToolCall(tc message.ToolCall) MessageOption {
50− return func(m *messageCmp) {
51− m.toolCall = tc
44+func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
45+ m := &messageCmp{
46+ message: msg,
5247 }
53−}
54−
55−func WithToolResult(tr message.ToolResult) MessageOption {
56− return func(m *messageCmp) {
57− m.toolResult = tr
58− }
59−}
60−
61−func WithMessage(msg message.Message) MessageOption {
62− return func(m *messageCmp) {
63− m.message = msg
64− }
65−}
66−
67−func WithCancelledToolCall(cancelled bool) MessageOption {
68− return func(m *messageCmp) {
69− m.cancelledToolCall = cancelled
70− }
71−}
72−
73−func NewMessageCmp(opts ...MessageOption) MessageCmp {
74− m := &messageCmp{}
7548 for _, opt := range opts {
7649 opt(m)
7750 }
@@ -95,17 +68,11 @@ func (m *messageCmp) View() string {
9568 default:
9669 return m.renderAssistantMessage()
9770 }
98− } else if m.toolCall.ID != "" {
99− // this is a tool call message
100− return m.renderToolCallMessage()
10171 }
10272 return "Unknown Message"
10373}
10474
10575func (m *messageCmp) textWidth() int {
106− if m.toolCall.ID != "" {
107− return m.width - 2 // take into account the border and PaddingLeft
108− }
10976 return m.width - 1 // take into account the border
11077}
11178
internal/tui/components/chat/messages/renderer.gonew file+555
@@ -0,0 +1,555 @@
1+package messages
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "strings"
7+ "time"
8+
9+ "github.com/charmbracelet/lipgloss/v2"
10+ "github.com/charmbracelet/x/ansi"
11+ "github.com/opencode-ai/opencode/internal/config"
12+ "github.com/opencode-ai/opencode/internal/diff"
13+ "github.com/opencode-ai/opencode/internal/highlight"
14+ "github.com/opencode-ai/opencode/internal/llm/agent"
15+ "github.com/opencode-ai/opencode/internal/llm/tools"
16+ "github.com/opencode-ai/opencode/internal/tui/styles"
17+ "github.com/opencode-ai/opencode/internal/tui/theme"
18+)
19+
20+const responseContextHeight = 10
21+
22+type renderer interface {
23+ // Render returns the complete (already styled) tool‑call view, not
24+ // including the outer border.
25+ Render(v *toolCallCmp) string
26+}
27+
28+type rendererFactory func() renderer
29+
30+type renderRegistry map[string]rendererFactory
31+
32+func (rr renderRegistry) register(name string, f rendererFactory) { rr[name] = f }
33+func (rr renderRegistry) lookup(name string) renderer {
34+ if f, ok := rr[name]; ok {
35+ return f()
36+ }
37+ return genericRenderer{} // sensible fallback
38+}
39+
40+var registry = renderRegistry{}
41+
42+// Registger tool renderers
43+func init() {
44+ registry.register(tools.BashToolName, func() renderer { return bashRenderer{} })
45+ registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} })
46+ registry.register(tools.EditToolName, func() renderer { return editRenderer{} })
47+ registry.register(tools.WriteToolName, func() renderer { return writeRenderer{} })
48+ registry.register(tools.FetchToolName, func() renderer { return fetchRenderer{} })
49+ registry.register(tools.GlobToolName, func() renderer { return globRenderer{} })
50+ registry.register(tools.GrepToolName, func() renderer { return grepRenderer{} })
51+ registry.register(tools.LSToolName, func() renderer { return lsRenderer{} })
52+ registry.register(tools.SourcegraphToolName, func() renderer { return sourcegraphRenderer{} })
53+ registry.register(tools.PatchToolName, func() renderer { return patchRenderer{} })
54+ registry.register(tools.DiagnosticsToolName, func() renderer { return diagnosticsRenderer{} })
55+}
56+
57+// -----------------------------------------------------------------------------
58+// Generic renderer
59+// -----------------------------------------------------------------------------
60+
61+type genericRenderer struct{}
62+
63+func (genericRenderer) Render(v *toolCallCmp) string {
64+ header := makeHeader(prettifyToolName(v.call.Name), v.textWidth(), v.call.Input)
65+ if res, done := earlyState(header, v); done {
66+ return res
67+ }
68+ body := renderPlainContent(v, v.result.Content)
69+ return joinHeaderBody(header, body)
70+}
71+
72+// -----------------------------------------------------------------------------
73+// Bash renderer
74+// -----------------------------------------------------------------------------
75+
76+type bashRenderer struct{}
77+
78+func (bashRenderer) Render(v *toolCallCmp) string {
79+ var p tools.BashParams
80+ _ = json.Unmarshal([]byte(v.call.Input), &p)
81+
82+ cmd := strings.ReplaceAll(p.Command, "\n", " ")
83+ header := makeHeader("Bash", v.textWidth(), cmd)
84+ if res, done := earlyState(header, v); done {
85+ return res
86+ }
87+ body := renderPlainContent(v, v.result.Content)
88+ return joinHeaderBody(header, body)
89+}
90+
91+// -----------------------------------------------------------------------------
92+// View renderer
93+// -----------------------------------------------------------------------------
94+
95+type viewRenderer struct{}
96+
97+func (viewRenderer) Render(v *toolCallCmp) string {
98+ var params tools.ViewParams
99+ _ = json.Unmarshal([]byte(v.call.Input), &params)
100+
101+ file := removeWorkingDirPrefix(params.FilePath)
102+ args := []string{file}
103+ if params.Limit != 0 {
104+ args = append(args, "limit", fmt.Sprintf("%d", params.Limit))
105+ }
106+ if params.Offset != 0 {
107+ args = append(args, "offset", fmt.Sprintf("%d", params.Offset))
108+ }
109+
110+ header := makeHeader("View", v.textWidth(), args...)
111+ if res, done := earlyState(header, v); done {
112+ return res
113+ }
114+
115+ var meta tools.ViewResponseMetadata
116+ _ = json.Unmarshal([]byte(v.result.Metadata), &meta)
117+
118+ body := renderCodeContent(v, meta.FilePath, meta.Content, params.Offset)
119+ return joinHeaderBody(header, body)
120+}
121+
122+// -----------------------------------------------------------------------------
123+// Edit renderer
124+// -----------------------------------------------------------------------------
125+
126+type editRenderer struct{}
127+
128+func (editRenderer) Render(v *toolCallCmp) string {
129+ var params tools.EditParams
130+ _ = json.Unmarshal([]byte(v.call.Input), &params)
131+
132+ file := removeWorkingDirPrefix(params.FilePath)
133+ header := makeHeader("Edit", v.textWidth(), file)
134+ if res, done := earlyState(header, v); done {
135+ return res
136+ }
137+
138+ var meta tools.EditResponseMetadata
139+ _ = json.Unmarshal([]byte(v.result.Metadata), &meta)
140+
141+ trunc := truncateHeight(meta.Diff, responseContextHeight)
142+ diffView, _ := diff.FormatDiff(trunc, diff.WithTotalWidth(v.textWidth()))
143+ return joinHeaderBody(header, diffView)
144+}
145+
146+// -----------------------------------------------------------------------------
147+// Write renderer
148+// -----------------------------------------------------------------------------
149+
150+type writeRenderer struct{}
151+
152+func (writeRenderer) Render(v *toolCallCmp) string {
153+ var params tools.WriteParams
154+ _ = json.Unmarshal([]byte(v.call.Input), &params)
155+
156+ file := removeWorkingDirPrefix(params.FilePath)
157+ header := makeHeader("Write", v.textWidth(), file)
158+ if res, done := earlyState(header, v); done {
159+ return res
160+ }
161+
162+ body := renderCodeContent(v, file, params.Content, 0)
163+ return joinHeaderBody(header, body)
164+}
165+
166+// -----------------------------------------------------------------------------
167+// Fetch renderer
168+// -----------------------------------------------------------------------------
169+
170+type fetchRenderer struct{}
171+
172+func (fetchRenderer) Render(v *toolCallCmp) string {
173+ var params tools.FetchParams
174+ _ = json.Unmarshal([]byte(v.call.Input), &params)
175+
176+ args := []string{params.URL}
177+ if params.Format != "" {
178+ args = append(args, "format", params.Format)
179+ }
180+ if params.Timeout != 0 {
181+ args = append(args, "timeout", (time.Duration(params.Timeout) * time.Second).String())
182+ }
183+
184+ header := makeHeader("Fetch", v.textWidth(), args...)
185+ if res, done := earlyState(header, v); done {
186+ return res
187+ }
188+
189+ file := "fetch.md"
190+ switch params.Format {
191+ case "text":
192+ file = "fetch.txt"
193+ case "html":
194+ file = "fetch.html"
195+ }
196+
197+ body := renderCodeContent(v, file, v.result.Content, 0)
198+ return joinHeaderBody(header, body)
199+}
200+
201+// -----------------------------------------------------------------------------
202+// Glob renderer
203+// -----------------------------------------------------------------------------
204+
205+type globRenderer struct{}
206+
207+func (globRenderer) Render(v *toolCallCmp) string {
208+ var params tools.GlobParams
209+ _ = json.Unmarshal([]byte(v.call.Input), &params)
210+
211+ args := []string{params.Pattern}
212+ if params.Path != "" {
213+ args = append(args, "path", params.Path)
214+ }
215+
216+ header := makeHeader("Glob", v.textWidth(), args...)
217+ if res, done := earlyState(header, v); done {
218+ return res
219+ }
220+
221+ body := renderPlainContent(v, v.result.Content)
222+ return joinHeaderBody(header, body)
223+}
224+
225+// -----------------------------------------------------------------------------
226+// Grep renderer
227+// -----------------------------------------------------------------------------
228+
229+type grepRenderer struct{}
230+
231+func (grepRenderer) Render(v *toolCallCmp) string {
232+ var params tools.GrepParams
233+ _ = json.Unmarshal([]byte(v.call.Input), &params)
234+
235+ args := []string{params.Pattern}
236+ if params.Path != "" {
237+ args = append(args, "path", params.Path)
238+ }
239+ if params.Include != "" {
240+ args = append(args, "include", params.Include)
241+ }
242+ if params.LiteralText {
243+ args = append(args, "literal", "true")
244+ }
245+
246+ header := makeHeader("Grep", v.textWidth(), args...)
247+ if res, done := earlyState(header, v); done {
248+ return res
249+ }
250+
251+ body := renderPlainContent(v, v.result.Content)
252+ return joinHeaderBody(header, body)
253+}
254+
255+// -----------------------------------------------------------------------------
256+// LS renderer
257+// -----------------------------------------------------------------------------
258+
259+type lsRenderer struct{}
260+
261+func (lsRenderer) Render(v *toolCallCmp) string {
262+ var params tools.LSParams
263+ _ = json.Unmarshal([]byte(v.call.Input), &params)
264+
265+ path := params.Path
266+ if path == "" {
267+ path = "."
268+ }
269+
270+ header := makeHeader("List", v.textWidth(), path)
271+ if res, done := earlyState(header, v); done {
272+ return res
273+ }
274+
275+ body := renderPlainContent(v, v.result.Content)
276+ return joinHeaderBody(header, body)
277+}
278+
279+// -----------------------------------------------------------------------------
280+// Sourcegraph renderer
281+// -----------------------------------------------------------------------------
282+
283+type sourcegraphRenderer struct{}
284+
285+func (sourcegraphRenderer) Render(v *toolCallCmp) string {
286+ var params tools.SourcegraphParams
287+ _ = json.Unmarshal([]byte(v.call.Input), &params)
288+
289+ args := []string{params.Query}
290+ if params.Count != 0 {
291+ args = append(args, "count", fmt.Sprintf("%d", params.Count))
292+ }
293+ if params.ContextWindow != 0 {
294+ args = append(args, "context", fmt.Sprintf("%d", params.ContextWindow))
295+ }
296+
297+ header := makeHeader("Sourcegraph", v.textWidth(), args...)
298+ if res, done := earlyState(header, v); done {
299+ return res
300+ }
301+
302+ body := renderPlainContent(v, v.result.Content)
303+ return joinHeaderBody(header, body)
304+}
305+
306+// -----------------------------------------------------------------------------
307+// Patch renderer
308+// -----------------------------------------------------------------------------
309+
310+type patchRenderer struct{}
311+
312+func (patchRenderer) Render(v *toolCallCmp) string {
313+ var params tools.PatchParams
314+ _ = json.Unmarshal([]byte(v.call.Input), &params)
315+
316+ header := makeHeader("Patch", v.textWidth(), "multiple files")
317+ if res, done := earlyState(header, v); done {
318+ return res
319+ }
320+
321+ var meta tools.PatchResponseMetadata
322+ _ = json.Unmarshal([]byte(v.result.Metadata), &meta)
323+
324+ // Format the result as a summary of changes
325+ summary := fmt.Sprintf("Changed %d files (%d+ %d-)",
326+ len(meta.FilesChanged), meta.Additions, meta.Removals)
327+
328+ // List the changed files
329+ filesList := strings.Join(meta.FilesChanged, "\n")
330+
331+ body := renderPlainContent(v, summary+"\n\n"+filesList)
332+ return joinHeaderBody(header, body)
333+}
334+
335+// -----------------------------------------------------------------------------
336+// Diagnostics renderer
337+// -----------------------------------------------------------------------------
338+
339+type diagnosticsRenderer struct{}
340+
341+func (diagnosticsRenderer) Render(v *toolCallCmp) string {
342+ header := makeHeader("Diagnostics", v.textWidth(), "project")
343+ if res, done := earlyState(header, v); done {
344+ return res
345+ }
346+
347+ body := renderPlainContent(v, v.result.Content)
348+ return joinHeaderBody(header, body)
349+}
350+
351+// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
352+func makeHeader(tool string, width int, params ...string) string {
353+ prefix := tool + ": "
354+ return prefix + renderParams(width-lipgloss.Width(prefix), params...)
355+}
356+
357+// renders params, params[0] (params[1]=params[2] ....)
358+func renderParams(paramsWidth int, params ...string) string {
359+ if len(params) == 0 {
360+ return ""
361+ }
362+ mainParam := params[0]
363+ if len(mainParam) > paramsWidth {
364+ mainParam = mainParam[:paramsWidth-3] + "..."
365+ }
366+
367+ if len(params) == 1 {
368+ return mainParam
369+ }
370+ otherParams := params[1:]
371+ // create pairs of key/value
372+ // if odd number of params, the last one is a key without value
373+ if len(otherParams)%2 != 0 {
374+ otherParams = append(otherParams, "")
375+ }
376+ parts := make([]string, 0, len(otherParams)/2)
377+ for i := 0; i < len(otherParams); i += 2 {
378+ key := otherParams[i]
379+ value := otherParams[i+1]
380+ if value == "" {
381+ continue
382+ }
383+ parts = append(parts, fmt.Sprintf("%s=%s", key, value))
384+ }
385+
386+ partsRendered := strings.Join(parts, ", ")
387+ remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
388+ if remainingWidth < 30 {
389+ // No space for the params, just show the main
390+ return mainParam
391+ }
392+
393+ if len(parts) > 0 {
394+ mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
395+ }
396+
397+ return ansi.Truncate(mainParam, paramsWidth, "...")
398+}
399+
400+// earlyState returns immediately‑rendered error/cancelled/ongoing states.
401+func earlyState(header string, v *toolCallCmp) (string, bool) {
402+ switch {
403+ case v.result.IsError:
404+ return lipgloss.JoinVertical(lipgloss.Left, header, v.renderToolError()), true
405+ case v.cancelled:
406+ return lipgloss.JoinVertical(lipgloss.Left, header, "Cancelled"), true
407+ case v.result.ToolCallID == "":
408+ return lipgloss.JoinVertical(lipgloss.Left, header, "Waiting for tool to finish..."), true
409+ default:
410+ return "", false
411+ }
412+}
413+
414+func joinHeaderBody(header, body string) string {
415+ return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "")
416+}
417+
418+func renderPlainContent(v *toolCallCmp, content string) string {
419+ t := theme.CurrentTheme()
420+ content = strings.TrimSpace(content)
421+ lines := strings.Split(content, "\n")
422+
423+ var out []string
424+ for i, ln := range lines {
425+ if i >= responseContextHeight {
426+ break
427+ }
428+ ln = " " + ln // left padding
429+ if len(ln) > v.textWidth() {
430+ ln = v.fit(ln, v.textWidth())
431+ }
432+ out = append(out, lipgloss.NewStyle().
433+ Width(v.textWidth()).
434+ Background(t.BackgroundSecondary()).
435+ Foreground(t.TextMuted()).
436+ Render(ln))
437+ }
438+
439+ if len(lines) > responseContextHeight {
440+ out = append(out, lipgloss.NewStyle().
441+ Background(t.BackgroundSecondary()).
442+ Foreground(t.TextMuted()).
443+ Render(fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight)))
444+ }
445+ return strings.Join(out, "\n")
446+}
447+
448+func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
449+ t := theme.CurrentTheme()
450+ truncated := truncateHeight(content, responseContextHeight)
451+
452+ highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BackgroundSecondary())
453+ lines := strings.Split(highlighted, "\n")
454+
455+ if len(strings.Split(content, "\n")) > responseContextHeight {
456+ lines = append(lines, lipgloss.NewStyle().
457+ Background(t.BackgroundSecondary()).
458+ Foreground(t.TextMuted()).
459+ Render(fmt.Sprintf("... (%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
460+ }
461+
462+ for i, ln := range lines {
463+ num := lipgloss.NewStyle().
464+ PaddingLeft(4).PaddingRight(2).
465+ Background(t.BackgroundSecondary()).
466+ Foreground(t.TextMuted()).
467+ Render(fmt.Sprintf("%d", i+1+offset))
468+ w := v.textWidth() - lipgloss.Width(num)
469+ lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
470+ num,
471+ lipgloss.NewStyle().
472+ Width(w).
473+ Background(t.BackgroundSecondary()).
474+ Render(v.fit(ln, w)))
475+ }
476+ return lipgloss.JoinVertical(lipgloss.Left, lines...)
477+}
478+
479+func (v *toolCallCmp) renderToolError() string {
480+ t := theme.CurrentTheme()
481+ err := strings.ReplaceAll(v.result.Content, "\n", " ")
482+ err = fmt.Sprintf("Error: %s", err)
483+ return styles.BaseStyle().Foreground(t.Error()).Render(v.fit(err, v.textWidth()))
484+}
485+
486+func removeWorkingDirPrefix(path string) string {
487+ wd := config.WorkingDirectory()
488+ return strings.TrimPrefix(path, wd)
489+}
490+
491+func truncateHeight(s string, h int) string {
492+ lines := strings.Split(s, "\n")
493+ if len(lines) > h {
494+ return strings.Join(lines[:h], "\n")
495+ }
496+ return s
497+}
498+
499+func prettifyToolName(name string) string {
500+ switch name {
501+ case agent.AgentToolName:
502+ return "Task"
503+ case tools.BashToolName:
504+ return "Bash"
505+ case tools.EditToolName:
506+ return "Edit"
507+ case tools.FetchToolName:
508+ return "Fetch"
509+ case tools.GlobToolName:
510+ return "Glob"
511+ case tools.GrepToolName:
512+ return "Grep"
513+ case tools.LSToolName:
514+ return "List"
515+ case tools.SourcegraphToolName:
516+ return "Sourcegraph"
517+ case tools.ViewToolName:
518+ return "View"
519+ case tools.WriteToolName:
520+ return "Write"
521+ case tools.PatchToolName:
522+ return "Patch"
523+ default:
524+ return name
525+ }
526+}
527+
528+func toolAction(name string) string {
529+ switch name {
530+ case agent.AgentToolName:
531+ return "Preparing prompt..."
532+ case tools.BashToolName:
533+ return "Building command..."
534+ case tools.EditToolName:
535+ return "Preparing edit..."
536+ case tools.FetchToolName:
537+ return "Writing fetch..."
538+ case tools.GlobToolName:
539+ return "Finding files..."
540+ case tools.GrepToolName:
541+ return "Searching content..."
542+ case tools.LSToolName:
543+ return "Listing directory..."
544+ case tools.SourcegraphToolName:
545+ return "Searching code..."
546+ case tools.ViewToolName:
547+ return "Reading file..."
548+ case tools.WriteToolName:
549+ return "Preparing write..."
550+ case tools.PatchToolName:
551+ return "Preparing patch..."
552+ default:
553+ return "Working..."
554+ }
555+}
internal/tui/components/chat/messages/tool.gonew file+155
@@ -0,0 +1,155 @@
1+package messages
2+
3+import (
4+ "fmt"
5+
6+ tea "github.com/charmbracelet/bubbletea/v2"
7+ "github.com/charmbracelet/lipgloss/v2"
8+ "github.com/charmbracelet/x/ansi"
9+ "github.com/opencode-ai/opencode/internal/llm/agent"
10+ "github.com/opencode-ai/opencode/internal/llm/tools"
11+ "github.com/opencode-ai/opencode/internal/message"
12+ "github.com/opencode-ai/opencode/internal/tui/layout"
13+ "github.com/opencode-ai/opencode/internal/tui/styles"
14+ "github.com/opencode-ai/opencode/internal/tui/theme"
15+ "github.com/opencode-ai/opencode/internal/tui/util"
16+)
17+
18+type ToolCallCmp interface {
19+ util.Model
20+ layout.Sizeable
21+ layout.Focusable
22+}
23+
24+type toolCallCmp struct {
25+ width int
26+ focused bool
27+
28+ call message.ToolCall
29+ result message.ToolResult
30+ cancelled bool
31+}
32+
33+type ToolCallOption func(*toolCallCmp)
34+
35+func WithToolCallCancelled() ToolCallOption {
36+ return func(m *toolCallCmp) {
37+ m.cancelled = true
38+ }
39+}
40+
41+func WithToolCallResult(result message.ToolResult) ToolCallOption {
42+ return func(m *toolCallCmp) {
43+ m.result = result
44+ }
45+}
46+
47+func NewToolCallCmp(tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
48+ m := &toolCallCmp{
49+ call: tc,
50+ }
51+ for _, opt := range opts {
52+ opt(m)
53+ }
54+ return m
55+}
56+
57+func (m *toolCallCmp) Init() tea.Cmd {
58+ return nil
59+}
60+
61+func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
62+ return m, nil
63+}
64+
65+func (m *toolCallCmp) View() string {
66+ box := m.style()
67+
68+ if !m.call.Finished && !m.cancelled {
69+ return box.PaddingLeft(1).Render(m.renderPending())
70+ }
71+
72+ r := registry.lookup(m.call.Name)
73+ return box.PaddingLeft(1).Render(r.Render(m))
74+}
75+
76+func (v *toolCallCmp) renderPending() string {
77+ return fmt.Sprintf("%s: %s", prettifyToolName(v.call.Name), toolAction(v.call.Name))
78+}
79+
80+func (msg *toolCallCmp) style() lipgloss.Style {
81+ t := theme.CurrentTheme()
82+ borderStyle := lipgloss.NormalBorder()
83+ if msg.focused {
84+ borderStyle = lipgloss.DoubleBorder()
85+ }
86+ return styles.BaseStyle().
87+ BorderLeft(true).
88+ Foreground(t.TextMuted()).
89+ BorderForeground(t.TextMuted()).
90+ BorderStyle(borderStyle)
91+}
92+
93+func (m *toolCallCmp) textWidth() int {
94+ return m.width - 2 // take into account the border and PaddingLeft
95+}
96+
97+func (m *toolCallCmp) fit(content string, width int) string {
98+ t := theme.CurrentTheme()
99+ lineStyle := lipgloss.NewStyle().Background(t.BackgroundSecondary()).Foreground(t.TextMuted())
100+ dots := lineStyle.Render("...")
101+ return ansi.Truncate(content, width, dots)
102+}
103+
104+func (m *toolCallCmp) toolName() string {
105+ switch m.call.Name {
106+ case agent.AgentToolName:
107+ return "Task"
108+ case tools.BashToolName:
109+ return "Bash"
110+ case tools.EditToolName:
111+ return "Edit"
112+ case tools.FetchToolName:
113+ return "Fetch"
114+ case tools.GlobToolName:
115+ return "Glob"
116+ case tools.GrepToolName:
117+ return "Grep"
118+ case tools.LSToolName:
119+ return "List"
120+ case tools.SourcegraphToolName:
121+ return "Sourcegraph"
122+ case tools.ViewToolName:
123+ return "View"
124+ case tools.WriteToolName:
125+ return "Write"
126+ case tools.PatchToolName:
127+ return "Patch"
128+ default:
129+ return m.call.Name
130+ }
131+}
132+
133+func (m *toolCallCmp) Blur() tea.Cmd {
134+ m.focused = false
135+ return nil
136+}
137+
138+func (m *toolCallCmp) Focus() tea.Cmd {
139+ m.focused = true
140+ return nil
141+}
142+
143+// IsFocused implements MessageModel.
144+func (m *toolCallCmp) IsFocused() bool {
145+ return m.focused
146+}
147+
148+func (m *toolCallCmp) GetSize() (int, int) {
149+ return m.width, 0
150+}
151+
152+func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
153+ m.width = width
154+ return nil
155+}
internal/tui/components/chat/tool_message.godeleted−365
@@ -1,365 +0,0 @@
1−package chat
2−
3−import (
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−
19−const responseContextHeight = 10
20−
21−func (m *messageCmp) renderUnfinishedToolCall() string {
22− toolName := m.toolName()
23− toolAction := m.getToolAction()
24− return fmt.Sprintf("%s: %s", toolName, toolAction)
25−}
26−
27−func (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−
35−func (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−
49−func (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−
76−func (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−
114−func (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−
144−func (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−
158−func (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−
177−func (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−
191−func (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−
211−func (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−
238−func (m *messageCmp) renderTool(header, result string) string {
239− return lipgloss.JoinVertical(
240− lipgloss.Left,
241− header,
242− "",
243− result,
244− "",
245− )
246−}
247−
248−func removeWorkingDirPrefix(path string) string {
249− wd := config.WorkingDirectory()
250− path = strings.TrimPrefix(path, wd)
251− return path
252−}
253−
254−func 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−
262−func (m *messageCmp) fit(content string) string {
263− return ansi.Truncate(content, m.textWidth(), "...")
264−}
265−
266−func (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−
295−func (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] ....)
325−func 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−}
internal/tui/components/core/list/list.go+1−1
@@ -169,7 +169,7 @@ func (m *model) View() string {
169169 if m.needsRerender {
170170 m.renderVisible()
171171 }
172− return lipgloss.NewStyle().Padding(m.padding...).Render(m.content)
172+ return lipgloss.NewStyle().Padding(m.padding...).Height(m.height).Render(m.content)
173173}
174174
175175func (m *model) renderVisibleReverse() {
internal/tui/page/chat.go+2
@@ -9,6 +9,7 @@ import (
99 "github.com/charmbracelet/lipgloss/v2"
1010 "github.com/opencode-ai/opencode/internal/app"
1111 "github.com/opencode-ai/opencode/internal/completions"
12+ "github.com/opencode-ai/opencode/internal/logging"
1213 "github.com/opencode-ai/opencode/internal/message"
1314 "github.com/opencode-ai/opencode/internal/session"
1415 "github.com/opencode-ai/opencode/internal/tui/components/chat"
@@ -62,6 +63,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6263 var cmds []tea.Cmd
6364 switch msg := msg.(type) {
6465 case tea.WindowSizeMsg:
66+ logging.Info("Window size changed Chat:", "Width", msg.Width, "Height", msg.Height)
6567 cmd := p.layout.SetSize(msg.Width, msg.Height)
6668 cmds = append(cmds, cmd)
6769 case dialog.CompletionDialogCloseMsg:
internal/tui/theme/manager.go+6
@@ -2,6 +2,7 @@ package theme
22
33import (
44 "fmt"
5+ "image/color"
56 "slices"
67 "strings"
78 "sync"
@@ -74,6 +75,11 @@ func CurrentTheme() Theme {
7475 return globalManager.themes[globalManager.currentName]
7576}
7677
78+func GetColor(c color.Color) string {
79+ rgba := color.RGBAModel.Convert(c).(color.RGBA)
80+ return fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
81+}
82+
7783// CurrentThemeName returns the name of the currently active theme.
7884func CurrentThemeName() string {
7985 globalManager.mu.RLock()
internal/tui/tui.go+1−10
@@ -186,6 +186,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
186186 var cmd tea.Cmd
187187 switch msg := msg.(type) {
188188 case tea.WindowSizeMsg:
189+ logging.Info("Window size changed main: ", "Width", msg.Width, "Height", msg.Height)
189190 msg.Height -= 1 // Make space for the status bar
190191 a.width, a.height = msg.Width, msg.Height
191192
@@ -674,15 +675,6 @@ func (a *appModel) RegisterCommand(cmd dialog.Command) {
674675 a.commands = append(a.commands, cmd)
675676}
676677
677−func (a *appModel) findCommand(id string) (dialog.Command, bool) {
678− for _, cmd := range a.commands {
679− if cmd.ID == id {
680− return cmd, true
681− }
682− }
683− return dialog.Command{}, false
684−}
685−
686678func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
687679 if a.app.CoderAgent.IsBusy() {
688680 // For now we don't move to any page if the agent is busy
@@ -709,7 +701,6 @@ func (a appModel) View() string {
709701 components := []string{
710702 a.pages[a.currentPage].View(),
711703 }
712−
713704 components = append(components, a.status.View())
714705
715706 appView := lipgloss.JoinVertical(lipgloss.Top, components...)