on main help sign in
feat: filetracker per session (#2033)

* feat: filetracker per session Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * fix: only in the new ui Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * fix: tests, lint Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * fix: old tui Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * test: added test, improve schema Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * test: synctest Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * test: fix race Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * fix: relpath Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * fix: simplify Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * chore: trigger ci Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> --------- Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

commit3a929ffcff89aba677c2fb7620e93870f1c47f5b authorCarlos Alexandro Becker <caarlos0@users.noreply.github.com> dateThu 29 Jan 2026, 10:19 (9 months ago) parenta81443c
21 files changed+446−164
internal/agent/agentic_fetch_tool.go+1−1 internal/agent/common_test.go+8−4 internal/agent/coordinator.go+8−4 internal/agent/tools/edit.go+28−25 internal/agent/tools/multiedit.go+19−16 internal/agent/tools/multiedit_test.go−14 internal/agent/tools/view.go+13−7 internal/agent/tools/write.go+15−10 internal/app/app.go+4 internal/db/db.go+20 internal/db/migrations/20260127000000_add_read_files_table.sql+20 internal/db/models.go+6 internal/db/querier.go+2 internal/db/read_files.sql.go+57 internal/db/sql/read_files.sql+15 internal/filetracker/filetracker.go−70 internal/filetracker/service.go+70 internal/filetracker/service_test.go+116 internal/tui/components/chat/editor/editor.go+21−6 internal/tui/page/chat/chat.go+3 internal/ui/model/ui.go+20−7
internal/agent/agentic_fetch_tool.go+1−1
@@ -168,7 +168,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
168168 tools.NewGlobTool(tmpDir),
169169 tools.NewGrepTool(tmpDir),
170170 tools.NewSourcegraphTool(client),
171− tools.NewViewTool(c.lspClients, c.permissions, tmpDir),
171+ tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, tmpDir),
172172 }
173173
174174 agent := NewSessionAgent(SessionAgentOptions{
internal/agent/common_test.go+8−4
@@ -20,6 +20,7 @@ import (
2020 "github.com/charmbracelet/crush/internal/config"
2121 "github.com/charmbracelet/crush/internal/csync"
2222 "github.com/charmbracelet/crush/internal/db"
23+ "github.com/charmbracelet/crush/internal/filetracker"
2324 "github.com/charmbracelet/crush/internal/history"
2425 "github.com/charmbracelet/crush/internal/lsp"
2526 "github.com/charmbracelet/crush/internal/message"
@@ -37,6 +38,7 @@ type fakeEnv struct {
3738 messages message.Service
3839 permissions permission.Service
3940 history history.Service
41+ filetracker *filetracker.Service
4042 lspClients *csync.Map[string, *lsp.Client]
4143}
4244
@@ -117,6 +119,7 @@ func testEnv(t *testing.T) fakeEnv {
117119
118120 permissions := permission.NewPermissionService(workingDir, true, []string{})
119121 history := history.NewService(q, conn)
122+ filetrackerService := filetracker.NewService(q)
120123 lspClients := csync.NewMap[string, *lsp.Client]()
121124
122125 t.Cleanup(func() {
@@ -130,6 +133,7 @@ func testEnv(t *testing.T) fakeEnv {
130133 messages,
131134 permissions,
132135 history,
136+ &filetrackerService,
133137 lspClients,
134138 }
135139}
@@ -200,15 +204,15 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
200204 allTools := []fantasy.AgentTool{
201205 tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution, modelName),
202206 tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
203− tools.NewEditTool(env.lspClients, env.permissions, env.history, env.workingDir),
204− tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, env.workingDir),
207+ tools.NewEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
208+ tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
205209 tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
206210 tools.NewGlobTool(env.workingDir),
207211 tools.NewGrepTool(env.workingDir),
208212 tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls),
209213 tools.NewSourcegraphTool(r.GetDefaultClient()),
210− tools.NewViewTool(env.lspClients, env.permissions, env.workingDir),
211− tools.NewWriteTool(env.lspClients, env.permissions, env.history, env.workingDir),
214+ tools.NewViewTool(env.lspClients, env.permissions, *env.filetracker, env.workingDir),
215+ tools.NewWriteTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
212216 }
213217
214218 return testSessionAgent(env, large, small, systemPrompt, allTools...), nil
internal/agent/coordinator.go+8−4
@@ -22,6 +22,7 @@ import (
2222 "github.com/charmbracelet/crush/internal/agent/tools"
2323 "github.com/charmbracelet/crush/internal/config"
2424 "github.com/charmbracelet/crush/internal/csync"
25+ "github.com/charmbracelet/crush/internal/filetracker"
2526 "github.com/charmbracelet/crush/internal/history"
2627 "github.com/charmbracelet/crush/internal/log"
2728 "github.com/charmbracelet/crush/internal/lsp"
@@ -64,6 +65,7 @@ type coordinator struct {
6465 messages message.Service
6566 permissions permission.Service
6667 history history.Service
68+ filetracker filetracker.Service
6769 lspClients *csync.Map[string, *lsp.Client]
6870
6971 currentAgent SessionAgent
@@ -79,6 +81,7 @@ func NewCoordinator(
7981 messages message.Service,
8082 permissions permission.Service,
8183 history history.Service,
84+ filetracker filetracker.Service,
8285 lspClients *csync.Map[string, *lsp.Client],
8386) (Coordinator, error) {
8487 c := &coordinator{
@@ -87,6 +90,7 @@ func NewCoordinator(
8790 messages: messages,
8891 permissions: permissions,
8992 history: history,
93+ filetracker: filetracker,
9094 lspClients: lspClients,
9195 agents: make(map[string]SessionAgent),
9296 }
@@ -393,16 +397,16 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
393397 tools.NewJobOutputTool(),
394398 tools.NewJobKillTool(),
395399 tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
396− tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
397− tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
400+ tools.NewEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
401+ tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
398402 tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
399403 tools.NewGlobTool(c.cfg.WorkingDir()),
400404 tools.NewGrepTool(c.cfg.WorkingDir()),
401405 tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
402406 tools.NewSourcegraphTool(nil),
403407 tools.NewTodosTool(c.sessions),
404− tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
405− tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
408+ tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
409+ tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
406410 )
407411
408412 if len(c.cfg.LSP) > 0 {
internal/agent/tools/edit.go+28−25
@@ -56,10 +56,17 @@ type editContext struct {
5656 ctx context.Context
5757 permissions permission.Service
5858 files history.Service
59+ filetracker filetracker.Service
5960 workingDir string
6061}
6162
62−func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
63+func NewEditTool(
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 {
6370 return fantasy.NewAgentTool(
6471 EditToolName,
6572 string(editDescription),
@@ -73,7 +80,7 @@ func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
7380 var response fantasy.ToolResponse
7481 var err error
7582
76− editCtx := editContext{ctx, permissions, files, workingDir}
83+ editCtx := editContext{ctx, permissions, files, filetracker, workingDir}
7784
7885 if params.OldString == "" {
7986 response, err = createNewFile(editCtx, params.FilePath, params.NewString, call)
@@ -168,8 +175,7 @@ func createNewFile(edit editContext, filePath, content string, call fantasy.Tool
168175 slog.Error("Error creating file history version", "error", err)
169176 }
170177
171− filetracker.RecordWrite(filePath)
172− filetracker.RecordRead(filePath)
178+ edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
173179
174180 return fantasy.WithResponseMetadata(
175181 fantasy.NewTextResponse("File created: "+filePath),
@@ -195,12 +201,17 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
195201 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
196202 }
197203
198− if filetracker.LastReadTime(filePath).IsZero() {
204+ sessionID := GetSessionFromContext(edit.ctx)
205+ if sessionID == "" {
206+ return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for deleting content")
207+ }
208+
209+ lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
210+ if lastRead.IsZero() {
199211 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
200212 }
201213
202− modTime := fileInfo.ModTime()
203− lastRead := filetracker.LastReadTime(filePath)
214+ modTime := fileInfo.ModTime().Truncate(time.Second)
204215 if modTime.After(lastRead) {
205216 return fantasy.NewTextErrorResponse(
206217 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
236247 newContent = oldContent[:index] + oldContent[index+len(oldString):]
237248 }
238249
239− sessionID := GetSessionFromContext(edit.ctx)
240−
241− if sessionID == "" {
242− return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for deleting content")
243− }
244−
245250 _, additions, removals := diff.GenerateDiff(
246251 oldContent,
247252 newContent,
@@ -301,8 +306,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
301306 slog.Error("Error creating file history version", "error", err)
302307 }
303308
304− filetracker.RecordWrite(filePath)
305− filetracker.RecordRead(filePath)
309+ edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
306310
307311 return fantasy.WithResponseMetadata(
308312 fantasy.NewTextResponse("Content deleted from file: "+filePath),
@@ -328,12 +332,17 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
328332 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
329333 }
330334
331− if filetracker.LastReadTime(filePath).IsZero() {
335+ sessionID := GetSessionFromContext(edit.ctx)
336+ if sessionID == "" {
337+ return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for edit a file")
338+ }
339+
340+ lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
341+ if lastRead.IsZero() {
332342 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
333343 }
334344
335− modTime := fileInfo.ModTime()
336− lastRead := filetracker.LastReadTime(filePath)
345+ modTime := fileInfo.ModTime().Truncate(time.Second)
337346 if modTime.After(lastRead) {
338347 return fantasy.NewTextErrorResponse(
339348 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
369378 if oldContent == newContent {
370379 return fantasy.NewTextErrorResponse("new content is the same as old content. No changes made."), nil
371380 }
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− }
377381 _, additions, removals := diff.GenerateDiff(
378382 oldContent,
379383 newContent,
@@ -433,8 +437,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
433437 slog.Error("Error creating file history version", "error", err)
434438 }
435439
436− filetracker.RecordWrite(filePath)
437− filetracker.RecordRead(filePath)
440+ edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
438441
439442 return fantasy.WithResponseMetadata(
440443 fantasy.NewTextResponse("Content replaced in file: "+filePath),
internal/agent/tools/multiedit.go+19−16
@@ -58,7 +58,13 @@ const MultiEditToolName = "multiedit"
5858//go:embed multiedit.md
5959var multieditDescription []byte
6060
61−func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
61+func NewMultiEditTool(
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 {
6268 return fantasy.NewAgentTool(
6369 MultiEditToolName,
6470 string(multieditDescription),
@@ -81,7 +87,7 @@ func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions pe
8187 var response fantasy.ToolResponse
8288 var err error
8389
84− editCtx := editContext{ctx, permissions, files, workingDir}
90+ editCtx := editContext{ctx, permissions, files, filetracker, workingDir}
8591 // Handle file creation case (first edit has empty old_string)
8692 if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
8793 response, err = processMultiEditWithCreation(editCtx, params, call)
@@ -210,8 +216,7 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call
210216 slog.Error("Error creating file history version", "error", err)
211217 }
212218
213− filetracker.RecordWrite(params.FilePath)
214− filetracker.RecordRead(params.FilePath)
219+ edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
215220
216221 var message string
217222 if len(failedEdits) > 0 {
@@ -247,14 +252,19 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
247252 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
248253 }
249254
255+ sessionID := GetSessionFromContext(edit.ctx)
256+ if sessionID == "" {
257+ return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
258+ }
259+
250260 // 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() {
252263 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
253264 }
254265
255− // Check if file was modified since last read
256− modTime := fileInfo.ModTime()
257− lastRead := filetracker.LastReadTime(params.FilePath)
266+ // Check if file was modified since last read.
267+ modTime := fileInfo.ModTime().Truncate(time.Second)
258268 if modTime.After(lastRead) {
259269 return fantasy.NewTextErrorResponse(
260270 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
301311 return fantasy.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
302312 }
303313
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−
310314 // Generate diff and check permissions
311315 _, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
312316
@@ -369,8 +373,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
369373 slog.Error("Error creating file history version", "error", err)
370374 }
371375
372− filetracker.RecordWrite(params.FilePath)
373− filetracker.RecordRead(params.FilePath)
376+ edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
374377
375378 var message string
376379 if len(failedEdits) > 0 {
internal/agent/tools/multiedit_test.go−14
@@ -6,10 +6,7 @@ import (
66 "path/filepath"
77 "testing"
88
9− "github.com/charmbracelet/crush/internal/csync"
10− "github.com/charmbracelet/crush/internal/filetracker"
119 "github.com/charmbracelet/crush/internal/history"
12− "github.com/charmbracelet/crush/internal/lsp"
1310 "github.com/charmbracelet/crush/internal/permission"
1411 "github.com/charmbracelet/crush/internal/pubsub"
1512 "github.com/stretchr/testify/require"
@@ -111,17 +108,6 @@ func TestMultiEditSequentialApplication(t *testing.T) {
111108 err := os.WriteFile(testFile, []byte(content), 0o644)
112109 require.NoError(t, err)
113110
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−
125111 // Manually test the sequential application logic.
126112 currentContent := content
127113
internal/agent/tools/view.go+13−7
@@ -47,7 +47,13 @@ const (
4747 MaxLineLength = 2000
4848)
4949
50−func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string, skillsPaths ...string) fantasy.AgentTool {
50+func NewViewTool(
51+ lspClients *csync.Map[string, *lsp.Client],
52+ permissions permission.Service,
53+ filetracker filetracker.Service,
54+ workingDir string,
55+ skillsPaths ...string,
56+) fantasy.AgentTool {
5157 return fantasy.NewAgentTool(
5258 ViewToolName,
5359 string(viewDescription),
@@ -74,13 +80,13 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
7480 isOutsideWorkDir := err != nil || strings.HasPrefix(relPath, "..")
7581 isSkillFile := isInSkillsPath(absFilePath, skillsPaths)
7682
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+
7788 // Request permission for files outside working directory, unless it's a skill file.
7889 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−
8490 granted, err := permissions.Request(ctx,
8591 permission.CreatePermissionRequest{
8692 SessionID: sessionID,
@@ -190,7 +196,7 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
190196 }
191197 output += "\n</file>\n"
192198 output += getDiagnostics(filePath, lspClients)
193− filetracker.RecordRead(filePath)
199+ filetracker.RecordRead(ctx, sessionID, filePath)
194200 return fantasy.WithResponseMetadata(
195201 fantasy.NewTextResponse(output),
196202 ViewResponseMetadata{
internal/agent/tools/write.go+15−10
@@ -44,7 +44,13 @@ type WriteResponseMetadata struct {
4444
4545const WriteToolName = "write"
4646
47−func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
47+func NewWriteTool(
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 {
4854 return fantasy.NewAgentTool(
4955 WriteToolName,
5056 string(writeDescription),
@@ -57,6 +63,11 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
5763 return fantasy.NewTextErrorResponse("content is required"), nil
5864 }
5965
66+ sessionID := GetSessionFromContext(ctx)
67+ if sessionID == "" {
68+ return fantasy.ToolResponse{}, fmt.Errorf("session_id is required")
69+ }
70+
6071 filePath := filepathext.SmartJoin(workingDir, params.FilePath)
6172
6273 fileInfo, err := os.Stat(filePath)
@@ -65,8 +76,8 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
6576 return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
6677 }
6778
68− modTime := fileInfo.ModTime()
69− lastRead := filetracker.LastReadTime(filePath)
79+ modTime := fileInfo.ModTime().Truncate(time.Second)
80+ lastRead := filetracker.LastReadTime(ctx, sessionID, filePath)
7081 if modTime.After(lastRead) {
7182 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.",
7283 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
@@ -93,11 +104,6 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
93104 }
94105 }
95106
96− sessionID := GetSessionFromContext(ctx)
97− if sessionID == "" {
98− return fantasy.ToolResponse{}, fmt.Errorf("session_id is required")
99− }
100−
101107 diff, additions, removals := diff.GenerateDiff(
102108 oldContent,
103109 params.Content,
@@ -153,8 +159,7 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
153159 slog.Error("Error creating file history version", "error", err)
154160 }
155161
156− filetracker.RecordWrite(filePath)
157− filetracker.RecordRead(filePath)
162+ filetracker.RecordRead(ctx, sessionID, filePath)
158163
159164 notifyLSPs(ctx, lspClients, params.FilePath)
160165
internal/app/app.go+4
@@ -23,6 +23,7 @@ import (
2323 "github.com/charmbracelet/crush/internal/config"
2424 "github.com/charmbracelet/crush/internal/csync"
2525 "github.com/charmbracelet/crush/internal/db"
26+ "github.com/charmbracelet/crush/internal/filetracker"
2627 "github.com/charmbracelet/crush/internal/format"
2728 "github.com/charmbracelet/crush/internal/history"
2829 "github.com/charmbracelet/crush/internal/log"
@@ -53,6 +54,7 @@ type App struct {
5354 Messages message.Service
5455 History history.Service
5556 Permissions permission.Service
57+ FileTracker filetracker.Service
5658
5759 AgentCoordinator agent.Coordinator
5860
@@ -87,6 +89,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
8789 Messages: messages,
8890 History: files,
8991 Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
92+ FileTracker: filetracker.NewService(q),
9093 LSPClients: csync.NewMap[string, *lsp.Client](),
9194
9295 globalCtx: ctx,
@@ -460,6 +463,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error {
460463 app.Messages,
461464 app.Permissions,
462465 app.History,
466+ app.FileTracker,
463467 app.LSPClients,
464468 )
465469 if err != nil {
internal/db/db.go+20
@@ -57,6 +57,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
5757 if q.getFileByPathAndSessionStmt, err = db.PrepareContext(ctx, getFileByPathAndSession); err != nil {
5858 return nil, fmt.Errorf("error preparing query GetFileByPathAndSession: %w", err)
5959 }
60+ if q.getFileReadStmt, err = db.PrepareContext(ctx, getFileRead); err != nil {
61+ return nil, fmt.Errorf("error preparing query GetFileRead: %w", err)
62+ }
6063 if q.getHourDayHeatmapStmt, err = db.PrepareContext(ctx, getHourDayHeatmap); err != nil {
6164 return nil, fmt.Errorf("error preparing query GetHourDayHeatmap: %w", err)
6265 }
@@ -111,6 +114,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
111114 if q.listUserMessagesBySessionStmt, err = db.PrepareContext(ctx, listUserMessagesBySession); err != nil {
112115 return nil, fmt.Errorf("error preparing query ListUserMessagesBySession: %w", err)
113116 }
117+ if q.recordFileReadStmt, err = db.PrepareContext(ctx, recordFileRead); err != nil {
118+ return nil, fmt.Errorf("error preparing query RecordFileRead: %w", err)
119+ }
114120 if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
115121 return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
116122 }
@@ -180,6 +186,11 @@ func (q *Queries) Close() error {
180186 err = fmt.Errorf("error closing getFileByPathAndSessionStmt: %w", cerr)
181187 }
182188 }
189+ if q.getFileReadStmt != nil {
190+ if cerr := q.getFileReadStmt.Close(); cerr != nil {
191+ err = fmt.Errorf("error closing getFileReadStmt: %w", cerr)
192+ }
193+ }
183194 if q.getHourDayHeatmapStmt != nil {
184195 if cerr := q.getHourDayHeatmapStmt.Close(); cerr != nil {
185196 err = fmt.Errorf("error closing getHourDayHeatmapStmt: %w", cerr)
@@ -270,6 +281,11 @@ func (q *Queries) Close() error {
270281 err = fmt.Errorf("error closing listUserMessagesBySessionStmt: %w", cerr)
271282 }
272283 }
284+ if q.recordFileReadStmt != nil {
285+ if cerr := q.recordFileReadStmt.Close(); cerr != nil {
286+ err = fmt.Errorf("error closing recordFileReadStmt: %w", cerr)
287+ }
288+ }
273289 if q.updateMessageStmt != nil {
274290 if cerr := q.updateMessageStmt.Close(); cerr != nil {
275291 err = fmt.Errorf("error closing updateMessageStmt: %w", cerr)
@@ -335,6 +351,7 @@ type Queries struct {
335351 getAverageResponseTimeStmt *sql.Stmt
336352 getFileStmt *sql.Stmt
337353 getFileByPathAndSessionStmt *sql.Stmt
354+ getFileReadStmt *sql.Stmt
338355 getHourDayHeatmapStmt *sql.Stmt
339356 getMessageStmt *sql.Stmt
340357 getRecentActivityStmt *sql.Stmt
@@ -353,6 +370,7 @@ type Queries struct {
353370 listNewFilesStmt *sql.Stmt
354371 listSessionsStmt *sql.Stmt
355372 listUserMessagesBySessionStmt *sql.Stmt
373+ recordFileReadStmt *sql.Stmt
356374 updateMessageStmt *sql.Stmt
357375 updateSessionStmt *sql.Stmt
358376 updateSessionTitleAndUsageStmt *sql.Stmt
@@ -373,6 +391,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
373391 getAverageResponseTimeStmt: q.getAverageResponseTimeStmt,
374392 getFileStmt: q.getFileStmt,
375393 getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt,
394+ getFileReadStmt: q.getFileReadStmt,
376395 getHourDayHeatmapStmt: q.getHourDayHeatmapStmt,
377396 getMessageStmt: q.getMessageStmt,
378397 getRecentActivityStmt: q.getRecentActivityStmt,
@@ -391,6 +410,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
391410 listNewFilesStmt: q.listNewFilesStmt,
392411 listSessionsStmt: q.listSessionsStmt,
393412 listUserMessagesBySessionStmt: q.listUserMessagesBySessionStmt,
413+ recordFileReadStmt: q.recordFileReadStmt,
394414 updateMessageStmt: q.updateMessageStmt,
395415 updateSessionStmt: q.updateSessionStmt,
396416 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 {
3131 IsSummaryMessage int64 `json:"is_summary_message"`
3232}
3333
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+
3440type Session struct {
3541 ID string `json:"id"`
3642 ParentSessionID sql.NullString `json:"parent_session_id"`
internal/db/querier.go+2
@@ -20,6 +20,7 @@ type Querier interface {
2020 GetAverageResponseTime(ctx context.Context) (int64, error)
2121 GetFile(ctx context.Context, id string) (File, error)
2222 GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error)
23+ GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error)
2324 GetHourDayHeatmap(ctx context.Context) ([]GetHourDayHeatmapRow, error)
2425 GetMessage(ctx context.Context, id string) (Message, error)
2526 GetRecentActivity(ctx context.Context) ([]GetRecentActivityRow, error)
@@ -38,6 +39,7 @@ type Querier interface {
3839 ListNewFiles(ctx context.Context) ([]File, error)
3940 ListSessions(ctx context.Context) ([]Session, error)
4041 ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
42+ RecordFileRead(ctx context.Context, arg RecordFileReadParams) error
4143 UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
4244 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
4345 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 @@
11package editor
22
33import (
4+ "context"
45 "fmt"
56 "math/rand"
67 "net/http"
@@ -17,7 +18,6 @@ import (
1718 tea "charm.land/bubbletea/v2"
1819 "charm.land/lipgloss/v2"
1920 "github.com/charmbracelet/crush/internal/app"
20− "github.com/charmbracelet/crush/internal/filetracker"
2121 "github.com/charmbracelet/crush/internal/fsext"
2222 "github.com/charmbracelet/crush/internal/message"
2323 "github.com/charmbracelet/crush/internal/session"
@@ -66,6 +66,7 @@ type editorCmp struct {
6666 x, y int
6767 app *app.App
6868 session session.Session
69+ sessionFileReads []string
6970 textarea textarea.Model
7071 attachments []message.Attachment
7172 deleteMode bool
@@ -181,6 +182,9 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
181182 var cmd tea.Cmd
182183 var cmds []tea.Cmd
183184 switch msg := msg.(type) {
185+ case chat.SessionClearedMsg:
186+ m.session = session.Session{}
187+ m.sessionFileReads = nil
184188 case tea.WindowSizeMsg:
185189 return m, m.repositionCompletions
186190 case filepicker.FilePickedMsg:
@@ -212,19 +216,27 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
212216 m.completionsStartIndex = 0
213217 }
214218 absPath, _ := filepath.Abs(item.Path)
219+
220+ ctx := context.Background()
221+
215222 // Skip attachment if file was already read and hasn't been modified.
216− lastRead := filetracker.LastReadTime(absPath)
217− if !lastRead.IsZero() {
218− if info, err := os.Stat(item.Path); err == nil && !info.ModTime().After(lastRead) {
219− return m, nil
223+ if m.session.ID != "" {
224+ lastRead := m.app.FileTracker.LastReadTime(ctx, m.session.ID, absPath)
225+ if !lastRead.IsZero() {
226+ if info, err := os.Stat(item.Path); err == nil && !info.ModTime().After(lastRead) {
227+ return m, nil
228+ }
220229 }
230+ } else if slices.Contains(m.sessionFileReads, absPath) {
231+ return m, nil
221232 }
233+
234+ m.sessionFileReads = append(m.sessionFileReads, absPath)
222235 content, err := os.ReadFile(item.Path)
223236 if err != nil {
224237 // if it fails, let the LLM handle it later.
225238 return m, nil
226239 }
227− filetracker.RecordRead(absPath)
228240 m.attachments = append(m.attachments, message.Attachment{
229241 FilePath: item.Path,
230242 FileName: filepath.Base(item.Path),
@@ -662,6 +674,9 @@ func (c *editorCmp) Bindings() []key.Binding {
662674// we need to move some functionality to the page level
663675func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
664676 c.session = session
677+ for _, path := range c.sessionFileReads {
678+ c.app.FileTracker.RecordRead(context.Background(), session.ID, path)
679+ }
665680 return nil
666681}
667682
internal/tui/page/chat/chat.go+3
@@ -327,6 +327,9 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
327327 u, cmd = p.chat.Update(msg)
328328 p.chat = u.(chat.MessageListCmp)
329329 cmds = append(cmds, cmd)
330+ u, cmd = p.editor.Update(msg)
331+ p.editor = u.(editor.Editor)
332+ cmds = append(cmds, cmd)
330333 return p, tea.Batch(cmds...)
331334 case filepicker.FilePickedMsg,
332335 completions.CompletionsClosedMsg,
internal/ui/model/ui.go+20−7
@@ -28,7 +28,6 @@ import (
2828 "github.com/charmbracelet/crush/internal/app"
2929 "github.com/charmbracelet/crush/internal/commands"
3030 "github.com/charmbracelet/crush/internal/config"
31− "github.com/charmbracelet/crush/internal/filetracker"
3231 "github.com/charmbracelet/crush/internal/fsext"
3332 "github.com/charmbracelet/crush/internal/history"
3433 "github.com/charmbracelet/crush/internal/home"
@@ -118,6 +117,9 @@ type UI struct {
118117 session *session.Session
119118 sessionFiles []SessionFile
120119
120+ // keeps track of read files while we don't have a session id
121+ sessionFileReads []string
122+
121123 lastUserMessageTime int64
122124
123125 // The width and height of the terminal in cells.
@@ -2414,21 +2416,27 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
24142416
24152417 return func() tea.Msg {
24162418 absPath, _ := filepath.Abs(path)
2417− // Skip attachment if file was already read and hasn't been modified.
2418− lastRead := filetracker.LastReadTime(absPath)
2419− if !lastRead.IsZero() {
2420− if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2421− return nil
2419+
2420+ if m.hasSession() {
2421+ // Skip attachment if file was already read and hasn't been modified.
2422+ lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
2423+ if !lastRead.IsZero() {
2424+ if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2425+ return nil
2426+ }
24222427 }
2428+ } else if slices.Contains(m.sessionFileReads, absPath) {
2429+ return nil
24232430 }
24242431
2432+ m.sessionFileReads = append(m.sessionFileReads, absPath)
2433+
24252434 // Add file as attachment.
24262435 content, err := os.ReadFile(path)
24272436 if err != nil {
24282437 // If it fails, let the LLM handle it later.
24292438 return nil
24302439 }
2431− filetracker.RecordRead(absPath)
24322440
24332441 return message.Attachment{
24342442 FilePath: path,
@@ -2555,6 +2563,10 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
25552563 m.setState(uiChat, m.focus)
25562564 }
25572565
2566+ for _, path := range m.sessionFileReads {
2567+ m.com.App.FileTracker.RecordRead(context.Background(), m.session.ID, path)
2568+ }
2569+
25582570 // Capture session ID to avoid race with main goroutine updating m.session.
25592571 sessionID := m.session.ID
25602572 cmds = append(cmds, func() tea.Msg {
@@ -2801,6 +2813,7 @@ func (m *UI) newSession() tea.Cmd {
28012813
28022814 m.session = nil
28032815 m.sessionFiles = nil
2816+ m.sessionFileReads = nil
28042817 m.setState(uiLanding, uiFocusEditor)
28052818 m.textarea.Focus()
28062819 m.chat.Blur()