on main help sign in
refactor: new compact dialog
b744b0fKujtim Hoxha
internal/llm/agent/agent.go+15−11
@@ -586,22 +586,26 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
586 a.Publish(pubsub.CreatedEvent, event)
586 a.Publish(pubsub.CreatedEvent, event)
587
587
588 // Send the messages to the summarize provider
588 // Send the messages to the summarize provider
589− response, err := a.summarizeProvider.SendMessages(
589+ response := a.summarizeProvider.StreamResponse(
590 summarizeCtx,
590 summarizeCtx,
591 msgsWithPrompt,
591 msgsWithPrompt,
592 make([]tools.BaseTool, 0),
592 make([]tools.BaseTool, 0),
593 )
593 )
594+ var finalResponse *provider.ProviderResponse
595+ for r := range response {
594− if err != nil {
596+ if r.Error != nil {
595− event = AgentEvent{
597+ event = AgentEvent{
596− Type: AgentEventTypeError,
598+ Type: AgentEventTypeError,
597− Error: fmt.Errorf("failed to summarize: %w", err),
599+ Error: fmt.Errorf("failed to summarize: %w", err),
598− Done: true,
600+ Done: true,
601+ }
602+ a.Publish(pubsub.CreatedEvent, event)
603+ return
599 }
604 }
600− a.Publish(pubsub.CreatedEvent, event)
605+ finalResponse = r.Response
601− return
602 }
606 }
603
607
604− summary := strings.TrimSpace(response.Content)
608+ summary := strings.TrimSpace(finalResponse.Content)
605 if summary == "" {
609 if summary == "" {
606 event = AgentEvent{
610 event = AgentEvent{
607 Type: AgentEventTypeError,
611 Type: AgentEventTypeError,
@@ -651,10 +655,10 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
651 return
655 return
652 }
656 }
653 oldSession.SummaryMessageID = msg.ID
657 oldSession.SummaryMessageID = msg.ID
654− oldSession.CompletionTokens = response.Usage.OutputTokens
658+ oldSession.CompletionTokens = finalResponse.Usage.OutputTokens
655 oldSession.PromptTokens = 0
659 oldSession.PromptTokens = 0
656 model := a.summarizeProvider.Model()
660 model := a.summarizeProvider.Model()
657− usage := response.Usage
661+ usage := finalResponse.Usage
658 cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
662 cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
659 model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
663 model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
660 model.CostPer1MIn/1e6*float64(usage.InputTokens) +
664 model.CostPer1MIn/1e6*float64(usage.InputTokens) +
internal/llm/provider/anthropic.go+2−1
@@ -195,7 +195,7 @@ func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, to
195 }
195 }
196}
196}
197
197
198−func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (resposne *ProviderResponse, err error) {
198+func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
199 preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
199 preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
200 cfg := config.Get()
200 cfg := config.Get()
201 if cfg.Debug {
201 if cfg.Debug {
@@ -339,6 +339,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
339 Usage: a.usage(accumulatedMessage),
339 Usage: a.usage(accumulatedMessage),
340 FinishReason: a.finishReason(string(accumulatedMessage.StopReason)),
340 FinishReason: a.finishReason(string(accumulatedMessage.StopReason)),
341 },
341 },
342+ Content: content,
342 }
343 }
343 }
344 }
344 }
345 }
internal/tui/components/dialogs/commands/commands.go+25−20
@@ -50,14 +50,18 @@ type commandDialogCmp struct {
50 help help.Model
50 help help.Model
51 commandType int // SystemCommands or UserCommands
51 commandType int // SystemCommands or UserCommands
52 userCommands []Command // User-defined commands
52 userCommands []Command // User-defined commands
53+ sessionID string // Current session ID
53}
54}
54
55
55type (
56type (
56 SwitchSessionsMsg struct{}
57 SwitchSessionsMsg struct{}
57 SwitchModelMsg struct{}
58 SwitchModelMsg struct{}
59+ CompactMsg struct {
60+ SessionID string
61+ }
58)
62)
59
63
60−func NewCommandDialog() CommandsDialog {
64+func NewCommandDialog(sessionID string) CommandsDialog {
61 listKeyMap := list.DefaultKeyMap()
65 listKeyMap := list.DefaultKeyMap()
62 keyMap := DefaultCommandsDialogKeyMap()
66 keyMap := DefaultCommandsDialogKeyMap()
63
67
@@ -87,6 +91,7 @@ func NewCommandDialog() CommandsDialog {
87 keyMap: DefaultCommandsDialogKeyMap(),
91 keyMap: DefaultCommandsDialogKeyMap(),
88 help: help,
92 help: help,
89 commandType: SystemCommands,
93 commandType: SystemCommands,
94+ sessionID: sessionID,
90 }
95 }
91}
96}
92
97
@@ -222,7 +227,7 @@ func (c *commandDialogCmp) Position() (int, int) {
222}
227}
223
228
224func (c *commandDialogCmp) defaultCommands() []Command {
229func (c *commandDialogCmp) defaultCommands() []Command {
225− return []Command{
230+ commands := []Command{
226 {
231 {
227 ID: "init",
232 ID: "init",
228 Title: "Initialize Project",
233 Title: "Initialize Project",
@@ -235,33 +240,35 @@ func (c *commandDialogCmp) defaultCommands() []Command {
235 The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
240 The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
236 If there's already a crush.md, improve it.
241 If there's already a crush.md, improve it.
237 If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
242 If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
238− return tea.Batch(
239− util.CmdHandler(chat.SendMsg{
243+ return util.CmdHandler(chat.SendMsg{
240− Text: prompt,
244+ Text: prompt,
241− }),
242− )
245+ })
243 },
246 },
244 },
247 },
245− {
248+ }
249+
250+ // Only show compact command if there's an active session
251+ if c.sessionID != "" {
252+ commands = append(commands, Command{
246 ID: "compact",
253 ID: "compact",
247 Title: "Compact Session",
254 Title: "Compact Session",
248 Description: "Summarize the current session and create a new one with the summary",
255 Description: "Summarize the current session and create a new one with the summary",
249 Handler: func(cmd Command) tea.Cmd {
256 Handler: func(cmd Command) tea.Cmd {
250− return func() tea.Msg {
257+ return util.CmdHandler(CompactMsg{
251− // TODO: implement compact message
258+ SessionID: c.sessionID,
252− return ""
253− }
259+ })
254 },
260 },
255− },
261+ })
262+ }
263+
264+ return append(commands, []Command{
256 {
265 {
257 ID: "switch_session",
266 ID: "switch_session",
258 Title: "Switch Session",
267 Title: "Switch Session",
259 Description: "Switch to a different session",
268 Description: "Switch to a different session",
260 Shortcut: "ctrl+s",
269 Shortcut: "ctrl+s",
261 Handler: func(cmd Command) tea.Cmd {
270 Handler: func(cmd Command) tea.Cmd {
262− return func() tea.Msg {
263− return SwitchSessionsMsg{}
271+ return util.CmdHandler(SwitchSessionsMsg{})
264− }
265 },
272 },
266 },
273 },
267 {
274 {
@@ -269,12 +276,10 @@ func (c *commandDialogCmp) defaultCommands() []Command {
269 Title: "Switch Model",
276 Title: "Switch Model",
270 Description: "Switch to a different model",
277 Description: "Switch to a different model",
271 Handler: func(cmd Command) tea.Cmd {
278 Handler: func(cmd Command) tea.Cmd {
272− return func() tea.Msg {
273− return SwitchModelMsg{}
279+ return util.CmdHandler(SwitchModelMsg{})
274− }
275 },
280 },
276 },
281 },
277− }
282+ }...)
278}
283}
279
284
280func (c *commandDialogCmp) ID() dialogs.DialogID {
285func (c *commandDialogCmp) ID() dialogs.DialogID {
internal/tui/components/dialogs/compact/compact.gonew file+266
@@ -0,0 +1,266 @@
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/llm/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+ state compactState
31+ progress string
32+ agent agent.Service
33+ noAsk bool // If true, skip confirmation dialog
34+}
35+
36+type compactState int
37+
38+const (
39+ stateConfirm compactState = iota
40+ stateCompacting
41+ stateError
42+)
43+
44+// NewCompactDialogCmp creates a new session compact dialog
45+func NewCompactDialogCmp(agent agent.Service, sessionID string, noAsk bool) CompactDialog {
46+ return &compactDialogCmp{
47+ sessionID: sessionID,
48+ keyMap: DefaultKeyMap(),
49+ state: stateConfirm,
50+ selected: 0,
51+ agent: agent,
52+ noAsk: noAsk,
53+ }
54+}
55+
56+func (c *compactDialogCmp) Init() tea.Cmd {
57+ if c.noAsk {
58+ // If noAsk is true, skip confirmation and start compaction immediately
59+ return c.startCompaction()
60+ }
61+ return nil
62+}
63+
64+func (c *compactDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
65+ switch msg := msg.(type) {
66+ case tea.WindowSizeMsg:
67+ c.wWidth = msg.Width
68+ c.wHeight = msg.Height
69+ cmd := c.SetSize()
70+ return c, cmd
71+
72+ case tea.KeyPressMsg:
73+ switch c.state {
74+ case stateConfirm:
75+ switch {
76+ case key.Matches(msg, c.keyMap.ChangeSelection):
77+ c.selected = (c.selected + 1) % 2
78+ return c, nil
79+ case key.Matches(msg, c.keyMap.Select):
80+ if c.selected == 0 {
81+ return c, c.startCompaction()
82+ } else {
83+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
84+ }
85+ case key.Matches(msg, c.keyMap.Y):
86+ return c, c.startCompaction()
87+ case key.Matches(msg, c.keyMap.N):
88+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
89+ case key.Matches(msg, c.keyMap.Close):
90+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
91+ }
92+ case stateCompacting:
93+ switch {
94+ case key.Matches(msg, c.keyMap.Close):
95+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
96+ }
97+ case stateError:
98+ switch {
99+ case key.Matches(msg, c.keyMap.Select):
100+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
101+ case key.Matches(msg, c.keyMap.Close):
102+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
103+ }
104+ }
105+
106+ case agent.AgentEvent:
107+ if msg.Type == agent.AgentEventTypeSummarize {
108+ if msg.Error != nil {
109+ c.state = stateError
110+ c.progress = "Error: " + msg.Error.Error()
111+ } else if msg.Done {
112+ return c, util.CmdHandler(
113+ dialogs.CloseDialogMsg{},
114+ )
115+ } else {
116+ c.progress = msg.Progress
117+ }
118+ }
119+ return c, nil
120+ }
121+
122+ return c, nil
123+}
124+
125+func (c *compactDialogCmp) startCompaction() tea.Cmd {
126+ c.state = stateCompacting
127+ c.progress = "Starting summarization..."
128+ return func() tea.Msg {
129+ err := c.agent.Summarize(context.Background(), c.sessionID)
130+ if err != nil {
131+ c.state = stateError
132+ c.progress = "Error: " + err.Error()
133+ }
134+ return nil
135+ }
136+}
137+
138+func (c *compactDialogCmp) renderButtons() string {
139+ t := styles.CurrentTheme()
140+ baseStyle := t.S().Base
141+
142+ buttons := []core.ButtonOpts{
143+ {
144+ Text: "Yes",
145+ UnderlineIndex: 0, // "Y"
146+ Selected: c.selected == 0,
147+ },
148+ {
149+ Text: "No",
150+ UnderlineIndex: 0, // "N"
151+ Selected: c.selected == 1,
152+ },
153+ }
154+
155+ content := core.SelectableButtons(buttons, " ")
156+
157+ return baseStyle.AlignHorizontal(lipgloss.Right).Width(c.width - 4).Render(content)
158+}
159+
160+func (c *compactDialogCmp) renderContent() string {
161+ t := styles.CurrentTheme()
162+ baseStyle := t.S().Base
163+
164+ switch c.state {
165+ case stateConfirm:
166+ explanation := t.S().Text.
167+ Width(c.width - 4).
168+ 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.")
169+
170+ question := t.S().Text.
171+ Width(c.width - 4).
172+ Render("Do you want to continue?")
173+
174+ return baseStyle.Render(lipgloss.JoinVertical(
175+ lipgloss.Left,
176+ explanation,
177+ "",
178+ question,
179+ ))
180+ case stateCompacting:
181+ return baseStyle.Render(lipgloss.JoinVertical(
182+ lipgloss.Left,
183+ c.progress,
184+ "",
185+ "Please wait...",
186+ ))
187+ case stateError:
188+ return baseStyle.Render(lipgloss.JoinVertical(
189+ lipgloss.Left,
190+ c.progress,
191+ "",
192+ "Press Enter to close",
193+ ))
194+ }
195+ return ""
196+}
197+
198+func (c *compactDialogCmp) render() string {
199+ t := styles.CurrentTheme()
200+ baseStyle := t.S().Base
201+
202+ var title string
203+ switch c.state {
204+ case stateConfirm:
205+ title = "Compact Session"
206+ case stateCompacting:
207+ title = "Compacting Session"
208+ case stateError:
209+ title = "Compact Failed"
210+ }
211+
212+ titleView := core.Title(title, c.width-4)
213+ content := c.renderContent()
214+
215+ var dialogContent string
216+ if c.state == stateConfirm {
217+ buttons := c.renderButtons()
218+ dialogContent = lipgloss.JoinVertical(
219+ lipgloss.Top,
220+ titleView,
221+ "",
222+ content,
223+ "",
224+ buttons,
225+ "",
226+ )
227+ } else {
228+ dialogContent = lipgloss.JoinVertical(
229+ lipgloss.Top,
230+ titleView,
231+ "",
232+ content,
233+ "",
234+ )
235+ }
236+
237+ return baseStyle.
238+ Padding(0, 1).
239+ Border(lipgloss.RoundedBorder()).
240+ BorderForeground(t.BorderFocus).
241+ Width(c.width).
242+ Render(dialogContent)
243+}
244+
245+func (c *compactDialogCmp) View() tea.View {
246+ return tea.NewView(c.render())
247+}
248+
249+// SetSize sets the size of the component.
250+func (c *compactDialogCmp) SetSize() tea.Cmd {
251+ c.width = min(90, c.wWidth)
252+ c.height = min(15, c.wHeight)
253+ return nil
254+}
255+
256+func (c *compactDialogCmp) Position() (int, int) {
257+ row := (c.wHeight / 2) - (c.height / 2)
258+ col := (c.wWidth / 2) - (c.width / 2)
259+ return row, col
260+}
261+
262+// ID implements CompactDialog.
263+func (c *compactDialogCmp) ID() dialogs.DialogID {
264+ return CompactDialogID
265+}
266+
internal/tui/components/dialogs/compact/keys.gonew file+61
@@ -0,0 +1,61 @@
1+package compact
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/charmbracelet/crush/internal/tui/layout"
6+)
7+
8+// KeyMap defines the key bindings for the compact dialog.
9+type KeyMap struct {
10+ ChangeSelection key.Binding
11+ Select key.Binding
12+ Y key.Binding
13+ N key.Binding
14+ Close key.Binding
15+}
16+
17+// DefaultKeyMap returns the default key bindings for the compact dialog.
18+func DefaultKeyMap() KeyMap {
19+ return KeyMap{
20+ ChangeSelection: key.NewBinding(
21+ key.WithKeys("tab", "left", "right", "h", "l"),
22+ key.WithHelp("tab/←/→", "toggle selection"),
23+ ),
24+ Select: key.NewBinding(
25+ key.WithKeys("enter"),
26+ key.WithHelp("enter", "confirm"),
27+ ),
28+ Y: key.NewBinding(
29+ key.WithKeys("y"),
30+ key.WithHelp("y", "yes"),
31+ ),
32+ N: key.NewBinding(
33+ key.WithKeys("n"),
34+ key.WithHelp("n", "no"),
35+ ),
36+ Close: key.NewBinding(
37+ key.WithKeys("esc"),
38+ key.WithHelp("esc", "cancel"),
39+ ),
40+ }
41+}
42+
43+// FullHelp implements help.KeyMap.
44+func (k KeyMap) FullHelp() [][]key.Binding {
45+ m := [][]key.Binding{}
46+ slice := layout.KeyMapToSlice(k)
47+ for i := 0; i < len(slice); i += 4 {
48+ end := min(i+4, len(slice))
49+ m = append(m, slice[i:end])
50+ }
51+ return m
52+}
53+
54+// ShortHelp implements help.KeyMap.
55+func (k KeyMap) ShortHelp() []key.Binding {
56+ return []key.Binding{
57+ k.ChangeSelection,
58+ k.Select,
59+ k.Close,
60+ }
61+}
internal/tui/components/dialogs/init/init.go+38−4
@@ -5,6 +5,8 @@ import (
5 tea "github.com/charmbracelet/bubbletea/v2"
5 tea "github.com/charmbracelet/bubbletea/v2"
6 "github.com/charmbracelet/lipgloss/v2"
6 "github.com/charmbracelet/lipgloss/v2"
7
7
8+ "github.com/charmbracelet/crush/internal/config"
9+ cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
8 "github.com/charmbracelet/crush/internal/tui/components/core"
10 "github.com/charmbracelet/crush/internal/tui/components/core"
9 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
11 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
10 "github.com/charmbracelet/crush/internal/tui/styles"
12 "github.com/charmbracelet/crush/internal/tui/styles"
@@ -51,7 +53,7 @@ func (m *initDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
51 case key.Matches(msg, m.keyMap.Close):
53 case key.Matches(msg, m.keyMap.Close):
52 return m, tea.Batch(
54 return m, tea.Batch(
53 util.CmdHandler(dialogs.CloseDialogMsg{}),
55 util.CmdHandler(dialogs.CloseDialogMsg{}),
54− util.CmdHandler(CloseInitDialogMsg{Initialize: false}),
56+ m.handleInitialization(false),
55 )
57 )
56 case key.Matches(msg, m.keyMap.ChangeSelection):
58 case key.Matches(msg, m.keyMap.ChangeSelection):
57 m.selected = (m.selected + 1) % 2
59 m.selected = (m.selected + 1) % 2
@@ -59,17 +61,17 @@ func (m *initDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
59 case key.Matches(msg, m.keyMap.Select):
61 case key.Matches(msg, m.keyMap.Select):
60 return m, tea.Batch(
62 return m, tea.Batch(
61 util.CmdHandler(dialogs.CloseDialogMsg{}),
63 util.CmdHandler(dialogs.CloseDialogMsg{}),
62− util.CmdHandler(CloseInitDialogMsg{Initialize: m.selected == 0}),
64+ m.handleInitialization(m.selected == 0),
63 )
65 )
64 case key.Matches(msg, m.keyMap.Y):
66 case key.Matches(msg, m.keyMap.Y):
65 return m, tea.Batch(
67 return m, tea.Batch(
66 util.CmdHandler(dialogs.CloseDialogMsg{}),
68 util.CmdHandler(dialogs.CloseDialogMsg{}),
67− util.CmdHandler(CloseInitDialogMsg{Initialize: true}),
69+ m.handleInitialization(true),
68 )
70 )
69 case key.Matches(msg, m.keyMap.N):
71 case key.Matches(msg, m.keyMap.N):
70 return m, tea.Batch(
72 return m, tea.Batch(
71 util.CmdHandler(dialogs.CloseDialogMsg{}),
73 util.CmdHandler(dialogs.CloseDialogMsg{}),
72− util.CmdHandler(CloseInitDialogMsg{Initialize: false}),
74+ m.handleInitialization(false),
73 )
75 )
74 }
76 }
75 }
77 }
@@ -168,6 +170,38 @@ func (m *initDialogCmp) Position() (int, int) {
168 return row, col
170 return row, col
169}
171}
170
172
173+// handleInitialization handles the initialization logic when the dialog is closed.
174+func (m *initDialogCmp) handleInitialization(initialize bool) tea.Cmd {
175+ if initialize {
176+ // Run the initialization command
177+ prompt := `Please analyze this codebase and create a Crush.md file containing:
178+1. Build/lint/test commands - especially for running a single test
179+2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
180+
181+The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
182+If there's already a crush.md, improve it.
183+If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
184+
185+ // Mark the project as initialized
186+ if err := config.MarkProjectInitialized(); err != nil {
187+ return util.ReportError(err)
188+ }
189+
190+ return tea.Sequence(
191+ util.CmdHandler(cmpChat.SessionClearedMsg{}),
192+ util.CmdHandler(cmpChat.SendMsg{
193+ Text: prompt,
194+ }),
195+ )
196+ } else {
197+ // Mark the project as initialized without running the command
198+ if err := config.MarkProjectInitialized(); err != nil {
199+ return util.ReportError(err)
200+ }
201+ }
202+ return nil
203+}
204+
171// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
205// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
172type CloseInitDialogMsg struct {
206type CloseInitDialogMsg struct {
173 Initialize bool
207 Initialize bool
internal/tui/tui.go+36−26
@@ -7,6 +7,7 @@ import (
7 tea "github.com/charmbracelet/bubbletea/v2"
7 tea "github.com/charmbracelet/bubbletea/v2"
8 "github.com/charmbracelet/crush/internal/app"
8 "github.com/charmbracelet/crush/internal/app"
9 "github.com/charmbracelet/crush/internal/config"
9 "github.com/charmbracelet/crush/internal/config"
10+ "github.com/charmbracelet/crush/internal/llm/agent"
10 "github.com/charmbracelet/crush/internal/llm/tools"
11 "github.com/charmbracelet/crush/internal/llm/tools"
11 "github.com/charmbracelet/crush/internal/logging"
12 "github.com/charmbracelet/crush/internal/logging"
12 "github.com/charmbracelet/crush/internal/permission"
13 "github.com/charmbracelet/crush/internal/permission"
@@ -16,6 +17,7 @@ import (
16 "github.com/charmbracelet/crush/internal/tui/components/core/status"
17 "github.com/charmbracelet/crush/internal/tui/components/core/status"
17 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
18 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
18 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
20+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/compact"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
20 initDialog "github.com/charmbracelet/crush/internal/tui/components/dialogs/init"
22 initDialog "github.com/charmbracelet/crush/internal/tui/components/dialogs/init"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
23 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
@@ -157,6 +159,12 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
157 Model: models.NewModelDialogCmp(),
159 Model: models.NewModelDialogCmp(),
158 },
160 },
159 )
161 )
162+ // Compact
163+ case commands.CompactMsg:
164+ return a, util.CmdHandler(dialogs.OpenDialogMsg{
165+ Model: compact.NewCompactDialogCmp(a.app.CoderAgent, msg.SessionID, true),
166+ })
167+
160 // File Picker
168 // File Picker
161 case chat.OpenFilePickerMsg:
169 case chat.OpenFilePickerMsg:
162 if a.dialog.ActiveDialogId() == filepicker.FilePickerID {
170 if a.dialog.ActiveDialogId() == filepicker.FilePickerID {
@@ -181,33 +189,35 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
181 a.app.Permissions.Deny(msg.Permission)
189 a.app.Permissions.Deny(msg.Permission)
182 }
190 }
183 return a, nil
191 return a, nil
184− // Init Dialog
192+ // Agent Events
185− case initDialog.CloseInitDialogMsg:
193+ case pubsub.Event[agent.AgentEvent]:
186− if msg.Initialize {
194+ payload := msg.Payload
187− // Run the initialization command
195+
188− prompt := `Please analyze this codebase and create a Crush.md file containing:
196+ // Forward agent events to dialogs
189−1. Build/lint/test commands - especially for running a single test
197+ if a.dialog.HasDialogs() && a.dialog.ActiveDialogId() == compact.CompactDialogID {
190−2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
198+ u, dialogCmd := a.dialog.Update(payload)
191−
199+ a.dialog = u.(dialogs.DialogCmp)
192−The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
200+ cmds = append(cmds, dialogCmd)
193−If there's already a crush.md, improve it.
201+ }
194−If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
202+
195−
203+ // Handle auto-compact logic
196− // Mark the project as initialized
204+ if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSessionID != "" {
197− if err := config.MarkProjectInitialized(); err != nil {
205+ // Get current session to check token usage
198− return a, util.ReportError(err)
206+ session, err := a.app.Sessions.Get(context.Background(), a.selectedSessionID)
199− }
207+ if err == nil {
200−
208+ model := a.app.CoderAgent.Model()
201− return a, util.CmdHandler(cmpChat.SendMsg{
209+ contextWindow := model.ContextWindow
202− Text: prompt,
210+ tokens := session.CompletionTokens + session.PromptTokens
203− })
211+ if (tokens >= int64(float64(contextWindow)*0.95)) && config.Get().AutoCompact {
204− } else {
212+ // Show compact confirmation dialog
205− // Mark the project as initialized without running the command
213+ cmds = append(cmds, util.CmdHandler(dialogs.OpenDialogMsg{
206− if err := config.MarkProjectInitialized(); err != nil {
214+ Model: compact.NewCompactDialogCmp(a.app.CoderAgent, a.selectedSessionID, false),
207− return a, util.ReportError(err)
215+ }))
216+ }
208 }
217 }
209 }
218 }
219+
210− return a, nil
220+ return a, tea.Batch(cmds...)
211 // Key Press Messages
221 // Key Press Messages
212 case tea.KeyPressMsg:
222 case tea.KeyPressMsg:
213 if msg.String() == "ctrl+t" {
223 if msg.String() == "ctrl+t" {
@@ -296,7 +306,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
296 return util.CmdHandler(dialogs.CloseDialogMsg{})
306 return util.CmdHandler(dialogs.CloseDialogMsg{})
297 }
307 }
298 return util.CmdHandler(dialogs.OpenDialogMsg{
308 return util.CmdHandler(dialogs.OpenDialogMsg{
299− Model: commands.NewCommandDialog(),
309+ Model: commands.NewCommandDialog(a.selectedSessionID),
300 })
310 })
301 case key.Matches(msg, a.keyMap.Sessions):
311 case key.Matches(msg, a.keyMap.Sessions):
302 if a.dialog.ActiveDialogId() == sessions.SessionsDialogID {
312 if a.dialog.ActiveDialogId() == sessions.SessionsDialogID {