v0.0.2: polish tui rendering

Add visible activity state for running turns.
Move model metadata below the input area.
Render assistant Markdown with theme-aware styling.
This commit is contained in:
loveuer
2026-06-23 09:06:20 +08:00
parent a431d1ec95
commit 86f5ca4aef
7 changed files with 399 additions and 46 deletions
+115 -37
View File
@@ -10,6 +10,7 @@ import (
"agentu/internal/agent"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
@@ -70,6 +71,7 @@ type model struct {
viewport viewport.Model
input textarea.Model
spinner spinner.Model
width int
height int
@@ -118,6 +120,10 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
applyTextareaTheme(&input, th)
input.Focus()
spin := spinner.New(
spinner.WithSpinner(spinner.Line),
)
vp := viewport.New(80, 20)
vp.Style = st.Viewport
vp.SetContent("")
@@ -132,10 +138,8 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
styles: st,
viewport: vp,
input: input,
spinner: spin,
status: "ready",
messages: []message{
{role: roleSystem, content: "Ask anything. Enter sends, ctrl+j or alt+enter inserts a newline, esc cancels a running turn."},
},
}
}
@@ -154,6 +158,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.refreshViewport(true)
return m, nil
case spinner.TickMsg:
if !m.running {
return m, nil
}
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
@@ -201,6 +213,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.status = "ready"
}
m.resize()
m.refreshViewport(true)
cmds = append(cmds, textarea.Blink)
}
@@ -239,10 +252,12 @@ func (m model) View() string {
return "agentu"
}
header := m.headerView()
status := m.statusView()
input := m.inputView()
body := lipgloss.JoinVertical(lipgloss.Left, header, m.viewport.View(), input, status)
parts := []string{m.headerView(), m.viewport.View()}
if activity := m.activityView(); activity != "" {
parts = append(parts, activity)
}
parts = append(parts, m.inputView(), m.modelMetaView(), m.footerView())
body := lipgloss.JoinVertical(lipgloss.Left, parts...)
return m.styles.App.Width(m.width).Height(m.height).Render(body)
}
@@ -276,6 +291,7 @@ func (m *model) submit() (tea.Model, tea.Cmd) {
m.afterInputChanged()
m.running = true
m.status = "thinking..."
m.resize()
m.refreshViewport(true)
turnCtx, cancel := context.WithCancel(m.ctx)
@@ -301,7 +317,7 @@ func (m *model) submit() (tea.Model, tea.Cmd) {
return turnStartedMsg{}
}
return *m, tea.Batch(start, m.waitForEvent())
return *m, tea.Batch(start, m.waitForEvent(), m.spinner.Tick)
}
func (m *model) insertInputNewline() (tea.Model, tea.Cmd) {
@@ -343,10 +359,14 @@ func (m *model) resize() {
m.syncInputHeight()
headerHeight := 1
statusHeight := 1
activityHeight := 0
if m.running {
activityHeight = 1
}
inputHeight := m.inputBlockHeight()
footerHeight := 2
m.viewport.Width = width
m.viewport.Height = max(4, m.height-headerHeight-statusHeight-inputHeight)
m.viewport.Height = max(4, m.height-headerHeight-activityHeight-inputHeight-footerHeight)
m.viewport.Style = m.styles.Viewport
}
@@ -369,80 +389,115 @@ func (m model) renderMessages() string {
parts = append(parts, m.messageView(msg.role, content, contentWidth))
}
if len(parts) == 0 {
return m.styles.Muted.Width(contentWidth).Render("Start a conversation.")
return ""
}
return strings.Join(parts, "\n\n")
}
func (m model) messageView(r role, content string, width int) string {
label := string(r)
label := roleLabel(r)
style := m.styles.AssistantMsg
labelStyle := m.styles.AssistantLabel
switch r {
case roleUser:
label = "you"
style = m.styles.UserMessage
labelStyle = m.styles.UserLabel
case roleTool:
label = "tool"
style = m.styles.ToolMessage
labelStyle = m.styles.ToolLabel
case roleError:
label = "error"
style = m.styles.ErrorMessage
labelStyle = m.styles.ErrorLabel
case roleSystem:
label = "note"
style = m.styles.SystemMessage
labelStyle = m.styles.SystemLabel
default:
label = "agentu"
}
bodyContent := content
if r == roleAssistant {
bodyWidth := max(minMarkdownWidth, width-style.GetHorizontalFrameSize())
bodyContent = renderMarkdown(content, bodyWidth, m.theme)
}
labelLine := labelStyle.Render(label)
body := style.Width(width).Render(content)
body := style.Width(width).Render(bodyContent)
return lipgloss.JoinVertical(lipgloss.Left, labelLine, body)
}
func roleLabel(r role) string {
switch r {
case roleUser:
return "You"
case roleAssistant:
return "Agentu"
case roleTool:
return "Tool"
case roleError:
return "Error"
case roleSystem:
return "Note"
default:
return string(r)
}
}
func (m model) headerView() string {
title := m.styles.Title.Render("agentu")
meta := m.styles.Muted.Render("local agent")
line := lipgloss.JoinHorizontal(lipgloss.Center, title, " ", meta)
return m.styles.Header.Width(max(1, m.width)).Render(line)
}
func (m model) modelMetaView() string {
mode := "read-only tools"
if m.yolo {
mode = "yolo tools"
}
modelName := m.modelName
thinking := ""
parts := []string{}
if m.models != nil {
if provider := m.models.CurrentProvider(); provider != "" {
parts = append(parts, "provider "+provider)
}
modelName = m.models.CurrentModel()
if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" {
thinking = " · thinking " + currentThinking
parts = append(parts, "thinking "+currentThinking)
}
}
title := m.styles.Title.Render("agentu")
meta := m.styles.Muted.Render(fmt.Sprintf("%s · %s%s · %s", modelName, mode, thinking, m.theme.Mode))
line := lipgloss.JoinHorizontal(lipgloss.Center, title, " ", meta)
return m.styles.Header.Width(max(1, m.width)).Render(line)
parts = append([]string{modelName, mode}, parts...)
parts = append(parts, "theme "+string(m.theme.Mode))
line := fitLine(strings.Join(parts, " · "), max(1, m.width-2))
return m.styles.ModelMeta.Width(max(1, m.width)).Render(line)
}
func (m model) inputView() string {
title := m.styles.Muted.Render("Enter sends · ctrl+j newline · alt+enter newline · /clear · /exit")
if m.running {
title = m.styles.Muted.Render("Working · esc cancel · ctrl+c quit")
}
suggestions := m.slashSuggestionsView()
box := m.styles.InputBox.Width(max(20, m.width-2)).Render(m.input.View())
if suggestions != "" {
return lipgloss.JoinVertical(lipgloss.Left, suggestions, title, box)
return lipgloss.JoinVertical(lipgloss.Left, suggestions, box)
}
return lipgloss.JoinVertical(lipgloss.Left, title, box)
return box
}
func (m model) statusView() string {
left := m.status
if left == "" {
left = "ready"
func (m model) activityView() string {
if !m.running {
return ""
}
return m.styles.Status.Width(max(1, m.width)).Render(left)
status := strings.TrimSpace(m.status)
if status == "" || status == "ready" {
status = "working..."
}
line := fitLine(fmt.Sprintf("%s Working · %s · esc cancels", m.spinner.View(), status), max(1, m.width-2))
return m.styles.Activity.Width(max(1, m.width)).Render(line)
}
func (m model) footerView() string {
hint := "enter send · ctrl+j newline · alt+enter newline · / commands"
if m.running {
hint = "esc cancel · ctrl+c quit"
}
return m.styles.Footer.Width(max(1, m.width)).Render(fitLine(hint, max(1, m.width-2)))
}
type eventWriter struct {
@@ -514,7 +569,7 @@ func (m model) desiredInputHeight() int {
}
func (m model) inputBlockHeight() int {
height := 1 + m.input.Height() + m.styles.InputBox.GetVerticalFrameSize()
height := m.input.Height() + m.styles.InputBox.GetVerticalFrameSize()
if m.showSlashSuggestions() {
height++
}
@@ -555,6 +610,29 @@ func (m model) modelInfo() string {
return fmt.Sprintf("model: %s\nmode: %s\ntheme: %s", m.modelName, mode, m.theme.Mode)
}
func fitLine(text string, width int) string {
width = max(1, width)
if lipgloss.Width(text) <= width {
return text
}
if width <= 3 {
return strings.Repeat(".", width)
}
target := width - 3
used := 0
var b strings.Builder
for _, r := range text {
next := lipgloss.Width(string(r))
if used+next > target {
break
}
b.WriteRune(r)
used += next
}
return b.String() + "..."
}
func (m *model) runLocalCommand(input string) (tea.Model, tea.Cmd) {
output, err := m.handleLocalCommand(input)
role := roleSystem