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/layout"
17 "github.com/charmbracelet/crush/internal/tui/components/core/status"
18 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
20 "github.com/charmbracelet/crush/internal/tui/components/dialogs/compact"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
22 initDialog "github.com/charmbracelet/crush/internal/tui/components/dialogs/init"
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 // Check if we should show the init dialog
92 cmds = append(cmds, func() tea.Msg {
93 shouldShow, err := config.ProjectNeedsInitialization()
94 if err != nil {
95 return util.InfoMsg{
96 Type: util.InfoTypeError,
97 Msg: "Failed to check init status: " + err.Error(),
98 }
99 }
100 if shouldShow {
101 return dialogs.OpenDialogMsg{
102 Model: initDialog.NewInitDialogCmp(),
103 }
104 }
105 return nil
106 })
107
108 return tea.Batch(cmds...)
109}
110
111// Update handles incoming messages and updates the application state.
112func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
113 var cmds []tea.Cmd
114 var cmd tea.Cmd
115
116 switch msg := msg.(type) {
117 case tea.KeyboardEnhancementsMsg:
118 return a, nil
119 case tea.WindowSizeMsg:
120 return a, a.handleWindowResize(msg.Width, msg.Height)
121
122 // Completions messages
123 case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, completions.CloseCompletionsMsg:
124 u, completionCmd := a.completions.Update(msg)
125 a.completions = u.(completions.Completions)
126 return a, completionCmd
127
128 // Dialog messages
129 case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
130 u, dialogCmd := a.dialog.Update(msg)
131 a.dialog = u.(dialogs.DialogCmp)
132 return a, dialogCmd
133 case commands.ShowArgumentsDialogMsg:
134 return a, util.CmdHandler(
135 dialogs.OpenDialogMsg{
136 Model: commands.NewCommandArgumentsDialog(
137 msg.CommandID,
138 msg.Content,
139 msg.ArgNames,
140 ),
141 },
142 )
143 // Page change messages
144 case page.PageChangeMsg:
145 return a, a.moveToPage(msg.ID)
146
147 // Status Messages
148 case util.InfoMsg, util.ClearStatusMsg:
149 s, statusCmd := a.status.Update(msg)
150 a.status = s.(status.StatusCmp)
151 cmds = append(cmds, statusCmd)
152 return a, tea.Batch(cmds...)
153
154 // Session
155 case cmpChat.SessionSelectedMsg:
156 a.selectedSessionID = msg.ID
157 case cmpChat.SessionClearedMsg:
158 a.selectedSessionID = ""
159 // Commands
160 case commands.SwitchSessionsMsg:
161 return a, func() tea.Msg {
162 allSessions, _ := a.app.Sessions.List(context.Background())
163 return dialogs.OpenDialogMsg{
164 Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
165 }
166 }
167
168 case commands.SwitchModelMsg:
169 return a, util.CmdHandler(
170 dialogs.OpenDialogMsg{
171 Model: models.NewModelDialogCmp(),
172 },
173 )
174 // Compact
175 case commands.CompactMsg:
176 return a, util.CmdHandler(dialogs.OpenDialogMsg{
177 Model: compact.NewCompactDialogCmp(a.app.CoderAgent, msg.SessionID, true),
178 })
179
180 // Model Switch
181 case models.ModelSelectedMsg:
182 config.UpdatePreferredModel(msg.ModelType, msg.Model)
183
184 // Update the agent with the new model/provider configuration
185 if err := a.app.UpdateAgentModel(); err != nil {
186 return a, util.ReportError(fmt.Errorf("model changed to %s but failed to update agent: %v", msg.Model.Model, err))
187 }
188
189 modelTypeName := "large"
190 if msg.ModelType == config.SelectedModelTypeSmall {
191 modelTypeName = "small"
192 }
193 return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.Model))
194
195 // File Picker
196 case chat.OpenFilePickerMsg:
197 if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
198 // If the commands dialog is already open, close it
199 return a, util.CmdHandler(dialogs.CloseDialogMsg{})
200 }
201 return a, util.CmdHandler(dialogs.OpenDialogMsg{
202 Model: filepicker.NewFilePickerCmp(),
203 })
204 // Permissions
205 case pubsub.Event[permission.PermissionRequest]:
206 return a, util.CmdHandler(dialogs.OpenDialogMsg{
207 Model: permissions.NewPermissionDialogCmp(msg.Payload),
208 })
209 case permissions.PermissionResponseMsg:
210 switch msg.Action {
211 case permissions.PermissionAllow:
212 a.app.Permissions.Grant(msg.Permission)
213 case permissions.PermissionAllowForSession:
214 a.app.Permissions.GrantPersistent(msg.Permission)
215 case permissions.PermissionDeny:
216 a.app.Permissions.Deny(msg.Permission)
217 }
218 return a, nil
219 // Agent Events
220 case pubsub.Event[agent.AgentEvent]:
221 payload := msg.Payload
222
223 // Forward agent events to dialogs
224 if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() == compact.CompactDialogID {
225 u, dialogCmd := a.dialog.Update(payload)
226 a.dialog = u.(dialogs.DialogCmp)
227 cmds = append(cmds, dialogCmd)
228 }
229
230 // Handle auto-compact logic
231 if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSessionID != "" {
232 // Get current session to check token usage
233 session, err := a.app.Sessions.Get(context.Background(), a.selectedSessionID)
234 if err == nil {
235 model := a.app.CoderAgent.Model()
236 contextWindow := model.ContextWindow
237 tokens := session.CompletionTokens + session.PromptTokens
238 if (tokens >= int64(float64(contextWindow)*0.95)) && !config.Get().Options.DisableAutoSummarize { // Show compact confirmation dialog
239 cmds = append(cmds, util.CmdHandler(dialogs.OpenDialogMsg{
240 Model: compact.NewCompactDialogCmp(a.app.CoderAgent, a.selectedSessionID, false),
241 }))
242 }
243 }
244 }
245
246 return a, tea.Batch(cmds...)
247 // Key Press Messages
248 case tea.KeyPressMsg:
249 return a, a.handleKeyPressMsg(msg)
250 }
251 s, _ := a.status.Update(msg)
252 a.status = s.(status.StatusCmp)
253 updated, cmd := a.pages[a.currentPage].Update(msg)
254 a.pages[a.currentPage] = updated.(util.Model)
255 if a.dialog.HasDialogs() {
256 u, dialogCmd := a.dialog.Update(msg)
257 a.dialog = u.(dialogs.DialogCmp)
258 cmds = append(cmds, dialogCmd)
259 }
260 cmds = append(cmds, cmd)
261 return a, tea.Batch(cmds...)
262}
263
264// handleWindowResize processes window resize events and updates all components.
265func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
266 var cmds []tea.Cmd
267 a.wWidth, a.wHeight = width, height
268 if a.showingFullHelp {
269 height -= 4
270 } else {
271 height -= 2
272 }
273 a.width, a.height = width, height
274 // Update status bar
275 s, cmd := a.status.Update(tea.WindowSizeMsg{Width: width, Height: height})
276 a.status = s.(status.StatusCmp)
277 cmds = append(cmds, cmd)
278
279 // Update the current page
280 for p, page := range a.pages {
281 updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
282 a.pages[p] = updated.(util.Model)
283 cmds = append(cmds, pageCmd)
284 }
285
286 // Update the dialogs
287 dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height})
288 a.dialog = dialog.(dialogs.DialogCmp)
289 cmds = append(cmds, cmd)
290
291 return tea.Batch(cmds...)
292}
293
294// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
295func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
296 switch {
297 // completions
298 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Up):
299 u, cmd := a.completions.Update(msg)
300 a.completions = u.(completions.Completions)
301 return cmd
302
303 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Down):
304 u, cmd := a.completions.Update(msg)
305 a.completions = u.(completions.Completions)
306 return cmd
307 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Select):
308 u, cmd := a.completions.Update(msg)
309 a.completions = u.(completions.Completions)
310 return cmd
311 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Cancel):
312 u, cmd := a.completions.Update(msg)
313 a.completions = u.(completions.Completions)
314 return cmd
315 // help
316 case key.Matches(msg, a.keyMap.Help):
317 a.status.ToggleFullHelp()
318 a.showingFullHelp = !a.showingFullHelp
319 return a.handleWindowResize(a.wWidth, a.wHeight)
320 // dialogs
321 case key.Matches(msg, a.keyMap.Quit):
322 if a.dialog.ActiveDialogID() == quit.QuitDialogID {
323 // if the quit dialog is already open, close the app
324 return tea.Quit
325 }
326 return util.CmdHandler(dialogs.OpenDialogMsg{
327 Model: quit.NewQuitDialog(),
328 })
329
330 case key.Matches(msg, a.keyMap.Commands):
331 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
332 // If the commands dialog is already open, close it
333 return util.CmdHandler(dialogs.CloseDialogMsg{})
334 }
335 return util.CmdHandler(dialogs.OpenDialogMsg{
336 Model: commands.NewCommandDialog(a.selectedSessionID),
337 })
338 case key.Matches(msg, a.keyMap.Sessions):
339 if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
340 // If the sessions dialog is already open, close it
341 return util.CmdHandler(dialogs.CloseDialogMsg{})
342 }
343 var cmds []tea.Cmd
344 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
345 // If the commands dialog is open, close it first
346 cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{}))
347 }
348 cmds = append(cmds,
349 func() tea.Msg {
350 allSessions, _ := a.app.Sessions.List(context.Background())
351 return dialogs.OpenDialogMsg{
352 Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
353 }
354 },
355 )
356 return tea.Sequence(cmds...)
357 default:
358 if a.dialog.HasDialogs() {
359 u, dialogCmd := a.dialog.Update(msg)
360 a.dialog = u.(dialogs.DialogCmp)
361 return dialogCmd
362 } else {
363 updated, cmd := a.pages[a.currentPage].Update(msg)
364 a.pages[a.currentPage] = updated.(util.Model)
365 return cmd
366 }
367 }
368}
369
370// moveToPage handles navigation between different pages in the application.
371func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
372 if a.app.CoderAgent.IsBusy() {
373 // TODO: maybe remove this : For now we don't move to any page if the agent is busy
374 return util.ReportWarn("Agent is busy, please wait...")
375 }
376
377 var cmds []tea.Cmd
378 if _, ok := a.loadedPages[pageID]; !ok {
379 cmd := a.pages[pageID].Init()
380 cmds = append(cmds, cmd)
381 a.loadedPages[pageID] = true
382 }
383 a.previousPage = a.currentPage
384 a.currentPage = pageID
385 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
386 cmd := sizable.SetSize(a.width, a.height)
387 cmds = append(cmds, cmd)
388 }
389
390 return tea.Batch(cmds...)
391}
392
393// View renders the complete application interface including pages, dialogs, and overlays.
394func (a *appModel) View() tea.View {
395 page := a.pages[a.currentPage]
396 if withHelp, ok := page.(layout.Help); ok {
397 a.keyMap.pageBindings = withHelp.Bindings()
398 }
399 a.status.SetKeyMap(a.keyMap)
400 pageView := page.View()
401 components := []string{
402 pageView,
403 }
404 components = append(components, a.status.View())
405
406 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
407 layers := []*lipgloss.Layer{
408 lipgloss.NewLayer(appView),
409 }
410 if a.dialog.HasDialogs() {
411 layers = append(
412 layers,
413 a.dialog.GetLayers()...,
414 )
415 }
416
417 var cursor *tea.Cursor
418 if v, ok := page.(util.Cursor); ok {
419 cursor = v.Cursor()
420 }
421 activeView := a.dialog.ActiveModel()
422 if activeView != nil {
423 cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor
424 if v, ok := activeView.(util.Cursor); ok {
425 cursor = v.Cursor()
426 }
427 }
428
429 if a.completions.Open() && cursor != nil {
430 cmp := a.completions.View()
431 x, y := a.completions.Position()
432 layers = append(
433 layers,
434 lipgloss.NewLayer(cmp).X(x).Y(y),
435 )
436 }
437
438 canvas := lipgloss.NewCanvas(
439 layers...,
440 )
441
442 var view tea.View
443 t := styles.CurrentTheme()
444 view.Layer = canvas
445 view.BackgroundColor = t.BgBase
446 view.Cursor = cursor
447 return view
448}
449
450// New creates and initializes a new TUI application model.
451func New(app *app.App) tea.Model {
452 chatPage := chat.NewChatPage(app)
453 keyMap := DefaultKeyMap()
454 keyMap.pageBindings = chatPage.Bindings()
455
456 model := &appModel{
457 currentPage: chat.ChatPageID,
458 app: app,
459 status: status.NewStatusCmp(keyMap),
460 loadedPages: make(map[page.PageID]bool),
461 keyMap: keyMap,
462
463 pages: map[page.PageID]util.Model{
464 chat.ChatPageID: chatPage,
465 },
466
467 dialog: dialogs.NewDialogCmp(),
468 completions: completions.New(),
469 }
470
471 return model
472}