reimplement agent,provider and add file history

This commit is contained in:
Kujtim Hoxha
2025-04-16 20:06:23 +02:00
parent 76b4065f17
commit bbfa60c787
73 changed files with 3742 additions and 4026 deletions

View File

@@ -6,7 +6,9 @@ import (
"fmt"
"math"
"strings"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
@@ -17,9 +19,11 @@ import (
"github.com/kujtimiihoxha/termai/internal/llm/agent"
"github.com/kujtimiihoxha/termai/internal/llm/models"
"github.com/kujtimiihoxha/termai/internal/llm/tools"
"github.com/kujtimiihoxha/termai/internal/logging"
"github.com/kujtimiihoxha/termai/internal/message"
"github.com/kujtimiihoxha/termai/internal/pubsub"
"github.com/kujtimiihoxha/termai/internal/session"
"github.com/kujtimiihoxha/termai/internal/tui/layout"
"github.com/kujtimiihoxha/termai/internal/tui/styles"
"github.com/kujtimiihoxha/termai/internal/tui/util"
)
@@ -32,6 +36,9 @@ const (
toolMessageType
)
// messagesTickMsg is a message sent by the timer to refresh messages
type messagesTickMsg time.Time
type uiMessage struct {
ID string
messageType uiMessageType
@@ -52,24 +59,34 @@ type messagesCmp struct {
renderer *glamour.TermRenderer
focusRenderer *glamour.TermRenderer
cachedContent map[string]string
agentWorking bool
spinner spinner.Model
needsRerender bool
lastViewport string
}
func (m *messagesCmp) Init() tea.Cmd {
return tea.Batch(m.viewport.Init())
return tea.Batch(m.viewport.Init(), m.spinner.Tick, m.tickMessages())
}
func (m *messagesCmp) tickMessages() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return messagesTickMsg(t)
})
}
func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case AgentWorkingMsg:
m.agentWorking = bool(msg)
if m.agentWorking {
cmds = append(cmds, m.spinner.Tick)
case messagesTickMsg:
// Refresh messages if we have an active session
if m.session.ID != "" {
messages, err := m.app.Messages.List(context.Background(), m.session.ID)
if err == nil {
m.messages = messages
m.needsRerender = true
}
}
// Continue ticking
cmds = append(cmds, m.tickMessages())
case EditorFocusMsg:
m.writingMode = bool(msg)
case SessionSelectedMsg:
@@ -84,6 +101,7 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.messages = make([]message.Message, 0)
m.currentMsgID = ""
m.needsRerender = true
m.cachedContent = make(map[string]string)
return m, nil
case tea.KeyMsg:
@@ -104,6 +122,12 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if !messageExists {
// If we have messages, ensure the previous last message is not cached
if len(m.messages) > 0 {
lastMsgID := m.messages[len(m.messages)-1].ID
delete(m.cachedContent, lastMsgID)
}
m.messages = append(m.messages, msg.Payload)
delete(m.cachedContent, m.currentMsgID)
m.currentMsgID = msg.Payload.ID
@@ -112,36 +136,40 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
for _, v := range m.messages {
for _, c := range v.ToolCalls() {
// the message is being added to the session of a tool called
if c.ID == msg.Payload.SessionID {
m.needsRerender = true
}
}
}
} else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
logging.Debug("Message", "finish", msg.Payload.FinishReason())
for i, v := range m.messages {
if v.ID == msg.Payload.ID {
if !m.messages[i].IsFinished() && msg.Payload.IsFinished() && msg.Payload.FinishReason() == "end_turn" || msg.Payload.FinishReason() == "canceled" {
cmds = append(cmds, util.CmdHandler(AgentWorkingMsg(false)))
}
m.messages[i] = msg.Payload
delete(m.cachedContent, msg.Payload.ID)
// If this is the last message, ensure it's not cached
if i == len(m.messages)-1 {
delete(m.cachedContent, msg.Payload.ID)
}
m.needsRerender = true
break
}
}
}
}
if m.agentWorking {
u, cmd := m.spinner.Update(msg)
m.spinner = u
cmds = append(cmds, cmd)
}
oldPos := m.viewport.YPosition
u, cmd := m.viewport.Update(msg)
m.viewport = u
m.needsRerender = m.needsRerender || m.viewport.YPosition != oldPos
cmds = append(cmds, cmd)
spinner, cmd := m.spinner.Update(msg)
m.spinner = spinner
cmds = append(cmds, cmd)
if m.needsRerender {
m.renderView()
if len(m.messages) > 0 {
@@ -157,10 +185,21 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
func (m *messagesCmp) IsAgentWorking() bool {
return m.app.CoderAgent.IsSessionBusy(m.session.ID)
}
func (m *messagesCmp) renderSimpleMessage(msg message.Message, info ...string) string {
if v, ok := m.cachedContent[msg.ID]; ok {
return v
// Check if this is the last message in the list
isLastMessage := len(m.messages) > 0 && m.messages[len(m.messages)-1].ID == msg.ID
// Only use cache for non-last messages
if !isLastMessage {
if v, ok := m.cachedContent[msg.ID]; ok {
return v
}
}
style := styles.BaseStyle.
Width(m.width).
BorderLeft(true).
@@ -191,7 +230,12 @@ func (m *messagesCmp) renderSimpleMessage(msg message.Message, info ...string) s
parts...,
),
)
m.cachedContent[msg.ID] = rendered
// Only cache if it's not the last message
if !isLastMessage {
m.cachedContent[msg.ID] = rendered
}
return rendered
}
@@ -207,32 +251,71 @@ func formatTimeDifference(unixTime1, unixTime2 int64) string {
return fmt.Sprintf("%dm%ds", minutes, seconds)
}
func (m *messagesCmp) findToolResponse(callID string) *message.ToolResult {
for _, v := range m.messages {
for _, c := range v.ToolResults() {
if c.ToolCallID == callID {
return &c
}
}
}
return nil
}
func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) string {
key := ""
value := ""
result := styles.BaseStyle.Foreground(styles.PrimaryColor).Render(m.spinner.View() + " waiting for response...")
response := m.findToolResponse(toolCall.ID)
if response != nil && response.IsError {
// Clean up error message for display by removing newlines
// This ensures error messages display properly in the UI
errMsg := strings.ReplaceAll(response.Content, "\n", " ")
result = styles.BaseStyle.Foreground(styles.Error).Render(ansi.Truncate(errMsg, 40, "..."))
} else if response != nil {
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render("Done")
}
switch toolCall.Name {
// TODO: add result data to the tools
case agent.AgentToolName:
key = "Task"
var params agent.AgentParams
json.Unmarshal([]byte(toolCall.Input), &params)
value = params.Prompt
// TODO: handle nested calls
value = strings.ReplaceAll(params.Prompt, "\n", " ")
if response != nil && !response.IsError {
firstRow := strings.ReplaceAll(response.Content, "\n", " ")
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(ansi.Truncate(firstRow, 40, "..."))
}
case tools.BashToolName:
key = "Bash"
var params tools.BashParams
json.Unmarshal([]byte(toolCall.Input), &params)
value = params.Command
if response != nil && !response.IsError {
metadata := tools.BashResponseMetadata{}
json.Unmarshal([]byte(response.Metadata), &metadata)
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("Took %s", formatTimeDifference(metadata.StartTime, metadata.EndTime)))
}
case tools.EditToolName:
key = "Edit"
var params tools.EditParams
json.Unmarshal([]byte(toolCall.Input), &params)
value = params.FilePath
if response != nil && !response.IsError {
metadata := tools.EditResponseMetadata{}
json.Unmarshal([]byte(response.Metadata), &metadata)
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d Additions %d Removals", metadata.Additions, metadata.Removals))
}
case tools.FetchToolName:
key = "Fetch"
var params tools.FetchParams
json.Unmarshal([]byte(toolCall.Input), &params)
value = params.URL
if response != nil && !response.IsError {
result = styles.BaseStyle.Foreground(styles.Error).Render(response.Content)
}
case tools.GlobToolName:
key = "Glob"
var params tools.GlobParams
@@ -241,6 +324,15 @@ func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) s
params.Path = "."
}
value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
if response != nil && !response.IsError {
metadata := tools.GlobResponseMetadata{}
json.Unmarshal([]byte(response.Metadata), &metadata)
if metadata.Truncated {
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfFiles))
} else {
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfFiles))
}
}
case tools.GrepToolName:
key = "Grep"
var params tools.GrepParams
@@ -249,19 +341,46 @@ func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) s
params.Path = "."
}
value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
if response != nil && !response.IsError {
metadata := tools.GrepResponseMetadata{}
json.Unmarshal([]byte(response.Metadata), &metadata)
if metadata.Truncated {
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfMatches))
} else {
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfMatches))
}
}
case tools.LSToolName:
key = "Ls"
key = "ls"
var params tools.LSParams
json.Unmarshal([]byte(toolCall.Input), &params)
if params.Path == "" {
params.Path = "."
}
value = params.Path
if response != nil && !response.IsError {
metadata := tools.LSResponseMetadata{}
json.Unmarshal([]byte(response.Metadata), &metadata)
if metadata.Truncated {
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfFiles))
} else {
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfFiles))
}
}
case tools.SourcegraphToolName:
key = "Sourcegraph"
var params tools.SourcegraphParams
json.Unmarshal([]byte(toolCall.Input), &params)
value = params.Query
if response != nil && !response.IsError {
metadata := tools.SourcegraphResponseMetadata{}
json.Unmarshal([]byte(response.Metadata), &metadata)
if metadata.Truncated {
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d matches found (truncated)", metadata.NumberOfMatches))
} else {
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d matches found", metadata.NumberOfMatches))
}
}
case tools.ViewToolName:
key = "View"
var params tools.ViewParams
@@ -272,6 +391,12 @@ func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) s
var params tools.WriteParams
json.Unmarshal([]byte(toolCall.Input), &params)
value = params.FilePath
if response != nil && !response.IsError {
metadata := tools.WriteResponseMetadata{}
json.Unmarshal([]byte(response.Metadata), &metadata)
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d Additions %d Removals", metadata.Additions, metadata.Removals))
}
default:
key = toolCall.Name
var params map[string]any
@@ -300,14 +425,15 @@ func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) s
)
if !isNested {
value = valyeStyle.
Width(m.width - lipgloss.Width(keyValye) - 2).
Render(
ansi.Truncate(
value,
m.width-lipgloss.Width(keyValye)-2,
value+" ",
m.width-lipgloss.Width(keyValye)-2-lipgloss.Width(result),
"...",
),
)
value += result
} else {
keyValye = keyStyle.Render(
fmt.Sprintf(" └ %s: ", key),
@@ -409,6 +535,27 @@ func (m *messagesCmp) renderView() {
m.uiMessages = make([]uiMessage, 0)
pos := 0
// If we have messages, ensure the last message is not cached
// This ensures we always render the latest content for the most recent message
// which may be actively updating (e.g., during generation)
if len(m.messages) > 0 {
lastMsgID := m.messages[len(m.messages)-1].ID
delete(m.cachedContent, lastMsgID)
}
// Limit cache to 10 messages
if len(m.cachedContent) > 15 {
// Create a list of keys to delete (oldest messages first)
keys := make([]string, 0, len(m.cachedContent))
for k := range m.cachedContent {
keys = append(keys, k)
}
// Delete oldest messages until we have 10 or fewer
for i := 0; i < len(keys)-15; i++ {
delete(m.cachedContent, keys[i])
}
}
for _, v := range m.messages {
switch v.Role {
case message.User:
@@ -487,7 +634,7 @@ func (m *messagesCmp) View() string {
func (m *messagesCmp) help() string {
text := ""
if m.agentWorking {
if m.IsAgentWorking() {
text += styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render(
fmt.Sprintf("%s %s ", m.spinner.View(), "Generating..."),
)
@@ -562,9 +709,15 @@ func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
m.messages = messages
m.currentMsgID = m.messages[len(m.messages)-1].ID
m.needsRerender = true
m.cachedContent = make(map[string]string)
return nil
}
func (m *messagesCmp) BindingKeys() []key.Binding {
bindings := layout.KeyMapToSlice(m.viewport.KeyMap)
return bindings
}
func NewMessagesCmp(app *app.App) tea.Model {
focusRenderer, _ := glamour.NewTermRenderer(
glamour.WithStyles(styles.MarkdownTheme(true)),