feat: complete v0.3.1 agent workflow

This commit is contained in:
loveuer
2026-09-15 03:39:22 -07:00
parent 47671f3098
commit 065b30a84c
33 changed files with 4921 additions and 170 deletions
+430 -72
View File
@@ -2,6 +2,7 @@ package tui
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -9,8 +10,11 @@ import (
"sort"
"strings"
"unicode"
"unicode/utf8"
"agentu/internal/agent"
"agentu/internal/skills"
"agentu/internal/tools"
"agentu/pkg/llm"
"github.com/charmbracelet/bubbles/spinner"
@@ -33,6 +37,8 @@ type Options struct {
ThemeMode ThemeMode
ModelManager ModelManager
WorkingDir string
Approval *ApprovalBroker
Rollback *tools.RollbackStore
}
type ModelManager interface {
@@ -67,6 +73,8 @@ const (
roleTool role = "tool"
roleError role = "error"
roleSystem role = "system"
roleBTWUser role = "btw-user"
roleBTW role = "btw"
)
type message struct {
@@ -75,14 +83,16 @@ type message struct {
}
type model struct {
ctx context.Context
agent *agent.Agent
modelName string
yolo bool
models ModelManager
workingDir string
theme theme
styles styles
ctx context.Context
agent *agent.Agent
modelName string
yolo bool
models ModelManager
workingDir string
approvalBroker *ApprovalBroker
rollback *tools.RollbackStore
theme theme
styles styles
viewport viewport.Model
input textarea.Model
@@ -99,9 +109,12 @@ type model struct {
draftInput string
commandCursor int
running bool
cancel context.CancelFunc
events <-chan tea.Msg
running bool
cancel context.CancelFunc
btwRunning bool
btwCancel context.CancelFunc
events chan tea.Msg
approval *toolApprovalMsg
// Session picker state
picking bool
@@ -117,11 +130,15 @@ type pickerItem struct {
}
type assistantChunkMsg string
type btwChunkMsg string
type toolLogMsg string
type turnStartedMsg struct{}
type turnDoneMsg struct {
err error
}
type btwDoneMsg struct {
err error
}
var errOpenPicker = fmt.Errorf("open picker")
@@ -142,7 +159,10 @@ type commandSuggestion struct {
executable bool
}
var btwSlashCommand = slashCommand{Name: "/btw", Description: "ask a temporary side question"}
var slashCommands = []slashCommand{
btwSlashCommand,
{Name: "/clear", Description: "reset context"},
{Name: "/compact", Description: "compress context"},
{Name: "/exit", Description: "quit"},
@@ -154,6 +174,9 @@ var slashCommands = []slashCommand{
{Name: "/new", Description: "new session"},
{Name: "/status", Description: "show changed files"},
{Name: "/diff", Description: "show unstaged diff"},
{Name: "/test", Description: "run project tests"},
{Name: "/skills", Description: "list or install global skills"},
{Name: "/rollback", Description: "restore the latest file snapshot"},
{Name: "/theme", Description: "switch theme light|dark"},
}
@@ -184,19 +207,21 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
vp.SetContent("")
m := model{
ctx: ctx,
agent: assistant,
modelName: opts.ModelName,
yolo: opts.Yolo,
models: opts.ModelManager,
workingDir: opts.WorkingDir,
theme: th,
styles: st,
viewport: vp,
input: input,
spinner: spin,
status: "ready",
historyIndex: -1,
ctx: ctx,
agent: assistant,
modelName: opts.ModelName,
yolo: opts.Yolo,
models: opts.ModelManager,
workingDir: opts.WorkingDir,
approvalBroker: opts.Approval,
rollback: opts.Rollback,
theme: th,
styles: st,
viewport: vp,
input: input,
spinner: spin,
status: "ready",
historyIndex: -1,
}
m.refreshContextUsage()
return m
@@ -242,7 +267,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case spinner.TickMsg:
if !m.running {
if !m.active() {
return m, nil
}
var cmd tea.Cmd
@@ -250,6 +275,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
case tea.KeyMsg:
if m.approval != nil {
return m.updateApproval(msg)
}
if m.picking {
return m.updatePicker(msg)
}
@@ -261,31 +289,30 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.cancel != nil {
m.cancel()
}
if m.btwCancel != nil {
m.btwCancel()
}
return m, tea.Quit
case "esc":
if m.running && m.cancel != nil {
if m.cancel != nil {
m.cancel()
}
if m.btwCancel != nil {
m.btwCancel()
}
if m.active() {
m.status = "canceling..."
return m, nil
}
case "enter":
if m.running {
return m, nil
}
return m.submit()
case "up":
if m.running {
return m, nil
}
if m.input.Line() == 0 {
m.previousInputHistory()
m.afterInputChanged()
return m, nil
}
case "down":
if m.running {
return m, nil
}
value := m.input.Value()
lineCount := 1
if value != "" {
@@ -297,16 +324,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
case "ctrl+p":
if m.running {
return m, nil
}
m.previousInputHistory()
m.afterInputChanged()
return m, nil
case "ctrl+n":
if m.running {
return m, nil
}
m.nextInputHistory()
m.afterInputChanged()
return m, nil
@@ -320,6 +341,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.refreshViewport(true)
return m, m.waitForEvent()
case btwChunkMsg:
m.appendBTWChunk(string(msg))
if !m.running {
m.status = "answering btw..."
}
m.refreshViewport(true)
return m, m.waitForEvent()
case toolLogMsg:
text := cleanToolLog(string(msg))
if text != "" {
@@ -331,13 +360,25 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, m.waitForEvent()
case toolApprovalMsg:
m.approval = &msg
m.status = "approval required"
m.resize()
m.refreshViewport(true)
return m, m.waitForEvent()
case turnDoneMsg:
m.running = false
m.events = nil
m.cancel = nil
m.approval = nil
if m.approvalBroker != nil {
m.approvalBroker.ClearEvents()
}
if msg.err != nil {
m.messages = append(m.messages, message{role: roleError, content: msg.err.Error()})
m.status = "error"
} else if m.btwRunning {
m.status = "answering btw..."
} else {
m.status = "ready"
}
@@ -349,6 +390,29 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.resize()
m.refreshViewport(true)
cmds = append(cmds, textarea.Blink)
if m.active() {
cmds = append(cmds, m.waitForEvent())
} else {
m.events = nil
}
case btwDoneMsg:
m.btwRunning = false
m.btwCancel = nil
if msg.err != nil && !errors.Is(msg.err, context.Canceled) {
m.messages = append(m.messages, message{role: roleBTW, content: "Error: " + msg.err.Error()})
}
if !m.running {
m.status = "ready"
}
m.resize()
m.refreshViewport(true)
cmds = append(cmds, textarea.Blink)
if m.active() {
cmds = append(cmds, m.waitForEvent())
} else {
m.events = nil
}
}
if keyMsg, ok := msg.(tea.KeyMsg); ok {
@@ -357,18 +421,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.viewport = nextViewport
return m, cmd
}
if !m.running {
nextInput, cmd := m.input.Update(msg)
m.input = nextInput
m.historyIndex = -1
m.draftInput = ""
m.afterInputChanged()
return m, cmd
}
return m, nil
nextInput, cmd := m.input.Update(msg)
m.input = nextInput
m.historyIndex = -1
m.draftInput = ""
m.afterInputChanged()
return m, cmd
}
if !m.running {
if !m.active() {
nextInput, cmd := m.input.Update(msg)
m.input = nextInput
cmds = append(cmds, cmd)
@@ -392,6 +453,15 @@ func (m model) View() string {
body := lipgloss.JoinVertical(lipgloss.Left, parts...)
return m.styles.App.Width(m.width).Height(m.height).Render(body)
}
if m.approval != nil {
parts := []string{m.viewport.View()}
if activity := m.activityView(); activity != "" {
parts = append(parts, activity)
}
parts = append(parts, m.approvalView(), m.footerView())
body := lipgloss.JoinVertical(lipgloss.Left, parts...)
return m.styles.App.Width(m.width).Height(m.height).Render(body)
}
parts := []string{m.viewport.View()}
if activity := m.activityView(); activity != "" {
@@ -407,6 +477,14 @@ func (m *model) submit() (tea.Model, tea.Cmd) {
if input == "" {
return *m, nil
}
fields := strings.Fields(input)
if len(fields) > 0 && fields[0] == "/btw" {
m.rememberInput(input)
return m.submitBTW(strings.TrimSpace(strings.TrimPrefix(input, "/btw")))
}
if m.active() {
return *m, nil
}
m.rememberInput(input)
switch input {
@@ -446,20 +524,19 @@ func (m *model) submit() (tea.Model, tea.Cmd) {
events := make(chan tea.Msg, 256)
m.events = events
if m.approvalBroker != nil {
m.approvalBroker.SetEvents(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():
}
events <- turnDoneMsg{err: err}
}()
return turnStartedMsg{}
}
@@ -467,6 +544,54 @@ func (m *model) submit() (tea.Model, tea.Cmd) {
return *m, tea.Batch(start, m.waitForEvent(), m.spinner.Tick)
}
func (m *model) submitBTW(question string) (tea.Model, tea.Cmd) {
if question == "" {
m.messages = append(m.messages, message{role: roleBTW, content: "Usage: /btw <question>"})
m.input.SetValue("")
m.afterInputChanged()
m.refreshViewport(true)
return *m, nil
}
if m.agent == nil {
m.messages = append(m.messages, message{role: roleBTW, content: "Error: agent is not configured"})
return *m, nil
}
if m.btwRunning {
m.messages = append(m.messages, message{role: roleBTW, content: "A /btw question is already running."})
return *m, nil
}
hadActive := m.active()
m.messages = append(m.messages, message{role: roleBTWUser, content: question})
m.input.SetValue("")
m.afterInputChanged()
m.btwRunning = true
if !m.running {
m.status = "answering btw..."
}
m.resize()
m.refreshViewport(true)
btwCtx, cancel := context.WithCancel(m.ctx)
m.btwCancel = cancel
if m.events == nil {
m.events = make(chan tea.Msg, 256)
}
events := m.events
start := func() tea.Msg {
go func() {
out := eventWriter{ctx: btwCtx, events: events, btw: true}
err := m.agent.BTW(btwCtx, question, out)
events <- btwDoneMsg{err: err}
}()
return turnStartedMsg{}
}
if hadActive {
return *m, start
}
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)
@@ -505,10 +630,38 @@ 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})
for i := len(m.messages) - 1; i >= 0; i-- {
switch m.messages[i].role {
case roleAssistant:
m.messages[i].content += chunk
return
case roleBTW, roleBTWUser:
continue
default:
i = -1
}
}
m.messages[len(m.messages)-1].content += chunk
m.messages = append(m.messages, message{role: roleAssistant, content: chunk})
}
func (m *model) appendBTWChunk(chunk string) {
if chunk == "" {
return
}
for i := len(m.messages) - 1; i >= 0; i-- {
if m.messages[i].role == roleBTW {
m.messages[i].content += chunk
return
}
if m.messages[i].role == roleBTWUser {
break
}
}
m.messages = append(m.messages, message{role: roleBTW, content: chunk})
}
func (m model) active() bool {
return m.running || m.btwRunning
}
func (m *model) resize() {
@@ -518,10 +671,13 @@ func (m *model) resize() {
m.syncInputHeight()
activityHeight := 0
if m.running {
if m.active() {
activityHeight = 1
}
inputHeight := m.inputBlockHeight()
if m.approval != nil {
inputHeight = lipgloss.Height(m.approvalView())
}
footerHeight := 2
m.viewport.Width = width
m.viewport.Height = max(4, m.height-activityHeight-inputHeight-footerHeight)
@@ -564,6 +720,12 @@ func (m model) messageView(r role, content string, width int) string {
switch r {
case roleUser:
style = m.styles.UserMessage
case roleBTWUser:
style = m.styles.UserMessage
labelStyle = m.styles.SystemLabel
case roleBTW:
style = m.styles.AssistantMsg
labelStyle = m.styles.SystemLabel
case roleTool:
style = m.styles.ToolMessage
labelStyle = m.styles.ToolLabel
@@ -576,7 +738,7 @@ func (m model) messageView(r role, content string, width int) string {
}
bodyContent := content
if r == roleAssistant {
if r == roleAssistant || r == roleBTW {
bodyWidth := max(minMarkdownWidth, width-style.GetHorizontalFrameSize())
bodyContent = renderMarkdown(content, bodyWidth, m.theme)
}
@@ -614,6 +776,10 @@ func roleLabel(r role) string {
return "Error"
case roleSystem:
return "Note"
case roleBTWUser:
return "BTW · You"
case roleBTW:
return "BTW"
default:
return string(r)
}
@@ -649,6 +815,11 @@ func parseToolLog(content string) toolLogParts {
func (m model) toolMessageView(content string, width int) string {
parts := parseToolLog(content)
if isShellRun(parts.name) {
if args, ok := parseShellRunDisplayArgs(parts.args); ok {
return m.shellRunMessageView(args, parts.output, width)
}
}
header := m.styles.ToolName.Render(strings.TrimSpace(iconTool + " " + parts.name))
lines := []string{header}
if parts.args != "" {
@@ -669,20 +840,130 @@ func (m model) toolMessageView(content string, width int) string {
const maxToolOutputPreviewLines = 8
type shellRunDisplayArgs struct {
Command string `json:"command"`
Workdir string `json:"workdir"`
}
func isShellRun(name string) bool {
return name == "shell_run" || name == "shell.run"
}
func parseShellRunDisplayArgs(raw string) (shellRunDisplayArgs, bool) {
var args shellRunDisplayArgs
if json.Unmarshal([]byte(raw), &args) != nil || strings.TrimSpace(args.Command) == "" {
return shellRunDisplayArgs{}, false
}
args.Command = strings.TrimSpace(args.Command)
args.Workdir = strings.TrimSpace(args.Workdir)
return args, true
}
func (m model) shellRunMessageView(args shellRunDisplayArgs, output string, width int) string {
innerWidth := max(1, width-m.styles.ToolMessage.GetHorizontalFrameSize())
lines := []string{m.styles.ToolName.Render(fitLine("$ "+args.Command, innerWidth))}
if args.Workdir != "" {
lines = append(lines, m.styles.ToolKey.Render("cwd ")+m.styles.ToolValue.Render(fitLine(args.Workdir, max(1, innerWidth-4))))
}
if output != "" {
lines = append(lines, m.styles.ToolKey.Render("output"))
for _, line := range shellOutputPreviewLines(output, innerWidth) {
lines = append(lines, m.styles.ToolValue.Render(line))
}
}
bodyContent := strings.Join(lines, "\n")
bodyWidth := messageBodyWidth(bodyContent, width, m.styles.ToolMessage)
return m.styles.ToolMessage.Width(bodyWidth).Render(bodyContent)
}
func shellOutputPreviewLines(output string, width int) []string {
output = sanitizeTerminalOutput(output)
lines := strings.Split(strings.TrimRight(output, "\n"), "\n")
if len(lines) > maxToolOutputPreviewLines {
head := maxToolOutputPreviewLines / 2
tail := maxToolOutputPreviewLines - head - 1
omitted := len(lines) - head - tail
lines = append(append(append([]string(nil), lines[:head]...), fmt.Sprintf("… %d lines omitted …", omitted)), lines[len(lines)-tail:]...)
}
result := make([]string, 0, len(lines))
for _, line := range lines {
result = append(result, fitLine(line, width))
}
return result
}
func sanitizeTerminalOutput(output string) string {
output = strings.ReplaceAll(output, "\r\n", "\n")
var clean strings.Builder
for i := 0; i < len(output); {
if end, ok := terminalEscapeEnd(output, i); ok {
i = end
continue
}
r, size := utf8.DecodeRuneInString(output[i:])
if r == utf8.RuneError && size == 0 {
break
}
if r == '\n' || r == '\r' || r == '\t' || r >= ' ' {
clean.WriteRune(r)
}
i += size
}
lines := strings.Split(clean.String(), "\n")
for i, line := range lines {
segments := strings.Split(line, "\r")
for j := len(segments) - 1; j >= 0; j-- {
if segments[j] != "" || j == 0 {
lines[i] = segments[j]
break
}
}
}
return strings.Join(lines, "\n")
}
func terminalEscapeEnd(value string, start int) (int, bool) {
if start+1 >= len(value) || value[start] != '\x1b' {
return 0, false
}
if value[start+1] == '[' {
if end, ok := ansiSequenceEnd(value, start); ok {
return end, true
}
return len(value), true
}
if value[start+1] == ']' {
for i := start + 2; i < len(value); i++ {
if value[i] == '\a' {
return i + 1, true
}
if value[i] == '\x1b' && i+1 < len(value) && value[i+1] == '\\' {
return i + 2, true
}
}
return len(value), true
}
return min(start+2, len(value)), true
}
func toolStatusText(content string) string {
content = strings.TrimSpace(content)
if content == "" {
return ""
}
lines := strings.SplitN(content, "\n", 2)
parts := parseToolLog(lines[0])
if isShellRun(parts.name) {
if args, ok := parseShellRunDisplayArgs(parts.args); ok {
return "$ " + args.Command
}
}
return lines[0]
}
func (m *model) mergeToolLog(text string) bool {
incoming := parseToolLog(text)
if incoming.output == "" {
return false
}
incomingHeader, _, ok := strings.Cut(text, "\n[output]\n")
if !ok {
return false
@@ -752,20 +1033,28 @@ func (m model) inputView() string {
}
func (m model) activityView() string {
if !m.running {
if !m.active() {
return ""
}
status := strings.TrimSpace(m.status)
if status == "" || status == "ready" {
status = "working..."
}
line := m.styles.StatusInfo.Render(iconWorking) + " Working " + m.spinner.View() + " " + status + " · esc cancels"
label := "Working"
if !m.running {
label = "BTW"
} else if m.btwRunning {
label = "Working + BTW"
}
line := m.styles.StatusInfo.Render(iconWorking) + " " + label + " " + m.spinner.View() + " " + status + " · esc cancels"
return m.styles.Activity.Render(fitLine(line, max(1, m.width-2)))
}
func (m model) footerView() string {
hint := ""
if m.running {
hint = "esc cancel · ctrl+c quit"
if m.btwRunning {
hint = "btw running · esc cancel · ctrl+c quit"
} else if m.running {
hint = "type /btw <question> · esc cancel · ctrl+c quit"
}
if m.showCommandPalette() {
if sug, ok := m.selectedCommandSuggestion(); ok && sug.executable {
@@ -847,6 +1136,7 @@ type eventWriter struct {
ctx context.Context
events chan<- tea.Msg
assistant bool
btw bool
}
func (w eventWriter) Write(p []byte) (int, error) {
@@ -855,7 +1145,9 @@ func (w eventWriter) Write(p []byte) (int, error) {
}
text := string(append([]byte(nil), p...))
var msg tea.Msg
if w.assistant {
if w.btw {
msg = btwChunkMsg(text)
} else if w.assistant {
msg = assistantChunkMsg(text)
} else {
msg = toolLogMsg(text)
@@ -869,8 +1161,12 @@ func (w eventWriter) Write(p []byte) (int, error) {
}
func cleanToolLog(text string) string {
text = strings.TrimSpace(text)
text = strings.TrimLeft(text, "\r\n")
text = strings.TrimPrefix(text, "[tool]")
text = strings.TrimLeft(text, " \t")
if strings.Contains(text, "\n[output]\n") {
return text
}
return strings.TrimSpace(text)
}
@@ -1120,6 +1416,17 @@ func subsequenceScore(candidate string, query string, base int) (int, bool) {
func (m model) commandSuggestions() []commandSuggestion {
input := m.input.Value()
if m.active() {
value := strings.TrimSpace(input)
if m.running && strings.HasPrefix(value, "/") && !strings.Contains(value, " ") {
query := commandPaletteQuery(input)
command := btwSlashCommand
if _, ok := scoreCommand(command, query); ok {
return []commandSuggestion{{label: command.Name, description: command.Description, value: command.Name}}
}
}
return nil
}
if sugs := m.modelCommandSuggestions(input); len(sugs) > 0 {
return sugs
}
@@ -1473,11 +1780,62 @@ func (m model) handleLocalCommand(input string) (string, error) {
return m.handleDiffCommand(fields[1:])
case "/test":
return m.handleTestCommand(fields[1:])
case "/skills":
return m.handleSkillsCommand(fields[1:])
case "/rollback":
return m.handleRollbackCommand(fields[1:])
default:
return "", fmt.Errorf("unknown command: %s", fields[0])
}
}
func (m model) handleSkillsCommand(args []string) (string, error) {
if len(args) == 0 || args[0] == "list" {
if len(args) > 1 {
return "", fmt.Errorf("usage: /skills [list|install <source>]")
}
items, err := skills.LoadGlobal()
if err != nil {
return "", err
}
return skills.FormatList(items), nil
}
if args[0] != "install" || len(args) < 2 {
return "", fmt.Errorf("usage: /skills [list|install <source>]")
}
result, err := skills.InstallGlobal(m.ctx, strings.Join(args[1:], " "))
if err != nil {
return "", err
}
if m.agent != nil {
m.agent.AddSystemInstructions(skills.FormatForSystemPrompt([]skills.Skill{result.Skill}))
}
return fmt.Sprintf("Installed skill %q from %s. It is active in this conversation.", result.Skill.Name, result.Skill.Path), nil
}
func (m model) handleRollbackCommand(args []string) (string, error) {
if m.rollback == nil {
return "", fmt.Errorf("rollback is not configured")
}
if len(args) > 1 {
return "", fmt.Errorf("usage: /rollback [id]")
}
id := ""
if len(args) == 1 {
id = strings.TrimSpace(args[0])
} else {
id = m.rollback.LastID()
}
if id == "" {
return "", fmt.Errorf("no rollback snapshots available")
}
snapshot, err := m.rollback.Restore(id)
if err != nil {
return "", err
}
return fmt.Sprintf("Rolled back %s (%s).", snapshot.Path, snapshot.ID), nil
}
func (m model) handleCompactCommand() (string, error) {
if m.agent == nil {
return "", fmt.Errorf("agent is not configured")
@@ -1646,7 +2004,7 @@ func (m *model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
func (m *model) updateCommandSuggestions(msg tea.KeyMsg) (bool, tea.Model, tea.Cmd) {
if m.running || len(m.commandSuggestions()) == 0 {
if len(m.commandSuggestions()) == 0 {
return false, *m, nil
}
m.clampCommandCursor()