1package tui
2
3import (
4 "context"
5 "fmt"
6 "strings"
7 "time"
8
9 "github.com/charmbracelet/bubbles/v2/key"
10 tea "github.com/charmbracelet/bubbletea/v2"
11 "github.com/charmbracelet/crush/internal/app"
12 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/event"
14 "github.com/charmbracelet/crush/internal/llm/agent"
15 "github.com/charmbracelet/crush/internal/permission"
16 "github.com/charmbracelet/crush/internal/pubsub"
17 cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
18 "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
19 "github.com/charmbracelet/crush/internal/tui/components/completions"
20 "github.com/charmbracelet/crush/internal/tui/components/core"
21 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
22 "github.com/charmbracelet/crush/internal/tui/components/core/status"
23 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
24 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
25 "github.com/charmbracelet/crush/internal/tui/components/dialogs/compact"
26 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
27 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
28 "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
29 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
30 "github.com/charmbracelet/crush/internal/tui/components/dialogs/sessions"
31 "github.com/charmbracelet/crush/internal/tui/page"
32 "github.com/charmbracelet/crush/internal/tui/page/chat"
33 "github.com/charmbracelet/crush/internal/tui/styles"
34 "github.com/charmbracelet/crush/internal/tui/util"
35 "github.com/charmbracelet/lipgloss/v2"
36)
37
38var lastMouseEvent time.Time
39
40func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
41 switch msg.(type) {
42 case tea.MouseWheelMsg, tea.MouseMotionMsg:
43 now := time.Now()
44 // trackpad is sending too many requests
45 if now.Sub(lastMouseEvent) < 15*time.Millisecond {
46 return nil
47 }
48 lastMouseEvent = now
49 }
50 return msg
51}
52
53// appModel represents the main application model that manages pages, dialogs, and UI state.
54type appModel struct {
55 wWidth, wHeight int // Window dimensions
56 width, height int
57 keyMap KeyMap
58
59 currentPage page.PageID
60 previousPage page.PageID
61 pages map[page.PageID]util.Model
62 loadedPages map[page.PageID]bool
63
64 // Status
65 status status.StatusCmp
66 showingFullHelp bool
67
68 app *app.App
69
70 dialog dialogs.DialogCmp
71 completions completions.Completions
72 isConfigured bool
73
74 // Chat Page Specific
75 selectedSessionID string // The ID of the currently selected session
76}
77
78// Init initializes the application model and returns initial commands.
79func (a appModel) Init() tea.Cmd {
80 item, ok := a.pages[a.currentPage]
81 if !ok {
82 return nil
83 }
84
85 var cmds []tea.Cmd
86 cmd := item.Init()
87 cmds = append(cmds, cmd)
88 a.loadedPages[a.currentPage] = true
89
90 cmd = a.status.Init()
91 cmds = append(cmds, cmd)
92
93 cmds = append(cmds, tea.EnableMouseAllMotion)
94
95 return tea.Batch(cmds...)
96}
97
98// Update handles incoming messages and updates the application state.
99func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
100 var cmds []tea.Cmd
101 var cmd tea.Cmd
102 a.isConfigured = config.HasInitialDataConfig()
103
104 switch msg := msg.(type) {
105 case tea.KeyboardEnhancementsMsg:
106 for id, page := range a.pages {
107 m, pageCmd := page.Update(msg)
108 if model, ok := m.(util.Model); ok {
109 a.pages[id] = model
110 }
111
112 if pageCmd != nil {
113 cmds = append(cmds, pageCmd)
114 }
115 }
116 return a, tea.Batch(cmds...)
117 case tea.WindowSizeMsg:
118 a.wWidth, a.wHeight = msg.Width, msg.Height
119 a.completions.Update(msg)
120 return a, a.handleWindowResize(msg.Width, msg.Height)
121
122 // Completions messages
123 case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg,
124 completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg:
125 u, completionCmd := a.completions.Update(msg)
126 if model, ok := u.(completions.Completions); ok {
127 a.completions = model
128 }
129
130 return a, completionCmd
131
132 // Dialog messages
133 case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
134 u, completionCmd := a.completions.Update(completions.CloseCompletionsMsg{})
135 a.completions = u.(completions.Completions)
136 u, dialogCmd := a.dialog.Update(msg)
137 a.dialog = u.(dialogs.DialogCmp)
138 return a, tea.Batch(completionCmd, dialogCmd)
139 case commands.ShowArgumentsDialogMsg:
140 return a, util.CmdHandler(
141 dialogs.OpenDialogMsg{
142 Model: commands.NewCommandArgumentsDialog(
143 msg.CommandID,
144 msg.Content,
145 msg.ArgNames,
146 ),
147 },
148 )
149 // Page change messages
150 case page.PageChangeMsg:
151 return a, a.moveToPage(msg.ID)
152
153 // Status Messages
154 case util.InfoMsg, util.ClearStatusMsg:
155 s, statusCmd := a.status.Update(msg)
156 a.status = s.(status.StatusCmp)
157 cmds = append(cmds, statusCmd)
158 return a, tea.Batch(cmds...)
159
160 // Session
161 case cmpChat.SessionSelectedMsg:
162 a.selectedSessionID = msg.ID
163 case cmpChat.SessionClearedMsg:
164 a.selectedSessionID = ""
165 // Commands
166 case commands.SwitchSessionsMsg:
167 return a, func() tea.Msg {
168 allSessions, _ := a.app.Sessions.List(context.Background())
169 return dialogs.OpenDialogMsg{
170 Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
171 }
172 }
173
174 case commands.SwitchModelMsg:
175 return a, util.CmdHandler(
176 dialogs.OpenDialogMsg{
177 Model: models.NewModelDialogCmp(),
178 },
179 )
180 // Compact
181 case commands.CompactMsg:
182 return a, util.CmdHandler(dialogs.OpenDialogMsg{
183 Model: compact.NewCompactDialogCmp(a.app.CoderAgent, msg.SessionID, true),
184 })
185 case commands.QuitMsg:
186 return a, util.CmdHandler(dialogs.OpenDialogMsg{
187 Model: quit.NewQuitDialog(),
188 })
189 case commands.ToggleYoloModeMsg:
190 a.app.Permissions.SetSkipRequests(!a.app.Permissions.SkipRequests())
191 case commands.ToggleHelpMsg:
192 a.status.ToggleFullHelp()
193 a.showingFullHelp = !a.showingFullHelp
194 return a, a.handleWindowResize(a.wWidth, a.wHeight)
195 // Model Switch
196 case models.ModelSelectedMsg:
197 if a.app.CoderAgent.IsBusy() {
198 return a, util.ReportWarn("Agent is busy, please wait...")
199 }
200
201 config.Get().UpdatePreferredModel(msg.ModelType, msg.Model)
202
203 // Update the agent with the new model/provider configuration
204 if err := a.app.UpdateAgentModel(); err != nil {
205 return a, util.ReportError(fmt.Errorf("model changed to %s but failed to update agent: %v", msg.Model.Model, err))
206 }
207
208 modelTypeName := "large"
209 if msg.ModelType == config.SelectedModelTypeSmall {
210 modelTypeName = "small"
211 }
212 return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.Model))
213
214 // File Picker
215 case commands.OpenFilePickerMsg:
216 event.FilePickerOpened()
217
218 if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
219 // If the commands dialog is already open, close it
220 return a, util.CmdHandler(dialogs.CloseDialogMsg{})
221 }
222 return a, util.CmdHandler(dialogs.OpenDialogMsg{
223 Model: filepicker.NewFilePickerCmp(a.app.Config().WorkingDir()),
224 })
225 // Permissions
226 case pubsub.Event[permission.PermissionNotification]:
227 item, ok := a.pages[a.currentPage]
228 if !ok {
229 return a, nil
230 }
231
232 // Forward to view.
233 updated, itemCmd := item.Update(msg)
234 if model, ok := updated.(util.Model); ok {
235 a.pages[a.currentPage] = model
236 }
237
238 return a, itemCmd
239 case pubsub.Event[permission.PermissionRequest]:
240 return a, util.CmdHandler(dialogs.OpenDialogMsg{
241 Model: permissions.NewPermissionDialogCmp(msg.Payload, &permissions.Options{
242 DiffMode: config.Get().Options.TUI.DiffMode,
243 }),
244 })
245 case permissions.PermissionResponseMsg:
246 switch msg.Action {
247 case permissions.PermissionAllow:
248 a.app.Permissions.Grant(msg.Permission)
249 case permissions.PermissionAllowForSession:
250 a.app.Permissions.GrantPersistent(msg.Permission)
251 case permissions.PermissionDeny:
252 a.app.Permissions.Deny(msg.Permission)
253 }
254 return a, nil
255 // Agent Events
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.CoderAgent.Model()
275 contextWindow := model.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.CoderAgent, a.selectedSessionID, false),
280 }))
281 }
282 }
283 }
284
285 return a, tea.Batch(cmds...)
286 case splash.OnboardingCompleteMsg:
287 item, ok := a.pages[a.currentPage]
288 if !ok {
289 return a, nil
290 }
291
292 a.isConfigured = config.HasInitialDataConfig()
293 updated, pageCmd := item.Update(msg)
294 if model, ok := updated.(util.Model); ok {
295 a.pages[a.currentPage] = model
296 }
297
298 cmds = append(cmds, pageCmd)
299 return a, tea.Batch(cmds...)
300
301 case tea.KeyPressMsg:
302 return a, a.handleKeyPressMsg(msg)
303
304 case tea.MouseWheelMsg:
305 if a.dialog.HasDialogs() {
306 u, dialogCmd := a.dialog.Update(msg)
307 a.dialog = u.(dialogs.DialogCmp)
308 cmds = append(cmds, dialogCmd)
309 } else {
310 item, ok := a.pages[a.currentPage]
311 if !ok {
312 return a, nil
313 }
314
315 updated, pageCmd := item.Update(msg)
316 if model, ok := updated.(util.Model); ok {
317 a.pages[a.currentPage] = model
318 }
319
320 cmds = append(cmds, pageCmd)
321 }
322 return a, tea.Batch(cmds...)
323 case tea.PasteMsg:
324 if a.dialog.HasDialogs() {
325 u, dialogCmd := a.dialog.Update(msg)
326 if model, ok := u.(dialogs.DialogCmp); ok {
327 a.dialog = model
328 }
329
330 cmds = append(cmds, dialogCmd)
331 } else {
332 item, ok := a.pages[a.currentPage]
333 if !ok {
334 return a, nil
335 }
336
337 updated, pageCmd := item.Update(msg)
338 if model, ok := updated.(util.Model); ok {
339 a.pages[a.currentPage] = model
340 }
341
342 cmds = append(cmds, pageCmd)
343 }
344 return a, tea.Batch(cmds...)
345 }
346 s, _ := a.status.Update(msg)
347 a.status = s.(status.StatusCmp)
348
349 item, ok := a.pages[a.currentPage]
350 if !ok {
351 return a, nil
352 }
353
354 updated, cmd := item.Update(msg)
355 if model, ok := updated.(util.Model); ok {
356 a.pages[a.currentPage] = model
357 }
358
359 if a.dialog.HasDialogs() {
360 u, dialogCmd := a.dialog.Update(msg)
361 if model, ok := u.(dialogs.DialogCmp); ok {
362 a.dialog = model
363 }
364
365 cmds = append(cmds, dialogCmd)
366 }
367 cmds = append(cmds, cmd)
368 return a, tea.Batch(cmds...)
369}
370
371// handleWindowResize processes window resize events and updates all components.
372func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
373 var cmds []tea.Cmd
374
375 // TODO: clean up these magic numbers.
376 if a.showingFullHelp {
377 height -= 5
378 } else {
379 height -= 2
380 }
381
382 a.width, a.height = width, height
383 // Update status bar
384 s, cmd := a.status.Update(tea.WindowSizeMsg{Width: width, Height: height})
385 if model, ok := s.(status.StatusCmp); ok {
386 a.status = model
387 }
388 cmds = append(cmds, cmd)
389
390 // Update the current view.
391 for p, page := range a.pages {
392 updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
393 if model, ok := updated.(util.Model); ok {
394 a.pages[p] = model
395 }
396
397 cmds = append(cmds, pageCmd)
398 }
399
400 // Update the dialogs
401 dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height})
402 if model, ok := dialog.(dialogs.DialogCmp); ok {
403 a.dialog = model
404 }
405
406 cmds = append(cmds, cmd)
407
408 return tea.Batch(cmds...)
409}
410
411// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
412func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
413 // Check this first as the user should be able to quit no matter what.
414 if key.Matches(msg, a.keyMap.Quit) {
415 if a.dialog.ActiveDialogID() == quit.QuitDialogID {
416 return tea.Quit
417 }
418 return util.CmdHandler(dialogs.OpenDialogMsg{
419 Model: quit.NewQuitDialog(),
420 })
421 }
422
423 if a.completions.Open() {
424 // completions
425 keyMap := a.completions.KeyMap()
426 switch {
427 case key.Matches(msg, keyMap.Up), key.Matches(msg, keyMap.Down),
428 key.Matches(msg, keyMap.Select), key.Matches(msg, keyMap.Cancel),
429 key.Matches(msg, keyMap.UpInsert), key.Matches(msg, keyMap.DownInsert):
430 u, cmd := a.completions.Update(msg)
431 a.completions = u.(completions.Completions)
432 return cmd
433 }
434 }
435 if a.dialog.HasDialogs() {
436 u, dialogCmd := a.dialog.Update(msg)
437 a.dialog = u.(dialogs.DialogCmp)
438 return dialogCmd
439 }
440 switch {
441 // help
442 case key.Matches(msg, a.keyMap.Help):
443 a.status.ToggleFullHelp()
444 a.showingFullHelp = !a.showingFullHelp
445 return a.handleWindowResize(a.wWidth, a.wHeight)
446 // dialogs
447 case key.Matches(msg, a.keyMap.Commands):
448 // if the app is not configured show no commands
449 if !a.isConfigured {
450 return nil
451 }
452 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
453 return util.CmdHandler(dialogs.CloseDialogMsg{})
454 }
455 if a.dialog.HasDialogs() {
456 return nil
457 }
458 return util.CmdHandler(dialogs.OpenDialogMsg{
459 Model: commands.NewCommandDialog(a.selectedSessionID),
460 })
461 case key.Matches(msg, a.keyMap.Sessions):
462 // if the app is not configured show no sessions
463 if !a.isConfigured {
464 return nil
465 }
466 if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
467 return util.CmdHandler(dialogs.CloseDialogMsg{})
468 }
469 if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() != commands.CommandsDialogID {
470 return nil
471 }
472 var cmds []tea.Cmd
473 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
474 // If the commands dialog is open, close it first
475 cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{}))
476 }
477 cmds = append(cmds,
478 func() tea.Msg {
479 allSessions, _ := a.app.Sessions.List(context.Background())
480 return dialogs.OpenDialogMsg{
481 Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
482 }
483 },
484 )
485 return tea.Sequence(cmds...)
486 case key.Matches(msg, a.keyMap.Suspend):
487 if a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() {
488 return util.ReportWarn("Agent is busy, please wait...")
489 }
490 return tea.Suspend
491 default:
492 item, ok := a.pages[a.currentPage]
493 if !ok {
494 return nil
495 }
496
497 updated, cmd := item.Update(msg)
498 if model, ok := updated.(util.Model); ok {
499 a.pages[a.currentPage] = model
500 }
501 return cmd
502 }
503}
504
505// moveToPage handles navigation between different pages in the application.
506func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
507 if a.app.CoderAgent.IsBusy() {
508 // TODO: maybe remove this : For now we don't move to any page if the agent is busy
509 return util.ReportWarn("Agent is busy, please wait...")
510 }
511
512 var cmds []tea.Cmd
513 if _, ok := a.loadedPages[pageID]; !ok {
514 cmd := a.pages[pageID].Init()
515 cmds = append(cmds, cmd)
516 a.loadedPages[pageID] = true
517 }
518 a.previousPage = a.currentPage
519 a.currentPage = pageID
520 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
521 cmd := sizable.SetSize(a.width, a.height)
522 cmds = append(cmds, cmd)
523 }
524
525 return tea.Batch(cmds...)
526}
527
528// View renders the complete application interface including pages, dialogs, and overlays.
529func (a *appModel) View() tea.View {
530 var view tea.View
531 t := styles.CurrentTheme()
532 view.BackgroundColor = t.BgBase
533 if a.wWidth < 25 || a.wHeight < 15 {
534 view.Layer = lipgloss.NewCanvas(
535 lipgloss.NewLayer(
536 t.S().Base.Width(a.wWidth).Height(a.wHeight).
537 Align(lipgloss.Center, lipgloss.Center).
538 Render(
539 t.S().Base.
540 Padding(1, 4).
541 Foreground(t.White).
542 BorderStyle(lipgloss.RoundedBorder()).
543 BorderForeground(t.Primary).
544 Render("Window too small!"),
545 ),
546 ),
547 )
548 return view
549 }
550
551 page := a.pages[a.currentPage]
552 if withHelp, ok := page.(core.KeyMapHelp); ok {
553 a.status.SetKeyMap(withHelp.Help())
554 }
555 pageView := page.View()
556 components := []string{
557 pageView,
558 }
559 components = append(components, a.status.View())
560
561 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
562 layers := []*lipgloss.Layer{
563 lipgloss.NewLayer(appView),
564 }
565 if a.dialog.HasDialogs() {
566 layers = append(
567 layers,
568 a.dialog.GetLayers()...,
569 )
570 }
571
572 var cursor *tea.Cursor
573 if v, ok := page.(util.Cursor); ok {
574 cursor = v.Cursor()
575 // Hide the cursor if it's positioned outside the textarea
576 statusHeight := a.height - strings.Count(pageView, "\n") + 1
577 if cursor != nil && cursor.Y+statusHeight+chat.EditorHeight-2 <= a.height { // 2 for the top and bottom app padding
578 cursor = nil
579 }
580 }
581 activeView := a.dialog.ActiveModel()
582 if activeView != nil {
583 cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor
584 if v, ok := activeView.(util.Cursor); ok {
585 cursor = v.Cursor()
586 }
587 }
588
589 if a.completions.Open() && cursor != nil {
590 cmp := a.completions.View()
591 x, y := a.completions.Position()
592 layers = append(
593 layers,
594 lipgloss.NewLayer(cmp).X(x).Y(y),
595 )
596 }
597
598 canvas := lipgloss.NewCanvas(
599 layers...,
600 )
601
602 view.Layer = canvas
603 view.Cursor = cursor
604 return view
605}
606
607// New creates and initializes a new TUI application model.
608func New(app *app.App) tea.Model {
609 chatPage := chat.New(app)
610 keyMap := DefaultKeyMap()
611 keyMap.pageBindings = chatPage.Bindings()
612
613 model := &appModel{
614 currentPage: chat.ChatPageID,
615 app: app,
616 status: status.NewStatusCmp(),
617 loadedPages: make(map[page.PageID]bool),
618 keyMap: keyMap,
619
620 pages: map[page.PageID]util.Model{
621 chat.ChatPageID: chatPage,
622 },
623
624 dialog: dialogs.NewDialogCmp(),
625 completions: completions.New(),
626 }
627
628 return model
629}