on main help sign in
perf: cache tool sets and provider options, batch DB deletes, add hot-path indexes, and reduce per-turn allocations
commit8cf6e16c0f870931de2f0813bc4cdd75bdd7d1fd authoriko <mail@iko.soy> dateTue 7 Jul 2026, 20:25 (3 months ago) parent7ea54eb
19 files changed+260−127
internal/agent/agent.go+10−7 internal/agent/coordinator.go+86−2 internal/agent/loop_detection.go+14−14 internal/agent/loop_detection_test.go+8−8 internal/agent/usage_fallback.go−9 internal/db/db.go+1−1 internal/db/files.sql.go+1−1 internal/db/messages.sql.go+1−1 internal/db/migrations/20260702000000_add_hot_path_indexes.sql+14 internal/db/models.go+1−1 internal/db/querier.go+3−3 internal/db/read_files.sql.go+1−1 internal/db/sessions.sql.go+39−9 internal/db/sql/sessions.sql+6−4 internal/db/stats.sql.go+1−1 internal/history/file.go+6−4 internal/message/message.go+55−41 internal/session/session.go+12−17 internal/ui/list/list.go+1−3
internal/agent/agent.go+10−7
@@ -349,7 +349,7 @@ func (a *sessionAgent) sessionMu(sessionID string) *sync.Mutex {
349349func (a *sessionAgent) enqueueCall(call SessionAgentCall) {
350350 existing, ok := a.messageQueue.Get(call.SessionID)
351351 if !ok {
352− existing = []SessionAgentCall{}
352+ existing = make([]SessionAgentCall, 0, 4)
353353 }
354354 queued := call
355355 if call.Accepted != nil {
@@ -866,9 +866,12 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
866866 prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(promptPrefix)}, prepared.Messages...)
867867 }
868868
869− sessionLock.Lock()
870− stepMessages = cloneFantasyMessages(prepared.Messages)
871− sessionLock.Unlock()
869+ // Save a reference to the prepared messages for token
870+ // estimation in OnStepFinish. No clone needed here — the
871+ // messages are not modified between PrepareStep and
872+ // OnStepFinish (which run sequentially), and the clone is
873+ // only needed when the provider returns zero usage.
874+ stepMessages = prepared.Messages
872875
873876 var assistantMsg message.Message
874877 assistantMsg, err = a.messages.Create(callContext, call.SessionID, message.CreateMessageParams{
@@ -1491,7 +1494,7 @@ func (a *sessionAgent) createUserMessage(ctx context.Context, call SessionAgentC
14911494}
14921495
14931496func (a *sessionAgent) preparePrompt(msgs []message.Message, supportsImages bool, attachments ...message.Attachment) ([]fantasy.Message, []fantasy.FilePart) {
1494− var history []fantasy.Message
1497+ history := make([]fantasy.Message, 0, len(msgs)+1)
14951498 if !a.isSubAgent {
14961499 history = append(history, fantasy.NewUserMessage(
14971500 fmt.Sprintf(
@@ -1591,7 +1594,7 @@ func filterOrphanedToolResults(m message.Message, knownToolCallIDs map[string]st
15911594 if len(aiMsgs) == 0 {
15921595 return fantasy.Message{}, false
15931596 }
1594− var validParts []fantasy.MessagePart
1597+ validParts := make([]fantasy.MessagePart, 0, len(aiMsgs[0].Content))
15951598 for _, part := range aiMsgs[0].Content {
15961599 tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part)
15971600 if !ok {
@@ -1623,7 +1626,7 @@ func filterOrphanedToolResults(m message.Message, knownToolCallIDs map[string]st
16231626// conversation. Returns the message and true if any synthetic results were
16241627// produced.
16251628func syntheticToolResultsForOrphanedCalls(m message.Message, knownToolResultIDs map[string]struct{}) (fantasy.Message, bool) {
1626− var syntheticParts []fantasy.MessagePart
1629+ syntheticParts := make([]fantasy.MessagePart, 0, len(m.ToolCalls()))
16271630 for _, tc := range m.ToolCalls() {
16281631 if _, hasResult := knownToolResultIDs[tc.ID]; hasResult {
16291632 continue
internal/agent/coordinator.go+86−2
@@ -15,6 +15,8 @@ import (
1515 "path/filepath"
1616 "slices"
1717 "strings"
18+ "sync"
19+ "sync/atomic"
1820
1921 "charm.land/catwalk/pkg/catwalk"
2022 "charm.land/fantasy"
@@ -22,6 +24,7 @@ import (
2224 "github.com/charmbracelet/crush/internal/agent/notify"
2325 "github.com/charmbracelet/crush/internal/agent/prompt"
2426 "github.com/charmbracelet/crush/internal/agent/tools"
27+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
2528 "github.com/charmbracelet/crush/internal/config"
2629 "github.com/charmbracelet/crush/internal/csync"
2730 "github.com/charmbracelet/crush/internal/discover"
@@ -132,6 +135,24 @@ type coordinator struct {
132135 // credential change misses the cache and rebuilds.
133136 providerClients *csync.Map[string, fantasy.Provider]
134137
138+ // toolsCache avoids rebuilding the tool set on every UpdateModels call.
139+ // The cache is invalidated when the config pointer changes (reload or
140+ // mutation) or when mcpToolsGen is bumped (MCP server connect/disconnect
141+ // or tools/list_changed notification).
142+ toolsCacheMu sync.Mutex
143+ toolsCache []fantasy.AgentTool
144+ toolsCacheCfg *config.Config
145+ toolsCacheMCP uint64
146+ mcpToolsGen atomic.Uint64
147+
148+ // providerOptsCache avoids repeating the 3× JSON marshal + merge +
149+ // unmarshal in getProviderOptions on every turn when the model and
150+ // config have not changed. Keyed by config pointer + model CatwalkCfg
151+ // ID + provider name.
152+ providerOptsCacheMu sync.Mutex
153+ providerOptsCache fantasy.ProviderOptions
154+ providerOptsCacheKey string
155+
135156 readyWg errgroup.Group
136157}
137158
@@ -196,6 +217,17 @@ func NewCoordinator(
196217 }
197218 c.currentAgent = agent
198219 c.agents[config.AgentCoder] = agent
220+
221+ // Subscribe to MCP tool-list changes so the tools cache is invalidated
222+ // when MCP servers connect/disconnect or announce new tools.
223+ go func() {
224+ for ev := range mcp.SubscribeEvents(ctx) {
225+ if ev.Payload.Type == mcp.EventToolsListChanged || ev.Payload.Type == mcp.EventStateChanged {
226+ c.mcpToolsGen.Add(1)
227+ }
228+ }
229+ }()
230+
199231 return c, nil
200232}
201233
@@ -246,7 +278,7 @@ func (c *coordinator) run(ctx context.Context, accept *AcceptedRun, sessionID st
246278 return nil, errModelProviderNotConfigured
247279 }
248280
249− mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
281+ mergedOptions, temp, topP, topK, freqPenalty, presPenalty := c.cachedMergeCallOptions(model, providerCfg)
250282
251283 if err := c.refreshTokenIfExpired(ctx, providerCfg); err != nil {
252284 // NOTE(@andreynering): We don't return here because the event handling to ask the user to reauthenticate
@@ -588,6 +620,39 @@ func mergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderO
588620 return modelOptions, temp, topP, topK, freqPenalty, presPenalty
589621}
590622
623+// cachedMergeCallOptions wraps mergeCallOptions with a memoization cache.
624+// The provider options JSON merge is expensive (3× marshal + merge +
625+// unmarshal) and the inputs rarely change between turns. The cache key
626+// combines the config pointer identity with the model's catwalk ID and
627+// provider name so any config reload or model switch misses the cache.
628+func (c *coordinator) cachedMergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderOptions, *float64, *float64, *int64, *float64, *float64) {
629+ key := fmt.Sprintf("%p|%s|%s", c.cfg.Config(), model.CatwalkCfg.ID, model.ModelCfg.Provider)
630+
631+ c.providerOptsCacheMu.Lock()
632+ if c.providerOptsCacheKey == key {
633+ opts := c.providerOptsCache
634+ c.providerOptsCacheMu.Unlock()
635+ // Recompute the scalar overrides (cheap) since they don't need
636+ // the JSON merge — they're just cmp.Or calls.
637+ temp := cmp.Or(model.ModelCfg.Temperature, model.CatwalkCfg.Options.Temperature)
638+ topP := cmp.Or(model.ModelCfg.TopP, model.CatwalkCfg.Options.TopP)
639+ topK := cmp.Or(model.ModelCfg.TopK, model.CatwalkCfg.Options.TopK)
640+ freqPenalty := cmp.Or(model.ModelCfg.FrequencyPenalty, model.CatwalkCfg.Options.FrequencyPenalty)
641+ presPenalty := cmp.Or(model.ModelCfg.PresencePenalty, model.CatwalkCfg.Options.PresencePenalty)
642+ return opts, temp, topP, topK, freqPenalty, presPenalty
643+ }
644+ c.providerOptsCacheMu.Unlock()
645+
646+ opts, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, cfg)
647+
648+ c.providerOptsCacheMu.Lock()
649+ c.providerOptsCache = opts
650+ c.providerOptsCacheKey = key
651+ c.providerOptsCacheMu.Unlock()
652+
653+ return opts, temp, topP, topK, freqPenalty, presPenalty
654+}
655+
591656func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent, isSubAgent bool) (SessionAgent, error) {
592657 large, small, err := c.buildAgentModels(ctx, isSubAgent)
593658 if err != nil {
@@ -1221,15 +1286,34 @@ func (c *coordinator) UpdateModels(ctx context.Context) error {
12211286 }
12221287 c.currentAgent.SetModels(large, small)
12231288
1224− agentCfg, ok := c.cfg.Config().Agents[config.AgentCoder]
1289+ cfg := c.cfg.Config()
1290+ agentCfg, ok := cfg.Agents[config.AgentCoder]
12251291 if !ok {
12261292 return errCoderAgentNotConfigured
12271293 }
12281294
1295+ // Fast path: reuse cached tools when neither the config nor the MCP
1296+ // tool set has changed since the last build.
1297+ mcpGen := c.mcpToolsGen.Load()
1298+ c.toolsCacheMu.Lock()
1299+ if c.toolsCache != nil && c.toolsCacheCfg == cfg && c.toolsCacheMCP == mcpGen {
1300+ c.currentAgent.SetTools(c.toolsCache)
1301+ c.toolsCacheMu.Unlock()
1302+ return nil
1303+ }
1304+ c.toolsCacheMu.Unlock()
1305+
12291306 tools, err := c.buildTools(ctx, agentCfg, false)
12301307 if err != nil {
12311308 return err
12321309 }
1310+
1311+ c.toolsCacheMu.Lock()
1312+ c.toolsCache = tools
1313+ c.toolsCacheCfg = cfg
1314+ c.toolsCacheMCP = mcpGen
1315+ c.toolsCacheMu.Unlock()
1316+
12331317 c.currentAgent.SetTools(tools)
12341318 return nil
12351319}
internal/agent/loop_detection.go+14−14
@@ -1,8 +1,7 @@
11package agent
22
33import (
4− "crypto/sha256"
5− "encoding/hex"
4+ "hash/fnv"
65 "io"
76
87 "charm.land/fantasy"
@@ -22,11 +21,11 @@ func hasRepeatedToolCalls(steps []fantasy.StepResult, windowSize, maxRepeats int
2221 }
2322
2423 window := steps[len(steps)-windowSize:]
25− counts := make(map[string]int)
24+ counts := make(map[uint64]int, windowSize)
2625
2726 for _, step := range window {
2827 sig := getToolInteractionSignature(step.Content)
29− if sig == "" {
28+ if sig == 0 {
3029 continue
3130 }
3231 counts[sig]++
@@ -40,34 +39,35 @@ func hasRepeatedToolCalls(steps []fantasy.StepResult, windowSize, maxRepeats int
4039
4140// getToolInteractionSignature computes a hash signature for the tool
4241// interactions in a single step's content. It pairs tool calls with their
43−// results (matched by ToolCallID) and returns a hex-encoded SHA-256 hash.
44−// If the step contains no tool calls, it returns "".
45−func getToolInteractionSignature(content fantasy.ResponseContent) string {
42+// results (matched by ToolCallID) and returns an FNV-1a hash. FNV is ~10x
43+// faster than SHA-256 and collision risk is negligible for loop detection
44+// over a 10-step window. If the step contains no tool calls, it returns 0.
45+func getToolInteractionSignature(content fantasy.ResponseContent) uint64 {
4646 toolCalls := content.ToolCalls()
4747 if len(toolCalls) == 0 {
48− return ""
48+ return 0
4949 }
5050
5151 // Index tool results by their ToolCallID for fast lookup.
52− resultsByID := make(map[string]fantasy.ToolResultContent)
52+ resultsByID := make(map[string]fantasy.ToolResultContent, len(toolCalls))
5353 for _, tr := range content.ToolResults() {
5454 resultsByID[tr.ToolCallID] = tr
5555 }
5656
57− h := sha256.New()
57+ h := fnv.New64a()
5858 for _, tc := range toolCalls {
5959 output := ""
6060 if tr, ok := resultsByID[tc.ToolCallID]; ok {
6161 output = toolResultOutputString(tr.Result)
6262 }
6363 io.WriteString(h, tc.ToolName)
64− io.WriteString(h, "\x00")
64+ h.Write([]byte{0})
6565 io.WriteString(h, tc.Input)
66− io.WriteString(h, "\x00")
66+ h.Write([]byte{0})
6767 io.WriteString(h, output)
68− io.WriteString(h, "\x00")
68+ h.Write([]byte{0})
6969 }
70− return hex.EncodeToString(h.Sum(nil))
70+ return h.Sum64()
7171}
7272
7373// toolResultOutputString converts a ToolResultOutputContent to a stable string
internal/agent/loop_detection_test.go+8−8
@@ -145,18 +145,18 @@ func TestHasRepeatedToolCalls(t *testing.T) {
145145func TestGetToolInteractionSignature(t *testing.T) {
146146 t.Run("empty content returns empty string", func(t *testing.T) {
147147 sig := getToolInteractionSignature(fantasy.ResponseContent{})
148− if sig != "" {
149− t.Errorf("expected empty string, got %q", sig)
148+ if sig != 0 {
149+ t.Errorf("expected zero signature, got %d", sig)
150150 }
151151 })
152152
153− t.Run("text only content returns empty string", func(t *testing.T) {
153+ t.Run("text only content returns zero signature", func(t *testing.T) {
154154 content := fantasy.ResponseContent{
155155 fantasy.TextContent{Text: "hello"},
156156 }
157157 sig := getToolInteractionSignature(content)
158− if sig != "" {
159− t.Errorf("expected empty string, got %q", sig)
158+ if sig != 0 {
159+ t.Errorf("expected zero signature, got %d", sig)
160160 }
161161 })
162162
@@ -166,8 +166,8 @@ func TestGetToolInteractionSignature(t *testing.T) {
166166 fantasy.ToolResultContent{ToolCallID: "1", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
167167 }
168168 sig := getToolInteractionSignature(content)
169− if sig == "" {
170− t.Error("expected non-empty signature")
169+ if sig == 0 {
170+ t.Error("expected non-zero signature")
171171 }
172172 })
173173
@@ -183,7 +183,7 @@ func TestGetToolInteractionSignature(t *testing.T) {
183183 sig1 := getToolInteractionSignature(content1)
184184 sig2 := getToolInteractionSignature(content2)
185185 if sig1 != sig2 {
186− t.Errorf("expected same signature for same interactions, got %q and %q", sig1, sig2)
186+ t.Errorf("expected same signature for same interactions, got %d and %d", sig1, sig2)
187187 }
188188 })
189189
internal/agent/usage_fallback.go−9
@@ -33,15 +33,6 @@ func fallbackStepUsage(messages []fantasy.Message, step fantasy.StepResult) (fan
3333 }, true
3434}
3535
36−func cloneFantasyMessages(messages []fantasy.Message) []fantasy.Message {
37− cloned := make([]fantasy.Message, len(messages))
38− for i, msg := range messages {
39− cloned[i] = msg
40− cloned[i].Content = append([]fantasy.MessagePart(nil), msg.Content...)
41− }
42− return cloned
43−}
44−
4536func estimateMessageTokens(messages []fantasy.Message) int64 {
4637 var tokens int64
4738 for _, msg := range messages {
internal/db/db.go+1−1
@@ -1,6 +1,6 @@
11// Code generated by sqlc. DO NOT EDIT.
22// versions:
3−// sqlc v1.30.0
3+// sqlc v1.31.1
44
55package db
66
internal/db/files.sql.go+1−1
@@ -1,6 +1,6 @@
11// Code generated by sqlc. DO NOT EDIT.
22// versions:
3−// sqlc v1.30.0
3+// sqlc v1.31.1
44// source: files.sql
55
66package db
internal/db/messages.sql.go+1−1
@@ -1,6 +1,6 @@
11// Code generated by sqlc. DO NOT EDIT.
22// versions:
3−// sqlc v1.30.0
3+// sqlc v1.31.1
44// source: messages.sql
55
66package db
internal/db/migrations/20260702000000_add_hot_path_indexes.sqlnew file+14
@@ -0,0 +1,14 @@
1+-- +goose Up
2+-- +goose StatementBegin
3+-- Add indexes for hot-path queries that previously required full table scans.
4+CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions (updated_at DESC);
5+CREATE INDEX IF NOT EXISTS idx_messages_role_created ON messages (role, created_at DESC);
6+CREATE INDEX IF NOT EXISTS idx_files_path_version ON files (path, version DESC);
7+-- +goose StatementEnd
8+
9+-- +goose Down
10+-- +goose StatementBegin
11+DROP INDEX IF EXISTS idx_sessions_updated_at;
12+DROP INDEX IF EXISTS idx_messages_role_created;
13+DROP INDEX IF EXISTS idx_files_path_version;
14+-- +goose StatementEnd
internal/db/models.go+1−1
@@ -1,6 +1,6 @@
11// Code generated by sqlc. DO NOT EDIT.
22// versions:
3−// sqlc v1.30.0
3+// sqlc v1.31.1
44
55package db
66
internal/db/querier.go+3−3
@@ -1,6 +1,6 @@
11// Code generated by sqlc. DO NOT EDIT.
22// versions:
3−// sqlc v1.30.0
3+// sqlc v1.31.1
44
55package db
66
@@ -43,10 +43,10 @@ type Querier interface {
4343 ListSessions(ctx context.Context) ([]Session, error)
4444 ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
4545 RecordFileRead(ctx context.Context, arg RecordFileReadParams) error
46− RenameSession(ctx context.Context, arg RenameSessionParams) error
46+ RenameSession(ctx context.Context, arg RenameSessionParams) (Session, error)
4747 UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
4848 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
49− UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error
49+ UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) (Session, error)
5050}
5151
5252var _ Querier = (*Queries)(nil)
internal/db/read_files.sql.go+1−1
@@ -1,6 +1,6 @@
11// Code generated by sqlc. DO NOT EDIT.
22// versions:
3−// sqlc v1.30.0
3+// sqlc v1.31.1
44// source: read_files.sql
55
66package db
internal/db/sessions.sql.go+39−9
@@ -1,6 +1,6 @@
11// Code generated by sqlc. DO NOT EDIT.
22// versions:
3−// sqlc v1.30.0
3+// sqlc v1.31.1
44// source: sessions.sql
55
66package db
@@ -176,11 +176,12 @@ func (q *Queries) ListSessions(ctx context.Context) ([]Session, error) {
176176 return items, nil
177177}
178178
179−const renameSession = `-- name: RenameSession :exec
179+const renameSession = `-- name: RenameSession :one
180180UPDATE sessions
181181SET
182182 title = ?
183183WHERE id = ?
184+RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary_message_id, todos
184185`
185186
186187type RenameSessionParams struct {
@@ -188,9 +189,23 @@ type RenameSessionParams struct {
188189 ID string `json:"id"`
189190}
190191
191−func (q *Queries) RenameSession(ctx context.Context, arg RenameSessionParams) error {
192− _, err := q.exec(ctx, q.renameSessionStmt, renameSession, arg.Title, arg.ID)
193− return err
192+func (q *Queries) RenameSession(ctx context.Context, arg RenameSessionParams) (Session, error) {
193+ row := q.queryRow(ctx, q.renameSessionStmt, renameSession, arg.Title, arg.ID)
194+ var i Session
195+ err := row.Scan(
196+ &i.ID,
197+ &i.ParentSessionID,
198+ &i.Title,
199+ &i.MessageCount,
200+ &i.PromptTokens,
201+ &i.CompletionTokens,
202+ &i.Cost,
203+ &i.UpdatedAt,
204+ &i.CreatedAt,
205+ &i.SummaryMessageID,
206+ &i.Todos,
207+ )
208+ return i, err
194209}
195210
196211const updateSession = `-- name: UpdateSession :one
@@ -243,7 +258,7 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (S
243258 return i, err
244259}
245260
246−const updateSessionTitleAndUsage = `-- name: UpdateSessionTitleAndUsage :exec
261+const updateSessionTitleAndUsage = `-- name: UpdateSessionTitleAndUsage :one
247262UPDATE sessions
248263SET
249264 title = ?,
@@ -252,6 +267,7 @@ SET
252267 cost = cost + ?,
253268 updated_at = strftime('%s', 'now')
254269WHERE id = ?
270+RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary_message_id, todos
255271`
256272
257273type UpdateSessionTitleAndUsageParams struct {
@@ -262,13 +278,27 @@ type UpdateSessionTitleAndUsageParams struct {
262278 ID string `json:"id"`
263279}
264280
265−func (q *Queries) UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error {
266− _, err := q.exec(ctx, q.updateSessionTitleAndUsageStmt, updateSessionTitleAndUsage,
281+func (q *Queries) UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) (Session, error) {
282+ row := q.queryRow(ctx, q.updateSessionTitleAndUsageStmt, updateSessionTitleAndUsage,
267283 arg.Title,
268284 arg.PromptTokens,
269285 arg.CompletionTokens,
270286 arg.Cost,
271287 arg.ID,
272288 )
273− return err
289+ var i Session
290+ err := row.Scan(
291+ &i.ID,
292+ &i.ParentSessionID,
293+ &i.Title,
294+ &i.MessageCount,
295+ &i.PromptTokens,
296+ &i.CompletionTokens,
297+ &i.Cost,
298+ &i.UpdatedAt,
299+ &i.CreatedAt,
300+ &i.SummaryMessageID,
301+ &i.Todos,
302+ )
303+ return i, err
274304}
internal/db/sql/sessions.sql+6−4
@@ -52,7 +52,7 @@ SET
5252WHERE id = ?
5353RETURNING *;
5454
55−-- name: UpdateSessionTitleAndUsage :exec
55+-- name: UpdateSessionTitleAndUsage :one
5656UPDATE sessions
5757SET
5858 title = ?,
@@ -60,14 +60,16 @@ SET
6060 completion_tokens = completion_tokens + ?,
6161 cost = cost + ?,
6262 updated_at = strftime('%s', 'now')
63−WHERE id = ?;
63+WHERE id = ?
64+RETURNING *;
6465
6566
66−-- name: RenameSession :exec
67+-- name: RenameSession :one
6768UPDATE sessions
6869SET
6970 title = ?
70−WHERE id = ?;
71+WHERE id = ?
72+RETURNING *;
7173
7274-- name: DeleteSession :exec
7375DELETE FROM sessions
internal/db/stats.sql.go+1−1
@@ -1,6 +1,6 @@
11// Code generated by sqlc. DO NOT EDIT.
22// versions:
3−// sqlc v1.30.0
3+// sqlc v1.31.1
44// source: stats.sql
55
66package db
internal/history/file.go+6−4
@@ -194,15 +194,17 @@ func (s *service) Delete(ctx context.Context, id string) error {
194194}
195195
196196func (s *service) DeleteSessionFiles(ctx context.Context, sessionID string) error {
197+ // Fetch all files first so we can publish per-file DeletedEvent.
197198 files, err := s.ListBySession(ctx, sessionID)
198199 if err != nil {
199200 return err
200201 }
202+ // Single batch DELETE instead of N individual deletes.
203+ if err := s.q.DeleteSessionFiles(ctx, sessionID); err != nil {
204+ return err
205+ }
201206 for _, file := range files {
202− err = s.Delete(ctx, file.ID)
203− if err != nil {
204− return err
205− }
207+ s.Publish(pubsub.DeletedEvent, file)
206208 }
207209 return nil
208210}
internal/message/message.go+55−41
@@ -196,18 +196,34 @@ func (s *service) Create(ctx context.Context, sessionID string, params CreateMes
196196}
197197
198198func (s *service) DeleteSessionMessages(ctx context.Context, sessionID string) error {
199+ // Fetch all messages first so we can publish per-message DeletedEvent
200+ // for each (the UI uses them to remove individual chat entries).
199201 messages, err := s.List(ctx, sessionID)
200202 if err != nil {
201203 return err
202204 }
203− for _, message := range messages {
204− if message.SessionID == sessionID {
205− err = s.Delete(ctx, message.ID)
206− if err != nil {
207− return err
205+
206+ // Single batch DELETE instead of N individual deletes.
207+ if err := s.q.DeleteSessionMessages(ctx, sessionID); err != nil {
208+ return err
209+ }
210+
211+ // Drop any pending coalesced state and publish events for the
212+ // already-fetched messages — no redundant Get queries needed.
213+ s.mu.Lock()
214+ for _, msg := range messages {
215+ if p, ok := s.pending[msg.ID]; ok {
216+ if p.timer != nil {
217+ p.timer.Stop()
208218 }
219+ delete(s.pending, msg.ID)
209220 }
210221 }
222+ s.mu.Unlock()
223+
224+ for _, msg := range messages {
225+ s.Publish(pubsub.DeletedEvent, msg.Clone())
226+ }
211227 return nil
212228}
213229
@@ -565,76 +581,74 @@ func marshalParts(parts []ContentPart) ([]byte, error) {
565581}
566582
567583func unmarshalParts(data []byte) ([]ContentPart, error) {
568− temp := []json.RawMessage{}
584+ // Single-pass unmarshal into a wrapper with Data as json.RawMessage,
585+ // avoiding the intermediate []json.RawMessage + per-element re-decode.
586+ var temp []struct {
587+ Type partType `json:"type"`
588+ Data json.RawMessage `json:"data"`
589+ }
569590
570591 if err := json.Unmarshal(data, &temp); err != nil {
571592 return nil, err
572593 }
573594
574− parts := make([]ContentPart, 0)
575−
576− for _, rawPart := range temp {
577− var wrapper struct {
578− Type partType `json:"type"`
579− Data json.RawMessage `json:"data"`
580− }
581−
582− if err := json.Unmarshal(rawPart, &wrapper); err != nil {
583− return nil, err
584− }
595+ parts := make([]ContentPart, 0, len(temp))
585596
597+ for _, wrapper := range temp {
598+ var part ContentPart
586599 switch wrapper.Type {
587600 case reasoningType:
588− part := ReasoningContent{}
589− if err := json.Unmarshal(wrapper.Data, &part); err != nil {
601+ p := ReasoningContent{}
602+ if err := json.Unmarshal(wrapper.Data, &p); err != nil {
590603 return nil, err
591604 }
592− parts = append(parts, part)
605+ part = p
593606 case textType:
594− part := TextContent{}
595− if err := json.Unmarshal(wrapper.Data, &part); err != nil {
607+ p := TextContent{}
608+ if err := json.Unmarshal(wrapper.Data, &p); err != nil {
596609 return nil, err
597610 }
598− parts = append(parts, part)
611+ part = p
599612 case imageURLType:
600− part := ImageURLContent{}
601− if err := json.Unmarshal(wrapper.Data, &part); err != nil {
613+ p := ImageURLContent{}
614+ if err := json.Unmarshal(wrapper.Data, &p); err != nil {
602615 return nil, err
603616 }
604− parts = append(parts, part)
617+ part = p
605618 case binaryType:
606− part := BinaryContent{}
607− if err := json.Unmarshal(wrapper.Data, &part); err != nil {
619+ p := BinaryContent{}
620+ if err := json.Unmarshal(wrapper.Data, &p); err != nil {
608621 return nil, err
609622 }
610− parts = append(parts, part)
623+ part = p
611624 case toolCallType:
612− part := ToolCall{}
613− if err := json.Unmarshal(wrapper.Data, &part); err != nil {
625+ p := ToolCall{}
626+ if err := json.Unmarshal(wrapper.Data, &p); err != nil {
614627 return nil, err
615628 }
616− parts = append(parts, part)
629+ part = p
617630 case toolResultType:
618− part := ToolResult{}
619− if err := json.Unmarshal(wrapper.Data, &part); err != nil {
631+ p := ToolResult{}
632+ if err := json.Unmarshal(wrapper.Data, &p); err != nil {
620633 return nil, err
621634 }
622− parts = append(parts, part)
635+ part = p
623636 case finishType:
624− part := Finish{}
625− if err := json.Unmarshal(wrapper.Data, &part); err != nil {
637+ p := Finish{}
638+ if err := json.Unmarshal(wrapper.Data, &p); err != nil {
626639 return nil, err
627640 }
628− parts = append(parts, part)
641+ part = p
629642 case shellCommandType:
630− part := ShellCommand{}
631− if err := json.Unmarshal(wrapper.Data, &part); err != nil {
643+ p := ShellCommand{}
644+ if err := json.Unmarshal(wrapper.Data, &p); err != nil {
632645 return nil, err
633646 }
634− parts = append(parts, part)
647+ part = p
635648 default:
636649 return nil, fmt.Errorf("unknown part type: %s", wrapper.Type)
637650 }
651+ parts = append(parts, part)
638652 }
639653
640654 return parts, nil
internal/session/session.go+12−17
@@ -223,29 +223,35 @@ func (s *service) Save(ctx context.Context, session Session) (Session, error) {
223223// UpdateTitleAndUsage updates only the title and usage fields atomically.
224224// This is safer than fetching, modifying, and saving the entire session.
225225func (s *service) UpdateTitleAndUsage(ctx context.Context, sessionID, title string, promptTokens, completionTokens int64, cost float64) error {
226− if err := s.q.UpdateSessionTitleAndUsage(ctx, db.UpdateSessionTitleAndUsageParams{
226+ dbSession, err := s.q.UpdateSessionTitleAndUsage(ctx, db.UpdateSessionTitleAndUsageParams{
227227 ID: sessionID,
228228 Title: title,
229229 PromptTokens: promptTokens,
230230 CompletionTokens: completionTokens,
231231 Cost: cost,
232− }); err != nil {
232+ })
233+ if err != nil {
233234 return err
234235 }
235− s.publishSessionUpdate(ctx, sessionID)
236+ session := s.fromDBItem(dbSession)
237+ s.applyEstimatedUsageState(&session)
238+ s.Publish(pubsub.UpdatedEvent, session)
236239 return nil
237240}
238241
239242// Rename updates only the title of a session without touching updated_at or
240243// usage fields.
241244func (s *service) Rename(ctx context.Context, id string, title string) error {
242− if err := s.q.RenameSession(ctx, db.RenameSessionParams{
245+ dbSession, err := s.q.RenameSession(ctx, db.RenameSessionParams{
243246 ID: id,
244247 Title: title,
245− }); err != nil {
248+ })
249+ if err != nil {
246250 return err
247251 }
248− s.publishSessionUpdate(ctx, id)
252+ session := s.fromDBItem(dbSession)
253+ s.applyEstimatedUsageState(&session)
254+ s.Publish(pubsub.UpdatedEvent, session)
249255 return nil
250256}
251257
@@ -262,17 +268,6 @@ func (s *service) List(ctx context.Context) ([]Session, error) {
262268 return sessions, nil
263269}
264270
265−// publishSessionUpdate re-fetches a session and publishes an UpdatedEvent so
266−// that UI subscribers reflect title or usage changes.
267−func (s *service) publishSessionUpdate(ctx context.Context, sessionID string) {
268− session, err := s.Get(ctx, sessionID)
269− if err != nil {
270− slog.Error("Failed to re-fetch session for event publish", "error", err, "sessionID", sessionID)
271− return
272− }
273− s.Publish(pubsub.UpdatedEvent, session)
274−}
275−
276271func (s *service) applyEstimatedUsageState(session *Session) {
277272 s.estimatedUsageMu.RLock()
278273 session.EstimatedUsage = s.estimatedUsage[session.ID]
internal/ui/list/list.go+1−3
@@ -334,9 +334,7 @@ func (l *List) renderItemEntry(idx int) *listCacheEntry {
334334
335335// invalidateAll drops every cache entry. Called on width changes.
336336func (l *List) invalidateAll() {
337− for k := range l.cache {
338− delete(l.cache, k)
339− }
337+ l.cache = make(map[Item]*listCacheEntry)
340338 l.totalHeightValid = false
341339}
342340