Files
agentu/internal/tui/app.go
T
loveuer 86f5ca4aef 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.
2026-06-23 09:06:20 +08:00

701 lines
15 KiB
Go

package tui
import (
"context"
"errors"
"fmt"
"io"
"math"
"strings"
"agentu/internal/agent"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
minInputLines = 1
maxInputLines = 6
)
type Options struct {
ModelName string
Yolo bool
ThemeMode ThemeMode
ModelManager ModelManager
}
type ModelManager interface {
CurrentProvider() string
CurrentModel() string
CurrentThinking() string
Info() string
SetProvider(name string) (string, error)
SetModel(model string) (string, error)
SetThinking(level string) (string, error)
}
func Run(ctx context.Context, assistant *agent.Agent, opts Options) error {
model := newModel(ctx, assistant, opts)
_, err := tea.NewProgram(model, tea.WithAltScreen()).Run()
return err
}
type role string
const (
roleUser role = "user"
roleAssistant role = "assistant"
roleTool role = "tool"
roleError role = "error"
roleSystem role = "system"
)
type message struct {
role role
content string
}
type model struct {
ctx context.Context
agent *agent.Agent
modelName string
yolo bool
models ModelManager
theme theme
styles styles
viewport viewport.Model
input textarea.Model
spinner spinner.Model
width int
height int
messages []message
status string
running bool
cancel context.CancelFunc
events <-chan tea.Msg
}
type assistantChunkMsg string
type toolLogMsg string
type turnStartedMsg struct{}
type turnDoneMsg struct {
err error
}
type slashCommand struct {
Name string
Description string
}
var slashCommands = []slashCommand{
{Name: "/clear", Description: "reset context"},
{Name: "/exit", Description: "quit"},
{Name: "/quit", Description: "quit"},
{Name: "/model", Description: "model/provider/thinking"},
}
func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
mode := opts.ThemeMode
if mode == "" {
mode = ThemeLight
}
th := themeForMode(mode)
st := th.styles()
input := textarea.New()
input.Placeholder = "Message agentu..."
input.Prompt = ""
input.ShowLineNumbers = false
input.MaxHeight = maxInputLines
input.SetHeight(minInputLines)
input.SetWidth(80)
applyTextareaTheme(&input, th)
input.Focus()
spin := spinner.New(
spinner.WithSpinner(spinner.Line),
)
vp := viewport.New(80, 20)
vp.Style = st.Viewport
vp.SetContent("")
return model{
ctx: ctx,
agent: assistant,
modelName: opts.ModelName,
yolo: opts.Yolo,
models: opts.ModelManager,
theme: th,
styles: st,
viewport: vp,
input: input,
spinner: spin,
status: "ready",
}
}
func (m model) Init() tea.Cmd {
return textarea.Blink
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.resize()
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":
if m.cancel != nil {
m.cancel()
}
return m, tea.Quit
case "esc":
if m.running && m.cancel != nil {
m.cancel()
m.status = "canceling..."
return m, nil
}
case "enter":
if m.running {
return m, nil
}
return m.submit()
case "alt+enter", "ctrl+j":
return m.insertInputNewline()
}
case assistantChunkMsg:
m.appendAssistantChunk(string(msg))
m.status = "streaming..."
m.refreshViewport(true)
return m, m.waitForEvent()
case toolLogMsg:
text := cleanToolLog(string(msg))
if text != "" {
m.messages = append(m.messages, message{role: roleTool, content: text})
m.status = text
m.refreshViewport(true)
}
return m, m.waitForEvent()
case turnDoneMsg:
m.running = false
m.events = nil
m.cancel = nil
if msg.err != nil {
m.messages = append(m.messages, message{role: roleError, content: msg.err.Error()})
m.status = "error"
} else {
m.status = "ready"
}
m.resize()
m.refreshViewport(true)
cmds = append(cmds, textarea.Blink)
}
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if isViewportKey(keyMsg.String()) {
nextViewport, cmd := m.viewport.Update(msg)
m.viewport = nextViewport
return m, cmd
}
if !m.running {
nextInput, cmd := m.input.Update(msg)
m.input = nextInput
m.afterInputChanged()
return m, cmd
}
return m, nil
}
if !m.running {
nextInput, cmd := m.input.Update(msg)
m.input = nextInput
m.afterInputChanged()
cmds = append(cmds, cmd)
}
nextViewport, cmd := m.viewport.Update(msg)
m.viewport = nextViewport
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m model) View() string {
if m.width <= 0 || m.height <= 0 {
return "agentu"
}
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)
}
func (m *model) submit() (tea.Model, tea.Cmd) {
input := strings.TrimSpace(m.input.Value())
if input == "" {
return *m, nil
}
switch input {
case "/exit", "/quit":
return *m, tea.Quit
case "/clear":
m.agent.Clear()
m.messages = []message{{role: roleSystem, content: "Context cleared."}}
m.input.SetValue("")
m.afterInputChanged()
m.status = "ready"
m.refreshViewport(true)
return *m, nil
case "/model":
return m.runLocalCommand(input)
}
if strings.HasPrefix(input, "/") {
return m.runLocalCommand(input)
}
m.messages = append(m.messages, message{role: roleUser, content: input})
m.input.SetValue("")
m.afterInputChanged()
m.running = true
m.status = "thinking..."
m.resize()
m.refreshViewport(true)
turnCtx, cancel := context.WithCancel(m.ctx)
m.cancel = cancel
events := make(chan tea.Msg, 256)
m.events = events
start := func() tea.Msg {
go func() {
defer close(events)
out := eventWriter{ctx: turnCtx, events: events, assistant: true}
logs := eventWriter{ctx: turnCtx, events: events}
err := m.agent.RunTurn(turnCtx, input, out, logs)
if errors.Is(err, context.Canceled) {
err = nil
}
select {
case events <- turnDoneMsg{err: err}:
case <-turnCtx.Done():
}
}()
return turnStartedMsg{}
}
return *m, tea.Batch(start, m.waitForEvent(), m.spinner.Tick)
}
func (m *model) insertInputNewline() (tea.Model, tea.Cmd) {
enter := tea.KeyMsg{Type: tea.KeyEnter}
nextInput, cmd := m.input.Update(enter)
m.input = nextInput
m.afterInputChanged()
return *m, cmd
}
func (m model) waitForEvent() tea.Cmd {
events := m.events
if events == nil {
return nil
}
return func() tea.Msg {
msg, ok := <-events
if !ok {
return turnDoneMsg{}
}
return msg
}
}
func (m *model) appendAssistantChunk(chunk string) {
if chunk == "" {
return
}
if len(m.messages) == 0 || m.messages[len(m.messages)-1].role != roleAssistant {
m.messages = append(m.messages, message{role: roleAssistant})
}
m.messages[len(m.messages)-1].content += chunk
}
func (m *model) resize() {
width := max(40, m.width)
inputWidth := max(20, width-4)
m.input.SetWidth(inputWidth)
m.syncInputHeight()
headerHeight := 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-activityHeight-inputHeight-footerHeight)
m.viewport.Style = m.styles.Viewport
}
func (m *model) refreshViewport(bottom bool) {
m.viewport.SetContent(m.renderMessages())
if bottom {
m.viewport.GotoBottom()
}
}
func (m model) renderMessages() string {
width := max(40, m.width)
contentWidth := max(20, width-6)
parts := make([]string, 0, len(m.messages))
for _, msg := range m.messages {
content := strings.TrimRight(msg.content, "\n")
if content == "" {
continue
}
parts = append(parts, m.messageView(msg.role, content, contentWidth))
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, "\n\n")
}
func (m model) messageView(r role, content string, width int) string {
label := roleLabel(r)
style := m.styles.AssistantMsg
labelStyle := m.styles.AssistantLabel
switch r {
case roleUser:
style = m.styles.UserMessage
labelStyle = m.styles.UserLabel
case roleTool:
style = m.styles.ToolMessage
labelStyle = m.styles.ToolLabel
case roleError:
style = m.styles.ErrorMessage
labelStyle = m.styles.ErrorLabel
case roleSystem:
style = m.styles.SystemMessage
labelStyle = m.styles.SystemLabel
}
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(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
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" {
parts = append(parts, "thinking "+currentThinking)
}
}
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 {
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, box)
}
return box
}
func (m model) activityView() string {
if !m.running {
return ""
}
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 {
ctx context.Context
events chan<- tea.Msg
assistant bool
}
func (w eventWriter) Write(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
text := string(append([]byte(nil), p...))
var msg tea.Msg
if w.assistant {
msg = assistantChunkMsg(text)
} else {
msg = toolLogMsg(text)
}
select {
case w.events <- msg:
return len(p), nil
case <-w.ctx.Done():
return 0, w.ctx.Err()
}
}
func cleanToolLog(text string) string {
text = strings.TrimSpace(text)
text = strings.TrimPrefix(text, "[tool]")
return strings.TrimSpace(text)
}
func isViewportKey(key string) bool {
switch key {
case "pgup", "pgdown", "ctrl+up", "ctrl+down":
return true
default:
return false
}
}
func (m *model) afterInputChanged() {
m.syncInputHeight()
if m.width > 0 && m.height > 0 {
m.resize()
m.refreshViewport(true)
}
}
func (m *model) syncInputHeight() {
m.input.SetHeight(m.desiredInputHeight())
}
func (m model) desiredInputHeight() int {
value := m.input.Value()
if value == "" {
return minInputLines
}
textWidth := max(1, m.input.Width())
lines := 0
for _, line := range strings.Split(value, "\n") {
displayWidth := lipgloss.Width(line)
wrapped := int(math.Ceil(float64(max(1, displayWidth)) / float64(textWidth)))
lines += max(1, wrapped)
}
return min(max(lines, minInputLines), maxInputLines)
}
func (m model) inputBlockHeight() int {
height := m.input.Height() + m.styles.InputBox.GetVerticalFrameSize()
if m.showSlashSuggestions() {
height++
}
return height
}
func (m model) showSlashSuggestions() bool {
value := strings.TrimSpace(m.input.Value())
return strings.HasPrefix(value, "/") && !strings.Contains(value, " ")
}
func (m model) slashSuggestionsView() string {
if !m.showSlashSuggestions() {
return ""
}
prefix := strings.TrimSpace(m.input.Value())
var parts []string
for _, command := range slashCommands {
if !strings.HasPrefix(command.Name, prefix) {
continue
}
parts = append(parts, fmt.Sprintf("%s %s", command.Name, command.Description))
}
if len(parts) == 0 {
parts = append(parts, "No matching commands")
}
return m.styles.CommandHint.Width(max(20, m.width-2)).Render(strings.Join(parts, " "))
}
func (m model) modelInfo() string {
if m.models != nil {
return m.models.Info()
}
mode := "read-only tools"
if m.yolo {
mode = "yolo tools"
}
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
status := "command"
if err != nil {
output = err.Error()
role = roleError
status = "command error"
}
m.messages = append(m.messages, message{role: role, content: output})
m.input.SetValue("")
m.afterInputChanged()
m.status = status
m.refreshViewport(true)
return *m, nil
}
func (m model) handleLocalCommand(input string) (string, error) {
fields := strings.Fields(input)
if len(fields) == 0 {
return "", fmt.Errorf("empty command")
}
switch fields[0] {
case "/model":
return m.handleModelCommand(fields[1:])
default:
return "", fmt.Errorf("unknown command: %s", fields[0])
}
}
func (m model) handleModelCommand(args []string) (string, error) {
if m.models == nil {
if len(args) > 0 {
return "", fmt.Errorf("model switching is not configured")
}
return m.modelInfo(), nil
}
if len(args) == 0 || args[0] == "help" {
return m.models.Info(), nil
}
if len(args) == 1 {
return m.models.SetModel(args[0])
}
switch args[0] {
case "provider":
if len(args) != 2 {
return "", fmt.Errorf("usage: /model provider <name>")
}
return m.models.SetProvider(args[1])
case "model":
if len(args) != 2 {
return "", fmt.Errorf("usage: /model model <name>")
}
return m.models.SetModel(args[1])
case "thinking":
if len(args) != 2 {
return "", fmt.Errorf("usage: /model thinking <none|middle|high|xhigh|max>")
}
return m.models.SetThinking(args[1])
default:
return "", fmt.Errorf("unknown /model command %q", args[0])
}
}
var _ io.Writer = eventWriter{}