initial message revamp

Kujtim Hoxha created

Change summary

internal/tui/components/chat/chat.go              | 51 ++++++---
internal/tui/components/chat/editor/editor.go     |  2 
internal/tui/components/chat/messages/messages.go | 39 +++----
internal/tui/components/chat/messages/renderer.go | 84 +++++++++++-----
internal/tui/components/chat/messages/tool.go     | 30 ++---
internal/tui/components/core/list/keys.go         | 12 +-
internal/tui/components/core/list/list.go         | 13 ++
internal/tui/styles/icons.go                      |  7 +
internal/tui/styles/theme.go                      |  8 
9 files changed, 151 insertions(+), 95 deletions(-)

Detailed changes

internal/tui/components/chat/chat.go πŸ”—

@@ -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
+}

internal/tui/components/chat/editor/editor.go πŸ”—

@@ -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("::: ")
 		}

internal/tui/components/chat/messages/messages.go πŸ”—

@@ -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
 }
 

internal/tui/components/chat/messages/renderer.go πŸ”—

@@ -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:

internal/tui/components/chat/messages/tool.go πŸ”—

@@ -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

internal/tui/components/core/list/keys.go πŸ”—

@@ -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"),
 		),
 	}
 }

internal/tui/components/core/list/list.go πŸ”—

@@ -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...)
+}

internal/tui/styles/icons.go πŸ”—

@@ -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 = "Γ—"
 )

internal/tui/styles/theme.go πŸ”—

@@ -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{},