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