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

Commit db179e7

Browse files
committed
initial message revamp
1 parent 3eb2a93 commit db179e7

File tree

9 files changed

+151
-95
lines changed

9 files changed

+151
-95
lines changed

internal/tui/components/chat/chat.go

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"context"
55
"time"
66

7-
"github.com/charmbracelet/bubbles/v2/key"
87
tea "github.com/charmbracelet/bubbletea/v2"
98
"github.com/charmbracelet/lipgloss/v2"
109
"github.com/opencode-ai/opencode/internal/app"
@@ -36,16 +35,19 @@ const (
3635
type MessageListCmp interface {
3736
util.Model
3837
layout.Sizeable
38+
layout.Focusable
3939
}
4040

4141
// messageListCmp implements MessageListCmp, providing a virtualized list
4242
// of chat messages with support for tool calls, real-time updates, and
4343
// session switching.
4444
type messageListCmp struct {
45-
app *app.App
46-
width, height int
47-
session session.Session
48-
listCmp list.ListModel
45+
app *app.App
46+
width, height int
47+
session session.Session
48+
listCmp list.ListModel
49+
focused bool // Focus state for styling
50+
previousSelected int // Last selected item index for restoring focus
4951

5052
lastUserMessageTime int64
5153
}
@@ -54,27 +56,14 @@ type messageListCmp struct {
5456
// and reverse ordering (newest messages at bottom).
5557
func NewMessagesListCmp(app *app.App) MessageListCmp {
5658
defaultKeymaps := list.DefaultKeyMap()
57-
defaultKeymaps.Up.SetEnabled(false)
58-
defaultKeymaps.Down.SetEnabled(false)
59-
defaultKeymaps.NDown = key.NewBinding(
60-
key.WithKeys("ctrl+j"),
61-
)
62-
defaultKeymaps.NUp = key.NewBinding(
63-
key.WithKeys("ctrl+k"),
64-
)
65-
defaultKeymaps.Home = key.NewBinding(
66-
key.WithKeys("ctrl+shift+up"),
67-
)
68-
defaultKeymaps.End = key.NewBinding(
69-
key.WithKeys("ctrl+shift+down"),
70-
)
7159
return &messageListCmp{
7260
app: app,
7361
listCmp: list.New(
7462
list.WithGapSize(1),
7563
list.WithReverse(true),
7664
list.WithKeyMap(defaultKeymaps),
7765
),
66+
previousSelected: list.NoSelection,
7867
}
7968
}
8069

@@ -491,3 +480,27 @@ func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
491480
m.height = height - 1
492481
return m.listCmp.SetSize(width, height-1)
493482
}
483+
484+
// Blur implements MessageListCmp.
485+
func (m *messageListCmp) Blur() tea.Cmd {
486+
m.focused = false
487+
m.previousSelected = m.listCmp.SelectedIndex()
488+
m.listCmp.ClearSelection()
489+
return nil
490+
}
491+
492+
// Focus implements MessageListCmp.
493+
func (m *messageListCmp) Focus() tea.Cmd {
494+
m.focused = true
495+
if m.previousSelected != list.NoSelection {
496+
m.listCmp.SetSelected(m.previousSelected)
497+
} else {
498+
m.listCmp.SetSelected(len(m.listCmp.Items()) - 1)
499+
}
500+
return nil
501+
}
502+
503+
// IsFocused implements MessageListCmp.
504+
func (m *messageListCmp) IsFocused() bool {
505+
return m.focused
506+
}

internal/tui/components/chat/editor/editor.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
361361
return " > "
362362
}
363363
if focused {
364-
return t.S().Base.Foreground(t.Blue).Render("::: ")
364+
return t.S().Base.Foreground(t.GreenDark).Render("::: ")
365365
} else {
366366
return t.S().Muted.Render("::: ")
367367
}

internal/tui/components/chat/messages/messages.go

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package messages
22

