Detailed changes
@@ -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
+}
@@ -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
}
@@ -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...))
}
}
}
@@ -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
@@ -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
}
@@ -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 "<Tool>: param (key=value)" and truncates as needed.
+func makeHeader(tool string, width int, params ...string) string {
+ prefix := tool + ": "
+ return prefix + renderParams(width-lipgloss.Width(prefix), params...)
+}
+
+// renders params, params[0] (params[1]=params[2] ....)
+func renderParams(paramsWidth int, params ...string) string {
+ if len(params) == 0 {
+ return ""
+ }
+ mainParam := params[0]
+ if len(mainParam) > paramsWidth {
+ mainParam = mainParam[:paramsWidth-3] + "..."
+ }
+
+ if len(params) == 1 {
+ return mainParam
+ }
+ otherParams := params[1:]
+ // create pairs of key/value
+ // if odd number of params, the last one is a key without value
+ if len(otherParams)%2 != 0 {
+ otherParams = append(otherParams, "")
+ }
+ parts := make([]string, 0, len(otherParams)/2)
+ for i := 0; i < len(otherParams); i += 2 {
+ key := otherParams[i]
+ value := otherParams[i+1]
+ if value == "" {
+ continue
+ }
+ parts = append(parts, fmt.Sprintf("%s=%s", key, value))
+ }
+
+ partsRendered := strings.Join(parts, ", ")
+ remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
+ if remainingWidth < 30 {
+ // No space for the params, just show the main
+ return mainParam
+ }
+
+ if len(parts) > 0 {
+ mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
+ }
+
+ return ansi.Truncate(mainParam, paramsWidth, "...")
+}
+
+// earlyState returns immediatelyβrendered error/cancelled/ongoing states.
+func earlyState(header string, v *toolCallCmp) (string, bool) {
+ switch {
+ case v.result.IsError:
+ return lipgloss.JoinVertical(lipgloss.Left, header, v.renderToolError()), true
+ case v.cancelled:
+ return lipgloss.JoinVertical(lipgloss.Left, header, "Cancelled"), true
+ case v.result.ToolCallID == "":
+ return lipgloss.JoinVertical(lipgloss.Left, header, "Waiting for tool to finish..."), true
+ default:
+ return "", false
+ }
+}
+
+func joinHeaderBody(header, body string) string {
+ return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "")
+}
+
+func renderPlainContent(v *toolCallCmp, content string) string {
+ t := theme.CurrentTheme()
+ content = strings.TrimSpace(content)
+ lines := strings.Split(content, "\n")
+
+ var out []string
+ for i, ln := range lines {
+ if i >= responseContextHeight {
+ break
+ }
+ ln = " " + ln // left padding
+ if len(ln) > v.textWidth() {
+ ln = v.fit(ln, v.textWidth())
+ }
+ out = append(out, lipgloss.NewStyle().
+ Width(v.textWidth()).
+ Background(t.BackgroundSecondary()).
+ Foreground(t.TextMuted()).
+ Render(ln))
+ }
+
+ if len(lines) > responseContextHeight {
+ out = append(out, lipgloss.NewStyle().
+ Background(t.BackgroundSecondary()).
+ Foreground(t.TextMuted()).
+ Render(fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight)))
+ }
+ return strings.Join(out, "\n")
+}
+
+func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
+ t := theme.CurrentTheme()
+ truncated := truncateHeight(content, responseContextHeight)
+
+ highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BackgroundSecondary())
+ lines := strings.Split(highlighted, "\n")
+
+ if len(strings.Split(content, "\n")) > responseContextHeight {
+ lines = append(lines, lipgloss.NewStyle().
+ Background(t.BackgroundSecondary()).
+ Foreground(t.TextMuted()).
+ Render(fmt.Sprintf("... (%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
+ }
+
+ for i, ln := range lines {
+ num := lipgloss.NewStyle().
+ PaddingLeft(4).PaddingRight(2).
+ Background(t.BackgroundSecondary()).
+ Foreground(t.TextMuted()).
+ Render(fmt.Sprintf("%d", i+1+offset))
+ w := v.textWidth() - lipgloss.Width(num)
+ lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
+ num,
+ lipgloss.NewStyle().
+ Width(w).
+ Background(t.BackgroundSecondary()).
+ Render(v.fit(ln, w)))
+ }
+ return lipgloss.JoinVertical(lipgloss.Left, lines...)
+}
+
+func (v *toolCallCmp) renderToolError() string {
+ t := theme.CurrentTheme()
+ err := strings.ReplaceAll(v.result.Content, "\n", " ")
+ err = fmt.Sprintf("Error: %s", err)
+ return styles.BaseStyle().Foreground(t.Error()).Render(v.fit(err, v.textWidth()))
+}
+
+func removeWorkingDirPrefix(path string) string {
+ wd := config.WorkingDirectory()
+ return strings.TrimPrefix(path, wd)
+}
+
+func truncateHeight(s string, h int) string {
+ lines := strings.Split(s, "\n")
+ if len(lines) > h {
+ return strings.Join(lines[:h], "\n")
+ }
+ return s
+}
+
+func prettifyToolName(name string) string {
+ switch name {
+ case agent.AgentToolName:
+ return "Task"
+ case tools.BashToolName:
+ return "Bash"
+ case tools.EditToolName:
+ return "Edit"
+ case tools.FetchToolName:
+ return "Fetch"
+ case tools.GlobToolName:
+ return "Glob"
+ case tools.GrepToolName:
+ return "Grep"
+ case tools.LSToolName:
+ return "List"
+ case tools.SourcegraphToolName:
+ return "Sourcegraph"
+ case tools.ViewToolName:
+ return "View"
+ case tools.WriteToolName:
+ return "Write"
+ case tools.PatchToolName:
+ return "Patch"
+ default:
+ return name
+ }
+}
+
+func toolAction(name string) string {
+ switch name {
+ case agent.AgentToolName:
+ return "Preparing prompt..."
+ case tools.BashToolName:
+ return "Building command..."
+ case tools.EditToolName:
+ return "Preparing edit..."
+ case tools.FetchToolName:
+ return "Writing fetch..."
+ case tools.GlobToolName:
+ return "Finding files..."
+ case tools.GrepToolName:
+ return "Searching content..."
+ case tools.LSToolName:
+ return "Listing directory..."
+ case tools.SourcegraphToolName:
+ return "Searching code..."
+ case tools.ViewToolName:
+ return "Reading file..."
+ case tools.WriteToolName:
+ return "Preparing write..."
+ case tools.PatchToolName:
+ return "Preparing patch..."
+ default:
+ return "Working..."
+ }
+}
@@ -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
+}
@@ -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, "...")
-}
@@ -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() {
@@ -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:
@@ -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()
@@ -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...)