feat: complete v0.3.1 agent workflow
This commit is contained in:
+430
-72
@@ -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()
|
||||
|
||||
@@ -2,13 +2,17 @@ package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agentu/internal/agent"
|
||||
"agentu/internal/tools"
|
||||
"agentu/pkg/llm"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -126,6 +130,116 @@ func TestRunningStateRendersActivityIndicator(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type tuiBTWProvider struct{}
|
||||
|
||||
func (tuiBTWProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error {
|
||||
return emit(llm.StreamEvent{Content: "side answer"})
|
||||
}
|
||||
|
||||
func TestBTWCanBeSubmittedWhileMainTurnIsRunning(t *testing.T) {
|
||||
assistant := agent.New(agent.Options{Provider: tuiBTWProvider{}, Model: "test-model"})
|
||||
m := newModel(context.Background(), assistant, Options{ModelName: "test-model"})
|
||||
m.running = true
|
||||
m.events = make(chan tea.Msg, 8)
|
||||
|
||||
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/btw why?")})
|
||||
m = next.(model)
|
||||
if m.input.Value() != "/btw why?" {
|
||||
t.Fatalf("running input = %q", m.input.Value())
|
||||
}
|
||||
next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
m = next.(model)
|
||||
if !m.btwRunning || m.input.Value() != "" {
|
||||
t.Fatalf("btw running/input = %v/%q", m.btwRunning, m.input.Value())
|
||||
}
|
||||
if got := m.messages[len(m.messages)-1]; got.role != roleBTWUser || got.content != "why?" {
|
||||
t.Fatalf("btw question message = %+v", got)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("btw did not return a start command")
|
||||
}
|
||||
_ = cmd()
|
||||
chunk := <-m.events
|
||||
done := <-m.events
|
||||
if _, ok := chunk.(btwChunkMsg); !ok {
|
||||
t.Fatalf("first btw event = %T", chunk)
|
||||
}
|
||||
if _, ok := done.(btwDoneMsg); !ok {
|
||||
t.Fatalf("final btw event = %T", done)
|
||||
}
|
||||
if len(assistant.Messages()) != 0 {
|
||||
t.Fatalf("btw polluted agent history: %+v", assistant.Messages())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWRequiresQuestion(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
m.input.SetValue("/btw")
|
||||
next, _ := m.submit()
|
||||
m = next.(model)
|
||||
if m.btwRunning {
|
||||
t.Fatal("empty btw question started a request")
|
||||
}
|
||||
last := m.messages[len(m.messages)-1]
|
||||
if last.role != roleBTW || !strings.Contains(last.content, "Usage: /btw") {
|
||||
t.Fatalf("empty btw message = %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWMessagesHaveDistinctLabels(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
question := stripANSI(m.messageView(roleBTWUser, "question", 80))
|
||||
answer := stripANSI(m.messageView(roleBTW, "answer", 80))
|
||||
if !strings.Contains(question, "BTW · You") || !strings.Contains(answer, "BTW") {
|
||||
t.Fatalf("btw labels missing:\n%s\n%s", question, answer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWAndMainStreamChunksStaySeparate(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
m.messages = append(m.messages, message{role: roleUser, content: "main"})
|
||||
m.appendAssistantChunk("main one ")
|
||||
m.messages = append(m.messages, message{role: roleBTWUser, content: "side"})
|
||||
m.appendBTWChunk("side one ")
|
||||
m.appendAssistantChunk("main two")
|
||||
m.appendBTWChunk("side two")
|
||||
|
||||
var mainAnswers, sideAnswers []string
|
||||
for _, msg := range m.messages {
|
||||
switch msg.role {
|
||||
case roleAssistant:
|
||||
mainAnswers = append(mainAnswers, msg.content)
|
||||
case roleBTW:
|
||||
sideAnswers = append(sideAnswers, msg.content)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(mainAnswers, []string{"main one main two"}) {
|
||||
t.Fatalf("main answers = %#v", mainAnswers)
|
||||
}
|
||||
if !reflect.DeepEqual(sideAnswers, []string{"side one side two"}) {
|
||||
t.Fatalf("side answers = %#v", sideAnswers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainCompletionKeepsBTWEventStreamAlive(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
events := make(chan tea.Msg, 1)
|
||||
m.events = events
|
||||
m.running = true
|
||||
m.btwRunning = true
|
||||
|
||||
next, _ := m.Update(turnDoneMsg{})
|
||||
m = next.(model)
|
||||
if m.running || !m.btwRunning || m.events != events {
|
||||
t.Fatalf("state after main completion: running=%v btw=%v events=%p", m.running, m.btwRunning, m.events)
|
||||
}
|
||||
next, _ = m.Update(btwDoneMsg{})
|
||||
m = next.(model)
|
||||
if m.active() || m.events != nil {
|
||||
t.Fatalf("state after btw completion: active=%v events=%p", m.active(), m.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserMessageRendersFullWidthBubble(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
@@ -417,6 +531,76 @@ func TestToolLogStreamingChunksAppendToSameCard(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellRunCardShowsCommandWorkdirAndOutput(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
m.messages = append(m.messages, message{
|
||||
role: roleTool,
|
||||
content: "shell_run {\"command\":\"go test ./...\",\"workdir\":\"internal/tui\"}\n[output]\nok agentu/internal/tui",
|
||||
})
|
||||
|
||||
plain := stripANSI(m.renderMessages())
|
||||
for _, want := range []string{"$ go test ./...", "cwd internal/tui", "output", "ok agentu/internal/tui"} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Fatalf("shell card missing %q:\n%s", want, plain)
|
||||
}
|
||||
}
|
||||
if strings.Contains(plain, `{"command"`) || strings.Contains(plain, iconTool+" shell_run") {
|
||||
t.Fatalf("shell card still exposes raw tool arguments:\n%s", plain)
|
||||
}
|
||||
if status := toolStatusText(`shell_run {"command":"go test ./...","workdir":"internal/tui"}`); status != "$ go test ./..." {
|
||||
t.Fatalf("shell status = %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellRunStreamingEventsPreserveLineBreaks(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
args := `{"command":"printf lines"}`
|
||||
m.messages = append(m.messages, message{role: roleTool, content: "shell_run " + args})
|
||||
|
||||
next, _ := m.Update(toolLogMsg("\n[tool] shell_run " + args + "\n[output]\none\n"))
|
||||
m = next.(model)
|
||||
next, _ = m.Update(toolLogMsg("\n[tool] shell_run " + args + "\n[output]\n\n"))
|
||||
m = next.(model)
|
||||
next, _ = m.Update(toolLogMsg("\n[tool] shell_run " + args + "\n[output]\ntwo\n"))
|
||||
m = next.(model)
|
||||
|
||||
parts := parseToolLog(m.messages[0].content)
|
||||
if parts.output != "one\n\ntwo" {
|
||||
t.Fatalf("streamed output = %q", parts.output)
|
||||
}
|
||||
if len(m.messages) != 1 {
|
||||
t.Fatalf("streaming created %d tool cards, want 1", len(m.messages))
|
||||
}
|
||||
plain := stripANSI(m.renderMessages())
|
||||
if strings.Contains(plain, "onetwo") {
|
||||
t.Fatalf("streamed lines were concatenated:\n%s", plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellOutputPreviewKeepsHeadAndLatestLines(t *testing.T) {
|
||||
var lines []string
|
||||
for i := 1; i <= 12; i++ {
|
||||
lines = append(lines, fmt.Sprintf("line %d", i))
|
||||
}
|
||||
preview := strings.Join(shellOutputPreviewLines(strings.Join(lines, "\n"), 80), "\n")
|
||||
for _, want := range []string{"line 1", "line 4", "5 lines omitted", "line 10", "line 12"} {
|
||||
if !strings.Contains(preview, want) {
|
||||
t.Fatalf("preview missing %q:\n%s", want, preview)
|
||||
}
|
||||
}
|
||||
if strings.Contains(preview, "line 5\n") || strings.Contains(preview, "line 9\n") {
|
||||
t.Fatalf("preview contains a line that should be omitted:\n%s", preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeTerminalOutputRemovesEscapesAndFoldsCarriageReturns(t *testing.T) {
|
||||
got := sanitizeTerminalOutput("\x1b[31mred\x1b[0m\nprogress 10%\rprogress 20%\r\nclean\x00text")
|
||||
want := "red\nprogress 20%\ncleantext"
|
||||
if got != want {
|
||||
t.Fatalf("sanitized output = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
type tuiCompactProvider struct{}
|
||||
|
||||
func (tuiCompactProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error {
|
||||
@@ -934,3 +1118,115 @@ func TestCleanToolLog(t *testing.T) {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalPromptAllowsAndDeniesToolCalls(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
key tea.KeyMsg
|
||||
want agent.ToolAuthorizationDecision
|
||||
}{
|
||||
{key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}, want: agent.ToolAuthorizationAllow},
|
||||
{key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}, want: agent.ToolAuthorizationDeny},
|
||||
} {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
m = next.(model)
|
||||
responses := make(chan agent.ToolAuthorizationDecision, 1)
|
||||
next, _ = m.Update(toolApprovalMsg{
|
||||
request: agent.ToolAuthorizationRequest{ToolName: "file_write", Preview: "write 5 bytes", RollbackAvailable: true},
|
||||
respond: responses,
|
||||
})
|
||||
m = next.(model)
|
||||
view := stripANSI(m.View())
|
||||
for _, want := range []string{"Approve file_write?", "write 5 bytes", "rollback snapshot", "y approve"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("approval view missing %q:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
next, _ = m.Update(tc.key)
|
||||
m = next.(model)
|
||||
if m.approval != nil {
|
||||
t.Fatal("approval state was not cleared")
|
||||
}
|
||||
if got := <-responses; got != tc.want {
|
||||
t.Fatalf("decision = %q, want %q", got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalBrokerBridgesAgentAndTUI(t *testing.T) {
|
||||
broker := NewApprovalBroker()
|
||||
events := make(chan tea.Msg, 1)
|
||||
broker.SetEvents(events)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
result := make(chan agent.ToolAuthorizationDecision, 1)
|
||||
go func() {
|
||||
decision, _ := broker.AuthorizeToolCall(ctx, agent.ToolAuthorizationRequest{ToolName: "file_edit"})
|
||||
result <- decision
|
||||
}()
|
||||
msg, ok := (<-events).(toolApprovalMsg)
|
||||
if !ok {
|
||||
t.Fatal("approval broker emitted unexpected message")
|
||||
}
|
||||
msg.respond <- agent.ToolAuthorizationAllow
|
||||
if decision := <-result; decision != agent.ToolAuthorizationAllow {
|
||||
t.Fatalf("decision = %q", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackCommandRestoresLatestSnapshot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "state.txt")
|
||||
if err := os.WriteFile(path, []byte("before"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rollback := tools.NewRollbackStore()
|
||||
if _, err := rollback.Capture("file_edit", "state.txt", path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("after"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Rollback: rollback})
|
||||
out, err := m.handleRollbackCommand(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "Rolled back state.txt") {
|
||||
t.Fatalf("output = %q", out)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "before" {
|
||||
t.Fatalf("content = %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsInstallCommandActivatesSkill(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
sourceDir := t.TempDir()
|
||||
source := filepath.Join(sourceDir, "review skill.md")
|
||||
content := "---\nname: review\ndescription: Review changes\n---\n\nCheck every diff.\n"
|
||||
if err := os.WriteFile(source, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assistant := agent.New(agent.Options{SystemPrompt: "base"})
|
||||
m := newModel(context.Background(), assistant, Options{ModelName: "test-model"})
|
||||
out, err := m.handleLocalCommand("/skills install " + source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `Installed skill "review"`) {
|
||||
t.Fatalf("output = %q", out)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".agentu", "skills", "review", "SKILL.md")); err != nil {
|
||||
t.Fatalf("installed skill missing: %v", err)
|
||||
}
|
||||
messages := assistant.Messages()
|
||||
if len(messages) == 0 || !strings.Contains(messages[0].Content, "Check every diff.") {
|
||||
t.Fatalf("skill not activated in system prompt: %+v", messages)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"agentu/internal/agent"
|
||||
)
|
||||
|
||||
// toolApprovalMsg is sent to the TUI when a mutating tool call needs approval.
|
||||
type toolApprovalMsg struct {
|
||||
request agent.ToolAuthorizationRequest
|
||||
respond chan agent.ToolAuthorizationDecision
|
||||
}
|
||||
|
||||
// ApprovalBroker bridges the agent's ToolAuthorizer interface with the TUI's
|
||||
// event loop, allowing interactive approval of mutating tool calls.
|
||||
type ApprovalBroker struct {
|
||||
mu sync.Mutex
|
||||
events chan<- tea.Msg
|
||||
}
|
||||
|
||||
// NewApprovalBroker creates a new approval broker.
|
||||
func NewApprovalBroker() *ApprovalBroker {
|
||||
return &ApprovalBroker{}
|
||||
}
|
||||
|
||||
// SetEvents sets the current event channel for sending approval requests.
|
||||
func (b *ApprovalBroker) SetEvents(events chan<- tea.Msg) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.events = events
|
||||
}
|
||||
|
||||
// ClearEvents removes the current event channel.
|
||||
func (b *ApprovalBroker) ClearEvents() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.events = nil
|
||||
}
|
||||
|
||||
// AuthorizeToolCall implements agent.ToolAuthorizer by sending an approval
|
||||
// request to the TUI and waiting for the user's decision.
|
||||
func (b *ApprovalBroker) AuthorizeToolCall(ctx context.Context, req agent.ToolAuthorizationRequest) (agent.ToolAuthorizationDecision, error) {
|
||||
b.mu.Lock()
|
||||
events := b.events
|
||||
b.mu.Unlock()
|
||||
|
||||
if events == nil {
|
||||
return "", fmt.Errorf("approval UI is not available")
|
||||
}
|
||||
|
||||
respond := make(chan agent.ToolAuthorizationDecision, 1)
|
||||
msg := toolApprovalMsg{
|
||||
request: req,
|
||||
respond: respond,
|
||||
}
|
||||
|
||||
select {
|
||||
case events <- msg:
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
|
||||
select {
|
||||
case decision := <-respond:
|
||||
return decision, nil
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) updateApproval(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.approval == nil {
|
||||
return *m, nil
|
||||
}
|
||||
switch msg.String() {
|
||||
case "y", "Y":
|
||||
m.respondToApproval(agent.ToolAuthorizationAllow)
|
||||
return *m, nil
|
||||
case "n", "N", "esc":
|
||||
m.respondToApproval(agent.ToolAuthorizationDeny)
|
||||
return *m, nil
|
||||
case "ctrl+c":
|
||||
m.respondToApproval(agent.ToolAuthorizationDeny)
|
||||
if m.cancel != nil {
|
||||
m.cancel()
|
||||
}
|
||||
return *m, tea.Quit
|
||||
default:
|
||||
return *m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) respondToApproval(decision agent.ToolAuthorizationDecision) {
|
||||
request := m.approval
|
||||
m.approval = nil
|
||||
if request != nil {
|
||||
request.respond <- decision
|
||||
}
|
||||
m.status = "working..."
|
||||
m.resize()
|
||||
m.refreshViewport(true)
|
||||
}
|
||||
|
||||
func (m model) approvalView() string {
|
||||
if m.approval == nil {
|
||||
return ""
|
||||
}
|
||||
req := m.approval.request
|
||||
preview := strings.TrimSpace(req.Preview)
|
||||
if preview == "" {
|
||||
preview = strings.TrimSpace(req.Arguments)
|
||||
}
|
||||
lines := []string{
|
||||
m.styles.ErrorLabel.Render("Approve " + req.ToolName + "?"),
|
||||
m.styles.ToolValue.Render(preview),
|
||||
}
|
||||
if req.RollbackAvailable {
|
||||
lines = append(lines, m.styles.Muted.Render("A rollback snapshot will be created."))
|
||||
}
|
||||
lines = append(lines, m.styles.Muted.Render("y approve · n/esc deny"))
|
||||
return m.styles.InputBox.Width(max(20, m.width)).Render(strings.Join(lines, "\n"))
|
||||
}
|
||||
Reference in New Issue
Block a user