33
import (
44
"fmt"
5-
"image/color"
65
"path/filepath"
76
"strings"
87
"time"
@@ -14,6 +13,7 @@ import (
1413

1514
"github.com/opencode-ai/opencode/internal/message"
1615
"github.com/opencode-ai/opencode/internal/tui/components/anim"
16+
"github.com/opencode-ai/opencode/internal/tui/components/core"
1717
"github.com/opencode-ai/opencode/internal/tui/layout"
1818
"github.com/opencode-ai/opencode/internal/tui/styles"
1919
"github.com/opencode-ai/opencode/internal/tui/util"
@@ -117,38 +117,35 @@ func (m *messageCmp) GetMessage() message.Message {
117117
// textWidth calculates the available width for text content,
118118
// accounting for borders and padding
119119
func (m *messageCmp) textWidth() int {
120-
return m.width - 1 // take into account the border
120+
return m.width - 2 // take into account the border and/or padding
121121
}
122122

123123
// style returns the lipgloss style for the message component.
124124
// Applies different border colors and styles based on message role and focus state.
125125
func (msg *messageCmp) style() lipgloss.Style {
126126
t := styles.CurrentTheme()
127-
var borderColor color.Color
128127
borderStyle := lipgloss.NormalBorder()
129128
if msg.focused {
130-
borderStyle = lipgloss.DoubleBorder()
129+
borderStyle = lipgloss.ThickBorder()
131130
}
132131

133-
switch msg.message.Role {
134-
case message.User:
135-
borderColor = t.Secondary
136-
case message.Assistant:
137-
borderColor = t.Primary
138-
default:
139-
// Tool call
140-
borderColor = t.BgSubtle
132+
style := t.S().Text
133+
if msg.message.Role == message.User {
134+
style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.Primary)
135+
} else {
136+
if msg.focused {
137+
style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.GreenDark)
138+
} else {
139+
style = style.PaddingLeft(2)
140+
}
141141
}
142-
143-
return t.S().Muted.
144-
BorderLeft(true).
145-
BorderForeground(borderColor).
146-
BorderStyle(borderStyle)
142+
return style
147143
}
148144

149145
// renderAssistantMessage renders assistant messages with optional footer information.
150146
// Shows model name, response time, and finish reason when the message is complete.
151147
func (m *messageCmp) renderAssistantMessage() string {
148+
t := styles.CurrentTheme()
152149
parts := []string{
153150
m.markdownContent(),
154151
}
@@ -170,7 +167,8 @@ func (m *messageCmp) renderAssistantMessage() string {
170167
case message.FinishReasonPermissionDenied:
171168
infoMsg = "permission denied"
172169
}
173-
parts = append(parts, fmt.Sprintf(" %s (%s)", models.SupportedModels[m.message.Model].Name, infoMsg))
170+
assistant := t.S().Muted.Render(fmt.Sprintf("⬡ %s (%s)", models.SupportedModels[m.message.Model].Name, infoMsg))
171+
parts = append(parts, core.Section(assistant, m.textWidth()))
174172
}
175173

176174
joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
@@ -202,7 +200,7 @@ func (m *messageCmp) renderUserMessage() string {
202200
parts = append(parts, "", strings.Join(attachments, ""))
203201
}
204202
joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
205-
return m.style().Render(joined)
203+
return m.style().MarginBottom(1).Render(joined)
206204
}
207205

208206
// toMarkdown converts text content to rendered markdown using the configured renderer
@@ -280,7 +278,8 @@ func (m *messageCmp) GetSize() (int, int) {
280278

281279
// SetSize updates the width of the message component for text wrapping
282280
func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
283-
m.width = width
281+
// For better readability, we limit the width to a maximum of 120 characters
282+
m.width = min(width, 120)
284283
return nil
285284
}
286285

internal/tui/components/chat/messages/renderer.go

Lines changed: 58 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package messages
33
import (
44
"encoding/json"
55
"fmt"
6+
"os"
67
"strings"
78
"time"
89

@@ -95,7 +96,7 @@ func (br baseRenderer) renderWithParams(v *toolCallCmp, toolName string, args []
9596
if v.isNested {
9697
width -= 4 // Adjust for nested tool call indentation
9798
}
98-
header := makeHeader(toolName, width, args...)
99+
header := br.makeHeader(v, toolName, width, args...)
99100
if v.isNested {
100101
return v.style().Render(header)
101102
}
@@ -111,6 +112,32 @@ func (br baseRenderer) unmarshalParams(input string, target any) error {
111112
return json.Unmarshal([]byte(input), target)
112113
}
113114

115+
// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
116+
func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params ...string) string {
117+
t := styles.CurrentTheme()
118+
icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
119+
if v.result.ToolCallID != "" {
120+
if v.result.IsError {
121+
icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
122+
} else {
123+
icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
124+
}
125+
} else if v.cancelled {
126+
icon = t.S().Muted.Render(styles.ToolPending)
127+
}
128+
tool = t.S().Base.Foreground(t.Blue).Render(tool)
129+
prefix := fmt.Sprintf("%s %s: ", icon, tool)
130+
return prefix + renderParamList(width-lipgloss.Width(prefix), params...)
131+
}
132+
133+
// renderError provides consistent error rendering
134+
func (br baseRenderer) renderError(v *toolCallCmp, message string) string {
135+
t := styles.CurrentTheme()
136+
header := br.makeHeader(v, prettifyToolName(v.call.Name), v.textWidth(), "")
137+
message = t.S().Error.Render(v.fit(message, v.textWidth()-2)) // -2 for padding
138+
return joinHeaderBody(header, message)
139+
}
140+
114141
// Register tool renderers
115142
func init() {
116143
registry.register(tools.BashToolName, func() renderer { return bashRenderer{} })
@@ -167,12 +194,6 @@ func (br bashRenderer) Render(v *toolCallCmp) string {
167194
})
168195
}
169196

170-
// renderError provides consistent error rendering
171-
func (br baseRenderer) renderError(v *toolCallCmp, message string) string {
172-
header := makeHeader("Error", v.textWidth(), message)
173-
return joinHeaderBody(header, "")
174-
}
175-
176197
// -----------------------------------------------------------------------------
177198
// View renderer
178199
// -----------------------------------------------------------------------------
@@ -189,7 +210,7 @@ func (vr viewRenderer) Render(v *toolCallCmp) string {
189210
return vr.renderError(v, "Invalid view parameters")
190211
}
191212

192-
file := removeWorkingDirPrefix(params.FilePath)
213+
file := prettyPath(params.FilePath)
193214
args := newParamBuilder().
194215
addMain(file).
195216
addKeyValue("limit", formatNonZero(params.Limit)).
@@ -229,7 +250,7 @@ func (er editRenderer) Render(v *toolCallCmp) string {
229250
return er.renderError(v, "Invalid edit parameters")
230251
}
231252

232-
file := removeWorkingDirPrefix(params.FilePath)
253+
file := prettyPath(params.FilePath)
233254
args := newParamBuilder().addMain(file).build()
234255

235256
return er.renderWithParams(v, "Edit", args, func() string {
@@ -239,7 +260,7 @@ func (er editRenderer) Render(v *toolCallCmp) string {
239260
}
240261

241262
trunc := truncateHeight(meta.Diff, responseContextHeight)
242-
diffView, _ := diff.FormatDiff(trunc, diff.WithTotalWidth(v.textWidth()))
263+
diffView, _ := diff.FormatDiff(trunc, diff.WithTotalWidth(v.textWidth()-2))
243264
return diffView
244265
})
245266
}
@@ -260,7 +281,7 @@ func (wr writeRenderer) Render(v *toolCallCmp) string {
260281
return wr.renderError(v, "Invalid write parameters")
261282
}
262283

