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