From db179e7f31eaad6124e7a80a212446cca65154ce Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 5 Jun 2025 23:08:50 +0200 Subject: [PATCH] initial message revamp --- internal/tui/components/chat/chat.go | 51 ++++++----- internal/tui/components/chat/editor/editor.go | 2 +- .../tui/components/chat/messages/messages.go | 39 +++++---- .../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(-) diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 4abff286babc4c3609371fc084567f7c96d91cd3..eaff4c7e3b697abd18aa9b2831ed2814e4c7c2cf 100644 --- a/internal/tui/components/chat/chat.go +++ b/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 +} diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index e9e565daade4c855c9a50fa7f84cfd0a07d2b016..096e6c5e97ce8d6f2a3fc243fe44fa095a858f11 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/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("::: ") } diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 647db5595978fc44b44d82e4bcf54fddaaebe3f6..f04fe6a08a97175d2c69a01f75096394a2d3aef5 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/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 } diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go index ee8d679529505e8e984a0b5fa5c57ccb9d266b57..c67d2bbed7c8793969400459e81325b5c92cdf56 100644 --- a/internal/tui/components/chat/messages/renderer.go +++ b/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 ": 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 ": 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: diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index 33d711f3941af28c233242d4e4f94783358d1031..fa9de764ee20a90b4680e8495eb57bc64e028f4c 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/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 diff --git a/internal/tui/components/core/list/keys.go b/internal/tui/components/core/list/keys.go index 23035c4030542b6a157a3dd08448ea4271d095d6..46b6cf2b01d67e097799de0df11c34b3efa436f6 100644 --- a/internal/tui/components/core/list/keys.go +++ b/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"), ), } } diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go index 247672e53d76e931e04dd74c3f7d4690f7162cd9..cfbcc89033d49dd7cdb8af32351dd1cedcbe27ec 100644 --- a/internal/tui/components/core/list/list.go +++ b/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...) +} diff --git a/internal/tui/styles/icons.go b/internal/tui/styles/icons.go index 59f43dca6d995255688268f7859bce774b49aad5..2b02442437918adbc675bd3ff01b5e5cd71902b7 100644 --- a/internal/tui/styles/icons.go +++ b/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 = "×" ) diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go index 099cef8ef957ee2e45c931bb6323e2630f9f74ce..0bfcc0388c4da1336863445a9524284569c35722 100644 --- a/internal/tui/styles/theme.go +++ b/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{},