From abbbc05fd74dec055b31cb7c06dcaccbd21068d0 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 22 May 2025 11:23:15 +0200 Subject: [PATCH] refactor tool rendering --- 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 ++ .../{message_v2.go => messages/messages.go} | 41 +- .../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, 1056 insertions(+), 431 deletions(-) create mode 100644 internal/tui/components/anim/anim.go rename internal/tui/components/chat/{message_v2.go => messages/messages.go} (85%) create mode 100644 internal/tui/components/chat/messages/renderer.go create mode 100644 internal/tui/components/chat/messages/tool.go delete mode 100644 internal/tui/components/chat/tool_message.go diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go new file mode 100644 index 0000000000000000000000000000000000000000..23eaf21c714c7370d97cf5d7ecd8a2ddc50a9aeb --- /dev/null +++ b/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 +} diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go index 4f3f69665a9af6d622214431ca563dff20764412..6dae5418d4ffeef5d4f29703b7bc45a580d9d958 100644 --- a/internal/tui/components/chat/editor.go +++ b/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 } diff --git a/internal/tui/components/chat/list_v2.go b/internal/tui/components/chat/list_v2.go index 10010ab8b635f7ecfc265163e07d1c87b984e187..bec9b206e996149ac1574f99e3f71dbbfb8b280b 100644 --- a/internal/tui/components/chat/list_v2.go +++ b/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...)) } } } diff --git a/internal/tui/components/chat/message.go b/internal/tui/components/chat/message.go index 96a33da9150cd135d006007a0d659217c261f738..fa96c54fb27af23341b425bfccc88d2bcdaa1322 100644 --- a/internal/tui/components/chat/message.go +++ b/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 diff --git a/internal/tui/components/chat/message_v2.go b/internal/tui/components/chat/messages/messages.go similarity index 85% rename from internal/tui/components/chat/message_v2.go rename to internal/tui/components/chat/messages/messages.go index 1e281ec01b2fe3c72dd1a6faf62da4a685404348..10d82961348413f08ced31e88385099bf85fa091 100644 --- a/internal/tui/components/chat/message_v2.go +++ b/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 } diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go new file mode 100644 index 0000000000000000000000000000000000000000..ebea3eff09461fa87a44c0789e39dff232e023d7 --- /dev/null +++ b/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), ¶ms) + + 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), ¶ms) + + 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), ¶ms) + + 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), ¶ms) + + 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), ¶ms) + + 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), ¶ms) + + 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), ¶ms) + + 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), ¶ms) + + 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), ¶ms) + + 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 ": 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..." + } +} diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go new file mode 100644 index 0000000000000000000000000000000000000000..5547170e630142f07152db19f0371c6b816a5819 --- /dev/null +++ b/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 +} diff --git a/internal/tui/components/chat/tool_message.go b/internal/tui/components/chat/tool_message.go deleted file mode 100644 index 60333c2e6d3ab3fb70c670fa07b573c84b8fa37c..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/tool_message.go +++ /dev/null @@ -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), ¶ms) - 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), ¶ms) - 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), ¶ms) - 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), ¶ms) - 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, "...") -} diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go index 4ed851ce546ed39f6b829877f4f25b55a862d091..f8b0b4fe6a8f56d9cad4d414a581f93cdea01cb1 100644 --- a/internal/tui/components/core/list/list.go +++ b/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() { diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go index 9ba3ebac1701d8446222dc7f8f704de3166823c4..37be69bdaf32f1bbb96a422350e16d681a6c7800 100644 --- a/internal/tui/page/chat.go +++ b/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: diff --git a/internal/tui/theme/manager.go b/internal/tui/theme/manager.go index a81ba45c12db73823f41fa416778895046dd5ec7..e00c9f0ec9ab83dafda8c7fa97ed5496cd0a7ebb 100644 --- a/internal/tui/theme/manager.go +++ b/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() diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 92c2177ade6b0e604505b222ef299f3f4b8ca94b..3d4b54e790cdded0caa4ab1d3fb0863d28f51b94 100644 --- a/internal/tui/tui.go +++ b/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...)