Detailed changes
@@ -4,7 +4,6 @@ import (
"context"
"time"
- "github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/app"
@@ -36,16 +35,19 @@ const (
type MessageListCmp interface {
util.Model
layout.Sizeable
+ layout.Focusable
}
// messageListCmp implements MessageListCmp, providing a virtualized list
// of chat messages with support for tool calls, real-time updates, and
// session switching.
type messageListCmp struct {
- app *app.App
- width, height int
- session session.Session
- listCmp list.ListModel
+ app *app.App
+ width, height int
+ session session.Session
+ listCmp list.ListModel
+ focused bool // Focus state for styling
+ previousSelected int // Last selected item index for restoring focus
lastUserMessageTime int64
}
@@ -54,20 +56,6 @@ type messageListCmp struct {
// and reverse ordering (newest messages at bottom).
func NewMessagesListCmp(app *app.App) MessageListCmp {
defaultKeymaps := list.DefaultKeyMap()
- defaultKeymaps.Up.SetEnabled(false)
- defaultKeymaps.Down.SetEnabled(false)
- defaultKeymaps.NDown = key.NewBinding(
- key.WithKeys("ctrl+j"),
- )
- defaultKeymaps.NUp = key.NewBinding(
- key.WithKeys("ctrl+k"),
- )
- defaultKeymaps.Home = key.NewBinding(
- key.WithKeys("ctrl+shift+up"),
- )
- defaultKeymaps.End = key.NewBinding(
- key.WithKeys("ctrl+shift+down"),
- )
return &messageListCmp{
app: app,
listCmp: list.New(
@@ -75,6 +63,7 @@ func NewMessagesListCmp(app *app.App) MessageListCmp {
list.WithReverse(true),
list.WithKeyMap(defaultKeymaps),
),
+ previousSelected: list.NoSelection,
}
}
@@ -491,3 +480,27 @@ func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
m.height = height - 1
return m.listCmp.SetSize(width, height-1)
}
+
+// Blur implements MessageListCmp.
+func (m *messageListCmp) Blur() tea.Cmd {
+ m.focused = false
+ m.previousSelected = m.listCmp.SelectedIndex()
+ m.listCmp.ClearSelection()
+ return nil
+}
+
+// Focus implements MessageListCmp.
+func (m *messageListCmp) Focus() tea.Cmd {
+ m.focused = true
+ if m.previousSelected != list.NoSelection {
+ m.listCmp.SetSelected(m.previousSelected)
+ } else {
+ m.listCmp.SetSelected(len(m.listCmp.Items()) - 1)
+ }
+ return nil
+}
+
+// IsFocused implements MessageListCmp.
+func (m *messageListCmp) IsFocused() bool {
+ return m.focused
+}
@@ -361,7 +361,7 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
return " > "
}
if focused {
- return t.S().Base.Foreground(t.Blue).Render("::: ")
+ return t.S().Base.Foreground(t.GreenDark).Render("::: ")
} else {
return t.S().Muted.Render("::: ")
}
@@ -2,7 +2,6 @@ package messages
import (
"fmt"
- "image/color"
"path/filepath"
"strings"
"time"
@@ -14,6 +13,7 @@ import (
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/tui/components/anim"
+ "github.com/opencode-ai/opencode/internal/tui/components/core"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/util"
@@ -117,38 +117,35 @@ func (m *messageCmp) GetMessage() message.Message {
// textWidth calculates the available width for text content,
// accounting for borders and padding
func (m *messageCmp) textWidth() int {
- return m.width - 1 // take into account the border
+ return m.width - 2 // take into account the border and/or padding
}
// style returns the lipgloss style for the message component.
// Applies different border colors and styles based on message role and focus state.
func (msg *messageCmp) style() lipgloss.Style {
t := styles.CurrentTheme()
- var borderColor color.Color
borderStyle := lipgloss.NormalBorder()
if msg.focused {
- borderStyle = lipgloss.DoubleBorder()
+ borderStyle = lipgloss.ThickBorder()
}
- switch msg.message.Role {
- case message.User:
- borderColor = t.Secondary
- case message.Assistant:
- borderColor = t.Primary
- default:
- // Tool call
- borderColor = t.BgSubtle
+ style := t.S().Text
+ if msg.message.Role == message.User {
+ style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.Primary)
+ } else {
+ if msg.focused {
+ style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.GreenDark)
+ } else {
+ style = style.PaddingLeft(2)
+ }
}
-
- return t.S().Muted.
- BorderLeft(true).
- BorderForeground(borderColor).
- BorderStyle(borderStyle)
+ return style
}
// renderAssistantMessage renders assistant messages with optional footer information.
// Shows model name, response time, and finish reason when the message is complete.
func (m *messageCmp) renderAssistantMessage() string {
+ t := styles.CurrentTheme()
parts := []string{
m.markdownContent(),
}
@@ -170,7 +167,8 @@ func (m *messageCmp) renderAssistantMessage() string {
case message.FinishReasonPermissionDenied:
infoMsg = "permission denied"
}
- parts = append(parts, fmt.Sprintf(" %s (%s)", models.SupportedModels[m.message.Model].Name, infoMsg))
+ assistant := t.S().Muted.Render(fmt.Sprintf("⬑ %s (%s)", models.SupportedModels[m.message.Model].Name, infoMsg))
+ parts = append(parts, core.Section(assistant, m.textWidth()))
}
joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
@@ -202,7 +200,7 @@ func (m *messageCmp) renderUserMessage() string {
parts = append(parts, "", strings.Join(attachments, ""))
}
joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
- return m.style().Render(joined)
+ return m.style().MarginBottom(1).Render(joined)
}
// toMarkdown converts text content to rendered markdown using the configured renderer
@@ -280,7 +278,8 @@ func (m *messageCmp) GetSize() (int, int) {
// SetSize updates the width of the message component for text wrapping
func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
- m.width = width
+ // For better readability, we limit the width to a maximum of 120 characters
+ m.width = min(width, 120)
return nil
}
@@ -3,6 +3,7 @@ package messages
import (
"encoding/json"
"fmt"
+ "os"
"strings"
"time"
@@ -95,7 +96,7 @@ func (br baseRenderer) renderWithParams(v *toolCallCmp, toolName string, args []
if v.isNested {
width -= 4 // Adjust for nested tool call indentation
}
- header := makeHeader(toolName, width, args...)
+ header := br.makeHeader(v, toolName, width, args...)
if v.isNested {
return v.style().Render(header)
}
@@ -111,6 +112,32 @@ func (br baseRenderer) unmarshalParams(input string, target any) error {
return json.Unmarshal([]byte(input), target)
}
+// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
+func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params ...string) string {
+ t := styles.CurrentTheme()
+ icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
+ if v.result.ToolCallID != "" {
+ if v.result.IsError {
+ icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
+ } else {
+ icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
+ }
+ } else if v.cancelled {
+ icon = t.S().Muted.Render(styles.ToolPending)
+ }
+ tool = t.S().Base.Foreground(t.Blue).Render(tool)
+ prefix := fmt.Sprintf("%s %s: ", icon, tool)
+ return prefix + renderParamList(width-lipgloss.Width(prefix), params...)
+}
+
+// renderError provides consistent error rendering
+func (br baseRenderer) renderError(v *toolCallCmp, message string) string {
+ t := styles.CurrentTheme()
+ header := br.makeHeader(v, prettifyToolName(v.call.Name), v.textWidth(), "")
+ message = t.S().Error.Render(v.fit(message, v.textWidth()-2)) // -2 for padding
+ return joinHeaderBody(header, message)
+}
+
// Register tool renderers
func init() {
registry.register(tools.BashToolName, func() renderer { return bashRenderer{} })
@@ -167,12 +194,6 @@ func (br bashRenderer) Render(v *toolCallCmp) string {
})
}
-// renderError provides consistent error rendering
-func (br baseRenderer) renderError(v *toolCallCmp, message string) string {
- header := makeHeader("Error", v.textWidth(), message)
- return joinHeaderBody(header, "")
-}
-
// -----------------------------------------------------------------------------
// View renderer
// -----------------------------------------------------------------------------
@@ -189,7 +210,7 @@ func (vr viewRenderer) Render(v *toolCallCmp) string {
return vr.renderError(v, "Invalid view parameters")
}
- file := removeWorkingDirPrefix(params.FilePath)
+ file := prettyPath(params.FilePath)
args := newParamBuilder().
addMain(file).
addKeyValue("limit", formatNonZero(params.Limit)).
@@ -229,7 +250,7 @@ func (er editRenderer) Render(v *toolCallCmp) string {
return er.renderError(v, "Invalid edit parameters")
}
- file := removeWorkingDirPrefix(params.FilePath)
+ file := prettyPath(params.FilePath)
args := newParamBuilder().addMain(file).build()
return er.renderWithParams(v, "Edit", args, func() string {
@@ -239,7 +260,7 @@ func (er editRenderer) Render(v *toolCallCmp) string {
}
trunc := truncateHeight(meta.Diff, responseContextHeight)
- diffView, _ := diff.FormatDiff(trunc, diff.WithTotalWidth(v.textWidth()))
+ diffView, _ := diff.FormatDiff(trunc, diff.WithTotalWidth(v.textWidth()-2))
return diffView
})
}
@@ -260,7 +281,7 @@ func (wr writeRenderer) Render(v *toolCallCmp) string {
return wr.renderError(v, "Invalid write parameters")
}
- file := removeWorkingDirPrefix(params.FilePath)
+ file := prettyPath(params.FilePath)
args := newParamBuilder().addMain(file).build()
return wr.renderWithParams(v, "Write", args, func() string {
@@ -494,7 +515,7 @@ func (tr agentRenderer) Render(v *toolCallCmp) string {
prompt = strings.ReplaceAll(prompt, "\n", " ")
args := newParamBuilder().addMain(prompt).build()
- header := makeHeader("Task", v.textWidth(), args...)
+ header := tr.makeHeader(v, "Task", v.textWidth(), args...)
t := tree.Root(header)
for _, call := range v.nestedToolCalls {
@@ -524,12 +545,6 @@ func (tr agentRenderer) Render(v *toolCallCmp) string {
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 + renderParamList(width-lipgloss.Width(prefix), params...)
-}
-
// renderParamList renders params, params[0] (params[1]=params[2] ....)
func renderParamList(paramsWidth int, params ...string) string {
if len(params) == 0 {
@@ -575,20 +590,27 @@ func renderParamList(paramsWidth int, params ...string) string {
// earlyState returns immediatelyβrendered error/cancelled/ongoing states.
func earlyState(header string, v *toolCallCmp) (string, bool) {
+ t := styles.CurrentTheme()
+ message := ""
switch {
case v.result.IsError:
- return lipgloss.JoinVertical(lipgloss.Left, header, v.renderToolError()), true
+ message = v.renderToolError()
case v.cancelled:
- return lipgloss.JoinVertical(lipgloss.Left, header, "Cancelled"), true
+ message = "Cancelled"
case v.result.ToolCallID == "":
- return lipgloss.JoinVertical(lipgloss.Left, header, "Waiting for tool to finish..."), true
+ message = "Waiting for tool to start..."
default:
return "", false
}
+
+ message = t.S().Base.PaddingLeft(2).Render(message)
+ return lipgloss.JoinVertical(lipgloss.Left, header, message), true
}
func joinHeaderBody(header, body string) string {
- return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "")
+ t := styles.CurrentTheme()
+ body = t.S().Base.PaddingLeft(2).Render(body)
+ return lipgloss.JoinVertical(lipgloss.Left, header, body, "")
}
func renderPlainContent(v *toolCallCmp, content string) string {
@@ -596,17 +618,18 @@ func renderPlainContent(v *toolCallCmp, content string) string {
content = strings.TrimSpace(content)
lines := strings.Split(content, "\n")
+ width := v.textWidth() - 2 // -2 for left padding
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())
+ if len(ln) > width {
+ ln = v.fit(ln, width)
}
out = append(out, t.S().Muted.
- Width(v.textWidth()).
+ Width(width).
Background(t.BgSubtle).
Render(ln))
}
@@ -638,7 +661,7 @@ func renderCodeContent(v *toolCallCmp, path, content string, offset int) string
PaddingLeft(4).
PaddingRight(2).
Render(fmt.Sprintf("%d", i+1+offset))
- w := v.textWidth() - lipgloss.Width(num)
+ w := v.textWidth() - 2 - lipgloss.Width(num) // -2 for left padding
lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
num,
t.S().Base.
@@ -669,6 +692,15 @@ func truncateHeight(s string, h int) string {
return s
}
+func prettyPath(path string) string {
+ // replace home directory with ~
+ homeDir, err := os.UserHomeDir()
+ if err == nil {
+ path = strings.ReplaceAll(path, homeDir, "~")
+ }
+ return path
+}
+
func prettifyToolName(name string) string {
switch name {
case agent.AgentToolName:
@@ -40,7 +40,7 @@ type toolCallCmp struct {
isNested bool // Whether this tool call is nested within another
// Tool call data and state
- parentMessageId string // ID of the message that initiated this tool call
+ parentMessageID string // ID of the message that initiated this tool call
call message.ToolCall // The tool call being executed
result message.ToolResult // The result of the tool execution
cancelled bool // Whether the tool call was cancelled
@@ -86,7 +86,7 @@ func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption {
func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
m := &toolCallCmp{
call: tc,
- parentMessageId: parentMessageId,
+ parentMessageID: parentMessageId,
}
for _, opt := range opts {
opt(m)
@@ -140,7 +140,7 @@ func (m *toolCallCmp) View() tea.View {
if m.isNested {
return tea.NewView(box.Render(m.renderPending()))
}
- return tea.NewView(box.PaddingLeft(1).Render(m.renderPending()))
+ return tea.NewView(box.Render(m.renderPending()))
}
r := registry.lookup(m.call.Name)
@@ -148,7 +148,7 @@ func (m *toolCallCmp) View() tea.View {
if m.isNested {
return tea.NewView(box.Render(r.Render(m)))
}
- return tea.NewView(box.PaddingLeft(1).Render(r.Render(m)))
+ return tea.NewView(box.Render(r.Render(m)))
}
// State management methods
@@ -168,7 +168,7 @@ func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
// ParentMessageId returns the ID of the message that initiated this tool call
func (m *toolCallCmp) ParentMessageId() string {
- return m.parentMessageId
+ return m.parentMessageID
}
// SetToolResult updates the tool result and stops the spinning animation
@@ -209,30 +209,24 @@ func (m *toolCallCmp) SetIsNested(isNested bool) {
// renderPending displays the tool name with a loading animation for pending tool calls
func (m *toolCallCmp) renderPending() string {
- return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), m.anim.View())
+ t := styles.CurrentTheme()
+ icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
+ tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name))
+ return fmt.Sprintf("%s %s: %s", icon, tool, m.anim.View())
}
// style returns the lipgloss style for the tool call component.
// Applies muted colors and focus-dependent border styles.
func (m *toolCallCmp) style() lipgloss.Style {
t := styles.CurrentTheme()
- if m.isNested {
- return t.S().Muted
- }
- borderStyle := lipgloss.NormalBorder()
- if m.focused {
- borderStyle = lipgloss.DoubleBorder()
- }
- return t.S().Muted.
- BorderLeft(true).
- BorderForeground(t.Border).
- BorderStyle(borderStyle)
+
+ return t.S().Muted.PaddingLeft(4)
}
// textWidth calculates the available width for text content,
// accounting for borders and padding
func (m *toolCallCmp) textWidth() int {
- return m.width - 2 // take into account the border and PaddingLeft
+ return m.width - 5 // take into account the border and PaddingLeft
}
// fit truncates content to fit within the specified width with ellipsis
@@ -33,22 +33,22 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("k"),
),
UpOneItem: key.NewBinding(
- key.WithKeys("shift+up"),
+ key.WithKeys("shift+up", "shift+k"),
),
DownOneItem: key.NewBinding(
- key.WithKeys("shift+down"),
+ key.WithKeys("shift+down", "shift+j"),
),
HalfPageDown: key.NewBinding(
- key.WithKeys("ctrl+d"),
+ key.WithKeys("d"),
),
HalfPageUp: key.NewBinding(
- key.WithKeys("ctrl+u"),
+ key.WithKeys("u"),
),
Home: key.NewBinding(
- key.WithKeys("ctrl+g", "home"),
+ key.WithKeys("g", "home"),
),
End: key.NewBinding(
- key.WithKeys("ctrl+shift+g", "end"),
+ key.WithKeys("shift+g", "end"),
),
}
}
@@ -40,6 +40,7 @@ type ListModel interface {
Items() []util.Model // Get all items in the list
SelectedIndex() int // Get the index of the currently selected item
SetSelected(int) tea.Cmd // Set the selected item by index and scroll to it
+ ClearSelection() tea.Cmd // Clear the current selection
Filter(string) tea.Cmd // Filter items based on a search term
}
@@ -1332,3 +1333,15 @@ func (m *model) SetSelected(index int) tea.Cmd {
}
return tea.Batch(cmds...)
}
+
+// ClearSelection clears the current selection and focus.
+func (m *model) ClearSelection() tea.Cmd {
+ cmds := []tea.Cmd{}
+ if m.selectionState.selectedIndex >= 0 && m.selectionState.selectedIndex < len(m.filteredItems) {
+ if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
+ cmds = append(cmds, i.Blur())
+ }
+ }
+ m.selectionState.selectedIndex = NoSelection
+ return tea.Batch(cmds...)
+}
@@ -2,11 +2,16 @@ package styles
const (
CheckIcon string = "β"
- ErrorIcon string = "β"
+ ErrorIcon string = "Γ"
WarningIcon string = "β "
InfoIcon string = ""
HintIcon string = "i"
SpinnerIcon string = "..."
LoadingIcon string = "β³"
DocumentIcon string = "πΌ"
+
+ // Tool call icons
+ ToolPending string = "β"
+ ToolSuccess string = "β"
+ ToolError string = "Γ"
)
@@ -199,11 +199,11 @@ func (t *Theme) buildStyles() *Styles {
Markdown: ansi.StyleConfig{
Document: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
- BlockPrefix: "\n",
- BlockSuffix: "\n",
- Color: stringPtr("252"),
+ // BlockPrefix: "\n",
+ // BlockSuffix: "\n",
+ Color: stringPtr("252"),
},
- Margin: uintPtr(defaultMargin),
+ // Margin: uintPtr(defaultMargin),
},
BlockQuote: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{},