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.wWidth, a.wHeight = msg.Width, msg.Height
113 a.completions.Update(msg)
114 return a, a.handleWindowResize(msg.Width, msg.Height)
115
116 // Completions messages
117 case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, completions.CloseCompletionsMsg:
118 u, completionCmd := a.completions.Update(msg)
119 a.completions = u.(completions.Completions)
120 switch msg := msg.(type) {
121 case completions.OpenCompletionsMsg:
122 x, _ := a.completions.Position()
123 if a.completions.Width()+x >= a.wWidth {
124 // Adjust X position to fit in the window.
125 msg.X = a.wWidth - a.completions.Width() - 1
126 u, completionCmd = a.completions.Update(msg)
127 a.completions = u.(completions.Completions)
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
186 // Model Switch
187 case models.ModelSelectedMsg:
188 config.Get().UpdatePreferredModel(msg.ModelType, msg.Model)
189
190 // Update the agent with the new model/provider configuration
191 if err := a.app.UpdateAgentModel(); err != nil {
192 return a, util.ReportError(fmt.Errorf("model changed to %s but failed to update agent: %v", msg.Model.Model, err))
193 }
194
195 modelTypeName := "large"
196 if msg.ModelType == config.SelectedModelTypeSmall {
197 modelTypeName = "small"
198 }
199 return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.Model))
200
201 // File Picker
202 case chat.OpenFilePickerMsg:
203 if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
204 // If the commands dialog is already open, close it
205 return a, util.CmdHandler(dialogs.CloseDialogMsg{})
206 }
207 return a, util.CmdHandler(dialogs.OpenDialogMsg{
208 Model: filepicker.NewFilePickerCmp(),
209 })
210 // Permissions
211 case pubsub.Event[permission.PermissionRequest]:
212 return a, util.CmdHandler(dialogs.OpenDialogMsg{
213 Model: permissions.NewPermissionDialogCmp(msg.Payload),
214 })
215 case permissions.PermissionResponseMsg:
216 switch msg.Action {
217 case permissions.PermissionAllow:
218 a.app.Permissions.Grant(msg.Permission)
219 case permissions.PermissionAllowForSession:
220 a.app.Permissions.GrantPersistent(msg.Permission)
221 case permissions.PermissionDeny:
222 a.app.Permissions.Deny(msg.Permission)
223 }
224 return a, nil
225 // Agent Events
226 case pubsub.Event[agent.AgentEvent]:
227 payload := msg.Payload
228
229 // Forward agent events to dialogs
230 if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() == compact.CompactDialogID {
231 u, dialogCmd := a.dialog.Update(payload)
232 a.dialog = u.(dialogs.DialogCmp)
233 cmds = append(cmds, dialogCmd)
234 }
235
236 // Handle auto-compact logic
237 if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSessionID != "" {
238 // Get current session to check token usage
239 session, err := a.app.Sessions.Get(context.Background(), a.selectedSessionID)
240 if err == nil {
241 model := a.app.CoderAgent.Model()
242 contextWindow := model.ContextWindow
243 tokens := session.CompletionTokens + session.PromptTokens
244 if (tokens >= int64(float64(contextWindow)*0.95)) && !config.Get().Options.DisableAutoSummarize { // Show compact confirmation dialog
245 cmds = append(cmds, util.CmdHandler(dialogs.OpenDialogMsg{
246 Model: compact.NewCompactDialogCmp(a.app.CoderAgent, a.selectedSessionID, false),
247 }))
248 }
249 }
250 }
251
252 return a, tea.Batch(cmds...)
253 // Key Press Messages
254 case tea.KeyPressMsg:
255 return a, a.handleKeyPressMsg(msg)
256 }
257 s, _ := a.status.Update(msg)
258 a.status = s.(status.StatusCmp)
259 updated, cmd := a.pages[a.currentPage].Update(msg)
260 a.pages[a.currentPage] = updated.(util.Model)
261 if a.dialog.HasDialogs() {
262 u, dialogCmd := a.dialog.Update(msg)
263 a.dialog = u.(dialogs.DialogCmp)
264 cmds = append(cmds, dialogCmd)
265 }
266 cmds = append(cmds, cmd)
267 return a, tea.Batch(cmds...)
268}
269
270// handleWindowResize processes window resize events and updates all components.
271func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
272 var cmds []tea.Cmd
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 var view tea.View
404 t := styles.CurrentTheme()
405 view.BackgroundColor = t.BgBase
406 if a.wWidth < 25 || a.wHeight < 15 {
407 view.Layer = lipgloss.NewCanvas(
408 lipgloss.NewLayer(
409 t.S().Base.Width(a.wWidth).Height(a.wHeight).
410 Align(lipgloss.Center, lipgloss.Center).
411 Render(
412 t.S().Base.
413 Padding(1, 2).
414 Background(t.BgSubtle).
415 Foreground(t.White).
416 Render("Too small!"),
417 ),
418 ),
419 )
420 return view
421 }
422
423 page := a.pages[a.currentPage]
424 if withHelp, ok := page.(core.KeyMapHelp); ok {
425 a.status.SetKeyMap(withHelp.Help())
426 }
427 pageView := page.View()
428 components := []string{
429 pageView,
430 }
431 components = append(components, a.status.View())
432
433 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
434 layers := []*lipgloss.Layer{
435 lipgloss.NewLayer(appView),
436 }
437 if a.dialog.HasDialogs() {
438 layers = append(
439 layers,
440 a.dialog.GetLayers()...,
441 )
442 }
443
444 var cursor *tea.Cursor
445 if v, ok := page.(util.Cursor); ok {
446 cursor = v.Cursor()
447 }
448 activeView := a.dialog.ActiveModel()
449 if activeView != nil {
450 cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor
451 if v, ok := activeView.(util.Cursor); ok {
452 cursor = v.Cursor()
453 }
454 }
455
456 if a.completions.Open() && cursor != nil {
457 cmp := a.completions.View()
458 x, y := a.completions.Position()
459 layers = append(
460 layers,
461 lipgloss.NewLayer(cmp).X(x).Y(y),
462 )
463 }
464
465 canvas := lipgloss.NewCanvas(
466 layers...,
467 )
468
469 view.Layer = canvas
470 view.Cursor = cursor
471 return view
472}
473
474// New creates and initializes a new TUI application model.
475func New(app *app.App) tea.Model {
476 chatPage := chat.New(app)
477 keyMap := DefaultKeyMap()
478 keyMap.pageBindings = chatPage.Bindings()
479
480 model := &appModel{
481 currentPage: chat.ChatPageID,
482 app: app,
483 status: status.NewStatusCmp(),
484 loadedPages: make(map[page.PageID]bool),
485 keyMap: keyMap,
486
487 pages: map[page.PageID]util.Model{
488 chat.ChatPageID: chatPage,
489 },
490
491 dialog: dialogs.NewDialogCmp(),
492 completions: completions.New(),
493 }
494
495 return model
496}