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/permission"
15 "github.com/charmbracelet/crush/internal/pubsub"
16 cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
17 "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
18 "github.com/charmbracelet/crush/internal/tui/components/completions"
19 "github.com/charmbracelet/crush/internal/tui/components/core"
20 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
21 "github.com/charmbracelet/crush/internal/tui/components/core/status"
22 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
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"
26 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
27 "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
28 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
29 "github.com/charmbracelet/crush/internal/tui/components/dialogs/sessions"
30 "github.com/charmbracelet/crush/internal/tui/page"
31 "github.com/charmbracelet/crush/internal/tui/page/chat"
32 "github.com/charmbracelet/crush/internal/tui/styles"
33 "github.com/charmbracelet/crush/internal/tui/util"
34 "github.com/charmbracelet/lipgloss/v2"
35)
36
37var lastMouseEvent time.Time
38
39func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
40 switch msg.(type) {
41 case tea.MouseWheelMsg, tea.MouseMotionMsg:
42 now := time.Now()
43 // trackpad is sending too many requests
44 if now.Sub(lastMouseEvent) < 15*time.Millisecond {
45 return nil
46 }
47 lastMouseEvent = now
48 }
49 return msg
50}
51
52// appModel represents the main application model that manages pages, dialogs, and UI state.
53type appModel struct {
54 wWidth, wHeight int // Window dimensions
55 width, height int
56 keyMap KeyMap
57
58 currentPage page.PageID
59 previousPage page.PageID
60 pages map[page.PageID]util.Model
61 loadedPages map[page.PageID]bool
62
63 // Status
64 status status.StatusCmp
65 showingFullHelp bool
66
67 app *app.App
68
69 dialog dialogs.DialogCmp
70 completions completions.Completions
71 isConfigured bool
72
73 // Chat Page Specific
74 selectedSessionID string // The ID of the currently selected session
75}
76
77// Init initializes the application model and returns initial commands.
78func (a appModel) Init() tea.Cmd {
79 item, ok := a.pages[a.currentPage]
80 if !ok {
81 return nil
82 }
83
84 var cmds []tea.Cmd
85 cmd := item.Init()
86 cmds = append(cmds, cmd)
87 a.loadedPages[a.currentPage] = true
88
89 cmd = a.status.Init()
90 cmds = append(cmds, cmd)
91
92 cmds = append(cmds, tea.EnableMouseAllMotion)
93
94 return tea.Batch(cmds...)
95}
96
97// Update handles incoming messages and updates the application state.
98func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
99 var cmds []tea.Cmd
100 var cmd tea.Cmd
101 a.isConfigured = config.HasInitialDataConfig()
102
103 switch msg := msg.(type) {
104 case tea.KeyboardEnhancementsMsg:
105 for id, page := range a.pages {
106 m, pageCmd := page.Update(msg)
107 if model, ok := m.(util.Model); ok {
108 a.pages[id] = model
109 }
110
111 if pageCmd != nil {
112 cmds = append(cmds, pageCmd)
113 }
114 }
115 return a, tea.Batch(cmds...)
116 case tea.WindowSizeMsg:
117 a.wWidth, a.wHeight = msg.Width, msg.Height
118 a.completions.Update(msg)
119 return a, a.handleWindowResize(msg.Width, msg.Height)
120
121 // Completions messages
122 case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg,
123 completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg:
124 u, completionCmd := a.completions.Update(msg)
125 if model, ok := u.(completions.Completions); ok {
126 a.completions = model
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.AgentCoordinator, msg.SessionID, true),
183 })
184 case commands.QuitMsg:
185 return a, util.CmdHandler(dialogs.OpenDialogMsg{
186 Model: quit.NewQuitDialog(),
187 })
188 case commands.ToggleYoloModeMsg:
189 a.app.Permissions.SetSkipRequests(!a.app.Permissions.SkipRequests())
190 case commands.ToggleHelpMsg:
191 a.status.ToggleFullHelp()
192 a.showingFullHelp = !a.showingFullHelp
193 return a, a.handleWindowResize(a.wWidth, a.wHeight)
194 // Model Switch
195 case models.ModelSelectedMsg:
196 if a.app.AgentCoordinator.IsBusy() {
197 return a, util.ReportWarn("Agent is busy, please wait...")
198 }
199
200 config.Get().UpdatePreferredModel(msg.ModelType, msg.Model)
201
202 // Update the agent with the new model/provider configuration
203 if err := a.app.UpdateAgentModel(); err != nil {
204 return a, util.ReportError(fmt.Errorf("model changed to %s but failed to update agent: %v", msg.Model.Model, err))
205 }
206
207 modelTypeName := "large"
208 if msg.ModelType == config.SelectedModelTypeSmall {
209 modelTypeName = "small"
210 }
211 return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.Model))
212
213 // File Picker
214 case commands.OpenFilePickerMsg:
215 event.FilePickerOpened()
216
217 if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
218 // If the commands dialog is already open, close it
219 return a, util.CmdHandler(dialogs.CloseDialogMsg{})
220 }
221 return a, util.CmdHandler(dialogs.OpenDialogMsg{
222 Model: filepicker.NewFilePickerCmp(a.app.Config().WorkingDir()),
223 })
224 // Permissions
225 case pubsub.Event[permission.PermissionNotification]:
226 item, ok := a.pages[a.currentPage]
227 if !ok {
228 return a, nil
229 }
230
231 // Forward to view.
232 updated, itemCmd := item.Update(msg)
233 if model, ok := updated.(util.Model); ok {
234 a.pages[a.currentPage] = model
235 }
236
237 return a, itemCmd
238 case pubsub.Event[permission.PermissionRequest]:
239 return a, util.CmdHandler(dialogs.OpenDialogMsg{
240 Model: permissions.NewPermissionDialogCmp(msg.Payload, &permissions.Options{
241 DiffMode: config.Get().Options.TUI.DiffMode,
242 }),
243 })
244 case permissions.PermissionResponseMsg:
245 switch msg.Action {
246 case permissions.PermissionAllow:
247 a.app.Permissions.Grant(msg.Permission)
248 case permissions.PermissionAllowForSession:
249 a.app.Permissions.GrantPersistent(msg.Permission)
250 case permissions.PermissionDeny:
251 a.app.Permissions.Deny(msg.Permission)
252 }
253 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:
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.AgentCoordinator != nil && a.app.AgentCoordinator.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.AgentCoordinator.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}