summaryrefslogtreecommitdiffhomepage
path: root/internal/tui/components/chat
diff options
context:
space:
mode:
authorKujtim Hoxha <[email protected]>2025-04-16 20:06:23 +0200
committerKujtim Hoxha <[email protected]>2025-04-21 13:42:00 +0200
commitbbfa60c787f2ec459f1689b9a650ddbec9693ed9 (patch)
treef7f2aa31c460c8cc22ec40cc299c386277152241 /internal/tui/components/chat
parent76b4065f17b87a63092acfd98c997bab53700b35 (diff)
downloadopencode-bbfa60c787f2ec459f1689b9a650ddbec9693ed9.tar.gz
opencode-bbfa60c787f2ec459f1689b9a650ddbec9693ed9.zip
reimplement agent,provider and add file history
Diffstat (limited to 'internal/tui/components/chat')
-rw-r--r--internal/tui/components/chat/chat.go2
-rw-r--r--internal/tui/components/chat/editor.go22
-rw-r--r--internal/tui/components/chat/messages.go205
-rw-r--r--internal/tui/components/chat/sidebar.go176
4 files changed, 358 insertions, 47 deletions
diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go
index e893ec2f5..e98001efa 100644
--- a/internal/tui/components/chat/chat.go
+++ b/internal/tui/components/chat/chat.go
@@ -19,8 +19,6 @@ type SessionSelectedMsg = session.Session
type SessionClearedMsg struct{}
-type AgentWorkingMsg bool
-
type EditorFocusMsg bool
func lspsConfigured(width int) string {
diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go
index e87f1ffae..e2f4da9e2 100644
--- a/internal/tui/components/chat/editor.go
+++ b/internal/tui/components/chat/editor.go
@@ -5,14 +5,17 @@ import (
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/termai/internal/app"
+ "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"
)
type editorCmp struct {
- textarea textarea.Model
- agentWorking bool
+ app *app.App
+ session session.Session
+ textarea textarea.Model
}
type focusedEditorKeyMaps struct {
@@ -32,7 +35,7 @@ var focusedKeyMaps = focusedEditorKeyMaps{
),
Blur: key.NewBinding(
key.WithKeys("esc"),
- key.WithHelp("esc", "blur editor"),
+ key.WithHelp("esc", "focus messages"),
),
}
@@ -52,7 +55,7 @@ func (m *editorCmp) Init() tea.Cmd {
}
func (m *editorCmp) send() tea.Cmd {
- if m.agentWorking {
+ if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
return util.ReportWarn("Agent is working, please wait...")
}
@@ -66,7 +69,6 @@ func (m *editorCmp) send() tea.Cmd {
util.CmdHandler(SendMsg{
Text: value,
}),
- util.CmdHandler(AgentWorkingMsg(true)),
util.CmdHandler(EditorFocusMsg(false)),
)
}
@@ -74,8 +76,11 @@ func (m *editorCmp) send() tea.Cmd {
func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
- case AgentWorkingMsg:
- m.agentWorking = bool(msg)
+ case SessionSelectedMsg:
+ if msg.ID != m.session.ID {
+ m.session = msg
+ }
+ return m, nil
case tea.KeyMsg:
// if the key does not match any binding, return
if m.textarea.Focused() && key.Matches(msg, focusedKeyMaps.Send) {
@@ -122,7 +127,7 @@ func (m *editorCmp) BindingKeys() []key.Binding {
return bindings
}
-func NewEditorCmp() tea.Model {
+func NewEditorCmp(app *app.App) tea.Model {
ti := textarea.New()
ti.Prompt = " "
ti.ShowLineNumbers = false
@@ -138,6 +143,7 @@ func NewEditorCmp() tea.Model {
ti.CharLimit = -1
ti.Focus()
return &editorCmp{
+ app: app,
textarea: ti,
}
}
diff --git a/internal/tui/components/chat/messages.go b/internal/tui/components/chat/messages.go
index dc21fca29..26a98970e 100644
--- a/internal/tui/components/chat/messages.go
+++ b/internal/tui/components/chat/messages.go
@@ -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)),
diff --git a/internal/tui/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go
index 51192cf9a..b90269d1a 100644
--- a/internal/tui/components/chat/sidebar.go
+++ b/internal/tui/components/chat/sidebar.go
@@ -1,10 +1,15 @@
package chat
import (
+ "context"
"fmt"
+ "strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/termai/internal/config"
+ "github.com/kujtimiihoxha/termai/internal/diff"
+ "github.com/kujtimiihoxha/termai/internal/history"
"github.com/kujtimiihoxha/termai/internal/pubsub"
"github.com/kujtimiihoxha/termai/internal/session"
"github.com/kujtimiihoxha/termai/internal/tui/styles"
@@ -13,9 +18,33 @@ import (
type sidebarCmp struct {
width, height int
session session.Session
+ history history.Service
+ modFiles map[string]struct {
+ additions int
+ removals int
+ }
}
func (m *sidebarCmp) Init() tea.Cmd {
+ if m.history != nil {
+ ctx := context.Background()
+ // Subscribe to file events
+ filesCh := m.history.Subscribe(ctx)
+
+ // Initialize the modified files map
+ m.modFiles = make(map[string]struct {
+ additions int
+ removals int
+ })
+
+ // Load initial files and calculate diffs
+ m.loadModifiedFiles(ctx)
+
+ // Return a command that will send file events to the Update method
+ return func() tea.Msg {
+ return <-filesCh
+ }
+ }
return nil
}
@@ -27,6 +56,13 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.session = msg.Payload
}
}
+ case pubsub.Event[history.File]:
+ if msg.Payload.SessionID == m.session.ID {
+ // When a file changes, reload all modified files
+ // This ensures we have the complete and accurate list
+ ctx := context.Background()
+ m.loadModifiedFiles(ctx)
+ }
}
return m, nil
}
@@ -86,18 +122,28 @@ func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) stri
func (m *sidebarCmp) modifiedFiles() string {
modifiedFiles := styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render("Modified Files:")
- files := []struct {
- path string
- additions int
- removals int
- }{
- {"file1.txt", 10, 5},
- {"file2.txt", 20, 0},
- {"file3.txt", 0, 15},
+
+ // If no modified files, show a placeholder message
+ if m.modFiles == nil || len(m.modFiles) == 0 {
+ message := "No modified files"
+ remainingWidth := m.width - lipgloss.Width(modifiedFiles)
+ if remainingWidth > 0 {
+ message += strings.Repeat(" ", remainingWidth)
+ }
+ return styles.BaseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ modifiedFiles,
+ styles.BaseStyle.Foreground(styles.ForgroundDim).Render(message),
+ ),
+ )
}
+
var fileViews []string
- for _, file := range files {
- fileViews = append(fileViews, m.modifiedFile(file.path, file.additions, file.removals))
+ for path, stats := range m.modFiles {
+ fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
}
return styles.BaseStyle.
@@ -123,8 +169,116 @@ func (m *sidebarCmp) GetSize() (int, int) {
return m.width, m.height
}
-func NewSidebarCmp(session session.Session) tea.Model {
+func NewSidebarCmp(session session.Session, history history.Service) tea.Model {
return &sidebarCmp{
session: session,
+ history: history,
+ }
+}
+
+func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
+ if m.history == nil || m.session.ID == "" {
+ return
+ }
+
+ // Get all latest files for this session
+ latestFiles, err := m.history.ListLatestSessionFiles(ctx, m.session.ID)
+ if err != nil {
+ return
+ }
+
+ // Get all files for this session (to find initial versions)
+ allFiles, err := m.history.ListBySession(ctx, m.session.ID)
+ if err != nil {
+ return
+ }
+
+ // Process each latest file
+ for _, file := range latestFiles {
+ // Skip if this is the initial version (no changes to show)
+ if file.Version == history.InitialVersion {
+ continue
+ }
+
+ // Find the initial version for this specific file
+ var initialVersion history.File
+ for _, v := range allFiles {
+ if v.Path == file.Path && v.Version == history.InitialVersion {
+ initialVersion = v
+ break
+ }
+ }
+
+ // Skip if we can't find the initial version
+ if initialVersion.ID == "" {
+ continue
+ }
+
+ // Calculate diff between initial and latest version
+ _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
+
+ // Only add to modified files if there are changes
+ if additions > 0 || removals > 0 {
+ // Remove working directory prefix from file path
+ displayPath := file.Path
+ workingDir := config.WorkingDirectory()
+ displayPath = strings.TrimPrefix(displayPath, workingDir)
+ displayPath = strings.TrimPrefix(displayPath, "/")
+
+ m.modFiles[displayPath] = struct {
+ additions int
+ removals int
+ }{
+ additions: additions,
+ removals: removals,
+ }
+ }
+ }
+}
+
+func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) {
+ // Skip if not the latest version
+ if file.Version == history.InitialVersion {
+ return
+ }
+
+ // Get all versions of this file
+ fileVersions, err := m.history.ListBySession(ctx, m.session.ID)
+ if err != nil {
+ return
+ }
+
+ // Find the initial version
+ var initialVersion history.File
+ for _, v := range fileVersions {
+ if v.Path == file.Path && v.Version == history.InitialVersion {
+ initialVersion = v
+ break
+ }
+ }
+
+ // Skip if we can't find the initial version
+ if initialVersion.ID == "" {
+ return
+ }
+
+ // Calculate diff between initial and latest version
+ _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
+
+ // Only add to modified files if there are changes
+ if additions > 0 || removals > 0 {
+ // Remove working directory prefix from file path
+ displayPath := file.Path
+ workingDir := config.WorkingDirectory()
+ displayPath = strings.TrimPrefix(displayPath, workingDir)
+ displayPath = strings.TrimPrefix(displayPath, "/")
+
+ m.modFiles[displayPath] = struct {
+ additions int
+ removals int
+ }{
+ additions: additions,
+ removals: removals,
+ }
}
}