feat: enhance TUI with markdown, scrolling, and improved input handling

This commit is contained in:
Luxferre
2026-03-21 19:13:06 +02:00
parent 2ec2d2f45d
commit dd333a0bc7
4 changed files with 111 additions and 23 deletions
+55 -16
View File
@@ -9,6 +9,7 @@ import (
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/lipgloss"
"sidekick"
)
@@ -45,13 +46,13 @@ type model struct {
textarea textarea.Model
agent *sidekick.Sidekick
pool map[string]*sidekick.Sidekick
messages []string
history []sidekick.Message
isThinking bool
err error
ready bool
width int
height int
renderer *glamour.TermRenderer
}
type agentResponseMsg struct {
@@ -105,13 +106,44 @@ func initialModel() model {
textarea: ta,
agent: agent,
pool: pool,
messages: []string{sysStyle.Render("System: Sidekick initialized. Type a message below.")},
history: []sidekick.Message{},
history: []sidekick.Message{{Role: sidekick.MessageRoleSystem, Content: "Sidekick initialized. Type a message below."}},
}
}
func (m model) Init() tea.Cmd { return textarea.Blink }
func (m model) renderMessage(msg sidekick.Message) string {
label := ""
switch msg.Role {
case sidekick.MessageRoleUser:
label = userStyle.Render("You")
case sidekick.MessageRoleAssistant:
label = agentStyle.Render("Sidekick")
case sidekick.MessageRoleSystem:
label = sysStyle.Render("System")
default:
label = sysStyle.Render(string(msg.Role))
}
content := msg.Content
if m.renderer != nil && msg.Role != sidekick.MessageRoleSystem {
rendered, err := m.renderer.Render(content)
if err == nil {
content = strings.TrimSpace(rendered)
}
}
return label + "\n" + content
}
func (m *model) updateViewportContent() {
var rendered []string
for _, msg := range m.history {
rendered = append(rendered, m.renderMessage(msg))
}
m.viewport.SetContent(strings.Join(rendered, "\n\n"))
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var (
tiCmd tea.Cmd
@@ -129,14 +161,21 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
vpWidth := msg.Width - 4 // Account for viewportStyle padding and border
vpHeight := msg.Height - headerHeight - inputHeight - 2
r, _ := glamour.NewTermRenderer(
glamour.WithAutoStyle(),
glamour.WithWordWrap(vpWidth-2),
)
m.renderer = r
if !m.ready {
m.viewport = viewport.New(vpWidth, vpHeight)
m.viewport.SetContent(strings.Join(m.messages, "\n\n"))
m.ready = true
} else {
m.viewport.Width = vpWidth
m.viewport.Height = vpHeight
}
m.updateViewportContent()
m.viewport.GotoBottom()
m.textarea.SetWidth(msg.Width - 4)
@@ -144,10 +183,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyCtrlC, tea.KeyEsc:
return m, tea.Quit
case tea.KeyPgUp, tea.KeyCtrlU:
m.viewport.HalfViewUp()
return m, nil
case tea.KeyPgDown, tea.KeyCtrlD:
m.viewport.HalfViewDown()
return m, nil
case tea.KeyEnter:
if msg.Alt {
break
}
v := m.textarea.Value()
if strings.TrimSpace(v) == "" || m.isThinking {
return m, nil
@@ -158,10 +200,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
Content: v,
})
wrappedUser := lipgloss.NewStyle().Width(m.viewport.Width).Render(userStyle.Render("You") + "\n" + v)
m.messages = append(m.messages, wrappedUser)
m.updateViewportContent()
m.textarea.Reset()
m.viewport.SetContent(strings.Join(m.messages, "\n\n"))
m.viewport.GotoBottom()
m.isThinking = true
return m, m.runAgent()
@@ -169,19 +209,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case agentResponseMsg:
m.isThinking = false
var newMsg string
if msg.err != nil {
newMsg = sysStyle.Render(fmt.Sprintf("Error: %v", msg.err))
m.history = append(m.history, sidekick.Message{
Role: sidekick.MessageRoleSystem,
Content: fmt.Sprintf("Error: %v", msg.err),
})
} else {
m.history = append(m.history, sidekick.Message{
Role: sidekick.MessageRoleAssistant,
Content: msg.response,
})
newMsg = agentStyle.Render("Sidekick") + "\n" + msg.response
}
wrappedMsg := lipgloss.NewStyle().Width(m.viewport.Width).Render(newMsg)
m.messages = append(m.messages, wrappedMsg)
m.viewport.SetContent(strings.Join(m.messages, "\n\n"))
m.updateViewportContent()
m.viewport.GotoBottom()
return m, nil
}
+11 -5
View File
@@ -23,6 +23,10 @@ func TestInitialModel(t *testing.T) {
if m.textarea.Value() != "" {
t.Errorf("expected empty textarea, got %s", m.textarea.Value())
}
if len(m.history) != 1 {
t.Errorf("expected 1 initial message in history, got %d", len(m.history))
}
}
func TestModelUpdate(t *testing.T) {
@@ -52,22 +56,24 @@ func TestModelUpdate(t *testing.T) {
if rm.isThinking != false {
t.Error("expected model to stop thinking after response")
}
if len(rm.messages) < 2 {
t.Error("expected message list to grow after response")
if len(rm.history) < 2 {
t.Error("expected history list to grow after response")
}
}
func TestMessageWrapping(t *testing.T) {
m := initialModel()
m.ready = true
m.width = 10
m.height = 20
m.viewport = viewport.New(10, 5) // Very narrow
longMsg := "This is a very long message that should be wrapped."
respModel, _ := m.Update(agentResponseMsg{response: longMsg})
rm := respModel.(model)
lastMsg := rm.messages[len(rm.messages)-1]
if !strings.Contains(lastMsg, "\n") {
t.Errorf("expected long message to be wrapped, but no newline found")
content := rm.viewport.View()
if !strings.Contains(content, "\n") {
t.Errorf("expected long message to be wrapped, but no newline found in viewport")
}
}