on main help sign in
initial message revamp
commitdb179e7f31eaad6124e7a80a212446cca65154ce authorKujtim Hoxha <kujtimii.h@gmail.com> dateThu 5 Jun 2025, 23:08 (1 year ago) parent3eb2a93
9 files changed+151−95
internal/tui/components/chat/chat.go+32−19 internal/tui/components/chat/editor/editor.go+1−1 internal/tui/components/chat/messages/messages.go+19−20 internal/tui/components/chat/messages/renderer.go+58−26 internal/tui/components/chat/messages/tool.go+12−18 internal/tui/components/core/list/keys.go+6−6 internal/tui/components/core/list/list.go+13 internal/tui/styles/icons.go+6−1 internal/tui/styles/theme.go+4−4
internal/tui/components/chat/chat.go+32−19
@@ -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 (
3635type 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.
4444type 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,20 +56,6 @@ type messageListCmp struct {
5456// and reverse ordering (newest messages at bottom).
5557func 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(
@@ -75,6 +63,7 @@ func NewMessagesListCmp(app *app.App) MessageListCmp {
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+1−1
@@ -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+19−20
@@ -2,7 +2,6 @@ package messages
22
33import (
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
119119func (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.
125125func (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.
151147func (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
282280func (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+58−26
@@ -3,6 +3,7 @@ package messages
33import (
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
115142func 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] ....)
534549func renderParamList(paramsWidth int, params ...string) string {
535550 if len(params) == 0 {
@@ -575,20 +590,27 @@ func renderParamList(paramsWidth int, params ...string) string {
575590
576591// earlyState returns immediately‑rendered error/cancelled/ongoing states.
577592func 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
590610func 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
594616func renderPlainContent(v *toolCallCmp, content string) string {
@@ -596,17 +618,18 @@ func renderPlainContent(v *toolCallCmp, content string) string {
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+
672704func prettifyToolName(name string) string {
673705 switch name {
674706 case agent.AgentToolName:
internal/tui/components/chat/messages/tool.go+12−18
@@ -40,7 +40,7 @@ type toolCallCmp struct {
4040 isNested bool // Whether this tool call is nested within another
4141
4242 // Tool call data and state
43− parentMessageId string // ID of the message that initiated this tool call
43+ parentMessageID string // ID of the message that initiated this tool call
4444 call message.ToolCall // The tool call being executed
4545 result message.ToolResult // The result of the tool execution
4646 cancelled bool // Whether the tool call was cancelled
@@ -86,7 +86,7 @@ func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption {
8686func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
8787 m := &toolCallCmp{
8888 call: tc,
89− parentMessageId: parentMessageId,
89+ parentMessageID: parentMessageId,
9090 }
9191 for _, opt := range opts {
9292 opt(m)
@@ -140,7 +140,7 @@ func (m *toolCallCmp) View() tea.View {
140140 if m.isNested {
141141 return tea.NewView(box.Render(m.renderPending()))
142142 }
143− return tea.NewView(box.PaddingLeft(1).Render(m.renderPending()))
143+ return tea.NewView(box.Render(m.renderPending()))
144144 }
145145
146146 r := registry.lookup(m.call.Name)
@@ -148,7 +148,7 @@ func (m *toolCallCmp) View() tea.View {
148148 if m.isNested {
149149 return tea.NewView(box.Render(r.Render(m)))
150150 }
151− return tea.NewView(box.PaddingLeft(1).Render(r.Render(m)))
151+ return tea.NewView(box.Render(r.Render(m)))
152152}
153153
154154// State management methods
@@ -168,7 +168,7 @@ func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
168168
169169// ParentMessageId returns the ID of the message that initiated this tool call
170170func (m *toolCallCmp) ParentMessageId() string {
171− return m.parentMessageId
171+ return m.parentMessageID
172172}
173173
174174// SetToolResult updates the tool result and stops the spinning animation
@@ -209,30 +209,24 @@ func (m *toolCallCmp) SetIsNested(isNested bool) {
209209
210210// renderPending displays the tool name with a loading animation for pending tool calls
211211func (m *toolCallCmp) renderPending() string {
212− return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), m.anim.View())
212+ t := styles.CurrentTheme()
213+ icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
214+ tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name))
215+ return fmt.Sprintf("%s %s: %s", icon, tool, m.anim.View())
213216}
214217
215218// style returns the lipgloss style for the tool call component.
216219// Applies muted colors and focus-dependent border styles.
217220func (m *toolCallCmp) style() lipgloss.Style {
218221 t := styles.CurrentTheme()
219− if m.isNested {
220− return t.S().Muted
221− }
222− borderStyle := lipgloss.NormalBorder()
223− if m.focused {
224− borderStyle = lipgloss.DoubleBorder()
225− }
226− return t.S().Muted.
227− BorderLeft(true).
228− BorderForeground(t.Border).
229− BorderStyle(borderStyle)
222+
223+ return t.S().Muted.PaddingLeft(4)
230224}
231225
232226// textWidth calculates the available width for text content,
233227// accounting for borders and padding
234228func (m *toolCallCmp) textWidth() int {
235− return m.width - 2 // take into account the border and PaddingLeft
229+ return m.width - 5 // take into account the border and PaddingLeft
236230}
237231
238232// fit truncates content to fit within the specified width with ellipsis
internal/tui/components/core/list/keys.go+6−6
@@ -33,22 +33,22 @@ func DefaultKeyMap() KeyMap {
3333 key.WithKeys("k"),
3434 ),
3535 UpOneItem: key.NewBinding(
36− key.WithKeys("shift+up"),
36+ key.WithKeys("shift+up", "shift+k"),
3737 ),
3838 DownOneItem: key.NewBinding(
39− key.WithKeys("shift+down"),
39+ key.WithKeys("shift+down", "shift+j"),
4040 ),
4141 HalfPageDown: key.NewBinding(
42− key.WithKeys("ctrl+d"),
42+ key.WithKeys("d"),
4343 ),
4444 HalfPageUp: key.NewBinding(
45− key.WithKeys("ctrl+u"),
45+ key.WithKeys("u"),
4646 ),
4747 Home: key.NewBinding(
48− key.WithKeys("ctrl+g", "home"),
48+ key.WithKeys("g", "home"),
4949 ),
5050 End: key.NewBinding(
51− key.WithKeys("ctrl+shift+g", "end"),
51+ key.WithKeys("shift+g", "end"),
5252 ),
5353 }
5454}
internal/tui/components/core/list/list.go+13
@@ -40,6 +40,7 @@ type ListModel interface {
4040 Items() []util.Model // Get all items in the list
4141 SelectedIndex() int // Get the index of the currently selected item
4242 SetSelected(int) tea.Cmd // Set the selected item by index and scroll to it
43+ ClearSelection() tea.Cmd // Clear the current selection
4344 Filter(string) tea.Cmd // Filter items based on a search term
4445}
4546
@@ -1332,3 +1333,15 @@ func (m *model) SetSelected(index int) tea.Cmd {
13321333 }
13331334 return tea.Batch(cmds...)
13341335}
1336+
1337+// ClearSelection clears the current selection and focus.
1338+func (m *model) ClearSelection() tea.Cmd {
1339+ cmds := []tea.Cmd{}
1340+ if m.selectionState.selectedIndex >= 0 && m.selectionState.selectedIndex < len(m.filteredItems) {
1341+ if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
1342+ cmds = append(cmds, i.Blur())
1343+ }
1344+ }
1345+ m.selectionState.selectedIndex = NoSelection
1346+ return tea.Batch(cmds...)
1347+}
internal/tui/styles/icons.go+6−1
@@ -2,11 +2,16 @@ package styles
22
33const (
44 CheckIcon string = "✓"
5− ErrorIcon string = "✖"
5+ ErrorIcon string = "×"
66 WarningIcon string = "⚠"
77 InfoIcon string = ""
88 HintIcon string = "i"
99 SpinnerIcon string = "..."
1010 LoadingIcon string = "⟳"
1111 DocumentIcon string = "🖼"
12+
13+ // Tool call icons
14+ ToolPending string = "●"
15+ ToolSuccess string = "✓"
16+ ToolError string = "×"
1217)
internal/tui/styles/theme.go+4−4
@@ -199,11 +199,11 @@ func (t *Theme) buildStyles() *Styles {
199199 Markdown: ansi.StyleConfig{
200200 Document: ansi.StyleBlock{
201201 StylePrimitive: ansi.StylePrimitive{
202− BlockPrefix: "\n",
203− BlockSuffix: "\n",
204− Color: stringPtr("252"),
202+ // BlockPrefix: "\n",
203+ // BlockSuffix: "\n",
204+ Color: stringPtr("252"),
205205 },
206− Margin: uintPtr(defaultMargin),
206+ // Margin: uintPtr(defaultMargin),
207207 },
208208 BlockQuote: ansi.StyleBlock{
209209 StylePrimitive: ansi.StylePrimitive{},