263-
file := removeWorkingDirPrefix(params.FilePath)
284+
file := prettyPath(params.FilePath)
264285
args := newParamBuilder().addMain(file).build()
265286

266287
return wr.renderWithParams(v, "Write", args, func() string {
@@ -494,7 +515,7 @@ func (tr agentRenderer) Render(v *toolCallCmp) string {
494515
prompt = strings.ReplaceAll(prompt, "\n", " ")
495516
args := newParamBuilder().addMain(prompt).build()
496517

497-
header := makeHeader("Task", v.textWidth(), args...)
518+
header := tr.makeHeader(v, "Task", v.textWidth(), args...)
498519
t := tree.Root(header)
499520

500521
for _, call := range v.nestedToolCalls {
@@ -524,12 +545,6 @@ func (tr agentRenderer) Render(v *toolCallCmp) string {
524545
return joinHeaderBody(header, body)
525546
}
526547

527-
// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
528-
func makeHeader(tool string, width int, params ...string) string {
529-
prefix := tool + ": "
530-
return prefix + renderParamList(width-lipgloss.Width(prefix), params...)
531-
}
532-
533548
// renderParamList renders params, params[0] (params[1]=params[2] ....)
534549
func renderParamList(paramsWidth int, params ...string) string {
535550
if len(params) == 0 {
@@ -575,38 +590,46 @@ func renderParamList(paramsWidth int, params ...string) string {
575590

576591
// earlyState returns immediately‑rendered error/cancelled/ongoing states.
577592
func earlyState(header string, v *toolCallCmp) (string, bool) {
593+
t := styles.CurrentTheme()
594+
message := ""
578595
switch {
579596
case v.result.IsError:
580-
return lipgloss.JoinVertical(lipgloss.Left, header, v.renderToolError()), true
597+
message = v.renderToolError()
581598
case v.cancelled:
582-
return lipgloss.JoinVertical(lipgloss.Left, header, "Cancelled"), true
599+
message = "Cancelled"
583600
case v.result.ToolCallID == "":
584-
return lipgloss.JoinVertical(lipgloss.Left, header, "Waiting for tool to finish..."), true
601+
message = "Waiting for tool to start..."
585602
default:
586603
return "", false
587604
}
605+
606+
message = t.S().Base.PaddingLeft(2).Render(message)
607+
return lipgloss.JoinVertical(lipgloss.Left, header, message), true
588608
}
589609

590610
func joinHeaderBody(header, body string) string {
591-
return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "")
611+
t := styles.CurrentTheme()
612+
body = t.S().Base.PaddingLeft(2).Render(body)
613+
return lipgloss.JoinVertical(lipgloss.Left, header, body, "")
592614
}
593615

594616
func renderPlainContent(v *toolCallCmp, content string) string {
595617
t := styles.CurrentTheme()
596618
content = strings.TrimSpace(content)
597619
lines := strings.Split(content, "\n")
598620

621+
width := v.textWidth() - 2 // -2 for left padding
599622
var out []string
600623
for i, ln := range lines {
601624
if i >= responseContextHeight {
602625
break
603626
}
604627
ln = " " + ln // left padding
605-
if len(ln) > v.textWidth() {
606-
ln = v.fit(ln, v.textWidth())
628+
if len(ln) > width {
629+
ln = v.fit(ln, width)
607630
}
608631
out = append(out, t.S().Muted.
609-
Width(v.textWidth()).
632+
Width(width).
610633
Background(t.BgSubtle).
611634
Render(ln))
612635
}
@@ -638,7 +661,7 @@ func renderCodeContent(v *toolCallCmp, path, content string, offset int) string
638661
PaddingLeft(4).
639662
PaddingRight(2).
640663
Render(fmt.Sprintf("%d", i+1+offset))
641-
w := v.textWidth() - lipgloss.Width(num)
664+
w := v.textWidth() - 2 - lipgloss.Width(num) // -2 for left padding
642665
lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
643666
num,
644667
t.S().Base.
@@ -669,6 +692,15 @@ func truncateHeight(s string, h int) string {
669692
return s
670693
}
671694

695+
func prettyPath(path string) string {
696+
// replace home directory with ~
697+
homeDir, err := os.UserHomeDir()
698+
if err == nil {
699+
path = strings.ReplaceAll(path, homeDir, "~")
700+
}
701+
return path
702+
}
703+
672704
func prettifyToolName(name string) string {
673705
switch name {
674706
case agent.AgentToolName:

0 commit comments

Comments
 (0)