on main help sign in
fix(summary): handle cancel and other cases
d7619a5kujtimiihoxha
CRUSH.md+1−1
@@ -6,7 +6,7 @@
6- **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
6- **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
7- **Update Golden Files**: `go test ./... -update` (regenerates .golden files when test output changes)
7- **Update Golden Files**: `go test ./... -update` (regenerates .golden files when test output changes)
8 - Update specific package: `go test ./internal/tui/components/core -update` (in this case, we're updating "core")
8 - Update specific package: `go test ./internal/tui/components/core -update` (in this case, we're updating "core")
9−- **Lint**: `task lint-fix`
9+- **Lint**: `task lint:fix`
10- **Format**: `task fmt` (gofumpt -w .)
10- **Format**: `task fmt` (gofumpt -w .)
11- **Dev**: `task dev` (runs with profiling enabled)
11- **Dev**: `task dev` (runs with profiling enabled)
12
12
internal/agent/agent.go+87−54
@@ -62,36 +62,41 @@ type Model struct {
62}
62}
63
63
64type sessionAgent struct {
64type sessionAgent struct {
65− largeModel Model
65+ largeModel Model
66− smallModel Model
66+ smallModel Model
67− systemPrompt string
67+ systemPrompt string
68− tools []ai.AgentTool
68+ tools []ai.AgentTool
69− sessions session.Service
69+ sessions session.Service
70− messages message.Service
70+ messages message.Service
71+ disableAutoSummarize bool
71
72
72 messageQueue *csync.Map[string, []SessionAgentCall]
73 messageQueue *csync.Map[string, []SessionAgentCall]
73 activeRequests *csync.Map[string, context.CancelFunc]
74 activeRequests *csync.Map[string, context.CancelFunc]
74}
75}
75
76
76−type SessionAgentOption func(*sessionAgent)
77+type SessionAgentOptions struct {
78+ LargeModel Model
79+ SmallModel Model
80+ SystemPrompt string
81+ DisableAutoSummarize bool
82+ Sessions session.Service
83+ Messages message.Service
84+ Tools []ai.AgentTool
85+}
77
86
78func NewSessionAgent(
87func NewSessionAgent(
79− largeModel Model,
88+ opts SessionAgentOptions,
80− smallModel Model,
81− systemPrompt string,
82− sessions session.Service,
83− messages message.Service,
84− tools ...ai.AgentTool,
85) SessionAgent {
89) SessionAgent {
86 return &sessionAgent{
90 return &sessionAgent{
87− largeModel: largeModel,
91+ largeModel: opts.LargeModel,
88− smallModel: smallModel,
92+ smallModel: opts.SmallModel,
89− systemPrompt: systemPrompt,
93+ systemPrompt: opts.SystemPrompt,
90− sessions: sessions,
94+ sessions: opts.Sessions,
91− messages: messages,
95+ messages: opts.Messages,
96+ disableAutoSummarize: opts.DisableAutoSummarize,
92− tools: tools,
97+ tools: opts.Tools,
93− messageQueue: csync.NewMap[string, []SessionAgentCall](),
98+ messageQueue: csync.NewMap[string, []SessionAgentCall](),
94− activeRequests: csync.NewMap[string, context.CancelFunc](),
99+ activeRequests: csync.NewMap[string, context.CancelFunc](),
95 }
100 }
96}
101}
97
102
@@ -164,6 +169,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*ai.Agen
164 history, files := a.preparePrompt(msgs, call.Attachments...)
169 history, files := a.preparePrompt(msgs, call.Attachments...)
165
170
166 var currentAssistant *message.Message
171 var currentAssistant *message.Message
172+ var shouldSummarize bool
167 result, err := agent.Stream(genCtx, ai.AgentStreamCall{
173 result, err := agent.Stream(genCtx, ai.AgentStreamCall{
168 Prompt: call.Prompt,
174 Prompt: call.Prompt,
169 Files: files,
175 Files: files,
@@ -292,16 +298,16 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*ai.Agen
292 IsError: isError,
298 IsError: isError,
293 Metadata: result.ClientMetadata,
299 Metadata: result.ClientMetadata,
294 }
300 }
295− _, err := a.messages.Create(context.Background(), currentAssistant.SessionID, message.CreateMessageParams{
301+ _, createMsgErr := a.messages.Create(genCtx, currentAssistant.SessionID, message.CreateMessageParams{
296 Role: message.Tool,
302 Role: message.Tool,
297 Parts: []message.ContentPart{
303 Parts: []message.ContentPart{
298 toolResult,
304 toolResult,
299 },
305 },
300 })
306 })
301− if err != nil {
307+ if createMsgErr != nil {
302− return err
308+ return createMsgErr
303 }
309 }
304− return a.messages.Update(genCtx, *currentAssistant)
310+ return nil
305 },
311 },
306 OnStepFinish: func(stepResult ai.StepResult) error {
312 OnStepFinish: func(stepResult ai.StepResult) error {
307 finishReason := message.FinishReasonUnknown
313 finishReason := message.FinishReasonUnknown
@@ -323,6 +329,18 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*ai.Agen
323 }
329 }
324 return a.messages.Update(genCtx, *currentAssistant)
330 return a.messages.Update(genCtx, *currentAssistant)
325 },
331 },
332+ StopWhen: []ai.StopCondition{
333+ func(_ []ai.StepResult) bool {
334+ contextWindow := a.largeModel.CatwalkCfg.ContextWindow
335+ tokens := currentSession.CompletionTokens + currentSession.PromptTokens
336+ percentage := (float64(tokens) / float64(contextWindow)) * 100
337+ if (percentage > 80) && !a.disableAutoSummarize {
338+ shouldSummarize = true
339+ return true
340+ }
341+ return false
342+ },
343+ },
326 })
344 })
327 if err != nil {
345 if err != nil {
328 isCancelErr := errors.Is(err, context.Canceled)
346 isCancelErr := errors.Is(err, context.Canceled)
@@ -358,28 +376,29 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*ai.Agen
358 break
376 break
359 }
377 }
360 }
378 }
361− if !found {
379+ if found {
380+ continue
381+ }
362− content := "There was an error while executing the tool"
382+ content := "There was an error while executing the tool"
363− if isCancelErr {
383+ if isCancelErr {
364− content = "Tool execution canceled by user"
384+ content = "Tool execution canceled by user"
365− } else if isPermissionErr {
385+ } else if isPermissionErr {
366− content = "Permission denied"
386+ content = "Permission denied"
367− }
387+ }
368− toolResult := message.ToolResult{
388+ toolResult := message.ToolResult{
369− ToolCallID: tc.ID,
389+ ToolCallID: tc.ID,
370− Name: tc.Name,
390+ Name: tc.Name,
371− Content: content,
391+ Content: content,
372− IsError: true,
392+ IsError: true,
373− }
393+ }
374− _, createErr = a.messages.Create(context.Background(), currentAssistant.SessionID, message.CreateMessageParams{
394+ _, createErr = a.messages.Create(context.Background(), currentAssistant.SessionID, message.CreateMessageParams{
375− Role: message.Tool,
395+ Role: message.Tool,
376− Parts: []message.ContentPart{
396+ Parts: []message.ContentPart{
377− toolResult,
397+ toolResult,
378− },
398+ },
379− })
399+ })
380− if createErr != nil {
400+ if createErr != nil {
381− return nil, createErr
401+ return nil, createErr
382− }
383 }
402 }
384 }
403 }
385 if isCancelErr {
404 if isCancelErr {
@@ -398,6 +417,13 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*ai.Agen
398 }
417 }
399 wg.Wait()
418 wg.Wait()
400
419
420+ if shouldSummarize {
421+ a.activeRequests.Del(call.SessionID)
422+ if summarizeErr := a.Summarize(genCtx, call.SessionID); summarizeErr != nil {
423+ return nil, summarizeErr
424+ }
425+ }
426+
401 queuedMessages, ok := a.messageQueue.Get(call.SessionID)
427 queuedMessages, ok := a.messageQueue.Get(call.SessionID)
402 if !ok || len(queuedMessages) == 0 {
428 if !ok || len(queuedMessages) == 0 {
403 return result, err
429 return result, err
@@ -437,20 +463,21 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string) error {
437 ai.WithSystemPrompt(string(summaryPrompt)),
463 ai.WithSystemPrompt(string(summaryPrompt)),
438 )
464 )
439 summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
465 summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
440− Role: message.Assistant,
466+ Role: message.Assistant,
441− Model: a.largeModel.Model.Model(),
467+ Model: a.largeModel.Model.Model(),
442− Provider: a.largeModel.Model.Provider(),
468+ Provider: a.largeModel.Model.Provider(),
469+ IsSummaryMessage: true,
443 })
470 })
444 if err != nil {
471 if err != nil {
445 return err
472 return err
446 }
473 }
447
474
448− resp, err := agent.Stream(ctx, ai.AgentStreamCall{
475+ resp, err := agent.Stream(genCtx, ai.AgentStreamCall{
449 Prompt: "Provide a detailed summary of our conversation above.",
476 Prompt: "Provide a detailed summary of our conversation above.",
450 Messages: aiMsgs,
477 Messages: aiMsgs,
451 OnReasoningDelta: func(id string, text string) error {
478 OnReasoningDelta: func(id string, text string) error {
452 summaryMessage.AppendReasoningContent(text)
479 summaryMessage.AppendReasoningContent(text)
453− return a.messages.Update(ctx, summaryMessage)
480+ return a.messages.Update(genCtx, summaryMessage)
454 },
481 },
455 OnReasoningEnd: func(id string, reasoning ai.ReasoningContent) error {
482 OnReasoningEnd: func(id string, reasoning ai.ReasoningContent) error {
456 // handle anthropic signature
483 // handle anthropic signature
@@ -460,14 +487,20 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string) error {
460 }
487 }
461 }
488 }
462 summaryMessage.FinishThinking()
489 summaryMessage.FinishThinking()
463− return a.messages.Update(ctx, summaryMessage)
490+ return a.messages.Update(genCtx, summaryMessage)
464 },
491 },
465 OnTextDelta: func(id, text string) error {
492 OnTextDelta: func(id, text string) error {
466 summaryMessage.AppendContent(text)
493 summaryMessage.AppendContent(text)
467− return a.messages.Update(ctx, summaryMessage)
494+ return a.messages.Update(genCtx, summaryMessage)
468 },
495 },
469 })
496 })
470 if err != nil {
497 if err != nil {
498+ isCancelErr := errors.Is(err, context.Canceled)
499+ if isCancelErr {
500+ // User cancelled summarize we need to remove the summary message
501+ deleteErr := a.messages.Delete(ctx, summaryMessage.ID)
502+ return deleteErr
503+ }
471 return err
504 return err
472 }
505 }
473
506
internal/agent/agent_tool.go+1−1
@@ -82,7 +82,7 @@ func (c *coordinator) agentTool() (ai.AgentTool, error) {
82 PresencePenalty: model.ModelCfg.PresencePenalty,
82 PresencePenalty: model.ModelCfg.PresencePenalty,
83 })
83 })
84 if err != nil {
84 if err != nil {
85− return ai.ToolResponse{}, fmt.Errorf("error generating agent: %s", err)
85+ return ai.NewTextErrorResponse("error generating response"), nil
86 }
86 }
87 updatedSession, err := c.sessions.Get(ctx, session.ID)
87 updatedSession, err := c.sessions.Get(ctx, session.ID)
88 if err != nil {
88 if err != nil {
internal/agent/common_test.go+1−1
@@ -127,7 +127,7 @@ func testSessionAgent(env env, large, small ai.LanguageModel, systemPrompt strin
127 // todo: add values
127 // todo: add values
128 },
128 },
129 }
129 }
130− agent := NewSessionAgent(largeModel, smallModel, systemPrompt, env.sessions, env.messages, tools...)
130+ agent := NewSessionAgent(SessionAgentOptions{largeModel, smallModel, systemPrompt, false, env.sessions, env.messages, tools})
131 return agent
131 return agent
132}
132}
133
133
internal/agent/coordinator.go+1−1
@@ -165,7 +165,7 @@ func (c *coordinator) buildAgent(prompt *prompt.Prompt, agent config.Agent) (Ses
165 if err != nil {
165 if err != nil {
166 return nil, err
166 return nil, err
167 }
167 }
168− return NewSessionAgent(large, small, systemPrompt, c.sessions, c.messages, tools...), nil
168+ return NewSessionAgent(SessionAgentOptions{large, small, systemPrompt, c.cfg.Options.DisableAutoSummarize, c.sessions, c.messages, tools}), nil
169}
169}
170
170
171func (c *coordinator) buildTools(agent config.Agent) ([]ai.AgentTool, error) {
171func (c *coordinator) buildTools(agent config.Agent) ([]ai.AgentTool, error) {
internal/agent/tools/grep.go+9−2
@@ -89,7 +89,10 @@ type GrepResponseMetadata struct {
89 Truncated bool `json:"truncated"`
89 Truncated bool `json:"truncated"`
90}
90}
91
91
92+const (
92−const GrepToolName = "grep"
93+ GrepToolName = "grep"
94+ maxGrepContentWidth = 500
95+)
93
96
94//go:embed grep.md
97//go:embed grep.md
95var grepDescription []byte
98var grepDescription []byte
@@ -135,7 +138,11 @@ func NewGrepTool(workingDir string) ai.AgentTool {
135 fmt.Fprintf(&output, "%s:\n", match.path)
138 fmt.Fprintf(&output, "%s:\n", match.path)
136 }
139 }
137 if match.lineNum > 0 {
140 if match.lineNum > 0 {
141+ lineText := match.lineText
142+ if len(lineText) > maxGrepContentWidth {
143+ lineText = lineText[:maxGrepContentWidth] + "..."
144+ }
138− fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, match.lineText)
145+ fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, lineText)
139 } else {
146 } else {
140 fmt.Fprintf(&output, " %s\n", match.path)
147 fmt.Fprintf(&output, " %s\n", match.path)
141 }
148 }
internal/db/db.go+1−1
@@ -1,6 +1,6 @@
1// Code generated by sqlc. DO NOT EDIT.
1// Code generated by sqlc. DO NOT EDIT.
2// versions:
2// versions:
3−// sqlc v1.29.0
3+// sqlc v1.30.0
4
4
5package db
5package db
6
6
internal/db/files.sql.go+1−1
@@ -1,6 +1,6 @@
1// Code generated by sqlc. DO NOT EDIT.
1// Code generated by sqlc. DO NOT EDIT.
2// versions:
2// versions:
3−// sqlc v1.29.0
3+// sqlc v1.30.0
4// source: files.sql
4// source: files.sql
5
5
6package db
6package db
internal/db/messages.sql.go+17−11
@@ -1,6 +1,6 @@
1// Code generated by sqlc. DO NOT EDIT.
1// Code generated by sqlc. DO NOT EDIT.
2// versions:
2// versions:
3−// sqlc v1.29.0
3+// sqlc v1.30.0
4// source: messages.sql
4// source: messages.sql
5
5
6package db
6package db
@@ -18,21 +18,23 @@ INSERT INTO messages (
18 parts,
18 parts,
19 model,
19 model,
20 provider,
20 provider,
21+ is_summary_message,
21 created_at,
22 created_at,
22 updated_at
23 updated_at
23) VALUES (
24) VALUES (
24− ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
25+ ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
25)
26)
26−RETURNING id, session_id, role, parts, model, created_at, updated_at, finished_at, provider
27+RETURNING id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
27`
28`
28
29
29type CreateMessageParams struct {
30type CreateMessageParams struct {
30− ID string `json:"id"`
31+ ID string `json:"id"`
31− SessionID string `json:"session_id"`
32+ SessionID string `json:"session_id"`
32− Role string `json:"role"`
33+ Role string `json:"role"`
33− Parts string `json:"parts"`
34+ Parts string `json:"parts"`
34− Model sql.NullString `json:"model"`
35+ Model sql.NullString `json:"model"`
35− Provider sql.NullString `json:"provider"`
36+ Provider sql.NullString `json:"provider"`
37+ IsSummaryMessage int64 `json:"is_summary_message"`
36}
38}
37
39
38func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) {
40func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) {
@@ -43,6 +45,7 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M
43 arg.Parts,
45 arg.Parts,
44 arg.Model,
46 arg.Model,
45 arg.Provider,
47 arg.Provider,
48+ arg.IsSummaryMessage,
46 )
49 )
47 var i Message
50 var i Message
48 err := row.Scan(
51 err := row.Scan(
@@ -55,6 +58,7 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M
55 &i.UpdatedAt,
58 &i.UpdatedAt,
56 &i.FinishedAt,
59 &i.FinishedAt,
57 &i.Provider,
60 &i.Provider,
61+ &i.IsSummaryMessage,
58 )
62 )
59 return i, err
63 return i, err
60}
64}
@@ -80,7 +84,7 @@ func (q *Queries) DeleteSessionMessages(ctx context.Context, sessionID string) e
80}
84}
81
85
82const getMessage = `-- name: GetMessage :one
86const getMessage = `-- name: GetMessage :one
83−SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider
87+SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
84FROM messages
88FROM messages
85WHERE id = ? LIMIT 1
89WHERE id = ? LIMIT 1
86`
90`
@@ -98,12 +102,13 @@ func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) {
98 &i.UpdatedAt,
102 &i.UpdatedAt,
99 &i.FinishedAt,
103 &i.FinishedAt,
100 &i.Provider,
104 &i.Provider,
105+ &i.IsSummaryMessage,
101 )
106 )
102 return i, err
107 return i, err
103}
108}
104
109
105const listMessagesBySession = `-- name: ListMessagesBySession :many
110const listMessagesBySession = `-- name: ListMessagesBySession :many
106−SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider
111+SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
107FROM messages
112FROM messages
108WHERE session_id = ?
113WHERE session_id = ?
109ORDER BY created_at ASC
114ORDER BY created_at ASC
@@ -128,6 +133,7 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) (
128 &i.UpdatedAt,
133 &i.UpdatedAt,
129 &i.FinishedAt,
134 &i.FinishedAt,
130 &i.Provider,
135 &i.Provider,
136+ &i.IsSummaryMessage,
131 ); err != nil {
137 ); err != nil {
132 return nil, err
138 return nil, err
133 }
139 }
internal/db/migrations/20250810000000_add_is_summary_message.sqlnew file+5
@@ -0,0 +1,5 @@
1+-- +goose Up
2+ALTER TABLE messages ADD COLUMN is_summary_message INTEGER DEFAULT 0 NOT NULL;
3+
4+-- +goose Down
5+ALTER TABLE messages DROP COLUMN is_summary_message;
internal/db/models.go+11−10
@@ -1,6 +1,6 @@
1// Code generated by sqlc. DO NOT EDIT.
1// Code generated by sqlc. DO NOT EDIT.
2// versions:
2// versions:
3−// sqlc v1.29.0
3+// sqlc v1.30.0
4
4
5package db
5package db
6
6
@@ -19,15 +19,16 @@ type File struct {
19}
19}
20
20
21type Message struct {
21type Message struct {
22− ID string `json:"id"`
22+ ID string `json:"id"`
23− SessionID string `json:"session_id"`
23+ SessionID string `json:"session_id"`
24− Role string `json:"role"`
24+ Role string `json:"role"`
25− Parts string `json:"parts"`
25+ Parts string `json:"parts"`
26− Model sql.NullString `json:"model"`
26+ Model sql.NullString `json:"model"`
27− CreatedAt int64 `json:"created_at"`
27+ CreatedAt int64 `json:"created_at"`
28− UpdatedAt int64 `json:"updated_at"`
28+ UpdatedAt int64 `json:"updated_at"`
29− FinishedAt sql.NullInt64 `json:"finished_at"`
29+ FinishedAt sql.NullInt64 `json:"finished_at"`
30− Provider sql.NullString `json:"provider"`
30+ Provider sql.NullString `json:"provider"`
31+ IsSummaryMessage int64 `json:"is_summary_message"`
31}
32}
32
33
33type Session struct {
34type Session struct {
internal/db/querier.go+1−1
@@ -1,6 +1,6 @@
1// Code generated by sqlc. DO NOT EDIT.
1// Code generated by sqlc. DO NOT EDIT.
2// versions:
2// versions:
3−// sqlc v1.29.0
3+// sqlc v1.30.0
4
4
5package db
5package db
6
6
internal/db/sessions.sql.go+1−1
@@ -1,6 +1,6 @@
1// Code generated by sqlc. DO NOT EDIT.
1// Code generated by sqlc. DO NOT EDIT.
2// versions:
2// versions:
3−// sqlc v1.29.0
3+// sqlc v1.30.0
4// source: sessions.sql
4// source: sessions.sql
5
5
6package db
6package db
internal/db/sql/messages.sql+2−1
@@ -17,10 +17,11 @@ INSERT INTO messages (
17 parts,
17 parts,
18 model,
18 model,
19 provider,
19 provider,
20+ is_summary_message,
20 created_at,
21 created_at,
21 updated_at
22 updated_at
22) VALUES (
23) VALUES (
23− ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
24+ ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
24)
25)
25RETURNING *;
26RETURNING *;
26
27
internal/message/content.go+9−8
@@ -120,14 +120,15 @@ type Finish struct {
120func (Finish) isPart() {}
120func (Finish) isPart() {}
121
121
122type Message struct {
122type Message struct {
123− ID string
123+ ID string
124− Role MessageRole
124+ Role MessageRole
125− SessionID string
125+ SessionID string
126− Parts []ContentPart
126+ Parts []ContentPart
127− Model string
127+ Model string
128− Provider string
128+ Provider string
129− CreatedAt int64
129+ CreatedAt int64
130− UpdatedAt int64
130+ UpdatedAt int64
131+ IsSummaryMessage bool
131}
132}
132
133
133func (m *Message) Content() TextContent {
134func (m *Message) Content() TextContent {
internal/message/message.go+25−18
@@ -13,10 +13,11 @@ import (
13)
13)
14
14
15type CreateMessageParams struct {
15type CreateMessageParams struct {
16− Role MessageRole
16+ Role MessageRole
17− Parts []ContentPart
17+ Parts []ContentPart
18− Model string
18+ Model string
19− Provider string
19+ Provider string
20+ IsSummaryMessage bool
20}
21}
21
22
22type Service interface {
23type Service interface {
@@ -64,13 +65,18 @@ func (s *service) Create(ctx context.Context, sessionID string, params CreateMes
64 if err != nil {
65 if err != nil {
65 return Message{}, err
66 return Message{}, err
66 }
67 }
68+ isSummary := int64(0)
69+ if params.IsSummaryMessage {
70+ isSummary = 1
71+ }
67 dbMessage, err := s.q.CreateMessage(ctx, db.CreateMessageParams{
72 dbMessage, err := s.q.CreateMessage(ctx, db.CreateMessageParams{
68− ID: uuid.New().String(),
73+ ID: uuid.New().String(),
69− SessionID: sessionID,
74+ SessionID: sessionID,
70− Role: string(params.Role),
75+ Role: string(params.Role),
71− Parts: string(partsJSON),
76+ Parts: string(partsJSON),
72− Model: sql.NullString{String: string(params.Model), Valid: true},
77+ Model: sql.NullString{String: string(params.Model), Valid: true},
73− Provider: sql.NullString{String: params.Provider, Valid: params.Provider != ""},
78+ Provider: sql.NullString{String: params.Provider, Valid: params.Provider != ""},
79+ IsSummaryMessage: isSummary,
74 })
80 })
75 if err != nil {
81 if err != nil {
76 return Message{}, err
82 return Message{}, err
@@ -151,14 +157,15 @@ func (s *service) fromDBItem(item db.Message) (Message, error) {
151 return Message{}, err
157 return Message{}, err
152 }
158 }
153 return Message{
159 return Message{
154− ID: item.ID,
160+ ID: item.ID,
155− SessionID: item.SessionID,
161+ SessionID: item.SessionID,
156− Role: MessageRole(item.Role),
162+ Role: MessageRole(item.Role),
157− Parts: parts,
163+ Parts: parts,
158− Model: item.Model.String,
164+ Model: item.Model.String,
159− Provider: item.Provider.String,
165+ Provider: item.Provider.String,
160− CreatedAt: item.CreatedAt,
166+ CreatedAt: item.CreatedAt,
161− UpdatedAt: item.UpdatedAt,
167+ UpdatedAt: item.UpdatedAt,
168+ IsSummaryMessage: item.IsSummaryMessage != 0,
162 }, nil
169 }, nil
163}
170}
164
171
internal/tui/components/chat/chat.go+17
@@ -334,6 +334,11 @@ func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message])
334 return nil
334 return nil
335 }
335 }
336 return m.handleNewMessage(event.Payload)
336 return m.handleNewMessage(event.Payload)
337+ case pubsub.DeletedEvent:
338+ if event.Payload.SessionID != m.session.ID {
339+ return nil
340+ }
341+ return m.handleDeleteMessage(event.Payload)
337 case pubsub.UpdatedEvent:
342 case pubsub.UpdatedEvent:
338 if event.Payload.SessionID != m.session.ID {
343 if event.Payload.SessionID != m.session.ID {
339 return m.handleChildSession(event)
344 return m.handleChildSession(event)
@@ -360,6 +365,18 @@ func (m *messageListCmp) messageExists(messageID string) bool {
360 return false
365 return false
361}
366}
362
367
368+// handleDeleteMessage removes a message from the list.
369+func (m *messageListCmp) handleDeleteMessage(msg message.Message) tea.Cmd {
370+ items := m.listCmp.Items()
371+ for i := len(items) - 1; i >= 0; i-- {
372+ if msgCmp, ok := items[i].(messages.MessageCmp); ok && msgCmp.GetMessage().ID == msg.ID {
373+ m.listCmp.DeleteItem(items[i].ID())
374+ return nil
375+ }
376+ }
377+ return nil
378+}
379+
363// handleNewMessage routes new messages to appropriate handlers based on role.
380// handleNewMessage routes new messages to appropriate handlers based on role.
364func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
381func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
365 switch msg.Role {
382 switch msg.Role {
internal/tui/components/chat/messages/messages.go+3
@@ -121,6 +121,9 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
121// Returns different views for spinning, user, and assistant messages.
121// Returns different views for spinning, user, and assistant messages.
122func (m *messageCmp) View() string {
122func (m *messageCmp) View() string {
123 if m.spinning && m.message.ReasoningContent().Thinking == "" {
123 if m.spinning && m.message.ReasoningContent().Thinking == "" {
124+ if m.message.IsSummaryMessage {
125+ m.anim.SetLabel("Summarizing")
126+ }
124 return m.style().PaddingLeft(1).Render(m.anim.View())
127 return m.style().PaddingLeft(1).Render(m.anim.View())
125 }
128 }
126 if m.message.ID != "" {
129 if m.message.ID != "" {
internal/tui/components/dialogs/compact/compact.godeleted−168
@@ -1,168 +0,0 @@
1−package compact
2−
3−import (
4− "context"
5−
6− "github.com/charmbracelet/bubbles/v2/key"
7− tea "github.com/charmbracelet/bubbletea/v2"
8− "github.com/charmbracelet/lipgloss/v2"
9−
10− "github.com/charmbracelet/crush/internal/agent"
11− "github.com/charmbracelet/crush/internal/tui/components/core"
12− "github.com/charmbracelet/crush/internal/tui/components/dialogs"
13− "github.com/charmbracelet/crush/internal/tui/styles"
14− "github.com/charmbracelet/crush/internal/tui/util"
15−)
16−
17−const CompactDialogID dialogs.DialogID = "compact"
18−
19−// CompactDialog interface for the session compact dialog
20−type CompactDialog interface {
21− dialogs.DialogModel
22−}
23−
24−type compactDialogCmp struct {
25− wWidth, wHeight int
26− width, height int
27− selected int
28− keyMap KeyMap
29− sessionID string
30− progress string
31− agent agent.Coordinator
32− noAsk bool // If true, skip confirmation dialog
33−}
34−
35−// NewCompactDialogCmp creates a new session compact dialog
36−func NewCompactDialogCmp(agent agent.Coordinator, sessionID string, noAsk bool) CompactDialog {
37− return &compactDialogCmp{
38− sessionID: sessionID,
39− keyMap: DefaultKeyMap(),
40− selected: 0,
41− agent: agent,
42− noAsk: noAsk,
43− }
44−}
45−
46−func (c *compactDialogCmp) Init() tea.Cmd {
47− if c.noAsk {
48− // If noAsk is true, skip confirmation and start compaction immediately
49− c.agent.Summarize(context.Background(), c.sessionID)
50− return util.CmdHandler(dialogs.CloseDialogMsg{})
51− }
52− return nil
53−}
54−
55−func (c *compactDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
56− switch msg := msg.(type) {
57− case tea.WindowSizeMsg:
58− c.wWidth = msg.Width
59− c.wHeight = msg.Height
60− cmd := c.SetSize()
61− return c, cmd
62− case tea.KeyPressMsg:
63− switch {
64− case key.Matches(msg, c.keyMap.ChangeSelection):
65− c.selected = (c.selected + 1) % 2
66− return c, nil
67− case key.Matches(msg, c.keyMap.Select):
68− if c.selected == 0 {
69− c.agent.Summarize(context.Background(), c.sessionID)
70− return c, util.CmdHandler(dialogs.CloseDialogMsg{})
71− } else {
72− return c, util.CmdHandler(dialogs.CloseDialogMsg{})
73− }
74− case key.Matches(msg, c.keyMap.Y):
75− c.agent.Summarize(context.Background(), c.sessionID)
76− return c, util.CmdHandler(dialogs.CloseDialogMsg{})
77− case key.Matches(msg, c.keyMap.N):
78− return c, util.CmdHandler(dialogs.CloseDialogMsg{})
79− case key.Matches(msg, c.keyMap.Close):
80− return c, util.CmdHandler(dialogs.CloseDialogMsg{})
81− }
82− }
83− return c, nil
84−}
85−
86−func (c *compactDialogCmp) renderButtons() string {
87− t := styles.CurrentTheme()
88− baseStyle := t.S().Base
89−
90− buttons := []core.ButtonOpts{
91− {
92− Text: "Yes",
93− UnderlineIndex: 0, // "Y"
94− Selected: c.selected == 0,
95− },
96− {
97− Text: "No",
98− UnderlineIndex: 0, // "N"
99− Selected: c.selected == 1,
100− },
101− }
102−
103− content := core.SelectableButtons(buttons, " ")
104−
105− return baseStyle.AlignHorizontal(lipgloss.Right).Width(c.width - 4).Render(content)
106−}
107−
108−func (c *compactDialogCmp) render() string {
109− t := styles.CurrentTheme()
110− baseStyle := t.S().Base
111−
112− title := "Compact Session"
113− titleView := core.Title(title, c.width-4)
114− explanation := t.S().Text.
115− Width(c.width - 4).
116− Render("This will summarize the current session and reset the context. The conversation history will be condensed into a summary to free up context space while preserving important information.")
117−
118− question := t.S().Text.
119− Width(c.width - 4).
120− Render("Do you want to continue?")
121−
122− content := baseStyle.Render(lipgloss.JoinVertical(
123− lipgloss.Left,
124− explanation,
125− "",
126− question,
127− ))
128−
129− buttons := c.renderButtons()
130− dialogContent := lipgloss.JoinVertical(
131− lipgloss.Top,
132− titleView,
133− "",
134− content,
135− "",
136− buttons,
137− "",
138− )
139−
140− return baseStyle.
141− Padding(0, 1).
142− Border(lipgloss.RoundedBorder()).
143− BorderForeground(t.BorderFocus).
144− Width(c.width).
145− Render(dialogContent)
146−}
147−
148−func (c *compactDialogCmp) View() string {
149− return c.render()
150−}
151−
152−// SetSize sets the size of the component.
153−func (c *compactDialogCmp) SetSize() tea.Cmd {
154− c.width = min(90, c.wWidth)
155− c.height = min(15, c.wHeight)
156− return nil
157−}
158−
159−func (c *compactDialogCmp) Position() (int, int) {
160− row := (c.wHeight / 2) - (c.height / 2)
161− col := (c.wWidth / 2) - (c.width / 2)
162− return row, col
163−}
164−
165−// ID implements CompactDialog.
166−func (c *compactDialogCmp) ID() dialogs.DialogID {
167− return CompactDialogID
168−}
internal/tui/components/dialogs/compact/keys.godeleted−71
@@ -1,71 +0,0 @@
1−package compact
2−
3−import (
4− "github.com/charmbracelet/bubbles/v2/key"
5−)
6−
7−// KeyMap defines the key bindings for the compact dialog.
8−type KeyMap struct {
9− ChangeSelection key.Binding
10− Select key.Binding
11− Y key.Binding
12− N key.Binding
13− Close key.Binding
14−}
15−
16−// DefaultKeyMap returns the default key bindings for the compact dialog.
17−func DefaultKeyMap() KeyMap {
18− return KeyMap{
19− ChangeSelection: key.NewBinding(
20− key.WithKeys("tab", "left", "right", "h", "l"),
21− key.WithHelp("tab/←/→", "toggle selection"),
22− ),
23− Select: key.NewBinding(
24− key.WithKeys("enter"),
25− key.WithHelp("enter", "confirm"),
26− ),
27− Y: key.NewBinding(
28− key.WithKeys("y"),
29− key.WithHelp("y", "yes"),
30− ),
31− N: key.NewBinding(
32− key.WithKeys("n"),
33− key.WithHelp("n", "no"),
34− ),
35− Close: key.NewBinding(
36− key.WithKeys("esc", "alt+esc"),
37− key.WithHelp("esc", "cancel"),
38− ),
39− }
40−}
41−
42−// KeyBindings implements layout.KeyMapProvider
43−func (k KeyMap) KeyBindings() []key.Binding {
44− return []key.Binding{
45− k.ChangeSelection,
46− k.Select,
47− k.Y,
48− k.N,
49− k.Close,
50− }
51−}
52−
53−// FullHelp implements help.KeyMap.
54−func (k KeyMap) FullHelp() [][]key.Binding {
55− m := [][]key.Binding{}
56− slice := k.KeyBindings()
57− for i := 0; i < len(slice); i += 4 {
58− end := min(i+4, len(slice))
59− m = append(m, slice[i:end])
60− }
61− return m
62−}
63−
64−// ShortHelp implements help.KeyMap.
65−func (k KeyMap) ShortHelp() []key.Binding {
66− return []key.Binding{
67− k.ChangeSelection,
68− k.Select,
69− k.Close,
70− }
71−}
internal/tui/tui.go+7−36
@@ -21,7 +21,6 @@ import (
21 "github.com/charmbracelet/crush/internal/tui/components/core/status"
21 "github.com/charmbracelet/crush/internal/tui/components/core/status"
22 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
22 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
23 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
23 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
24− "github.com/charmbracelet/crush/internal/tui/components/dialogs/compact"
25 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
24 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
26 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
25 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
27 "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
26 "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
@@ -178,9 +177,13 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
178 )
177 )
179 // Compact
178 // Compact
180 case commands.CompactMsg:
179 case commands.CompactMsg:
181− return a, util.CmdHandler(dialogs.OpenDialogMsg{
180+ return a, func() tea.Msg {
182− Model: compact.NewCompactDialogCmp(a.app.AgentCoordinator, msg.SessionID, true),
181+ err := a.app.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
182+ if err != nil {
183+ return util.ReportError(err)()
184+ }
185+ return nil
183− })
186+ }
184 case commands.QuitMsg:
187 case commands.QuitMsg:
185 return a, util.CmdHandler(dialogs.OpenDialogMsg{
188 return a, util.CmdHandler(dialogs.OpenDialogMsg{
186 Model: quit.NewQuitDialog(),
189 Model: quit.NewQuitDialog(),
@@ -251,38 +254,6 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
251 a.app.Permissions.Deny(msg.Permission)
254 a.app.Permissions.Deny(msg.Permission)
252 }
255 }
253 return a, nil
256 return a, nil
254− // Agent Events
255− // TODO: HANDLE AUTO COMPACT
256− // case pubsub.Event[agent.AgentEvent]:
257− // payload := msg.Payload
258− //
259− // // Forward agent events to dialogs
260− // if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() == compact.CompactDialogID {
261− // u, dialogCmd := a.dialog.Update(payload)
262− // if model, ok := u.(dialogs.DialogCmp); ok {
263− // a.dialog = model
264− // }
265− //
266− // cmds = append(cmds, dialogCmd)
267− // }
268− //
269− // // Handle auto-compact logic
270− // if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSessionID != "" {
271− // // Get current session to check token usage
272− // session, err := a.app.Sessions.Get(context.Background(), a.selectedSessionID)
273− // if err == nil {
274− // model := a.app.AgentCoordinator.Model()
275− // contextWindow := model.CatwalkCfg.ContextWindow
276− // tokens := session.CompletionTokens + session.PromptTokens
277− // if (tokens >= int64(float64(contextWindow)*0.95)) && !config.Get().Options.DisableAutoSummarize { // Show compact confirmation dialog
278− // cmds = append(cmds, util.CmdHandler(dialogs.OpenDialogMsg{
279− // Model: compact.NewCompactDialogCmp(a.app.AgentCoordinator, a.selectedSessionID, false),
280− // }))
281− // }
282− // }
283− // }
284− //
285− // return a, tea.Batch(cmds...)
286 case splash.OnboardingCompleteMsg:
257 case splash.OnboardingCompleteMsg:
287 item, ok := a.pages[a.currentPage]
258 item, ok := a.pages[a.currentPage]
288 if !ok {
259 if !ok {