86f5ca4aef
Add visible activity state for running turns. Move model metadata below the input area. Render assistant Markdown with theme-aware styling.
701 lines
15 KiB
Go
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{}
|