Skip to content
This repository was archived by the owner on Sep 18, 2025. It is now read-only.

Commit abbbc05

Browse files
committed
refactor tool rendering
1 parent c891295 commit abbbc05

12 files changed

Lines changed: 1056 additions & 431 deletions

File tree

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -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

5966
func (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,50 +75,47 @@ func (m *messageListCmp) GetSize() (int, int) {
6875
// SetSize implements MessageListCmp.
6976
func (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

7582
func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
7683
if m.session.ID == session.ID {
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
}

0 commit comments

Comments
 (0)