1package tui
2
3import (
4 "context"
5 "fmt"
6 "math/rand"
7 "strings"
8 "time"
9
10 "github.com/charmbracelet/bubbles/v2/key"
11 tea "github.com/charmbracelet/bubbletea/v2"
12 "github.com/charmbracelet/crush/internal/app"
13 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/event"
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/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 return tea.Batch(cmds...)
93}
94
95// Update handles incoming messages and updates the application state.
96func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
97 var cmds []tea.Cmd
98 var cmd tea.Cmd
99 a.isConfigured = config.HasInitialDataConfig()
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
106
107 if pageCmd != nil {
108 cmds = append(cmds, pageCmd)
109 }
110 }
111 return a, tea.Batch(cmds...)
112 case tea.WindowSizeMsg:
113 a.wWidth, a.wHeight = msg.Width, msg.Height
114 a.completions.Update(msg)
115 return a, a.handleWindowResize(msg.Width, msg.Height)
116
117 // Completions messages
118 case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg,
119 completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg:
120 u, completionCmd := a.completions.Update(msg)
121 if model, ok := u.(completions.Completions); ok {
122 a.completions = model
123 }
124
125 return a, completionCmd
126
127 // Dialog messages
128 case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
129 u, completionCmd := a.completions.Update(completions.CloseCompletionsMsg{})
130 a.completions = u.(completions.Completions)
131 u, dialogCmd := a.dialog.Update(msg)
132 a.dialog = u.(dialogs.DialogCmp)
133 return a, tea.Batch(completionCmd, dialogCmd)
134 case commands.ShowArgumentsDialogMsg:
135 return a, util.CmdHandler(
136 dialogs.OpenDialogMsg{
137 Model: commands.NewCommandArgumentsDialog(
138 msg.CommandID,
139 msg.Content,
140 msg.ArgNames,
141 ),
142 },
143 )
144 // Page change messages
145 case page.PageChangeMsg:
146 return a, a.moveToPage(msg.ID)
147
148 // Status Messages
149 case util.InfoMsg, util.ClearStatusMsg:
150 s, statusCmd := a.status.Update(msg)
151 a.status = s.(status.StatusCmp)
152 cmds = append(cmds, statusCmd)
153 return a, tea.Batch(cmds...)
154
155 // Session
156 case cmpChat.SessionSelectedMsg:
157 a.selectedSessionID = msg.ID
158 case cmpChat.SessionClearedMsg:
159 a.selectedSessionID = ""
160 // Commands
161 case commands.SwitchSessionsMsg:
162 return a, func() tea.Msg {
163 allSessions, _ := a.app.Sessions.List(context.Background())
164 return dialogs.OpenDialogMsg{
165 Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
166 }
167 }
168
169 case commands.SwitchModelMsg:
170 return a, util.CmdHandler(
171 dialogs.OpenDialogMsg{
172 Model: models.NewModelDialogCmp(),
173 },
174 )
175 // Compact
176 case commands.CompactMsg:
177 return a, func() tea.Msg {
178 err := a.app.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
179 if err != nil {
180 return util.ReportError(err)()
181 }
182 return nil
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(context.TODO()); 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 a.pages[a.currentPage] = updated
234
235 return a, itemCmd
236 case pubsub.Event[permission.PermissionRequest]:
237 return a, util.CmdHandler(dialogs.OpenDialogMsg{
238 Model: permissions.NewPermissionDialogCmp(msg.Payload, &permissions.Options{
239 DiffMode: config.Get().Options.TUI.DiffMode,
240 }),
241 })
242 case permissions.PermissionResponseMsg:
243 switch msg.Action {
244 case permissions.PermissionAllow:
245 a.app.Permissions.Grant(msg.Permission)
246 case permissions.PermissionAllowForSession:
247 a.app.Permissions.GrantPersistent(msg.Permission)
248 case permissions.PermissionDeny:
249 a.app.Permissions.Deny(msg.Permission)
250 }
251 return a, nil
252 case splash.OnboardingCompleteMsg:
253 item, ok := a.pages[a.currentPage]
254 if !ok {
255 return a, nil
256 }
257
258 a.isConfigured = config.HasInitialDataConfig()
259 updated, pageCmd := item.Update(msg)
260 a.pages[a.currentPage] = updated
261
262 cmds = append(cmds, pageCmd)
263 return a, tea.Batch(cmds...)
264
265 case tea.KeyPressMsg:
266 return a, a.handleKeyPressMsg(msg)
267
268 case tea.MouseWheelMsg:
269 if a.dialog.HasDialogs() {
270 u, dialogCmd := a.dialog.Update(msg)
271 a.dialog = u.(dialogs.DialogCmp)
272 cmds = append(cmds, dialogCmd)
273 } else {
274 item, ok := a.pages[a.currentPage]
275 if !ok {
276 return a, nil
277 }
278
279 updated, pageCmd := item.Update(msg)
280 a.pages[a.currentPage] = updated
281
282 cmds = append(cmds, pageCmd)
283 }
284 return a, tea.Batch(cmds...)
285 case tea.PasteMsg:
286 if a.dialog.HasDialogs() {
287 u, dialogCmd := a.dialog.Update(msg)
288 if model, ok := u.(dialogs.DialogCmp); ok {
289 a.dialog = model
290 }
291
292 cmds = append(cmds, dialogCmd)
293 } else {
294 item, ok := a.pages[a.currentPage]
295 if !ok {
296 return a, nil
297 }
298
299 updated, pageCmd := item.Update(msg)
300 a.pages[a.currentPage] = updated
301
302 cmds = append(cmds, pageCmd)
303 }
304 return a, tea.Batch(cmds...)
305 }
306 s, _ := a.status.Update(msg)
307 a.status = s.(status.StatusCmp)
308
309 item, ok := a.pages[a.currentPage]
310 if !ok {
311 return a, nil
312 }
313
314 updated, cmd := item.Update(msg)
315 a.pages[a.currentPage] = updated
316
317 if a.dialog.HasDialogs() {
318 u, dialogCmd := a.dialog.Update(msg)
319 if model, ok := u.(dialogs.DialogCmp); ok {
320 a.dialog = model
321 }
322
323 cmds = append(cmds, dialogCmd)
324 }
325 cmds = append(cmds, cmd)
326 return a, tea.Batch(cmds...)
327}
328
329// handleWindowResize processes window resize events and updates all components.
330func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
331 var cmds []tea.Cmd
332
333 // TODO: clean up these magic numbers.
334 if a.showingFullHelp {
335 height -= 5
336 } else {
337 height -= 2
338 }
339
340 a.width, a.height = width, height
341 // Update status bar
342 s, cmd := a.status.Update(tea.WindowSizeMsg{Width: width, Height: height})
343 if model, ok := s.(status.StatusCmp); ok {
344 a.status = model
345 }
346 cmds = append(cmds, cmd)
347
348 // Update the current view.
349 for p, page := range a.pages {
350 updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
351 a.pages[p] = updated
352
353 cmds = append(cmds, pageCmd)
354 }
355
356 // Update the dialogs
357 dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height})
358 if model, ok := dialog.(dialogs.DialogCmp); ok {
359 a.dialog = model
360 }
361
362 cmds = append(cmds, cmd)
363
364 return tea.Batch(cmds...)
365}
366
367// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
368func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
369 // Check this first as the user should be able to quit no matter what.
370 if key.Matches(msg, a.keyMap.Quit) {
371 if a.dialog.ActiveDialogID() == quit.QuitDialogID {
372 return tea.Quit
373 }
374 return util.CmdHandler(dialogs.OpenDialogMsg{
375 Model: quit.NewQuitDialog(),
376 })
377 }
378
379 if a.completions.Open() {
380 // completions
381 keyMap := a.completions.KeyMap()
382 switch {
383 case key.Matches(msg, keyMap.Up), key.Matches(msg, keyMap.Down),
384 key.Matches(msg, keyMap.Select), key.Matches(msg, keyMap.Cancel),
385 key.Matches(msg, keyMap.UpInsert), key.Matches(msg, keyMap.DownInsert):
386 u, cmd := a.completions.Update(msg)
387 a.completions = u.(completions.Completions)
388 return cmd
389 }
390 }
391 if a.dialog.HasDialogs() {
392 u, dialogCmd := a.dialog.Update(msg)
393 a.dialog = u.(dialogs.DialogCmp)
394 return dialogCmd
395 }
396 switch {
397 // help
398 case key.Matches(msg, a.keyMap.Help):
399 a.status.ToggleFullHelp()
400 a.showingFullHelp = !a.showingFullHelp
401 return a.handleWindowResize(a.wWidth, a.wHeight)
402 // dialogs
403 case key.Matches(msg, a.keyMap.Commands):
404 // if the app is not configured show no commands
405 if !a.isConfigured {
406 return nil
407 }
408 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
409 return util.CmdHandler(dialogs.CloseDialogMsg{})
410 }
411 if a.dialog.HasDialogs() {
412 return nil
413 }
414 return util.CmdHandler(dialogs.OpenDialogMsg{
415 Model: commands.NewCommandDialog(a.selectedSessionID),
416 })
417 case key.Matches(msg, a.keyMap.Sessions):
418 // if the app is not configured show no sessions
419 if !a.isConfigured {
420 return nil
421 }
422 if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
423 return util.CmdHandler(dialogs.CloseDialogMsg{})
424 }
425 if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() != commands.CommandsDialogID {
426 return nil
427 }
428 var cmds []tea.Cmd
429 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
430 // If the commands dialog is open, close it first
431 cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{}))
432 }
433 cmds = append(cmds,
434 func() tea.Msg {
435 allSessions, _ := a.app.Sessions.List(context.Background())
436 return dialogs.OpenDialogMsg{
437 Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
438 }
439 },
440 )
441 return tea.Sequence(cmds...)
442 case key.Matches(msg, a.keyMap.Suspend):
443 if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
444 return util.ReportWarn("Agent is busy, please wait...")
445 }
446 return tea.Suspend
447 default:
448 item, ok := a.pages[a.currentPage]
449 if !ok {
450 return nil
451 }
452
453 updated, cmd := item.Update(msg)
454 a.pages[a.currentPage] = updated
455 return cmd
456 }
457}
458
459// moveToPage handles navigation between different pages in the application.
460func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
461 if a.app.AgentCoordinator.IsBusy() {
462 // TODO: maybe remove this : For now we don't move to any page if the agent is busy
463 return util.ReportWarn("Agent is busy, please wait...")
464 }
465
466 var cmds []tea.Cmd
467 if _, ok := a.loadedPages[pageID]; !ok {
468 cmd := a.pages[pageID].Init()
469 cmds = append(cmds, cmd)
470 a.loadedPages[pageID] = true
471 }
472 a.previousPage = a.currentPage
473 a.currentPage = pageID
474 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
475 cmd := sizable.SetSize(a.width, a.height)
476 cmds = append(cmds, cmd)
477 }
478
479 return tea.Batch(cmds...)
480}
481
482// View renders the complete application interface including pages, dialogs, and overlays.
483func (a *appModel) View() tea.View {
484 var view tea.View
485 t := styles.CurrentTheme()
486 view.BackgroundColor = t.BgBase
487 if a.wWidth < 25 || a.wHeight < 15 {
488 view.Layer = lipgloss.NewCanvas(
489 lipgloss.NewLayer(
490 t.S().Base.Width(a.wWidth).Height(a.wHeight).
491 Align(lipgloss.Center, lipgloss.Center).
492 Render(
493 t.S().Base.
494 Padding(1, 4).
495 Foreground(t.White).
496 BorderStyle(lipgloss.RoundedBorder()).
497 BorderForeground(t.Primary).
498 Render("Window too small!"),
499 ),
500 ),
501 )
502 return view
503 }
504
505 page := a.pages[a.currentPage]
506 if withHelp, ok := page.(core.KeyMapHelp); ok {
507 a.status.SetKeyMap(withHelp.Help())
508 }
509 pageView := page.View()
510 components := []string{
511 pageView,
512 }
513 components = append(components, a.status.View())
514
515 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
516 layers := []*lipgloss.Layer{
517 lipgloss.NewLayer(appView),
518 }
519 if a.dialog.HasDialogs() {
520 layers = append(
521 layers,
522 a.dialog.GetLayers()...,
523 )
524 }
525
526 var cursor *tea.Cursor
527 if v, ok := page.(util.Cursor); ok {
528 cursor = v.Cursor()
529 // Hide the cursor if it's positioned outside the textarea
530 statusHeight := a.height - strings.Count(pageView, "\n") + 1
531 if cursor != nil && cursor.Y+statusHeight+chat.EditorHeight-2 <= a.height { // 2 for the top and bottom app padding
532 cursor = nil
533 }
534 }
535 activeView := a.dialog.ActiveModel()
536 if activeView != nil {
537 cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor
538 if v, ok := activeView.(util.Cursor); ok {
539 cursor = v.Cursor()
540 }
541 }
542
543 if a.completions.Open() && cursor != nil {
544 cmp := a.completions.View()
545 x, y := a.completions.Position()
546 layers = append(
547 layers,
548 lipgloss.NewLayer(cmp).X(x).Y(y),
549 )
550 }
551
552 canvas := lipgloss.NewCanvas(
553 layers...,
554 )
555
556 view.Layer = canvas
557 view.Cursor = cursor
558 view.MouseMode = tea.MouseModeCellMotion
559 view.AltScreen = true
560
561 if a.app != nil && a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
562 // HACK: use a random percentage to prevent ghostty from hiding it
563 // after a timeout.
564 view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
565 }
566 return view
567}
568
569// New creates and initializes a new TUI application model.
570func New(app *app.App) tea.Model {
571 chatPage := chat.New(app)
572 keyMap := DefaultKeyMap()
573 keyMap.pageBindings = chatPage.Bindings()
574
575 model := &appModel{
576 currentPage: chat.ChatPageID,
577 app: app,
578 status: status.NewStatusCmp(),
579 loadedPages: make(map[page.PageID]bool),
580 keyMap: keyMap,
581
582 pages: map[page.PageID]util.Model{
583 chat.ChatPageID: chatPage,
584 },
585
586 dialog: dialogs.NewDialogCmp(),
587 completions: completions.New(),
588 }
589
590 return model
591}