1package tui
2
3import (
4 "context"
5 "fmt"
6
7 "github.com/charmbracelet/bubbles/v2/key"
8 tea "github.com/charmbracelet/bubbletea/v2"
9 "github.com/charmbracelet/crush/internal/app"
10 "github.com/charmbracelet/crush/internal/config"
11 "github.com/charmbracelet/crush/internal/llm/agent"
12 "github.com/charmbracelet/crush/internal/permission"
13 "github.com/charmbracelet/crush/internal/pubsub"
14 cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
15 "github.com/charmbracelet/crush/internal/tui/components/completions"
16 "github.com/charmbracelet/crush/internal/tui/components/core"
17 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
18 "github.com/charmbracelet/crush/internal/tui/components/core/status"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
20 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs/compact"
22 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
23 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
24 "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
25 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
26 "github.com/charmbracelet/crush/internal/tui/components/dialogs/sessions"
27 "github.com/charmbracelet/crush/internal/tui/page"
28 "github.com/charmbracelet/crush/internal/tui/page/chat"
29 "github.com/charmbracelet/crush/internal/tui/styles"
30 "github.com/charmbracelet/crush/internal/tui/util"
31 "github.com/charmbracelet/lipgloss/v2"
32)
33
34// MouseEventFilter filters mouse events based on the current focus state
35// This is used with tea.WithFilter to prevent mouse scroll events from
36// interfering with typing performance in the editor
37func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
38 // Only filter mouse events
39 switch msg.(type) {
40 case tea.MouseWheelMsg, tea.MouseMotionMsg:
41 // Check if we have an appModel and if editor is focused
42 if appModel, ok := m.(*appModel); ok {
43 if appModel.currentPage == chat.ChatPageID {
44 if chatPage, ok := appModel.pages[appModel.currentPage].(chat.ChatPage); ok {
45 // If editor is focused (not chatFocused), filter out mouse wheel/motion events
46 if !chatPage.IsChatFocused() {
47 return nil // Filter out the event
48 }
49 }
50 }
51 }
52 }
53 // Allow all other events to pass through
54 return msg
55}
56
57// appModel represents the main application model that manages pages, dialogs, and UI state.
58type appModel struct {
59 wWidth, wHeight int // Window dimensions
60 width, height int
61 keyMap KeyMap
62
63 currentPage page.PageID
64 previousPage page.PageID
65 pages map[page.PageID]util.Model
66 loadedPages map[page.PageID]bool
67
68 // Status
69 status status.StatusCmp
70 showingFullHelp bool
71
72 app *app.App
73
74 dialog dialogs.DialogCmp
75 completions completions.Completions
76
77 // Chat Page Specific
78 selectedSessionID string // The ID of the currently selected session
79}
80
81// Init initializes the application model and returns initial commands.
82func (a appModel) Init() tea.Cmd {
83 var cmds []tea.Cmd
84 cmd := a.pages[a.currentPage].Init()
85 cmds = append(cmds, cmd)
86 a.loadedPages[a.currentPage] = true
87
88 cmd = a.status.Init()
89 cmds = append(cmds, cmd)
90
91 cmds = append(cmds, tea.EnableMouseAllMotion)
92
93 return tea.Batch(cmds...)
94}
95
96// Update handles incoming messages and updates the application state.
97func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
98 var cmds []tea.Cmd
99 var cmd tea.Cmd
100
101 switch msg := msg.(type) {
102 case tea.KeyboardEnhancementsMsg:
103 for id, page := range a.pages {
104 m, pageCmd := page.Update(msg)
105 a.pages[id] = m.(util.Model)
106 if pageCmd != nil {
107 cmds = append(cmds, pageCmd)
108 }
109 }
110 return a, tea.Batch(cmds...)
111 case tea.WindowSizeMsg:
112 a.completions.Update(msg)
113 return a, a.handleWindowResize(msg.Width, msg.Height)
114
115 // Completions messages
116 case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, completions.CloseCompletionsMsg:
117 u, completionCmd := a.completions.Update(msg)
118 a.completions = u.(completions.Completions)
119 switch msg := msg.(type) {
120 case completions.OpenCompletionsMsg:
121 x, _ := a.completions.Position()
122 if a.completions.Width()+x >= a.wWidth {
123 // Adjust X position to fit in the window.
124 msg.X = a.wWidth - a.completions.Width() - 1
125 u, completionCmd = a.completions.Update(msg)
126 a.completions = u.(completions.Completions)
127 }
128 }
129 return a, completionCmd
130
131 // Dialog messages
132 case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
133 u, completionCmd := a.completions.Update(completions.CloseCompletionsMsg{})
134 a.completions = u.(completions.Completions)
135 u, dialogCmd := a.dialog.Update(msg)
136 a.dialog = u.(dialogs.DialogCmp)
137 return a, tea.Batch(completionCmd, dialogCmd)
138 case commands.ShowArgumentsDialogMsg:
139 return a, util.CmdHandler(
140 dialogs.OpenDialogMsg{
141 Model: commands.NewCommandArgumentsDialog(
142 msg.CommandID,
143 msg.Content,
144 msg.ArgNames,
145 ),
146 },
147 )
148 // Page change messages
149 case page.PageChangeMsg:
150 return a, a.moveToPage(msg.ID)
151
152 // Status Messages
153 case util.InfoMsg, util.ClearStatusMsg:
154 s, statusCmd := a.status.Update(msg)
155 a.status = s.(status.StatusCmp)
156 cmds = append(cmds, statusCmd)
157 return a, tea.Batch(cmds...)
158
159 // Session
160 case cmpChat.SessionSelectedMsg:
161 a.selectedSessionID = msg.ID
162 case cmpChat.SessionClearedMsg:
163 a.selectedSessionID = ""
164 // Commands
165 case commands.SwitchSessionsMsg:
166 return a, func() tea.Msg {
167 allSessions, _ := a.app.Sessions.List(context.Background())
168 return dialogs.OpenDialogMsg{
169 Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
170 }
171 }
172
173 case commands.SwitchModelMsg:
174 return a, util.CmdHandler(
175 dialogs.OpenDialogMsg{
176 Model: models.NewModelDialogCmp(),
177 },
178 )
179 // Compact
180 case commands.CompactMsg:
181 return a, util.CmdHandler(dialogs.OpenDialogMsg{
182 Model: compact.NewCompactDialogCmp(a.app.CoderAgent, msg.SessionID, true),
183 })
184
185 // Model Switch
186 case models.ModelSelectedMsg:
187 config.Get().UpdatePreferredModel(msg.ModelType, msg.Model)
188
189 // Update the agent with the new model/provider configuration
190 if err := a.app.UpdateAgentModel(); err != nil {
191 return a, util.ReportError(fmt.Errorf("model changed to %s but failed to update agent: %v", msg.Model.Model, err))
192 }
193
194 modelTypeName := "large"
195 if msg.ModelType == config.SelectedModelTypeSmall {
196 modelTypeName = "small"
197 }
198 return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.Model))
199
200 // File Picker
201 case chat.OpenFilePickerMsg:
202 if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
203 // If the commands dialog is already open, close it
204 return a, util.CmdHandler(dialogs.CloseDialogMsg{})
205 }
206 return a, util.CmdHandler(dialogs.OpenDialogMsg{
207 Model: filepicker.NewFilePickerCmp(),
208 })
209 // Permissions
210 case pubsub.Event[permission.PermissionRequest]:
211 return a, util.CmdHandler(dialogs.OpenDialogMsg{
212 Model: permissions.NewPermissionDialogCmp(msg.Payload),
213 })
214 case permissions.PermissionResponseMsg:
215 switch msg.Action {
216 case permissions.PermissionAllow:
217 a.app.Permissions.Grant(msg.Permission)
218 case permissions.PermissionAllowForSession:
219 a.app.Permissions.GrantPersistent(msg.Permission)
220 case permissions.PermissionDeny:
221 a.app.Permissions.Deny(msg.Permission)
222 }
223 return a, nil
224 // Agent Events
225 case pubsub.Event[agent.AgentEvent]:
226 payload := msg.Payload
227
228 // Forward agent events to dialogs
229 if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() == compact.CompactDialogID {
230 u, dialogCmd := a.dialog.Update(payload)
231 a.dialog = u.(dialogs.DialogCmp)
232 cmds = append(cmds, dialogCmd)
233 }
234
235 // Handle auto-compact logic
236 if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSessionID != "" {
237 // Get current session to check token usage
238 session, err := a.app.Sessions.Get(context.Background(), a.selectedSessionID)
239 if err == nil {
240 model := a.app.CoderAgent.Model()
241 contextWindow := model.ContextWindow
242 tokens := session.CompletionTokens + session.PromptTokens
243 if (tokens >= int64(float64(contextWindow)*0.95)) && !config.Get().Options.DisableAutoSummarize { // Show compact confirmation dialog
244 cmds = append(cmds, util.CmdHandler(dialogs.OpenDialogMsg{
245 Model: compact.NewCompactDialogCmp(a.app.CoderAgent, a.selectedSessionID, false),
246 }))
247 }
248 }
249 }
250
251 return a, tea.Batch(cmds...)
252 // Key Press Messages
253 case tea.KeyPressMsg:
254 return a, a.handleKeyPressMsg(msg)
255 }
256 s, _ := a.status.Update(msg)
257 a.status = s.(status.StatusCmp)
258 updated, cmd := a.pages[a.currentPage].Update(msg)
259 a.pages[a.currentPage] = updated.(util.Model)
260 if a.dialog.HasDialogs() {
261 u, dialogCmd := a.dialog.Update(msg)
262 a.dialog = u.(dialogs.DialogCmp)
263 cmds = append(cmds, dialogCmd)
264 }
265 cmds = append(cmds, cmd)
266 return a, tea.Batch(cmds...)
267}
268
269// handleWindowResize processes window resize events and updates all components.
270func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
271 var cmds []tea.Cmd
272 a.wWidth, a.wHeight = width, height
273 if a.showingFullHelp {
274 height -= 5
275 } else {
276 height -= 2
277 }
278 a.width, a.height = width, height
279 // Update status bar
280 s, cmd := a.status.Update(tea.WindowSizeMsg{Width: width, Height: height})
281 a.status = s.(status.StatusCmp)
282 cmds = append(cmds, cmd)
283
284 // Update the current page
285 for p, page := range a.pages {
286 updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
287 a.pages[p] = updated.(util.Model)
288 cmds = append(cmds, pageCmd)
289 }
290
291 // Update the dialogs
292 dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height})
293 a.dialog = dialog.(dialogs.DialogCmp)
294 cmds = append(cmds, cmd)
295
296 return tea.Batch(cmds...)
297}
298
299// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
300func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
301 switch {
302 // completions
303 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Up):
304 u, cmd := a.completions.Update(msg)
305 a.completions = u.(completions.Completions)
306 return cmd
307
308 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Down):
309 u, cmd := a.completions.Update(msg)
310 a.completions = u.(completions.Completions)
311 return cmd
312 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Select):
313 u, cmd := a.completions.Update(msg)
314 a.completions = u.(completions.Completions)
315 return cmd
316 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Cancel):
317 u, cmd := a.completions.Update(msg)
318 a.completions = u.(completions.Completions)
319 return cmd
320 // help
321 case key.Matches(msg, a.keyMap.Help):
322 a.status.ToggleFullHelp()
323 a.showingFullHelp = !a.showingFullHelp
324 return a.handleWindowResize(a.wWidth, a.wHeight)
325 // dialogs
326 case key.Matches(msg, a.keyMap.Quit):
327 if a.dialog.ActiveDialogID() == quit.QuitDialogID {
328 return tea.Quit
329 }
330 return util.CmdHandler(dialogs.OpenDialogMsg{
331 Model: quit.NewQuitDialog(),
332 })
333
334 case key.Matches(msg, a.keyMap.Commands):
335 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
336 return util.CmdHandler(dialogs.CloseDialogMsg{})
337 }
338 if a.dialog.HasDialogs() {
339 return nil
340 }
341 return util.CmdHandler(dialogs.OpenDialogMsg{
342 Model: commands.NewCommandDialog(a.selectedSessionID),
343 })
344 case key.Matches(msg, a.keyMap.Sessions):
345 if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
346 return util.CmdHandler(dialogs.CloseDialogMsg{})
347 }
348 if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() != commands.CommandsDialogID {
349 return nil
350 }
351 var cmds []tea.Cmd
352 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
353 // If the commands dialog is open, close it first
354 cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{}))
355 }
356 cmds = append(cmds,
357 func() tea.Msg {
358 allSessions, _ := a.app.Sessions.List(context.Background())
359 return dialogs.OpenDialogMsg{
360 Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
361 }
362 },
363 )
364 return tea.Sequence(cmds...)
365 default:
366 if a.dialog.HasDialogs() {
367 u, dialogCmd := a.dialog.Update(msg)
368 a.dialog = u.(dialogs.DialogCmp)
369 return dialogCmd
370 } else {
371 updated, cmd := a.pages[a.currentPage].Update(msg)
372 a.pages[a.currentPage] = updated.(util.Model)
373 return cmd
374 }
375 }
376}
377
378// moveToPage handles navigation between different pages in the application.
379func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
380 if a.app.CoderAgent.IsBusy() {
381 // TODO: maybe remove this : For now we don't move to any page if the agent is busy
382 return util.ReportWarn("Agent is busy, please wait...")
383 }
384
385 var cmds []tea.Cmd
386 if _, ok := a.loadedPages[pageID]; !ok {
387 cmd := a.pages[pageID].Init()
388 cmds = append(cmds, cmd)
389 a.loadedPages[pageID] = true
390 }
391 a.previousPage = a.currentPage
392 a.currentPage = pageID
393 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
394 cmd := sizable.SetSize(a.width, a.height)
395 cmds = append(cmds, cmd)
396 }
397
398 return tea.Batch(cmds...)
399}
400
401// View renders the complete application interface including pages, dialogs, and overlays.
402func (a *appModel) View() tea.View {
403 page := a.pages[a.currentPage]
404 if withHelp, ok := page.(core.KeyMapHelp); ok {
405 a.status.SetKeyMap(withHelp.Help())
406 }
407 pageView := page.View()
408 components := []string{
409 pageView,
410 }
411 components = append(components, a.status.View())
412
413 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
414 layers := []*lipgloss.Layer{
415 lipgloss.NewLayer(appView),
416 }
417 if a.dialog.HasDialogs() {
418 layers = append(
419 layers,
420 a.dialog.GetLayers()...,
421 )
422 }
423
424 var cursor *tea.Cursor
425 if v, ok := page.(util.Cursor); ok {
426 cursor = v.Cursor()
427 }
428 activeView := a.dialog.ActiveModel()
429 if activeView != nil {
430 cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor
431 if v, ok := activeView.(util.Cursor); ok {
432 cursor = v.Cursor()
433 }
434 }
435
436 if a.completions.Open() && cursor != nil {
437 cmp := a.completions.View()
438 x, y := a.completions.Position()
439 layers = append(
440 layers,
441 lipgloss.NewLayer(cmp).X(x).Y(y),
442 )
443 }
444
445 canvas := lipgloss.NewCanvas(
446 layers...,
447 )
448
449 var view tea.View
450 t := styles.CurrentTheme()
451 view.Layer = canvas
452 view.BackgroundColor = t.BgBase
453 view.Cursor = cursor
454 return view
455}
456
457// New creates and initializes a new TUI application model.
458func New(app *app.App) tea.Model {
459 chatPage := chat.New(app)
460 keyMap := DefaultKeyMap()
461 keyMap.pageBindings = chatPage.Bindings()
462
463 model := &appModel{
464 currentPage: chat.ChatPageID,
465 app: app,
466 status: status.NewStatusCmp(),
467 loadedPages: make(map[page.PageID]bool),
468 keyMap: keyMap,
469
470 pages: map[page.PageID]util.Model{
471 chat.ChatPageID: chatPage,
472 },
473
474 dialog: dialogs.NewDialogCmp(),
475 completions: completions.New(),
476 }
477
478 return model
479}