on main help sign in
feat: filetracker per session (#2033)
3a929ffCarlos Alexandro Becker
internal/agent/agentic_fetch_tool.go+1−1
@@ -168,7 +168,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
168 tools.NewGlobTool(tmpDir),
168 tools.NewGlobTool(tmpDir),
169 tools.NewGrepTool(tmpDir),
169 tools.NewGrepTool(tmpDir),
170 tools.NewSourcegraphTool(client),
170 tools.NewSourcegraphTool(client),
171− tools.NewViewTool(c.lspClients, c.permissions, tmpDir),
171+ tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, tmpDir),
172 }
172 }
173
173
174 agent := NewSessionAgent(SessionAgentOptions{
174 agent := NewSessionAgent(SessionAgentOptions{
internal/agent/common_test.go+8−4
@@ -20,6 +20,7 @@ import (
20 "github.com/charmbracelet/crush/internal/config"
20 "github.com/charmbracelet/crush/internal/config"
21 "github.com/charmbracelet/crush/internal/csync"
21 "github.com/charmbracelet/crush/internal/csync"
22 "github.com/charmbracelet/crush/internal/db"
22 "github.com/charmbracelet/crush/internal/db"
23+ "github.com/charmbracelet/crush/internal/filetracker"
23 "github.com/charmbracelet/crush/internal/history"
24 "github.com/charmbracelet/crush/internal/history"
24 "github.com/charmbracelet/crush/internal/lsp"
25 "github.com/charmbracelet/crush/internal/lsp"
25 "github.com/charmbracelet/crush/internal/message"
26 "github.com/charmbracelet/crush/internal/message"
@@ -37,6 +38,7 @@ type fakeEnv struct {
37 messages message.Service
38 messages message.Service
38 permissions permission.Service
39 permissions permission.Service
39 history history.Service
40 history history.Service
41+ filetracker *filetracker.Service
40 lspClients *csync.Map[string, *lsp.Client]
42 lspClients *csync.Map[string, *lsp.Client]
41}
43}
42
44
@@ -117,6 +119,7 @@ func testEnv(t *testing.T) fakeEnv {
117
119
118 permissions := permission.NewPermissionService(workingDir, true, []string{})
120 permissions := permission.NewPermissionService(workingDir, true, []string{})
119 history := history.NewService(q, conn)
121 history := history.NewService(q, conn)
122+ filetrackerService := filetracker.NewService(q)
120 lspClients := csync.NewMap[string, *lsp.Client]()
123 lspClients := csync.NewMap[string, *lsp.Client]()
121
124
122 t.Cleanup(func() {
125 t.Cleanup(func() {
@@ -130,6 +133,7 @@ func testEnv(t *testing.T) fakeEnv {
130 messages,
133 messages,
131 permissions,
134 permissions,
132 history,
135 history,
136+ &filetrackerService,
133 lspClients,
137 lspClients,
134 }
138 }
135}
139}
@@ -200,15 +204,15 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
200 allTools := []fantasy.AgentTool{
204 allTools := []fantasy.AgentTool{
201 tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution, modelName),
205 tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution, modelName),
202 tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
206 tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
203− tools.NewEditTool(env.lspClients, env.permissions, env.history, env.workingDir),
207+ tools.NewEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
204− tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, env.workingDir),
208+ tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
205 tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
209 tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
206 tools.NewGlobTool(env.workingDir),
210 tools.NewGlobTool(env.workingDir),
207 tools.NewGrepTool(env.workingDir),
211 tools.NewGrepTool(env.workingDir),
208 tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls),
212 tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls),
209 tools.NewSourcegraphTool(r.GetDefaultClient()),
213 tools.NewSourcegraphTool(r.GetDefaultClient()),
210− tools.NewViewTool(env.lspClients, env.permissions, env.workingDir),
214+ tools.NewViewTool(env.lspClients, env.permissions, *env.filetracker, env.workingDir),
211− tools.NewWriteTool(env.lspClients, env.permissions, env.history, env.workingDir),
215+ tools.NewWriteTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
212 }
216 }
213
217
214 return testSessionAgent(env, large, small, systemPrompt, allTools...), nil
218 return testSessionAgent(env, large, small, systemPrompt, allTools...), nil
internal/agent/coordinator.go+8−4
@@ -22,6 +22,7 @@ import (
22 "github.com/charmbracelet/crush/internal/agent/tools"
22 "github.com/charmbracelet/crush/internal/agent/tools"
23 "github.com/charmbracelet/crush/internal/config"
23 "github.com/charmbracelet/crush/internal/config"
24 "github.com/charmbracelet/crush/internal/csync"
24 "github.com/charmbracelet/crush/internal/csync"
25+ "github.com/charmbracelet/crush/internal/filetracker"
25 "github.com/charmbracelet/crush/internal/history"
26 "github.com/charmbracelet/crush/internal/history"
26 "github.com/charmbracelet/crush/internal/log"
27 "github.com/charmbracelet/crush/internal/log"
27 "github.com/charmbracelet/crush/internal/lsp"
28 "github.com/charmbracelet/crush/internal/lsp"
@@ -64,6 +65,7 @@ type coordinator struct {
64 messages message.Service
65 messages message.Service
65 permissions permission.Service
66 permissions permission.Service
66 history history.Service
67 history history.Service
68+ filetracker filetracker.Service
67 lspClients *csync.Map[string, *lsp.Client]
69 lspClients *csync.Map[string, *lsp.Client]
68
70
69 currentAgent SessionAgent
71 currentAgent SessionAgent
@@ -79,6 +81,7 @@ func NewCoordinator(
79 messages message.Service,
81 messages message.Service,
80 permissions permission.Service,
82 permissions permission.Service,
81 history history.Service,
83 history history.Service,
84+ filetracker filetracker.Service,
82 lspClients *csync.Map[string, *lsp.Client],
85 lspClients *csync.Map[string, *lsp.Client],
83) (Coordinator, error) {
86) (Coordinator, error) {
84 c := &coordinator{
87 c := &coordinator{
@@ -87,6 +90,7 @@ func NewCoordinator(
87 messages: messages,
90 messages: messages,
88 permissions: permissions,
91 permissions: permissions,
89 history: history,
92 history: history,
93+ filetracker: filetracker,
90 lspClients: lspClients,
94 lspClients: lspClients,
91 agents: make(map[string]SessionAgent),
95 agents: make(map[string]SessionAgent),
92 }
96 }
@@ -393,16 +397,16 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
393 tools.NewJobOutputTool(),
397 tools.NewJobOutputTool(),
394 tools.NewJobKillTool(),
398 tools.NewJobKillTool(),
395 tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
399 tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
396− tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
400+ tools.NewEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
397− tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
401+ tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
398 tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
402 tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
399 tools.NewGlobTool(c.cfg.WorkingDir()),
403 tools.NewGlobTool(c.cfg.WorkingDir()),
400 tools.NewGrepTool(c.cfg.WorkingDir()),
404 tools.NewGrepTool(c.cfg.WorkingDir()),
401 tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
405 tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
402 tools.NewSourcegraphTool(nil),
406 tools.NewSourcegraphTool(nil),
403 tools.NewTodosTool(c.sessions),
407 tools.NewTodosTool(c.sessions),
404− tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
408+ tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
405− tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
409+ tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
406 )
410 )
407
411
408 if len(c.cfg.LSP) > 0 {
412 if len(c.cfg.LSP) > 0 {
internal/agent/tools/edit.go+28−25
@@ -56,10 +56,17 @@ type editContext struct {
56 ctx context.Context
56 ctx context.Context
57 permissions permission.Service
57 permissions permission.Service
58 files history.Service
58 files history.Service
59+ filetracker filetracker.Service
59 workingDir string
60 workingDir string
60}
61}
61
62
63+func NewEditTool(
62−func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
64+ lspClients *csync.Map[string, *lsp.Client],
65+ permissions permission.Service,
66+ files history.Service,
67+ filetracker filetracker.Service,
68+ workingDir string,
69+) fantasy.AgentTool {
63 return fantasy.NewAgentTool(
70 return fantasy.NewAgentTool(
64 EditToolName,
71 EditToolName,
65 string(editDescription),
72 string(editDescription),
@@ -73,7 +80,7 @@ func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
73 var response fantasy.ToolResponse
80 var response fantasy.ToolResponse
74 var err error
81 var err error
75
82
76− editCtx := editContext{ctx, permissions, files, workingDir}
83+ editCtx := editContext{ctx, permissions, files, filetracker, workingDir}
77
84
78 if params.OldString == "" {
85 if params.OldString == "" {
79 response, err = createNewFile(editCtx, params.FilePath, params.NewString, call)
86 response, err = createNewFile(editCtx, params.FilePath, params.NewString, call)
@@ -168,8 +175,7 @@ func createNewFile(edit editContext, filePath, content string, call fantasy.Tool
168 slog.Error("Error creating file history version", "error", err)
175 slog.Error("Error creating file history version", "error", err)
169 }
176 }
170
177
171− filetracker.RecordWrite(filePath)
172− filetracker.RecordRead(filePath)
178+ edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
173
179
174 return fantasy.WithResponseMetadata(
180 return fantasy.WithResponseMetadata(
175 fantasy.NewTextResponse("File created: "+filePath),
181 fantasy.NewTextResponse("File created: "+filePath),
@@ -195,12 +201,17 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
195 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
201 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
196 }
202 }
197
203
204+ sessionID := GetSessionFromContext(edit.ctx)
205+ if sessionID == "" {
206+ return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for deleting content")
207+ }
208+
198− if filetracker.LastReadTime(filePath).IsZero() {
209+ lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
210+ if lastRead.IsZero() {
199 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
211 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
200 }
212 }
201
213
202− modTime := fileInfo.ModTime()
214+ modTime := fileInfo.ModTime().Truncate(time.Second)
203− lastRead := filetracker.LastReadTime(filePath)
204 if modTime.After(lastRead) {
215 if modTime.After(lastRead) {
205 return fantasy.NewTextErrorResponse(
216 return fantasy.NewTextErrorResponse(
206 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
217 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
@@ -236,12 +247,6 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
236 newContent = oldContent[:index] + oldContent[index+len(oldString):]
247 newContent = oldContent[:index] + oldContent[index+len(oldString):]
237 }
248 }
238
249
239− sessionID := GetSessionFromContext(edit.ctx)
240−
241− if sessionID == "" {
242− return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for deleting content")
243− }
244−
245 _, additions, removals := diff.GenerateDiff(
250 _, additions, removals := diff.GenerateDiff(
246 oldContent,
251 oldContent,
247 newContent,
252 newContent,
@@ -301,8 +306,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
301 slog.Error("Error creating file history version", "error", err)
306 slog.Error("Error creating file history version", "error", err)
302 }
307 }
303
308
304− filetracker.RecordWrite(filePath)
305− filetracker.RecordRead(filePath)
309+ edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
306
310
307 return fantasy.WithResponseMetadata(
311 return fantasy.WithResponseMetadata(
308 fantasy.NewTextResponse("Content deleted from file: "+filePath),
312 fantasy.NewTextResponse("Content deleted from file: "+filePath),
@@ -328,12 +332,17 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
328 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
332 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
329 }
333 }
330
334
335+ sessionID := GetSessionFromContext(edit.ctx)
336+ if sessionID == "" {
337+ return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for edit a file")
338+ }
339+
331− if filetracker.LastReadTime(filePath).IsZero() {
340+ lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
341+ if lastRead.IsZero() {
332 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
342 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
333 }
343 }
334
344
335− modTime := fileInfo.ModTime()
345+ modTime := fileInfo.ModTime().Truncate(time.Second)
336− lastRead := filetracker.LastReadTime(filePath)
337 if modTime.After(lastRead) {
346 if modTime.After(lastRead) {
338 return fantasy.NewTextErrorResponse(
347 return fantasy.NewTextErrorResponse(
339 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
348 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
@@ -369,11 +378,6 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
369 if oldContent == newContent {
378 if oldContent == newContent {
370 return fantasy.NewTextErrorResponse("new content is the same as old content. No changes made."), nil
379 return fantasy.NewTextErrorResponse("new content is the same as old content. No changes made."), nil
371 }
380 }
372− sessionID := GetSessionFromContext(edit.ctx)
373−
374− if sessionID == "" {
375− return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
376− }
377 _, additions, removals := diff.GenerateDiff(
381 _, additions, removals := diff.GenerateDiff(
378 oldContent,
382 oldContent,
379 newContent,
383 newContent,
@@ -433,8 +437,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
433 slog.Error("Error creating file history version", "error", err)
437 slog.Error("Error creating file history version", "error", err)
434 }
438 }
435
439
436− filetracker.RecordWrite(filePath)
437− filetracker.RecordRead(filePath)
440+ edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
438
441
439 return fantasy.WithResponseMetadata(
442 return fantasy.WithResponseMetadata(
440 fantasy.NewTextResponse("Content replaced in file: "+filePath),
443 fantasy.NewTextResponse("Content replaced in file: "+filePath),
internal/agent/tools/multiedit.go+19−16
@@ -58,7 +58,13 @@ const MultiEditToolName = "multiedit"
58//go:embed multiedit.md
58//go:embed multiedit.md
59var multieditDescription []byte
59var multieditDescription []byte
60
60
61+func NewMultiEditTool(
61−func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
62+ lspClients *csync.Map[string, *lsp.Client],
63+ permissions permission.Service,
64+ files history.Service,
65+ filetracker filetracker.Service,
66+ workingDir string,
67+) fantasy.AgentTool {
62 return fantasy.NewAgentTool(
68 return fantasy.NewAgentTool(
63 MultiEditToolName,
69 MultiEditToolName,
64 string(multieditDescription),
70 string(multieditDescription),
@@ -81,7 +87,7 @@ func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions pe
81 var response fantasy.ToolResponse
87 var response fantasy.ToolResponse
82 var err error
88 var err error
83
89
84− editCtx := editContext{ctx, permissions, files, workingDir}
90+ editCtx := editContext{ctx, permissions, files, filetracker, workingDir}
85 // Handle file creation case (first edit has empty old_string)
91 // Handle file creation case (first edit has empty old_string)
86 if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
92 if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
87 response, err = processMultiEditWithCreation(editCtx, params, call)
93 response, err = processMultiEditWithCreation(editCtx, params, call)
@@ -210,8 +216,7 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call
210 slog.Error("Error creating file history version", "error", err)
216 slog.Error("Error creating file history version", "error", err)
211 }
217 }
212
218
213− filetracker.RecordWrite(params.FilePath)
214− filetracker.RecordRead(params.FilePath)
219+ edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
215
220
216 var message string
221 var message string
217 if len(failedEdits) > 0 {
222 if len(failedEdits) > 0 {
@@ -247,14 +252,19 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
247 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
252 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
248 }
253 }
249
254
255+ sessionID := GetSessionFromContext(edit.ctx)
256+ if sessionID == "" {
257+ return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
258+ }
259+
250 // Check if file was read before editing
260 // Check if file was read before editing
251− if filetracker.LastReadTime(params.FilePath).IsZero() {
261+ lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, params.FilePath)
262+ if lastRead.IsZero() {
252 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
263 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
253 }
264 }
254
265
255− // Check if file was modified since last read
266+ // Check if file was modified since last read.
256− modTime := fileInfo.ModTime()
267+ modTime := fileInfo.ModTime().Truncate(time.Second)
257− lastRead := filetracker.LastReadTime(params.FilePath)
258 if modTime.After(lastRead) {
268 if modTime.After(lastRead) {
259 return fantasy.NewTextErrorResponse(
269 return fantasy.NewTextErrorResponse(
260 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
270 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
@@ -301,12 +311,6 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
301 return fantasy.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
311 return fantasy.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
302 }
312 }
303
313
304− // Get session and message IDs
305− sessionID := GetSessionFromContext(edit.ctx)
306− if sessionID == "" {
307− return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
308− }
309−
310 // Generate diff and check permissions
314 // Generate diff and check permissions
311 _, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
315 _, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
312
316
@@ -369,8 +373,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
369 slog.Error("Error creating file history version", "error", err)
373 slog.Error("Error creating file history version", "error", err)
370 }
374 }
371
375
372− filetracker.RecordWrite(params.FilePath)
373− filetracker.RecordRead(params.FilePath)
376+ edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
374
377
375 var message string
378 var message string
376 if len(failedEdits) > 0 {
379 if len(failedEdits) > 0 {
internal/agent/tools/multiedit_test.go−14
@@ -6,10 +6,7 @@ import (
6 "path/filepath"
6 "path/filepath"
7 "testing"
7 "testing"
8
8
9− "github.com/charmbracelet/crush/internal/csync"
10− "github.com/charmbracelet/crush/internal/filetracker"
11 "github.com/charmbracelet/crush/internal/history"
9 "github.com/charmbracelet/crush/internal/history"
12− "github.com/charmbracelet/crush/internal/lsp"
13 "github.com/charmbracelet/crush/internal/permission"
10 "github.com/charmbracelet/crush/internal/permission"
14 "github.com/charmbracelet/crush/internal/pubsub"
11 "github.com/charmbracelet/crush/internal/pubsub"
15 "github.com/stretchr/testify/require"
12 "github.com/stretchr/testify/require"
@@ -111,17 +108,6 @@ func TestMultiEditSequentialApplication(t *testing.T) {
111 err := os.WriteFile(testFile, []byte(content), 0o644)
108 err := os.WriteFile(testFile, []byte(content), 0o644)
112 require.NoError(t, err)
109 require.NoError(t, err)
113
110
114− // Mock components.
115− lspClients := csync.NewMap[string, *lsp.Client]()
116− permissions := &mockPermissionService{Broker: pubsub.NewBroker[permission.PermissionRequest]()}
117− files := &mockHistoryService{Broker: pubsub.NewBroker[history.File]()}
118−
119− // Create multiedit tool.
120− _ = NewMultiEditTool(lspClients, permissions, files, tmpDir)
121−
122− // Simulate reading the file first.
123− filetracker.RecordRead(testFile)
124−
125 // Manually test the sequential application logic.
111 // Manually test the sequential application logic.
126 currentContent := content
112 currentContent := content
127
113
internal/agent/tools/view.go+13−7
@@ -47,7 +47,13 @@ const (
47 MaxLineLength = 2000
47 MaxLineLength = 2000
48)
48)
49
49
50+func NewViewTool(
50−func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string, skillsPaths ...string) fantasy.AgentTool {
51+ lspClients *csync.Map[string, *lsp.Client],
52+ permissions permission.Service,
53+ filetracker filetracker.Service,
54+ workingDir string,
55+ skillsPaths ...string,
56+) fantasy.AgentTool {
51 return fantasy.NewAgentTool(
57 return fantasy.NewAgentTool(
52 ViewToolName,
58 ViewToolName,
53 string(viewDescription),
59 string(viewDescription),
@@ -74,13 +80,13 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
74 isOutsideWorkDir := err != nil || strings.HasPrefix(relPath, "..")
80 isOutsideWorkDir := err != nil || strings.HasPrefix(relPath, "..")
75 isSkillFile := isInSkillsPath(absFilePath, skillsPaths)
81 isSkillFile := isInSkillsPath(absFilePath, skillsPaths)
76
82
83+ sessionID := GetSessionFromContext(ctx)
84+ if sessionID == "" {
85+ return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
86+ }
87+
77 // Request permission for files outside working directory, unless it's a skill file.
88 // Request permission for files outside working directory, unless it's a skill file.
78 if isOutsideWorkDir && !isSkillFile {
89 if isOutsideWorkDir && !isSkillFile {
79− sessionID := GetSessionFromContext(ctx)
80− if sessionID == "" {
81− return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
82− }
83−
84 granted, err := permissions.Request(ctx,
90 granted, err := permissions.Request(ctx,
85 permission.CreatePermissionRequest{
91 permission.CreatePermissionRequest{
86 SessionID: sessionID,
92 SessionID: sessionID,
@@ -190,7 +196,7 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
190 }
196 }
191 output += "\n</file>\n"
197 output += "\n</file>\n"
192 output += getDiagnostics(filePath, lspClients)
198 output += getDiagnostics(filePath, lspClients)
193− filetracker.RecordRead(filePath)
199+ filetracker.RecordRead(ctx, sessionID, filePath)
194 return fantasy.WithResponseMetadata(
200 return fantasy.WithResponseMetadata(
195 fantasy.NewTextResponse(output),
201 fantasy.NewTextResponse(output),
196 ViewResponseMetadata{
202 ViewResponseMetadata{
internal/agent/tools/write.go+15−10
@@ -44,7 +44,13 @@ type WriteResponseMetadata struct {
44
44
45const WriteToolName = "write"
45const WriteToolName = "write"
46
46
47+func NewWriteTool(
47−func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
48+ lspClients *csync.Map[string, *lsp.Client],
49+ permissions permission.Service,
50+ files history.Service,
51+ filetracker filetracker.Service,
52+ workingDir string,
53+) fantasy.AgentTool {
48 return fantasy.NewAgentTool(
54 return fantasy.NewAgentTool(
49 WriteToolName,
55 WriteToolName,
50 string(writeDescription),
56 string(writeDescription),
@@ -57,6 +63,11 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
57 return fantasy.NewTextErrorResponse("content is required"), nil
63 return fantasy.NewTextErrorResponse("content is required"), nil
58 }
64 }
59
65
66+ sessionID := GetSessionFromContext(ctx)
67+ if sessionID == "" {
68+ return fantasy.ToolResponse{}, fmt.Errorf("session_id is required")
69+ }
70+
60 filePath := filepathext.SmartJoin(workingDir, params.FilePath)
71 filePath := filepathext.SmartJoin(workingDir, params.FilePath)
61
72
62 fileInfo, err := os.Stat(filePath)
73 fileInfo, err := os.Stat(filePath)
@@ -65,8 +76,8 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
65 return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
76 return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
66 }
77 }
67
78
68− modTime := fileInfo.ModTime()
79+ modTime := fileInfo.ModTime().Truncate(time.Second)
69− lastRead := filetracker.LastReadTime(filePath)
80+ lastRead := filetracker.LastReadTime(ctx, sessionID, filePath)
70 if modTime.After(lastRead) {
81 if modTime.After(lastRead) {
71 return fantasy.NewTextErrorResponse(fmt.Sprintf("File %s has been modified since it was last read.\nLast modification: %s\nLast read: %s\n\nPlease read the file again before modifying it.",
82 return fantasy.NewTextErrorResponse(fmt.Sprintf("File %s has been modified since it was last read.\nLast modification: %s\nLast read: %s\n\nPlease read the file again before modifying it.",
72 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
83 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
@@ -93,11 +104,6 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
93 }
104 }
94 }
105 }
95
106
96− sessionID := GetSessionFromContext(ctx)
97− if sessionID == "" {
98− return fantasy.ToolResponse{}, fmt.Errorf("session_id is required")
99− }
100−
101 diff, additions, removals := diff.GenerateDiff(
107 diff, additions, removals := diff.GenerateDiff(
102 oldContent,
108 oldContent,
103 params.Content,
109 params.Content,
@@ -153,8 +159,7 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
153 slog.Error("Error creating file history version", "error", err)
159 slog.Error("Error creating file history version", "error", err)
154 }
160 }
155
161
156− filetracker.RecordWrite(filePath)
157− filetracker.RecordRead(filePath)
162+ filetracker.RecordRead(ctx, sessionID, filePath)
158
163
159 notifyLSPs(ctx, lspClients, params.FilePath)
164 notifyLSPs(ctx, lspClients, params.FilePath)
160
165
internal/app/app.go+4
@@ -23,6 +23,7 @@ import (
23 "github.com/charmbracelet/crush/internal/config"
23 "github.com/charmbracelet/crush/internal/config"
24 "github.com/charmbracelet/crush/internal/csync"
24 "github.com/charmbracelet/crush/internal/csync"
25 "github.com/charmbracelet/crush/internal/db"
25 "github.com/charmbracelet/crush/internal/db"
26+ "github.com/charmbracelet/crush/internal/filetracker"
26 "github.com/charmbracelet/crush/internal/format"
27 "github.com/charmbracelet/crush/internal/format"
27 "github.com/charmbracelet/crush/internal/history"
28 "github.com/charmbracelet/crush/internal/history"
28 "github.com/charmbracelet/crush/internal/log"
29 "github.com/charmbracelet/crush/internal/log"
@@ -53,6 +54,7 @@ type App struct {
53 Messages message.Service
54 Messages message.Service
54 History history.Service
55 History history.Service
55 Permissions permission.Service
56 Permissions permission.Service
57+ FileTracker filetracker.Service
56
58
57 AgentCoordinator agent.Coordinator
59 AgentCoordinator agent.Coordinator
58
60
@@ -87,6 +89,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
87 Messages: messages,
89 Messages: messages,
88 History: files,
90 History: files,
89 Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
91 Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
92+ FileTracker: filetracker.NewService(q),
90 LSPClients: csync.NewMap[string, *lsp.Client](),
93 LSPClients: csync.NewMap[string, *lsp.Client](),
91
94
92 globalCtx: ctx,
95 globalCtx: ctx,
@@ -460,6 +463,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error {
460 app.Messages,
463 app.Messages,
461 app.Permissions,
464 app.Permissions,
462 app.History,
465 app.History,
466+ app.FileTracker,
463 app.LSPClients,
467 app.LSPClients,
464 )
468 )
465 if err != nil {
469 if err != nil {
internal/db/db.go+20
@@ -57,6 +57,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
57 if q.getFileByPathAndSessionStmt, err = db.PrepareContext(ctx, getFileByPathAndSession); err != nil {
57 if q.getFileByPathAndSessionStmt, err = db.PrepareContext(ctx, getFileByPathAndSession); err != nil {
58 return nil, fmt.Errorf("error preparing query GetFileByPathAndSession: %w", err)
58 return nil, fmt.Errorf("error preparing query GetFileByPathAndSession: %w", err)
59 }
59 }
60+ if q.getFileReadStmt, err = db.PrepareContext(ctx, getFileRead); err != nil {
61+ return nil, fmt.Errorf("error preparing query GetFileRead: %w", err)
62+ }
60 if q.getHourDayHeatmapStmt, err = db.PrepareContext(ctx, getHourDayHeatmap); err != nil {
63 if q.getHourDayHeatmapStmt, err = db.PrepareContext(ctx, getHourDayHeatmap); err != nil {
61 return nil, fmt.Errorf("error preparing query GetHourDayHeatmap: %w", err)
64 return nil, fmt.Errorf("error preparing query GetHourDayHeatmap: %w", err)
62 }
65 }
@@ -111,6 +114,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
111 if q.listUserMessagesBySessionStmt, err = db.PrepareContext(ctx, listUserMessagesBySession); err != nil {
114 if q.listUserMessagesBySessionStmt, err = db.PrepareContext(ctx, listUserMessagesBySession); err != nil {
112 return nil, fmt.Errorf("error preparing query ListUserMessagesBySession: %w", err)
115 return nil, fmt.Errorf("error preparing query ListUserMessagesBySession: %w", err)
113 }
116 }
117+ if q.recordFileReadStmt, err = db.PrepareContext(ctx, recordFileRead); err != nil {
118+ return nil, fmt.Errorf("error preparing query RecordFileRead: %w", err)
119+ }
114 if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
120 if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
115 return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
121 return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
116 }
122 }
@@ -180,6 +186,11 @@ func (q *Queries) Close() error {
180 err = fmt.Errorf("error closing getFileByPathAndSessionStmt: %w", cerr)
186 err = fmt.Errorf("error closing getFileByPathAndSessionStmt: %w", cerr)
181 }
187 }
182 }
188 }
189+ if q.getFileReadStmt != nil {
190+ if cerr := q.getFileReadStmt.Close(); cerr != nil {
191+ err = fmt.Errorf("error closing getFileReadStmt: %w", cerr)
192+ }
193+ }
183 if q.getHourDayHeatmapStmt != nil {
194 if q.getHourDayHeatmapStmt != nil {
184 if cerr := q.getHourDayHeatmapStmt.Close(); cerr != nil {
195 if cerr := q.getHourDayHeatmapStmt.Close(); cerr != nil {
185 err = fmt.Errorf("error closing getHourDayHeatmapStmt: %w", cerr)
196 err = fmt.Errorf("error closing getHourDayHeatmapStmt: %w", cerr)
@@ -270,6 +281,11 @@ func (q *Queries) Close() error {
270 err = fmt.Errorf("error closing listUserMessagesBySessionStmt: %w", cerr)
281 err = fmt.Errorf("error closing listUserMessagesBySessionStmt: %w", cerr)
271 }
282 }
272 }
283 }
284+ if q.recordFileReadStmt != nil {
285+ if cerr := q.recordFileReadStmt.Close(); cerr != nil {
286+ err = fmt.Errorf("error closing recordFileReadStmt: %w", cerr)
287+ }
288+ }
273 if q.updateMessageStmt != nil {
289 if q.updateMessageStmt != nil {
274 if cerr := q.updateMessageStmt.Close(); cerr != nil {
290 if cerr := q.updateMessageStmt.Close(); cerr != nil {
275 err = fmt.Errorf("error closing updateMessageStmt: %w", cerr)
291 err = fmt.Errorf("error closing updateMessageStmt: %w", cerr)
@@ -335,6 +351,7 @@ type Queries struct {
335 getAverageResponseTimeStmt *sql.Stmt
351 getAverageResponseTimeStmt *sql.Stmt
336 getFileStmt *sql.Stmt
352 getFileStmt *sql.Stmt
337 getFileByPathAndSessionStmt *sql.Stmt
353 getFileByPathAndSessionStmt *sql.Stmt
354+ getFileReadStmt *sql.Stmt
338 getHourDayHeatmapStmt *sql.Stmt
355 getHourDayHeatmapStmt *sql.Stmt
339 getMessageStmt *sql.Stmt
356 getMessageStmt *sql.Stmt
340 getRecentActivityStmt *sql.Stmt
357 getRecentActivityStmt *sql.Stmt
@@ -353,6 +370,7 @@ type Queries struct {
353 listNewFilesStmt *sql.Stmt
370 listNewFilesStmt *sql.Stmt
354 listSessionsStmt *sql.Stmt
371 listSessionsStmt *sql.Stmt
355 listUserMessagesBySessionStmt *sql.Stmt
372 listUserMessagesBySessionStmt *sql.Stmt
373+ recordFileReadStmt *sql.Stmt
356 updateMessageStmt *sql.Stmt
374 updateMessageStmt *sql.Stmt
357 updateSessionStmt *sql.Stmt
375 updateSessionStmt *sql.Stmt
358 updateSessionTitleAndUsageStmt *sql.Stmt
376 updateSessionTitleAndUsageStmt *sql.Stmt
@@ -373,6 +391,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
373 getAverageResponseTimeStmt: q.getAverageResponseTimeStmt,
391 getAverageResponseTimeStmt: q.getAverageResponseTimeStmt,
374 getFileStmt: q.getFileStmt,
392 getFileStmt: q.getFileStmt,
375 getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt,
393 getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt,
394+ getFileReadStmt: q.getFileReadStmt,
376 getHourDayHeatmapStmt: q.getHourDayHeatmapStmt,
395 getHourDayHeatmapStmt: q.getHourDayHeatmapStmt,
377 getMessageStmt: q.getMessageStmt,
396 getMessageStmt: q.getMessageStmt,
378 getRecentActivityStmt: q.getRecentActivityStmt,
397 getRecentActivityStmt: q.getRecentActivityStmt,
@@ -391,6 +410,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
391 listNewFilesStmt: q.listNewFilesStmt,
410 listNewFilesStmt: q.listNewFilesStmt,
392 listSessionsStmt: q.listSessionsStmt,
411 listSessionsStmt: q.listSessionsStmt,
393 listUserMessagesBySessionStmt: q.listUserMessagesBySessionStmt,
412 listUserMessagesBySessionStmt: q.listUserMessagesBySessionStmt,
413+ recordFileReadStmt: q.recordFileReadStmt,
394 updateMessageStmt: q.updateMessageStmt,
414 updateMessageStmt: q.updateMessageStmt,
395 updateSessionStmt: q.updateSessionStmt,
415 updateSessionStmt: q.updateSessionStmt,
396 updateSessionTitleAndUsageStmt: q.updateSessionTitleAndUsageStmt,
416 updateSessionTitleAndUsageStmt: q.updateSessionTitleAndUsageStmt,
internal/db/migrations/20260127000000_add_read_files_table.sqlnew file+20
@@ -0,0 +1,20 @@
1+-- +goose Up
2+-- +goose StatementBegin
3+CREATE TABLE IF NOT EXISTS read_files (
4+ session_id TEXT NOT NULL CHECK (session_id != ''),
5+ path TEXT NOT NULL CHECK (path != ''),
6+ read_at INTEGER NOT NULL, -- Unix timestamp in seconds when file was last read
7+ FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE,
8+ PRIMARY KEY (path, session_id)
9+);
10+
11+CREATE INDEX IF NOT EXISTS idx_read_files_session_id ON read_files (session_id);
12+CREATE INDEX IF NOT EXISTS idx_read_files_path ON read_files (path);
13+-- +goose StatementEnd
14+
15+-- +goose Down
16+-- +goose StatementBegin
17+DROP INDEX IF EXISTS idx_read_files_path;
18+DROP INDEX IF EXISTS idx_read_files_session_id;
19+DROP TABLE IF EXISTS read_files;
20+-- +goose StatementEnd
internal/db/models.go+6
@@ -31,6 +31,12 @@ type Message struct {
31 IsSummaryMessage int64 `json:"is_summary_message"`
31 IsSummaryMessage int64 `json:"is_summary_message"`
32}
32}
33
33
34+type ReadFile struct {
35+ SessionID string `json:"session_id"`
36+ Path string `json:"path"`
37+ ReadAt int64 `json:"read_at"` // Unix timestamp when file was last read
38+}
39+
34type Session struct {
40type Session struct {
35 ID string `json:"id"`
41 ID string `json:"id"`
36 ParentSessionID sql.NullString `json:"parent_session_id"`
42 ParentSessionID sql.NullString `json:"parent_session_id"`
internal/db/querier.go+2
@@ -20,6 +20,7 @@ type Querier interface {
20 GetAverageResponseTime(ctx context.Context) (int64, error)
20 GetAverageResponseTime(ctx context.Context) (int64, error)
21 GetFile(ctx context.Context, id string) (File, error)
21 GetFile(ctx context.Context, id string) (File, error)
22 GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error)
22 GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error)
23+ GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error)
23 GetHourDayHeatmap(ctx context.Context) ([]GetHourDayHeatmapRow, error)
24 GetHourDayHeatmap(ctx context.Context) ([]GetHourDayHeatmapRow, error)
24 GetMessage(ctx context.Context, id string) (Message, error)
25 GetMessage(ctx context.Context, id string) (Message, error)
25 GetRecentActivity(ctx context.Context) ([]GetRecentActivityRow, error)
26 GetRecentActivity(ctx context.Context) ([]GetRecentActivityRow, error)
@@ -38,6 +39,7 @@ type Querier interface {
38 ListNewFiles(ctx context.Context) ([]File, error)
39 ListNewFiles(ctx context.Context) ([]File, error)
39 ListSessions(ctx context.Context) ([]Session, error)
40 ListSessions(ctx context.Context) ([]Session, error)
40 ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
41 ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
42+ RecordFileRead(ctx context.Context, arg RecordFileReadParams) error
41 UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
43 UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
42 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
44 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
43 UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error
45 UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error
internal/db/read_files.sql.gonew file+57
@@ -0,0 +1,57 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+// source: read_files.sql
5+
6+package db
7+
8+import (
9+ "context"
10+)
11+
12+const getFileRead = `-- name: GetFileRead :one
13+SELECT session_id, path, read_at FROM read_files
14+WHERE session_id = ? AND path = ? LIMIT 1
15+`
16+
17+type GetFileReadParams struct {
18+ SessionID string `json:"session_id"`
19+ Path string `json:"path"`
20+}
21+
22+func (q *Queries) GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error) {
23+ row := q.queryRow(ctx, q.getFileReadStmt, getFileRead, arg.SessionID, arg.Path)
24+ var i ReadFile
25+ err := row.Scan(
26+ &i.SessionID,
27+ &i.Path,
28+ &i.ReadAt,
29+ )
30+ return i, err
31+}
32+
33+const recordFileRead = `-- name: RecordFileRead :exec
34+INSERT INTO read_files (
35+ session_id,
36+ path,
37+ read_at
38+) VALUES (
39+ ?,
40+ ?,
41+ strftime('%s', 'now')
42+) ON CONFLICT(path, session_id) DO UPDATE SET
43+ read_at = excluded.read_at
44+`
45+
46+type RecordFileReadParams struct {
47+ SessionID string `json:"session_id"`
48+ Path string `json:"path"`
49+}
50+
51+func (q *Queries) RecordFileRead(ctx context.Context, arg RecordFileReadParams) error {
52+ _, err := q.exec(ctx, q.recordFileReadStmt, recordFileRead,
53+ arg.SessionID,
54+ arg.Path,
55+ )
56+ return err
57+}
internal/db/sql/read_files.sqlnew file+15
@@ -0,0 +1,15 @@
1+-- name: RecordFileRead :exec
2+INSERT INTO read_files (
3+ session_id,
4+ path,
5+ read_at
6+) VALUES (
7+ ?,
8+ ?,
9+ strftime('%s', 'now')
10+) ON CONFLICT(path, session_id) DO UPDATE SET
11+ read_at = excluded.read_at;
12+
13+-- name: GetFileRead :one
14+SELECT * FROM read_files
15+WHERE session_id = ? AND path = ? LIMIT 1;
internal/filetracker/filetracker.godeleted−70
@@ -1,70 +0,0 @@
1−// Package filetracker tracks file read/write times to prevent editing files
2−// that haven't been read, and to detect external modifications.
3−//
4−// TODO: Consider moving this to persistent storage (e.g., the database) to
5−// preserve file access history across sessions.
6−// We would need to make sure to handle the case where we reload a session and the underlying files did change.
7−package filetracker
8−
9−import (
10− "sync"
11− "time"
12−)
13−
14−// record tracks when a file was read/written.
15−type record struct {
16− path string
17− readTime time.Time
18− writeTime time.Time
19−}
20−
21−var (
22− records = make(map[string]record)
23− recordMutex sync.RWMutex
24−)
25−
26−// RecordRead records when a file was read.
27−func RecordRead(path string) {
28− recordMutex.Lock()
29− defer recordMutex.Unlock()
30−
31− rec, exists := records[path]
32− if !exists {
33− rec = record{path: path}
34− }
35− rec.readTime = time.Now()
36− records[path] = rec
37−}
38−
39−// LastReadTime returns when a file was last read. Returns zero time if never
40−// read.
41−func LastReadTime(path string) time.Time {
42− recordMutex.RLock()
43− defer recordMutex.RUnlock()
44−
45− rec, exists := records[path]
46− if !exists {
47− return time.Time{}
48− }
49− return rec.readTime
50−}
51−
52−// RecordWrite records when a file was written.
53−func RecordWrite(path string) {
54− recordMutex.Lock()
55− defer recordMutex.Unlock()
56−
57− rec, exists := records[path]
58− if !exists {
59− rec = record{path: path}
60− }
61− rec.writeTime = time.Now()
62− records[path] = rec
63−}
64−
65−// Reset clears all file tracking records. Useful for testing.
66−func Reset() {
67− recordMutex.Lock()
68− defer recordMutex.Unlock()
69− records = make(map[string]record)
70−}
internal/filetracker/service.gonew file+70
@@ -0,0 +1,70 @@
1+// Package filetracker provides functionality to track file reads in sessions.
2+package filetracker
3+
4+import (
5+ "context"
6+ "log/slog"
7+ "os"
8+ "path/filepath"
9+ "time"
10+
11+ "github.com/charmbracelet/crush/internal/db"
12+)
13+
14+// Service defines the interface for tracking file reads in sessions.
15+type Service interface {
16+ // RecordRead records when a file was read.
17+ RecordRead(ctx context.Context, sessionID, path string)
18+
19+ // LastReadTime returns when a file was last read.
20+ // Returns zero time if never read.
21+ LastReadTime(ctx context.Context, sessionID, path string) time.Time
22+}
23+
24+type service struct {
25+ q *db.Queries
26+}
27+
28+// NewService creates a new file tracker service.
29+func NewService(q *db.Queries) Service {
30+ return &service{q: q}
31+}
32+
33+// RecordRead records when a file was read.
34+func (s *service) RecordRead(ctx context.Context, sessionID, path string) {
35+ if err := s.q.RecordFileRead(ctx, db.RecordFileReadParams{
36+ SessionID: sessionID,
37+ Path: relpath(path),
38+ }); err != nil {
39+ slog.Error("Error recording file read", "error", err, "file", path)
40+ }
41+}
42+
43+// LastReadTime returns when a file was last read.
44+// Returns zero time if never read.
45+func (s *service) LastReadTime(ctx context.Context, sessionID, path string) time.Time {
46+ readFile, err := s.q.GetFileRead(ctx, db.GetFileReadParams{
47+ SessionID: sessionID,
48+ Path: relpath(path),
49+ })
50+ if err != nil {
51+ return time.Time{}
52+ }
53+
54+ return time.Unix(readFile.ReadAt, 0)
55+}
56+
57+func relpath(path string) string {
58+ path = filepath.Clean(path)
59+ basepath, err := os.Getwd()
60+ if err != nil {
61+ slog.Warn("Error getting basepath", "error", err)
62+ return path
63+ }
64+ relpath, err := filepath.Rel(basepath, path)
65+ if err != nil {
66+ slog.Warn("Error getting relpath", "error", err)
67+ return path
68+ }
69+ return relpath
70+}
internal/filetracker/service_test.gonew file+116
@@ -0,0 +1,116 @@
1+package filetracker
2+
3+import (
4+ "context"
5+ "testing"
6+ "testing/synctest"
7+ "time"
8+
9+ "github.com/charmbracelet/crush/internal/db"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+type testEnv struct {
14+ ctx context.Context
15+ q *db.Queries
16+ svc Service
17+}
18+
19+func setupTest(t *testing.T) *testEnv {
20+ t.Helper()
21+
22+ conn, err := db.Connect(t.Context(), t.TempDir())
23+ require.NoError(t, err)
24+ t.Cleanup(func() { conn.Close() })
25+
26+ q := db.New(conn)
27+ return &testEnv{
28+ ctx: t.Context(),
29+ q: q,
30+ svc: NewService(q),
31+ }
32+}
33+
34+func (e *testEnv) createSession(t *testing.T, sessionID string) {
35+ t.Helper()
36+ _, err := e.q.CreateSession(e.ctx, db.CreateSessionParams{
37+ ID: sessionID,
38+ Title: "Test Session",
39+ })
40+ require.NoError(t, err)
41+}
42+
43+func TestService_RecordRead(t *testing.T) {
44+ env := setupTest(t)
45+
46+ sessionID := "test-session-1"
47+ path := "/path/to/file.go"
48+ env.createSession(t, sessionID)
49+
50+ env.svc.RecordRead(env.ctx, sessionID, path)
51+
52+ lastRead := env.svc.LastReadTime(env.ctx, sessionID, path)
53+ require.False(t, lastRead.IsZero(), "expected non-zero time after recording read")
54+ require.WithinDuration(t, time.Now(), lastRead, 2*time.Second)
55+}
56+
57+func TestService_LastReadTime_NotFound(t *testing.T) {
58+ env := setupTest(t)
59+
60+ lastRead := env.svc.LastReadTime(env.ctx, "nonexistent-session", "/nonexistent/path")
61+ require.True(t, lastRead.IsZero(), "expected zero time for unread file")
62+}
63+
64+func TestService_RecordRead_UpdatesTimestamp(t *testing.T) {
65+ env := setupTest(t)
66+
67+ sessionID := "test-session-2"
68+ path := "/path/to/file.go"
69+ env.createSession(t, sessionID)
70+
71+ env.svc.RecordRead(env.ctx, sessionID, path)
72+ firstRead := env.svc.LastReadTime(env.ctx, sessionID, path)
73+ require.False(t, firstRead.IsZero())
74+
75+ synctest.Test(t, func(t *testing.T) {
76+ time.Sleep(100 * time.Millisecond)
77+ synctest.Wait()
78+ env.svc.RecordRead(env.ctx, sessionID, path)
79+ secondRead := env.svc.LastReadTime(env.ctx, sessionID, path)
80+
81+ require.False(t, secondRead.Before(firstRead), "second read time should not be before first")
82+ })
83+}
84+
85+func TestService_RecordRead_DifferentSessions(t *testing.T) {
86+ env := setupTest(t)
87+
88+ path := "/shared/file.go"
89+ session1, session2 := "session-1", "session-2"
90+ env.createSession(t, session1)
91+ env.createSession(t, session2)
92+
93+ env.svc.RecordRead(env.ctx, session1, path)
94+
95+ lastRead1 := env.svc.LastReadTime(env.ctx, session1, path)
96+ require.False(t, lastRead1.IsZero())
97+
98+ lastRead2 := env.svc.LastReadTime(env.ctx, session2, path)
99+ require.True(t, lastRead2.IsZero(), "session 2 should not see session 1's read")
100+}
101+
102+func TestService_RecordRead_DifferentPaths(t *testing.T) {
103+ env := setupTest(t)
104+
105+ sessionID := "test-session-3"
106+ path1, path2 := "/path/to/file1.go", "/path/to/file2.go"
107+ env.createSession(t, sessionID)
108+
109+ env.svc.RecordRead(env.ctx, sessionID, path1)
110+
111+ lastRead1 := env.svc.LastReadTime(env.ctx, sessionID, path1)
112+ require.False(t, lastRead1.IsZero())
113+
114+ lastRead2 := env.svc.LastReadTime(env.ctx, sessionID, path2)
115+ require.True(t, lastRead2.IsZero(), "path2 should not be recorded")
116+}
internal/tui/components/chat/editor/editor.go+21−6
@@ -1,6 +1,7 @@
1package editor
1package editor
2
2
3import (
3import (
4+ "context"
4 "fmt"
5 "fmt"
5 "math/rand"
6 "math/rand"
6 "net/http"
7 "net/http"
@@ -17,7 +18,6 @@ import (
17 tea "charm.land/bubbletea/v2"
18 tea "charm.land/bubbletea/v2"
18 "charm.land/lipgloss/v2"
19 "charm.land/lipgloss/v2"
19 "github.com/charmbracelet/crush/internal/app"
20 "github.com/charmbracelet/crush/internal/app"
20− "github.com/charmbracelet/crush/internal/filetracker"
21 "github.com/charmbracelet/crush/internal/fsext"
21 "github.com/charmbracelet/crush/internal/fsext"
22 "github.com/charmbracelet/crush/internal/message"
22 "github.com/charmbracelet/crush/internal/message"
23 "github.com/charmbracelet/crush/internal/session"
23 "github.com/charmbracelet/crush/internal/session"
@@ -66,6 +66,7 @@ type editorCmp struct {
66 x, y int
66 x, y int
67 app *app.App
67 app *app.App
68 session session.Session
68 session session.Session
69+ sessionFileReads []string
69 textarea textarea.Model
70 textarea textarea.Model
70 attachments []message.Attachment
71 attachments []message.Attachment
71 deleteMode bool
72 deleteMode bool
@@ -181,6 +182,9 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
181 var cmd tea.Cmd
182 var cmd tea.Cmd
182 var cmds []tea.Cmd
183 var cmds []tea.Cmd
183 switch msg := msg.(type) {
184 switch msg := msg.(type) {
185+ case chat.SessionClearedMsg:
186+ m.session = session.Session{}
187+ m.sessionFileReads = nil
184 case tea.WindowSizeMsg:
188 case tea.WindowSizeMsg:
185 return m, m.repositionCompletions
189 return m, m.repositionCompletions
186 case filepicker.FilePickedMsg:
190 case filepicker.FilePickedMsg:
@@ -212,19 +216,27 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
212 m.completionsStartIndex = 0
216 m.completionsStartIndex = 0
213 }
217 }
214 absPath, _ := filepath.Abs(item.Path)
218 absPath, _ := filepath.Abs(item.Path)
219+
220+ ctx := context.Background()
221+
215 // Skip attachment if file was already read and hasn't been modified.
222 // Skip attachment if file was already read and hasn't been modified.
223+ if m.session.ID != "" {
216− lastRead := filetracker.LastReadTime(absPath)
224+ lastRead := m.app.FileTracker.LastReadTime(ctx, m.session.ID, absPath)
217− if !lastRead.IsZero() {
225+ if !lastRead.IsZero() {
218− if info, err := os.Stat(item.Path); err == nil && !info.ModTime().After(lastRead) {
226+ if info, err := os.Stat(item.Path); err == nil && !info.ModTime().After(lastRead) {
219− return m, nil
227+ return m, nil
228+ }
220 }
229 }
230+ } else if slices.Contains(m.sessionFileReads, absPath) {
231+ return m, nil
221 }
232 }
233+
234+ m.sessionFileReads = append(m.sessionFileReads, absPath)
222 content, err := os.ReadFile(item.Path)
235 content, err := os.ReadFile(item.Path)
223 if err != nil {
236 if err != nil {
224 // if it fails, let the LLM handle it later.
237 // if it fails, let the LLM handle it later.
225 return m, nil
238 return m, nil
226 }
239 }
227− filetracker.RecordRead(absPath)
228 m.attachments = append(m.attachments, message.Attachment{
240 m.attachments = append(m.attachments, message.Attachment{
229 FilePath: item.Path,
241 FilePath: item.Path,
230 FileName: filepath.Base(item.Path),
242 FileName: filepath.Base(item.Path),
@@ -662,6 +674,9 @@ func (c *editorCmp) Bindings() []key.Binding {
662// we need to move some functionality to the page level
674// we need to move some functionality to the page level
663func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
675func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
664 c.session = session
676 c.session = session
677+ for _, path := range c.sessionFileReads {
678+ c.app.FileTracker.RecordRead(context.Background(), session.ID, path)
679+ }
665 return nil
680 return nil
666}
681}
667
682
internal/tui/page/chat/chat.go+3
@@ -327,6 +327,9 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
327 u, cmd = p.chat.Update(msg)
327 u, cmd = p.chat.Update(msg)
328 p.chat = u.(chat.MessageListCmp)
328 p.chat = u.(chat.MessageListCmp)
329 cmds = append(cmds, cmd)
329 cmds = append(cmds, cmd)
330+ u, cmd = p.editor.Update(msg)
331+ p.editor = u.(editor.Editor)
332+ cmds = append(cmds, cmd)
330 return p, tea.Batch(cmds...)
333 return p, tea.Batch(cmds...)
331 case filepicker.FilePickedMsg,
334 case filepicker.FilePickedMsg,
332 completions.CompletionsClosedMsg,
335 completions.CompletionsClosedMsg,
internal/ui/model/ui.go+20−7
@@ -28,7 +28,6 @@ import (
28 "github.com/charmbracelet/crush/internal/app"
28 "github.com/charmbracelet/crush/internal/app"
29 "github.com/charmbracelet/crush/internal/commands"
29 "github.com/charmbracelet/crush/internal/commands"
30 "github.com/charmbracelet/crush/internal/config"
30 "github.com/charmbracelet/crush/internal/config"
31− "github.com/charmbracelet/crush/internal/filetracker"
32 "github.com/charmbracelet/crush/internal/fsext"
31 "github.com/charmbracelet/crush/internal/fsext"
33 "github.com/charmbracelet/crush/internal/history"
32 "github.com/charmbracelet/crush/internal/history"
34 "github.com/charmbracelet/crush/internal/home"
33 "github.com/charmbracelet/crush/internal/home"
@@ -118,6 +117,9 @@ type UI struct {
118 session *session.Session
117 session *session.Session
119 sessionFiles []SessionFile
118 sessionFiles []SessionFile
120
119
120+ // keeps track of read files while we don't have a session id
121+ sessionFileReads []string
122+
121 lastUserMessageTime int64
123 lastUserMessageTime int64
122
124
123 // The width and height of the terminal in cells.
125 // The width and height of the terminal in cells.
@@ -2414,21 +2416,27 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
2414
2416
2415 return func() tea.Msg {
2417 return func() tea.Msg {
2416 absPath, _ := filepath.Abs(path)
2418 absPath, _ := filepath.Abs(path)
2419+
2420+ if m.hasSession() {
2417− // Skip attachment if file was already read and hasn't been modified.
2421+ // Skip attachment if file was already read and hasn't been modified.
2418− lastRead := filetracker.LastReadTime(absPath)
2422+ lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
2419− if !lastRead.IsZero() {
2423+ if !lastRead.IsZero() {
2420− if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2424+ if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2421− return nil
2425+ return nil
2426+ }
2422 }
2427 }
2428+ } else if slices.Contains(m.sessionFileReads, absPath) {
2429+ return nil
2423 }
2430 }
2424
2431
2432+ m.sessionFileReads = append(m.sessionFileReads, absPath)
2433+
2425 // Add file as attachment.
2434 // Add file as attachment.
2426 content, err := os.ReadFile(path)
2435 content, err := os.ReadFile(path)
2427 if err != nil {
2436 if err != nil {
2428 // If it fails, let the LLM handle it later.
2437 // If it fails, let the LLM handle it later.
2429 return nil
2438 return nil
2430 }
2439 }
2431− filetracker.RecordRead(absPath)
2432
2440
2433 return message.Attachment{
2441 return message.Attachment{
2434 FilePath: path,
2442 FilePath: path,
@@ -2555,6 +2563,10 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
2555 m.setState(uiChat, m.focus)
2563 m.setState(uiChat, m.focus)
2556 }
2564 }
2557
2565
2566+ for _, path := range m.sessionFileReads {
2567+ m.com.App.FileTracker.RecordRead(context.Background(), m.session.ID, path)
2568+ }
2569+
2558 // Capture session ID to avoid race with main goroutine updating m.session.
2570 // Capture session ID to avoid race with main goroutine updating m.session.
2559 sessionID := m.session.ID
2571 sessionID := m.session.ID
2560 cmds = append(cmds, func() tea.Msg {
2572 cmds = append(cmds, func() tea.Msg {
@@ -2801,6 +2813,7 @@ func (m *UI) newSession() tea.Cmd {
2801
2813
2802 m.session = nil
2814 m.session = nil
2803 m.sessionFiles = nil
2815 m.sessionFiles = nil
2816+ m.sessionFileReads = nil
2804 m.setState(uiLanding, uiFocusEditor)
2817 m.setState(uiLanding, uiFocusEditor)
2805 m.textarea.Focus()
2818 m.textarea.Focus()
2806 m.chat.Blur()
2819 m.chat.Blur()