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 go a.app.UpdateAgentModel(context.TODO())
203
204 modelTypeName := "large"
205 if msg.ModelType == config.SelectedModelTypeSmall {
206 modelTypeName = "small"
207 }
208 return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.Model))
209
210 // File Picker
211 case commands.OpenFilePickerMsg:
212 event.FilePickerOpened()
213
214 if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
215 // If the commands dialog is already open, close it
216 return a, util.CmdHandler(dialogs.CloseDialogMsg{})
217 }
218 return a, util.CmdHandler(dialogs.OpenDialogMsg{
219 Model: filepicker.NewFilePickerCmp(a.app.Config().WorkingDir()),
220 })
221 // Permissions
222 case pubsub.Event[permission.PermissionNotification]:
223 item, ok := a.pages[a.currentPage]
224 if !ok {
225 return a, nil
226 }
227
228 // Forward to view.
229 updated, itemCmd := item.Update(msg)
230 a.pages[a.currentPage] = updated
231
232 return a, itemCmd
233 case pubsub.Event[permission.PermissionRequest]:
234 return a, util.CmdHandler(dialogs.OpenDialogMsg{
235 Model: permissions.NewPermissionDialogCmp(msg.Payload, &permissions.Options{
236 DiffMode: config.Get().Options.TUI.DiffMode,
237 }),
238 })
239 case permissions.PermissionResponseMsg:
240 switch msg.Action {
241 case permissions.PermissionAllow:
242 a.app.Permissions.Grant(msg.Permission)
243 case permissions.PermissionAllowForSession:
244 a.app.Permissions.GrantPersistent(msg.Permission)
245 case permissions.PermissionDeny:
246 a.app.Permissions.Deny(msg.Permission)
247 }
248 return a, nil
249 case splash.OnboardingCompleteMsg:
250 item, ok := a.pages[a.currentPage]
251 if !ok {
252 return a, nil
253 }
254
255 a.isConfigured = config.HasInitialDataConfig()
256 updated, pageCmd := item.Update(msg)
257 a.pages[a.currentPage] = updated
258
259 cmds = append(cmds, pageCmd)
260 return a, tea.Batch(cmds...)
261
262 case tea.KeyPressMsg:
263 return a, a.handleKeyPressMsg(msg)
264
265 case tea.MouseWheelMsg:
266 if a.dialog.HasDialogs() {
267 u, dialogCmd := a.dialog.Update(msg)
268 a.dialog = u.(dialogs.DialogCmp)
269 cmds = append(cmds, dialogCmd)
270 } else {
271 item, ok := a.pages[a.currentPage]
272 if !ok {
273 return a, nil
274 }
275
276 updated, pageCmd := item.Update(msg)
277 a.pages[a.currentPage] = updated
278
279 cmds = append(cmds, pageCmd)
280 }
281 return a, tea.Batch(cmds...)
282 case tea.PasteMsg:
283 if a.dialog.HasDialogs() {
284 u, dialogCmd := a.dialog.Update(msg)
285 if model, ok := u.(dialogs.DialogCmp); ok {
286 a.dialog = model
287 }
288
289 cmds = append(cmds, dialogCmd)
290 } else {
291 item, ok := a.pages[a.currentPage]
292 if !ok {
293 return a, nil
294 }
295
296 updated, pageCmd := item.Update(msg)
297 a.pages[a.currentPage] = updated
298
299 cmds = append(cmds, pageCmd)
300 }
301 return a, tea.Batch(cmds...)
302 }
303 s, _ := a.status.Update(msg)
304 a.status = s.(status.StatusCmp)
305
306 item, ok := a.pages[a.currentPage]
307 if !ok {
308 return a, nil
309 }
310
311 updated, cmd := item.Update(msg)
312 a.pages[a.currentPage] = updated
313
314 if a.dialog.HasDialogs() {
315 u, dialogCmd := a.dialog.Update(msg)
316 if model, ok := u.(dialogs.DialogCmp); ok {
317 a.dialog = model
318 }
319
320 cmds = append(cmds, dialogCmd)
321 }
322 cmds = append(cmds, cmd)
323 return a, tea.Batch(cmds...)
324}
325
326// handleWindowResize processes window resize events and updates all components.
327func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
328 var cmds []tea.Cmd
329
330 // TODO: clean up these magic numbers.
331 if a.showingFullHelp {
332 height -= 5
333 } else {
334 height -= 2
335 }
336
337 a.width, a.height = width, height
338 // Update status bar
339 s, cmd := a.status.Update(tea.WindowSizeMsg{Width: width, Height: height})
340 if model, ok := s.(status.StatusCmp); ok {
341 a.status = model
342 }
343 cmds = append(cmds, cmd)
344
345 // Update the current view.
346 for p, page := range a.pages {
347 updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
348 a.pages[p] = updated
349
350 cmds = append(cmds, pageCmd)
351 }
352
353 // Update the dialogs
354 dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height})
355 if model, ok := dialog.(dialogs.DialogCmp); ok {
356 a.dialog = model
357 }
358
359 cmds = append(cmds, cmd)
360
361 return tea.Batch(cmds...)
362}
363
364// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
365func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
366 // Check this first as the user should be able to quit no matter what.
367 if key.Matches(msg, a.keyMap.Quit) {
368 if a.dialog.ActiveDialogID() == quit.QuitDialogID {
369 return tea.Quit
370 }
371 return util.CmdHandler(dialogs.OpenDialogMsg{
372 Model: quit.NewQuitDialog(),
373 })
374 }
375
376 if a.completions.Open() {
377 // completions
378 keyMap := a.completions.KeyMap()
379 switch {
380 case key.Matches(msg, keyMap.Up), key.Matches(msg, keyMap.Down),
381 key.Matches(msg, keyMap.Select), key.Matches(msg, keyMap.Cancel),
382 key.Matches(msg, keyMap.UpInsert), key.Matches(msg, keyMap.DownInsert):
383 u, cmd := a.completions.Update(msg)
384 a.completions = u.(completions.Completions)
385 return cmd
386 }
387 }
388 if a.dialog.HasDialogs() {
389 u, dialogCmd := a.dialog.Update(msg)
390 a.dialog = u.(dialogs.DialogCmp)
391 return dialogCmd
392 }
393 switch {
394 // help
395 case key.Matches(msg, a.keyMap.Help):
396 a.status.ToggleFullHelp()
397 a.showingFullHelp = !a.showingFullHelp
398 return a.handleWindowResize(a.wWidth, a.wHeight)
399 // dialogs
400 case key.Matches(msg, a.keyMap.Commands):
401 // if the app is not configured show no commands
402 if !a.isConfigured {
403 return nil
404 }
405 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
406 return util.CmdHandler(dialogs.CloseDialogMsg{})
407 }
408 if a.dialog.HasDialogs() {
409 return nil
410 }
411 return util.CmdHandler(dialogs.OpenDialogMsg{
412 Model: commands.NewCommandDialog(a.selectedSessionID),
413 })
414 case key.Matches(msg, a.keyMap.Sessions):
415 // if the app is not configured show no sessions
416 if !a.isConfigured {
417 return nil
418 }
419 if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
420 return util.CmdHandler(dialogs.CloseDialogMsg{})
421 }
422 if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() != commands.CommandsDialogID {
423 return nil
424 }
425 var cmds []tea.Cmd
426 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
427 // If the commands dialog is open, close it first
428 cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{}))
429 }
430 cmds = append(cmds,
431 func() tea.Msg {
432 allSessions, _ := a.app.Sessions.List(context.Background())
433 return dialogs.OpenDialogMsg{
434 Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
435 }
436 },
437 )
438 return tea.Sequence(cmds...)
439 case key.Matches(msg, a.keyMap.Suspend):
440 if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
441 return util.ReportWarn("Agent is busy, please wait...")
442 }
443 return tea.Suspend
444 default:
445 item, ok := a.pages[a.currentPage]
446 if !ok {
447 return nil
448 }
449
450 updated, cmd := item.Update(msg)
451 a.pages[a.currentPage] = updated
452 return cmd
453 }
454}
455
456// moveToPage handles navigation between different pages in the application.
457func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
458 if a.app.AgentCoordinator.IsBusy() {
459 // TODO: maybe remove this : For now we don't move to any page if the agent is busy
460 return util.ReportWarn("Agent is busy, please wait...")
461 }
462
463 var cmds []tea.Cmd
464 if _, ok := a.loadedPages[pageID]; !ok {
465 cmd := a.pages[pageID].Init()
466 cmds = append(cmds, cmd)
467 a.loadedPages[pageID] = true
468 }
469 a.previousPage = a.currentPage
470 a.currentPage = pageID
471 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
472 cmd := sizable.SetSize(a.width, a.height)
473 cmds = append(cmds, cmd)
474 }
475
476 return tea.Batch(cmds...)
477}
478
479// View renders the complete application interface including pages, dialogs, and overlays.
480func (a *appModel) View() tea.View {
481 var view tea.View
482 t := styles.CurrentTheme()
483 view.BackgroundColor = t.BgBase
484 if a.wWidth < 25 || a.wHeight < 15 {
485 view.Layer = lipgloss.NewCanvas(
486 lipgloss.NewLayer(
487 t.S().Base.Width(a.wWidth).Height(a.wHeight).
488 Align(lipgloss.Center, lipgloss.Center).
489 Render(
490 t.S().Base.
491 Padding(1, 4).
492 Foreground(t.White).
493 BorderStyle(lipgloss.RoundedBorder()).
494 BorderForeground(t.Primary).
495 Render("Window too small!"),
496 ),
497 ),
498 )
499 return view
500 }
501
502 page := a.pages[a.currentPage]
503 if withHelp, ok := page.(core.KeyMapHelp); ok {
504 a.status.SetKeyMap(withHelp.Help())
505 }
506 pageView := page.View()
507 components := []string{
508 pageView,
509 }
510 components = append(components, a.status.View())
511
512 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
513 layers := []*lipgloss.Layer{
514 lipgloss.NewLayer(appView),
515 }
516 if a.dialog.HasDialogs() {
517 layers = append(
518 layers,
519 a.dialog.GetLayers()...,
520 )
521 }
522
523 var cursor *tea.Cursor
524 if v, ok := page.(util.Cursor); ok {
525 cursor = v.Cursor()
526 // Hide the cursor if it's positioned outside the textarea
527 statusHeight := a.height - strings.Count(pageView, "\n") + 1
528 if cursor != nil && cursor.Y+statusHeight+chat.EditorHeight-2 <= a.height { // 2 for the top and bottom app padding
529 cursor = nil
530 }
531 }
532 activeView := a.dialog.ActiveModel()
533 if activeView != nil {
534 cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor
535 if v, ok := activeView.(util.Cursor); ok {
536 cursor = v.Cursor()
537 }
538 }
539
540 if a.completions.Open() && cursor != nil {
541 cmp := a.completions.View()
542 x, y := a.completions.Position()
543 layers = append(
544 layers,
545 lipgloss.NewLayer(cmp).X(x).Y(y),
546 )
547 }
548
549 canvas := lipgloss.NewCanvas(
550 layers...,
551 )
552
553 view.Layer = canvas
554 view.Cursor = cursor
555 view.MouseMode = tea.MouseModeCellMotion
556 view.AltScreen = true
557
558 if a.app != nil && a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
559 // HACK: use a random percentage to prevent ghostty from hiding it
560 // after a timeout.
561 view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
562 }
563 return view
564}
565
566// New creates and initializes a new TUI application model.
567func New(app *app.App) tea.Model {
568 chatPage := chat.New(app)
569 keyMap := DefaultKeyMap()
570 keyMap.pageBindings = chatPage.Bindings()
571
572 model := &appModel{
573 currentPage: chat.ChatPageID,
574 app: app,
575 status: status.NewStatusCmp(),
576 loadedPages: make(map[page.PageID]bool),
577 keyMap: keyMap,
578
579 pages: map[page.PageID]util.Model{
580 chat.ChatPageID: chatPage,
581 },
582
583 dialog: dialogs.NewDialogCmp(),
584 completions: completions.New(),
585 }
586
587 return model
588}