on main help sign in
initial message revamp
db179e7Kujtim Hoxha
internal/tui/components/chat/chat.go+32−19
@@ -4,7 +4,6 @@ import (
4 "context"
4 "context"
5 "time"
5 "time"
6
6
7− "github.com/charmbracelet/bubbles/v2/key"
8 tea "github.com/charmbracelet/bubbletea/v2"
7 tea "github.com/charmbracelet/bubbletea/v2"
9 "github.com/charmbracelet/lipgloss/v2"
8 "github.com/charmbracelet/lipgloss/v2"
10 "github.com/opencode-ai/opencode/internal/app"
9 "github.com/opencode-ai/opencode/internal/app"
@@ -36,16 +35,19 @@ const (
36type MessageListCmp interface {
35type MessageListCmp interface {
37 util.Model
36 util.Model
38 layout.Sizeable
37 layout.Sizeable
38+ layout.Focusable
39}
39}
40
40
41// messageListCmp implements MessageListCmp, providing a virtualized list
41// messageListCmp implements MessageListCmp, providing a virtualized list
42// of chat messages with support for tool calls, real-time updates, and
42// of chat messages with support for tool calls, real-time updates, and
43// session switching.
43// session switching.
44type messageListCmp struct {
44type messageListCmp struct {
45− app *app.App
45+ app *app.App
46− width, height int
46+ width, height int
47− session session.Session
47+ session session.Session
48− listCmp list.ListModel
48+ listCmp list.ListModel
49+ focused bool // Focus state for styling
50+ previousSelected int // Last selected item index for restoring focus
49
51
50 lastUserMessageTime int64
52 lastUserMessageTime int64
51}
53}
@@ -54,20 +56,6 @@ type messageListCmp struct {
54// and reverse ordering (newest messages at bottom).
56// and reverse ordering (newest messages at bottom).
55func NewMessagesListCmp(app *app.App) MessageListCmp {
57func NewMessagesListCmp(app *app.App) MessageListCmp {
56 defaultKeymaps := list.DefaultKeyMap()
58 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− )
71 return &messageListCmp{
59 return &messageListCmp{
72 app: app,
60 app: app,
73 listCmp: list.New(
61 listCmp: list.New(
@@ -75,6 +63,7 @@ func NewMessagesListCmp(app *app.App) MessageListCmp {
75 list.WithReverse(true),
63 list.WithReverse(true),
76 list.WithKeyMap(defaultKeymaps),
64 list.WithKeyMap(defaultKeymaps),
77 ),
65 ),
66+ previousSelected: list.NoSelection,
78 }
67 }
79}
68}
80
69
@@ -491,3 +480,27 @@ func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
491 m.height = height - 1
480 m.height = height - 1
492 return m.listCmp.SetSize(width, height-1)
481 return m.listCmp.SetSize(width, height-1)
493}
482}
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 {
361 return " > "
361 return " > "
362 }
362 }
363 if focused {
363 if focused {
364− return t.S().Base.Foreground(t.Blue).Render("::: ")
364+ return t.S().Base.Foreground(t.GreenDark).Render("::: ")
365 } else {
365 } else {
366 return t.S().Muted.Render("::: ")
366 return t.S().Muted.Render("::: ")
367 }
367 }
internal/tui/components/chat/messages/messages.go+19−20
@@ -2,7 +2,6 @@ package messages
2
2
3import (
3import (
4 "fmt"
4 "fmt"
5− "image/color"
6 "path/filepath"
5 "path/filepath"
7 "strings"
6 "strings"
8 "time"
7 "time"
@@ -14,6 +13,7 @@ import (
14
13
15 "github.com/opencode-ai/opencode/internal/message"
14 "github.com/opencode-ai/opencode/internal/message"
16 "github.com/opencode-ai/opencode/internal/tui/components/anim"
15 "github.com/opencode-ai/opencode/internal/tui/components/anim"
16+ "github.com/opencode-ai/opencode/internal/tui/components/core"
17 "github.com/opencode-ai/opencode/internal/tui/layout"
17 "github.com/opencode-ai/opencode/internal/tui/layout"
18 "github.com/opencode-ai/opencode/internal/tui/styles"
18 "github.com/opencode-ai/opencode/internal/tui/styles"
19 "github.com/opencode-ai/opencode/internal/tui/util"
19 "github.com/opencode-ai/opencode/internal/tui/util"
@@ -117,38 +117,35 @@ func (m *messageCmp) GetMessage() message.Message {
117// textWidth calculates the available width for text content,
117// textWidth calculates the available width for text content,
118// accounting for borders and padding
118// accounting for borders and padding
119func (m *messageCmp) textWidth() int {
119func (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
121}
121}
122
122
123// style returns the lipgloss style for the message component.
123// style returns the lipgloss style for the message component.
124// Applies different border colors and styles based on message role and focus state.
124// Applies different border colors and styles based on message role and focus state.
125func (msg *messageCmp) style() lipgloss.Style {
125func (msg *messageCmp) style() lipgloss.Style {
126 t := styles.CurrentTheme()
126 t := styles.CurrentTheme()
127− var borderColor color.Color
128 borderStyle := lipgloss.NormalBorder()
127 borderStyle := lipgloss.NormalBorder()
129 if msg.focused {
128 if msg.focused {
130− borderStyle = lipgloss.DoubleBorder()
129+ borderStyle = lipgloss.ThickBorder()
131 }
130 }
132
131
132+ style := t.S().Text
133− switch msg.message.Role {
133+ if msg.message.Role == message.User {
134− case message.User:
134+ style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.Primary)
135− borderColor = t.Secondary
135+ } else {
136− case message.Assistant:
136+ if msg.focused {
137− borderColor = t.Primary
137+ style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.GreenDark)
138− default:
138+ } else {
139− // Tool call
139+ style = style.PaddingLeft(2)
140− borderColor = t.BgSubtle
140+ }
141 }
141 }
142−
143− return t.S().Muted.
142+ return style
144− BorderLeft(true).
145− BorderForeground(borderColor).
146− BorderStyle(borderStyle)
147}
143}
148
144
149// renderAssistantMessage renders assistant messages with optional footer information.
145// renderAssistantMessage renders assistant messages with optional footer information.
150// Shows model name, response time, and finish reason when the message is complete.
146// Shows model name, response time, and finish reason when the message is complete.
151func (m *messageCmp) renderAssistantMessage() string {
147func (m *messageCmp) renderAssistantMessage() string {
148+ t := styles.CurrentTheme()
152 parts := []string{
149 parts := []string{
153 m.markdownContent(),
150 m.markdownContent(),
154 }
151 }
@@ -170,7 +167,8 @@ func (m *messageCmp) renderAssistantMessage() string {
170 case message.FinishReasonPermissionDenied:
167 case message.FinishReasonPermissionDenied:
171 infoMsg = "permission denied"
168 infoMsg = "permission denied"
172 }
169 }
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()))
174 }
172 }
175
173
176 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
174 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
@@ -202,7 +200,7 @@ func (m *messageCmp) renderUserMessage() string {
202 parts = append(parts, "", strings.Join(attachments, ""))
200 parts = append(parts, "", strings.Join(attachments, ""))
203 }
201 }
204 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
202 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
205− return m.style().Render(joined)
203+ return m.style().MarginBottom(1).Render(joined)
206}
204}
207
205
208// toMarkdown converts text content to rendered markdown using the configured renderer
206// toMarkdown converts text content to rendered markdown using the configured renderer
@@ -280,7 +278,8 @@ func (m *messageCmp) GetSize() (int, int) {
280
278
281// SetSize updates the width of the message component for text wrapping
279// SetSize updates the width of the message component for text wrapping
282func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
280func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
281+ // For better readability, we limit the width to a maximum of 120 characters
283− m.width = width
282+ m.width = min(width, 120)
284 return nil
283 return nil
285}
284}
286
285
internal/tui/components/chat/messages/renderer.go+58−26
@@ -3,6 +3,7 @@ package messages
3import (
3import (
4 "encoding/json"
4 "encoding/json"
5 "fmt"
5 "fmt"
6+ "os"
6 "strings"
7 "strings"
7 "time"
8 "time"
8
9
@@ -95,7 +96,7 @@ func (br baseRenderer) renderWithParams(v *toolCallCmp, toolName string, args []
95 if v.isNested {
96 if v.isNested {
96 width -= 4 // Adjust for nested tool call indentation
97 width -= 4 // Adjust for nested tool call indentation
97 }
98 }
98− header := makeHeader(toolName, width, args...)
99+ header := br.makeHeader(v, toolName, width, args...)
99 if v.isNested {
100 if v.isNested {
100 return v.style().Render(header)
101 return v.style().Render(header)
101 }
102 }
@@ -111,6 +112,32 @@ func (br baseRenderer) unmarshalParams(input string, target any) error {
111 return json.Unmarshal([]byte(input), target)
112 return json.Unmarshal([]byte(input), target)
112}
113}
113
114
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+
114// Register tool renderers
141// Register tool renderers
115func init() {
142func init() {
116 registry.register(tools.BashToolName, func() renderer { return bashRenderer{} })
143 registry.register(tools.BashToolName, func() renderer { return bashRenderer{} })
@@ -167,12 +194,6 @@ func (br bashRenderer) Render(v *toolCallCmp) string {
167 })
194 })
168}
195}
169
196
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−
176// -----------------------------------------------------------------------------
197// -----------------------------------------------------------------------------
177// View renderer
198// View renderer
178// -----------------------------------------------------------------------------
199// -----------------------------------------------------------------------------
@@ -189,7 +210,7 @@ func (vr viewRenderer) Render(v *toolCallCmp) string {
189 return vr.renderError(v, "Invalid view parameters")
210 return vr.renderError(v, "Invalid view parameters")
190 }
211 }
191
212
192− file := removeWorkingDirPrefix(params.FilePath)
213+ file := prettyPath(params.FilePath)
193 args := newParamBuilder().
214 args := newParamBuilder().
194 addMain(file).
215 addMain(file).
195 addKeyValue("limit", formatNonZero(params.Limit)).
216 addKeyValue("limit", formatNonZero(params.Limit)).
@@ -229,7 +250,7 @@ func (er editRenderer) Render(v *toolCallCmp) string {
229 return er.renderError(v, "Invalid edit parameters")
250 return er.renderError(v, "Invalid edit parameters")
230 }
251 }
231
252
232− file := removeWorkingDirPrefix(params.FilePath)
253+ file := prettyPath(params.FilePath)
233 args := newParamBuilder().addMain(file).build()
254 args := newParamBuilder().addMain(file).build()
234
255
235 return er.renderWithParams(v, "Edit", args, func() string {
256 return er.renderWithParams(v, "Edit", args, func() string {
@@ -239,7 +260,7 @@ func (er editRenderer) Render(v *toolCallCmp) string {
239 }
260 }
240
261
241 trunc := truncateHeight(meta.Diff, responseContextHeight)
262 trunc := truncateHeight(meta.Diff, responseContextHeight)
242− diffView, _ := diff.FormatDiff(trunc, diff.WithTotalWidth(v.textWidth()))
263+ diffView, _ := diff.FormatDiff(trunc, diff.WithTotalWidth(v.textWidth()-2))
243 return diffView
264 return diffView
244 })
265 })
245}
266}
@@ -260,7 +281,7 @@ func (wr writeRenderer) Render(v *toolCallCmp) string {
260 return wr.renderError(v, "Invalid write parameters")
281 return wr.renderError(v, "Invalid write parameters")
261 }
282 }
262
283
263− file := removeWorkingDirPrefix(params.FilePath)
284+ file := prettyPath(params.FilePath)
264 args := newParamBuilder().addMain(file).build()
285 args := newParamBuilder().addMain(file).build()
265
286
266 return wr.renderWithParams(v, "Write", args, func() string {
287 return wr.renderWithParams(v, "Write", args, func() string {
@@ -494,7 +515,7 @@ func (tr agentRenderer) Render(v *toolCallCmp) string {
494 prompt = strings.ReplaceAll(prompt, "\n", " ")
515 prompt = strings.ReplaceAll(prompt, "\n", " ")
495 args := newParamBuilder().addMain(prompt).build()
516 args := newParamBuilder().addMain(prompt).build()
496
517
497− header := makeHeader("Task", v.textWidth(), args...)
518+ header := tr.makeHeader(v, "Task", v.textWidth(), args...)
498 t := tree.Root(header)
519 t := tree.Root(header)
499
520
500 for _, call := range v.nestedToolCalls {
521 for _, call := range v.nestedToolCalls {
@@ -524,12 +545,6 @@ func (tr agentRenderer) Render(v *toolCallCmp) string {
524 return joinHeaderBody(header, body)
545 return joinHeaderBody(header, body)
525}
546}
526
547
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−
533// renderParamList renders params, params[0] (params[1]=params[2] ....)
548// renderParamList renders params, params[0] (params[1]=params[2] ....)
534func renderParamList(paramsWidth int, params ...string) string {
549func renderParamList(paramsWidth int, params ...string) string {
535 if len(params) == 0 {
550 if len(params) == 0 {
@@ -575,20 +590,27 @@ func renderParamList(paramsWidth int, params ...string) string {
575
590
576// earlyState returns immediately‑rendered error/cancelled/ongoing states.
591// earlyState returns immediately‑rendered error/cancelled/ongoing states.
577func earlyState(header string, v *toolCallCmp) (string, bool) {
592func earlyState(header string, v *toolCallCmp) (string, bool) {
593+ t := styles.CurrentTheme()
594+ message := ""
578 switch {
595 switch {
579 case v.result.IsError:
596 case v.result.IsError:
580− return lipgloss.JoinVertical(lipgloss.Left, header, v.renderToolError()), true
597+ message = v.renderToolError()
581 case v.cancelled:
598 case v.cancelled:
582− return lipgloss.JoinVertical(lipgloss.Left, header, "Cancelled"), true
599+ message = "Cancelled"
583 case v.result.ToolCallID == "":
600 case v.result.ToolCallID == "":
584− return lipgloss.JoinVertical(lipgloss.Left, header, "Waiting for tool to finish..."), true
601+ message = "Waiting for tool to start..."
585 default:
602 default:
586 return "", false
603 return "", false
587 }
604 }
605+
606+ message = t.S().Base.PaddingLeft(2).Render(message)
607+ return lipgloss.JoinVertical(lipgloss.Left, header, message), true
588}
608}
589
609
590func joinHeaderBody(header, body string) string {
610func joinHeaderBody(header, body string) string {
611+ t := styles.CurrentTheme()
612+ body = t.S().Base.PaddingLeft(2).Render(body)
591− return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "")
613+ return lipgloss.JoinVertical(lipgloss.Left, header, body, "")
592}
614}
593
615
594func renderPlainContent(v *toolCallCmp, content string) string {
616func renderPlainContent(v *toolCallCmp, content string) string {
@@ -596,17 +618,18 @@ func renderPlainContent(v *toolCallCmp, content string) string {
596 content = strings.TrimSpace(content)
618 content = strings.TrimSpace(content)
597 lines := strings.Split(content, "\n")
619 lines := strings.Split(content, "\n")
598
620
621+ width := v.textWidth() - 2 // -2 for left padding
599 var out []string
622 var out []string
600 for i, ln := range lines {
623 for i, ln := range lines {
601 if i >= responseContextHeight {
624 if i >= responseContextHeight {
602 break
625 break
603 }
626 }
604 ln = " " + ln // left padding
627 ln = " " + ln // left padding
605− if len(ln) > v.textWidth() {
628+ if len(ln) > width {
606− ln = v.fit(ln, v.textWidth())
629+ ln = v.fit(ln, width)
607 }
630 }
608 out = append(out, t.S().Muted.
631 out = append(out, t.S().Muted.
609− Width(v.textWidth()).
632+ Width(width).
610 Background(t.BgSubtle).
633 Background(t.BgSubtle).
611 Render(ln))
634 Render(ln))
612 }
635 }
@@ -638,7 +661,7 @@ func renderCodeContent(v *toolCallCmp, path, content string, offset int) string
638 PaddingLeft(4).
661 PaddingLeft(4).
639 PaddingRight(2).
662 PaddingRight(2).
640 Render(fmt.Sprintf("%d", i+1+offset))
663 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
642 lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
665 lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
643 num,
666 num,
644 t.S().Base.
667 t.S().Base.
@@ -669,6 +692,15 @@ func truncateHeight(s string, h int) string {
669 return s
692 return s
670}
693}
671
694
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+
672func prettifyToolName(name string) string {
704func prettifyToolName(name string) string {
673 switch name {
705 switch name {
674 case agent.AgentToolName:
706 case agent.AgentToolName:
internal/tui/components/chat/messages/tool.go+12−18
@@ -40,7 +40,7 @@ type toolCallCmp struct {
40 isNested bool // Whether this tool call is nested within another
40 isNested bool // Whether this tool call is nested within another
41
41
42 // Tool call data and state
42 // 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
44 call message.ToolCall // The tool call being executed
44 call message.ToolCall // The tool call being executed
45 result message.ToolResult // The result of the tool execution
45 result message.ToolResult // The result of the tool execution
46 cancelled bool // Whether the tool call was cancelled
46 cancelled bool // Whether the tool call was cancelled
@@ -86,7 +86,7 @@ func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption {
86func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
86func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
87 m := &toolCallCmp{
87 m := &toolCallCmp{
88 call: tc,
88 call: tc,
89− parentMessageId: parentMessageId,
89+ parentMessageID: parentMessageId,
90 }
90 }
91 for _, opt := range opts {
91 for _, opt := range opts {
92 opt(m)
92 opt(m)
@@ -140,7 +140,7 @@ func (m *toolCallCmp) View() tea.View {
140 if m.isNested {
140 if m.isNested {
141 return tea.NewView(box.Render(m.renderPending()))
141 return tea.NewView(box.Render(m.renderPending()))
142 }
142 }
143− return tea.NewView(box.PaddingLeft(1).Render(m.renderPending()))
143+ return tea.NewView(box.Render(m.renderPending()))
144 }
144 }
145
145
146 r := registry.lookup(m.call.Name)
146 r := registry.lookup(m.call.Name)
@@ -148,7 +148,7 @@ func (m *toolCallCmp) View() tea.View {
148 if m.isNested {
148 if m.isNested {
149 return tea.NewView(box.Render(r.Render(m)))
149 return tea.NewView(box.Render(r.Render(m)))
150 }
150 }
151− return tea.NewView(box.PaddingLeft(1).Render(r.Render(m)))
151+ return tea.NewView(box.Render(r.Render(m)))
152}
152}
153
153
154// State management methods
154// State management methods
@@ -168,7 +168,7 @@ func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
168
168
169// ParentMessageId returns the ID of the message that initiated this tool call
169// ParentMessageId returns the ID of the message that initiated this tool call
170func (m *toolCallCmp) ParentMessageId() string {
170func (m *toolCallCmp) ParentMessageId() string {
171− return m.parentMessageId
171+ return m.parentMessageID
172}
172}
173
173
174// SetToolResult updates the tool result and stops the spinning animation
174// SetToolResult updates the tool result and stops the spinning animation
@@ -209,30 +209,24 @@ func (m *toolCallCmp) SetIsNested(isNested bool) {
209
209
210// renderPending displays the tool name with a loading animation for pending tool calls
210// renderPending displays the tool name with a loading animation for pending tool calls
211func (m *toolCallCmp) renderPending() string {
211func (m *toolCallCmp) renderPending() string {
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))
212− return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), m.anim.View())
215+ return fmt.Sprintf("%s %s: %s", icon, tool, m.anim.View())
213}
216}
214
217
215// style returns the lipgloss style for the tool call component.
218// style returns the lipgloss style for the tool call component.
216// Applies muted colors and focus-dependent border styles.
219// Applies muted colors and focus-dependent border styles.
217func (m *toolCallCmp) style() lipgloss.Style {
220func (m *toolCallCmp) style() lipgloss.Style {
218 t := styles.CurrentTheme()
221 t := styles.CurrentTheme()
219− if m.isNested {
222+
220− return t.S().Muted
221− }
222− borderStyle := lipgloss.NormalBorder()
223− if m.focused {
224− borderStyle = lipgloss.DoubleBorder()
225− }
226− return t.S().Muted.
223+ return t.S().Muted.PaddingLeft(4)
227− BorderLeft(true).
228− BorderForeground(t.Border).
229− BorderStyle(borderStyle)
230}
224}
231
225
232// textWidth calculates the available width for text content,
226// textWidth calculates the available width for text content,
233// accounting for borders and padding
227// accounting for borders and padding
234func (m *toolCallCmp) textWidth() int {
228func (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
236}
230}
237
231
238// fit truncates content to fit within the specified width with ellipsis
232// 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 {
33 key.WithKeys("k"),
33 key.WithKeys("k"),
34 ),
34 ),
35 UpOneItem: key.NewBinding(
35 UpOneItem: key.NewBinding(
36− key.WithKeys("shift+up"),
36+ key.WithKeys("shift+up", "shift+k"),
37 ),
37 ),
38 DownOneItem: key.NewBinding(
38 DownOneItem: key.NewBinding(
39− key.WithKeys("shift+down"),
39+ key.WithKeys("shift+down", "shift+j"),
40 ),
40 ),
41 HalfPageDown: key.NewBinding(
41 HalfPageDown: key.NewBinding(
42− key.WithKeys("ctrl+d"),
42+ key.WithKeys("d"),
43 ),
43 ),
44 HalfPageUp: key.NewBinding(
44 HalfPageUp: key.NewBinding(
45− key.WithKeys("ctrl+u"),
45+ key.WithKeys("u"),
46 ),
46 ),
47 Home: key.NewBinding(
47 Home: key.NewBinding(
48− key.WithKeys("ctrl+g", "home"),
48+ key.WithKeys("g", "home"),
49 ),
49 ),
50 End: key.NewBinding(
50 End: key.NewBinding(
51− key.WithKeys("ctrl+shift+g", "end"),
51+ key.WithKeys("shift+g", "end"),
52 ),
52 ),
53 }
53 }
54}
54}
internal/tui/components/core/list/list.go+13
@@ -40,6 +40,7 @@ type ListModel interface {
40 Items() []util.Model // Get all items in the list
40 Items() []util.Model // Get all items in the list
41 SelectedIndex() int // Get the index of the currently selected item
41 SelectedIndex() int // Get the index of the currently selected item
42 SetSelected(int) tea.Cmd // Set the selected item by index and scroll to it
42 SetSelected(int) tea.Cmd // Set the selected item by index and scroll to it
43+ ClearSelection() tea.Cmd // Clear the current selection
43 Filter(string) tea.Cmd // Filter items based on a search term
44 Filter(string) tea.Cmd // Filter items based on a search term
44}
45}
45
46
@@ -1332,3 +1333,15 @@ func (m *model) SetSelected(index int) tea.Cmd {
1332 }
1333 }
1333 return tea.Batch(cmds...)
1334 return tea.Batch(cmds...)
1334}
1335}
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
2
2
3const (
3const (
4 CheckIcon string = "✓"
4 CheckIcon string = "✓"
5− ErrorIcon string = "✖"
5+ ErrorIcon string = "×"
6 WarningIcon string = "⚠"
6 WarningIcon string = "⚠"
7 InfoIcon string = ""
7 InfoIcon string = ""
8 HintIcon string = "i"
8 HintIcon string = "i"
9 SpinnerIcon string = "..."
9 SpinnerIcon string = "..."
10 LoadingIcon string = "⟳"
10 LoadingIcon string = "⟳"
11 DocumentIcon string = "🖼"
11 DocumentIcon string = "🖼"
12+
13+ // Tool call icons
14+ ToolPending string = "●"
15+ ToolSuccess string = "✓"
16+ ToolError string = "×"
12)
17)
internal/tui/styles/theme.go+4−4
@@ -199,11 +199,11 @@ func (t *Theme) buildStyles() *Styles {
199 Markdown: ansi.StyleConfig{
199 Markdown: ansi.StyleConfig{
200 Document: ansi.StyleBlock{
200 Document: ansi.StyleBlock{
201 StylePrimitive: ansi.StylePrimitive{
201 StylePrimitive: ansi.StylePrimitive{
202− BlockPrefix: "\n",
202+ // BlockPrefix: "\n",
203− BlockSuffix: "\n",
203+ // BlockSuffix: "\n",
204− Color: stringPtr("252"),
204+ Color: stringPtr("252"),
205 },
205 },
206− Margin: uintPtr(defaultMargin),
206+ // Margin: uintPtr(defaultMargin),
207 },
207 },
208 BlockQuote: ansi.StyleBlock{
208 BlockQuote: ansi.StyleBlock{
209 StylePrimitive: ansi.StylePrimitive{},
209 StylePrimitive: ansi.StylePrimitive{},