mirror of
https://github.com/safedep/vet.git
synced 2025-12-10 13:43:01 -06:00
652 lines
16 KiB
Go
652 lines
16 KiB
Go
package agent
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/charmbracelet/bubbles/textarea"
|
||
"github.com/charmbracelet/bubbles/viewport"
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/glamour"
|
||
"github.com/charmbracelet/lipgloss"
|
||
)
|
||
|
||
// Message types for Bubbletea updates
|
||
type statusUpdateMsg struct {
|
||
message string
|
||
}
|
||
|
||
type agentResponseMsg struct {
|
||
content string
|
||
}
|
||
|
||
type agentThinkingMsg struct {
|
||
thinking bool
|
||
}
|
||
|
||
type agentToolCallMsg struct {
|
||
toolName string
|
||
toolArgs string
|
||
}
|
||
|
||
type thinkingTickMsg struct{}
|
||
|
||
var (
|
||
headerStyle = lipgloss.NewStyle().
|
||
Foreground(lipgloss.Color("240")).
|
||
Padding(0, 1)
|
||
|
||
inputPromptStyle = lipgloss.NewStyle().
|
||
Foreground(lipgloss.Color("240"))
|
||
|
||
inputCursorStyle = lipgloss.NewStyle().
|
||
Foreground(lipgloss.Color("255"))
|
||
|
||
inputBorderStyle = lipgloss.NewStyle().
|
||
Border(lipgloss.NormalBorder()).
|
||
BorderForeground(lipgloss.Color("240")).
|
||
Padding(0, 1)
|
||
|
||
thinkingStyle = lipgloss.NewStyle().
|
||
Foreground(lipgloss.Color("33")).
|
||
Bold(true)
|
||
)
|
||
|
||
// AgentUI represents the main TUI model
|
||
type agentUI struct {
|
||
viewport viewport.Model
|
||
textInput textarea.Model
|
||
width int
|
||
height int
|
||
statusMessage string
|
||
isThinking bool
|
||
messages []uiMessage
|
||
ready bool
|
||
agent Agent
|
||
session Session
|
||
config AgentUIConfig
|
||
thinkingFrame int
|
||
inputHistory []string
|
||
historyIndex int
|
||
currentInput string
|
||
}
|
||
|
||
// Message represents a chat message
|
||
type uiMessage struct {
|
||
Role string // "user", "agent", "system"
|
||
Content string
|
||
Timestamp time.Time
|
||
}
|
||
|
||
// AgentUIConfig defines the configuration for the UI
|
||
type AgentUIConfig struct {
|
||
Width int
|
||
Height int
|
||
InitialSystemMessage string
|
||
TextInputPlaceholder string
|
||
TitleText string
|
||
MaxHistory int
|
||
|
||
// Only for informational purposes.
|
||
ModelName string
|
||
ModelVendor string
|
||
ModelFast bool
|
||
}
|
||
|
||
// DefaultAgentUIConfig returns the opinionated default configuration for the UI
|
||
func DefaultAgentUIConfig() AgentUIConfig {
|
||
return AgentUIConfig{
|
||
Width: 80,
|
||
Height: 20,
|
||
MaxHistory: 50,
|
||
InitialSystemMessage: "Security Agent initialized",
|
||
TextInputPlaceholder: "Ask me anything...",
|
||
TitleText: "Security Agent",
|
||
}
|
||
}
|
||
|
||
// NewAgentUI creates a new agent UI instance
|
||
func NewAgentUI(agent Agent, session Session, config AgentUIConfig) *agentUI {
|
||
vp := viewport.New(config.Width, config.Height)
|
||
|
||
ta := textarea.New()
|
||
ta.Placeholder = ""
|
||
ta.Focus()
|
||
ta.SetHeight(1)
|
||
ta.SetWidth(80)
|
||
ta.CharLimit = 1000
|
||
ta.ShowLineNumbers = false
|
||
|
||
ui := &agentUI{
|
||
viewport: vp,
|
||
textInput: ta,
|
||
statusMessage: "",
|
||
messages: []uiMessage{},
|
||
agent: agent,
|
||
session: session,
|
||
config: config,
|
||
thinkingFrame: 0,
|
||
inputHistory: []string{},
|
||
historyIndex: -1,
|
||
currentInput: "",
|
||
}
|
||
|
||
ui.addSystemMessage(config.InitialSystemMessage)
|
||
|
||
return ui
|
||
}
|
||
|
||
// Init implements the tea.Model interface
|
||
func (m *agentUI) Init() tea.Cmd {
|
||
return tea.Batch(
|
||
textarea.Blink,
|
||
m.tickThinking(),
|
||
)
|
||
}
|
||
|
||
// Update implements the tea.Model interface
|
||
func (m *agentUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
var cmds []tea.Cmd
|
||
var cmd tea.Cmd
|
||
|
||
switch msg := msg.(type) {
|
||
case tea.KeyMsg:
|
||
switch msg.Type {
|
||
case tea.KeyCtrlC, tea.KeyEsc:
|
||
return m, tea.Quit
|
||
|
||
case tea.KeyEnter:
|
||
if m.textInput.Focused() && !m.isThinking {
|
||
// Handle user input only if agent is not in thinking mode
|
||
userInput := strings.TrimSpace(m.textInput.Value())
|
||
if userInput != "" {
|
||
// Add to history and reset navigation
|
||
m.addToHistory(userInput)
|
||
|
||
// Add the input to the message list and reset user input field
|
||
m.addUserMessage(userInput)
|
||
m.resetInputField()
|
||
|
||
// Check if it's a slash command
|
||
if strings.HasPrefix(userInput, "/") {
|
||
// Handle slash command
|
||
cmd := m.handleSlashCommand(userInput)
|
||
if cmd != nil {
|
||
cmds = append(cmds, cmd)
|
||
}
|
||
} else {
|
||
// Execute agent query
|
||
cmds = append(cmds,
|
||
m.setThinking(true),
|
||
m.executeAgentQuery(userInput),
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
case tea.KeyTab:
|
||
// Switch focus between input and viewport, but not while agent is thinking
|
||
if !m.isThinking {
|
||
if m.textInput.Focused() {
|
||
m.textInput.Blur()
|
||
} else {
|
||
m.textInput.Focus()
|
||
cmds = append(cmds, textarea.Blink)
|
||
}
|
||
}
|
||
|
||
case tea.KeyUp, tea.KeyDown:
|
||
if m.textInput.Focused() && !m.isThinking {
|
||
// Navigate input history when text input is focused
|
||
var direction int
|
||
if msg.Type == tea.KeyUp {
|
||
direction = 1 // Go back in history
|
||
} else {
|
||
direction = -1 // Go forward in history
|
||
}
|
||
|
||
historyEntry := m.navigateHistory(direction)
|
||
m.textInput.SetValue(historyEntry)
|
||
m.textInput.CursorEnd()
|
||
} else if !m.textInput.Focused() {
|
||
// Allow scrolling in viewport when not focused on text input
|
||
m.viewport, cmd = m.viewport.Update(msg)
|
||
cmds = append(cmds, cmd)
|
||
}
|
||
|
||
case tea.KeyPgUp, tea.KeyPgDown:
|
||
// Allow scrolling in viewport when not focused on text input
|
||
if !m.textInput.Focused() {
|
||
m.viewport, cmd = m.viewport.Update(msg)
|
||
cmds = append(cmds, cmd)
|
||
}
|
||
|
||
case tea.KeyHome:
|
||
if !m.textInput.Focused() {
|
||
m.viewport.GotoTop()
|
||
}
|
||
|
||
case tea.KeyEnd:
|
||
if !m.textInput.Focused() {
|
||
m.viewport.GotoBottom()
|
||
}
|
||
}
|
||
|
||
case tea.WindowSizeMsg:
|
||
// Handle window resize
|
||
m.width = msg.Width
|
||
m.height = msg.Height
|
||
|
||
// Calculate dimensions for minimal UI
|
||
headerHeight := 2 // Header + blank line
|
||
inputHeight := 2 // Input area + status
|
||
spacing := 1 // Bottom spacing
|
||
|
||
// Calculate viewport dimensions to maximize output area
|
||
viewportHeight := m.height - headerHeight - inputHeight - spacing
|
||
|
||
// Ensure minimum height
|
||
if viewportHeight < 10 {
|
||
viewportHeight = 10
|
||
}
|
||
|
||
// Full width utilization
|
||
viewportWidth := m.width
|
||
|
||
// Ensure minimum width
|
||
if viewportWidth < 50 {
|
||
viewportWidth = 50
|
||
}
|
||
|
||
m.viewport.Width = viewportWidth
|
||
m.viewport.Height = viewportHeight
|
||
m.textInput.SetWidth(m.width - 3)
|
||
|
||
// Update content when dimensions change
|
||
m.viewport.SetContent(m.renderMessages())
|
||
|
||
if !m.ready {
|
||
m.ready = true
|
||
}
|
||
|
||
case statusUpdateMsg:
|
||
m.statusMessage = msg.message
|
||
|
||
case agentThinkingMsg:
|
||
m.isThinking = msg.thinking
|
||
|
||
// When agent starts thinking, blur the input
|
||
if m.isThinking {
|
||
m.resetInputField()
|
||
m.textInput.Blur()
|
||
m.thinkingFrame = 0
|
||
cmds = append(cmds, m.tickThinking())
|
||
} else {
|
||
// Re-focus input when thinking stops
|
||
m.textInput.Focus()
|
||
cmds = append(cmds, textarea.Blink)
|
||
}
|
||
|
||
case agentResponseMsg:
|
||
m.addAgentMessage(msg.content)
|
||
cmds = append(cmds, m.setThinking(false))
|
||
|
||
case agentToolCallMsg:
|
||
m.addToolCallMessage(fmt.Sprintf("🔧 %s", msg.toolName), msg.toolArgs)
|
||
|
||
case thinkingTickMsg:
|
||
if m.isThinking {
|
||
m.thinkingFrame = (m.thinkingFrame + 1) % 4
|
||
cmds = append(cmds, m.tickThinking())
|
||
}
|
||
|
||
}
|
||
|
||
// Update child components
|
||
m.viewport, cmd = m.viewport.Update(msg)
|
||
cmds = append(cmds, cmd)
|
||
|
||
// Only update text input if not thinking
|
||
if !m.isThinking {
|
||
m.textInput, cmd = m.textInput.Update(msg)
|
||
cmds = append(cmds, cmd)
|
||
}
|
||
|
||
return m, tea.Batch(cmds...)
|
||
}
|
||
|
||
// View implements the tea.Model interface
|
||
func (m *agentUI) View() string {
|
||
if !m.ready {
|
||
return "Loading..."
|
||
}
|
||
|
||
if m.width == 0 || m.height == 0 {
|
||
return "Initializing..."
|
||
}
|
||
|
||
modelAbility := "fast"
|
||
if !m.config.ModelFast {
|
||
modelAbility = "slow"
|
||
}
|
||
|
||
modelStatusLine := fmt.Sprintf("%s/%s (%s)", m.config.ModelVendor, m.config.ModelName, modelAbility)
|
||
|
||
header := headerStyle.Render(fmt.Sprintf("%s %s", m.config.TitleText, modelStatusLine))
|
||
|
||
content := m.viewport.View()
|
||
|
||
var thinkingIndicator string
|
||
if m.isThinking {
|
||
thinkingFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||
spinner := thinkingFrames[m.thinkingFrame%len(thinkingFrames)]
|
||
thinkingIndicator = thinkingStyle.Render(fmt.Sprintf("%s thinking...", spinner))
|
||
}
|
||
|
||
var inputArea string
|
||
userInput := m.textInput.Value()
|
||
cursor := ""
|
||
if m.textInput.Focused() && !m.isThinking {
|
||
cursor = inputCursorStyle.Render("▊")
|
||
}
|
||
|
||
inputContent := fmt.Sprintf("%s%s%s", inputPromptStyle.Render("> "), userInput, cursor)
|
||
inputArea = inputBorderStyle.Width(m.width - 2).Render(inputContent)
|
||
|
||
statusLine := inputPromptStyle.Render(fmt.Sprintf("** %s | ctrl+c to exit", modelStatusLine))
|
||
|
||
var components []string
|
||
components = append(components, header, "", content, "")
|
||
|
||
if thinkingIndicator != "" {
|
||
components = append(components, thinkingIndicator)
|
||
}
|
||
|
||
components = append(components, inputArea, statusLine)
|
||
|
||
return lipgloss.JoinVertical(lipgloss.Left, components...)
|
||
}
|
||
|
||
func (m *agentUI) resetInputField() {
|
||
m.textInput.Reset()
|
||
m.textInput.SetValue("")
|
||
m.textInput.CursorStart()
|
||
}
|
||
|
||
func (m *agentUI) addUserMessage(content string) {
|
||
m.messages = append(m.messages, uiMessage{
|
||
Role: "user",
|
||
Content: content,
|
||
Timestamp: time.Now(),
|
||
})
|
||
|
||
m.viewport.SetContent(m.renderMessages())
|
||
m.viewport.GotoBottom()
|
||
}
|
||
|
||
func (m *agentUI) addAgentMessage(content string) {
|
||
m.messages = append(m.messages, uiMessage{
|
||
Role: "agent",
|
||
Content: content,
|
||
Timestamp: time.Now(),
|
||
})
|
||
|
||
m.viewport.SetContent(m.renderMessages())
|
||
m.viewport.GotoBottom()
|
||
}
|
||
|
||
func (m *agentUI) addSystemMessage(content string) {
|
||
m.messages = append(m.messages, uiMessage{
|
||
Role: "system",
|
||
Content: content,
|
||
Timestamp: time.Now(),
|
||
})
|
||
|
||
m.viewport.SetContent(m.renderMessages())
|
||
m.viewport.GotoBottom()
|
||
}
|
||
|
||
func (m *agentUI) addToolCallMessage(toolName string, toolArgs string) {
|
||
content := fmt.Sprintf(" %s", toolName)
|
||
if toolArgs != "" && toolArgs != "{}" {
|
||
content += fmt.Sprintf("\n └─ %s", toolArgs)
|
||
}
|
||
|
||
m.messages = append(m.messages, uiMessage{
|
||
Role: "tool",
|
||
Content: content,
|
||
Timestamp: time.Now(),
|
||
})
|
||
|
||
m.viewport.SetContent(m.renderMessages())
|
||
m.viewport.GotoBottom()
|
||
}
|
||
|
||
// renderMessages formats all messages for display
|
||
func (m *agentUI) renderMessages() string {
|
||
var rendered []string
|
||
|
||
rendered = append(rendered, "", "")
|
||
|
||
contentWidth := m.viewport.Width - 2 // Account for internal padding
|
||
if contentWidth < 40 {
|
||
contentWidth = 40
|
||
}
|
||
|
||
r, err := glamour.NewTermRenderer(
|
||
glamour.WithStandardStyle("notty"),
|
||
glamour.WithWordWrap(contentWidth),
|
||
)
|
||
if err != nil {
|
||
r = nil
|
||
}
|
||
|
||
for _, msg := range m.messages {
|
||
timestamp := msg.Timestamp.Format("15:04:05")
|
||
|
||
switch msg.Role {
|
||
case "user":
|
||
userHeaderStyle := lipgloss.NewStyle().
|
||
Foreground(lipgloss.Color("86")).
|
||
Bold(true).
|
||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||
BorderForeground(lipgloss.Color("86")).
|
||
Padding(0, 1)
|
||
|
||
userContentStyle := lipgloss.NewStyle().
|
||
Foreground(lipgloss.Color("255")).
|
||
Padding(0, 2)
|
||
|
||
rendered = append(rendered,
|
||
userHeaderStyle.Render(fmt.Sprintf("[%s] → You:", timestamp)),
|
||
userContentStyle.Render(msg.Content),
|
||
"",
|
||
)
|
||
case "agent":
|
||
var content string
|
||
if r != nil {
|
||
renderedMarkdown, err := r.Render(msg.Content)
|
||
if err == nil {
|
||
content = strings.TrimSpace(renderedMarkdown)
|
||
} else {
|
||
content = msg.Content // Fallback to plain text
|
||
}
|
||
} else {
|
||
content = msg.Content
|
||
}
|
||
|
||
agentHeaderStyle := lipgloss.NewStyle().
|
||
Foreground(lipgloss.Color("39")).
|
||
Bold(true).
|
||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||
BorderForeground(lipgloss.Color("39")).
|
||
Padding(0, 1)
|
||
|
||
agentContentStyle := lipgloss.NewStyle().
|
||
Foreground(lipgloss.Color("255")).
|
||
Padding(0, 2)
|
||
|
||
rendered = append(rendered,
|
||
agentHeaderStyle.Render(fmt.Sprintf("[%s] ← Agent:", timestamp)),
|
||
agentContentStyle.Render(content),
|
||
"",
|
||
)
|
||
case "system":
|
||
systemStyle := lipgloss.NewStyle().
|
||
Foreground(lipgloss.Color("241")).
|
||
Italic(true).
|
||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||
BorderForeground(lipgloss.Color("241")).
|
||
Padding(0, 1)
|
||
|
||
rendered = append(rendered,
|
||
systemStyle.Render(fmt.Sprintf("[%s] ℹ %s", timestamp, msg.Content)),
|
||
"",
|
||
)
|
||
case "tool":
|
||
toolStyle := lipgloss.NewStyle().
|
||
Foreground(lipgloss.Color("245")).
|
||
Italic(true).
|
||
Faint(true).
|
||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||
BorderForeground(lipgloss.Color("245")).
|
||
Padding(0, 1)
|
||
|
||
rendered = append(rendered,
|
||
toolStyle.Render(fmt.Sprintf("[%s] %s", timestamp, msg.Content)),
|
||
"",
|
||
)
|
||
}
|
||
}
|
||
|
||
rendered = append(rendered, "", "")
|
||
|
||
return strings.Join(rendered, "\n")
|
||
}
|
||
|
||
func (m *agentUI) updateStatus(message string) tea.Cmd {
|
||
return func() tea.Msg {
|
||
return statusUpdateMsg{message: message}
|
||
}
|
||
}
|
||
|
||
func (m *agentUI) setThinking(thinking bool) tea.Cmd {
|
||
return func() tea.Msg {
|
||
return agentThinkingMsg{thinking: thinking}
|
||
}
|
||
}
|
||
|
||
func (m *agentUI) executeAgentQuery(userInput string) tea.Cmd {
|
||
return func() tea.Msg {
|
||
ctx := context.Background()
|
||
input := Input{
|
||
Query: userInput,
|
||
}
|
||
|
||
toolCallHook := func(_ context.Context, _ Session, _ Input, toolName string, toolArgs string) error {
|
||
m.Update(agentToolCallMsg{toolName: toolName, toolArgs: toolArgs})
|
||
return nil
|
||
}
|
||
|
||
output, err := m.agent.Execute(ctx, m.session, input, WithToolCallHook(toolCallHook))
|
||
if err != nil {
|
||
return agentResponseMsg{
|
||
content: fmt.Sprintf("❌ **Error**\n\nSorry, I encountered an error while processing your query:\n\n%s", err.Error()),
|
||
}
|
||
}
|
||
|
||
return agentResponseMsg{content: output.Answer}
|
||
}
|
||
}
|
||
|
||
// StartUI starts the TUI application with the default configuration
|
||
func StartUI(agent Agent, session Session) error {
|
||
config := DefaultAgentUIConfig()
|
||
config.InitialSystemMessage = ""
|
||
return StartUIWithConfig(agent, session, config)
|
||
}
|
||
|
||
// StartUIWithConfig starts the TUI application with the provided configuration
|
||
func StartUIWithConfig(agent Agent, session Session, config AgentUIConfig) error {
|
||
ui := NewAgentUI(agent, session, config)
|
||
|
||
p := tea.NewProgram(
|
||
ui,
|
||
tea.WithAltScreen(),
|
||
tea.WithMouseCellMotion(),
|
||
)
|
||
|
||
_, err := p.Run()
|
||
return err
|
||
}
|
||
|
||
func (m *agentUI) tickThinking() tea.Cmd {
|
||
return tea.Tick(150*time.Millisecond, func(time.Time) tea.Msg {
|
||
return thinkingTickMsg{}
|
||
})
|
||
}
|
||
|
||
// handleSlashCommand processes commands that start with '/'
|
||
func (m *agentUI) handleSlashCommand(command string) tea.Cmd {
|
||
switch command {
|
||
case "/exit":
|
||
m.addSystemMessage("Goodbye! Exiting gracefully...")
|
||
return tea.Quit
|
||
default:
|
||
m.addSystemMessage(fmt.Sprintf("Unknown command: %s", command))
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// addToHistory adds input to history buffer with a maximum of 50 entries
|
||
func (m *agentUI) addToHistory(input string) {
|
||
// Don't add empty strings or duplicates of the last entry
|
||
if input == "" || (len(m.inputHistory) > 0 && m.inputHistory[len(m.inputHistory)-1] == input) {
|
||
return
|
||
}
|
||
|
||
m.inputHistory = append(m.inputHistory, input)
|
||
|
||
// Keep only the last maxHistory entries
|
||
if len(m.inputHistory) > m.config.MaxHistory {
|
||
m.inputHistory = m.inputHistory[len(m.inputHistory)-m.config.MaxHistory:]
|
||
}
|
||
|
||
// Reset history navigation
|
||
m.historyIndex = -1
|
||
m.currentInput = ""
|
||
}
|
||
|
||
// navigateHistory moves through input history and returns the selected entry
|
||
func (m *agentUI) navigateHistory(direction int) string {
|
||
if len(m.inputHistory) == 0 {
|
||
return ""
|
||
}
|
||
|
||
// Save current input when starting navigation
|
||
if m.historyIndex == -1 {
|
||
m.currentInput = m.textInput.Value()
|
||
}
|
||
|
||
// Calculate new index
|
||
newIndex := m.historyIndex + direction
|
||
|
||
// Handle boundaries
|
||
if newIndex < -1 {
|
||
newIndex = -1
|
||
} else if newIndex >= len(m.inputHistory) {
|
||
newIndex = len(m.inputHistory) - 1
|
||
}
|
||
|
||
m.historyIndex = newIndex
|
||
|
||
// Return the appropriate entry
|
||
if m.historyIndex == -1 {
|
||
return m.currentInput
|
||
}
|
||
|
||
return m.inputHistory[len(m.inputHistory)-1-m.historyIndex]
|
||
}
|