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 configv2 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/llm/agent"
13 "github.com/charmbracelet/crush/internal/logging"
14 "github.com/charmbracelet/crush/internal/permission"
15 "github.com/charmbracelet/crush/internal/pubsub"
16 cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
17 "github.com/charmbracelet/crush/internal/tui/components/completions"
18 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
19 "github.com/charmbracelet/crush/internal/tui/components/core/status"
20 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
22 "github.com/charmbracelet/crush/internal/tui/components/dialogs/compact"
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 := configv2.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 // Compact
169 case commands.CompactMsg:
170 return a, util.CmdHandler(dialogs.OpenDialogMsg{
171 Model: compact.NewCompactDialogCmp(a.app.CoderAgent, msg.SessionID, true),
172 })
173
174 // Model Switch
175 case models.ModelSelectedMsg:
176 model, err := a.app.CoderAgent.Update(msg.Model)
177 if err != nil {
178 return a, util.ReportError(err)
179 }
180
181 return a, util.ReportInfo(fmt.Sprintf("Model changed to %s", model.Name))
182
183 // File Picker
184 case chat.OpenFilePickerMsg:
185 if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
186 // If the commands dialog is already open, close it
187 return a, util.CmdHandler(dialogs.CloseDialogMsg{})
188 }
189 return a, util.CmdHandler(dialogs.OpenDialogMsg{
190 Model: filepicker.NewFilePickerCmp(),
191 })
192 // Permissions
193 case pubsub.Event[permission.PermissionRequest]:
194 return a, util.CmdHandler(dialogs.OpenDialogMsg{
195 Model: permissions.NewPermissionDialogCmp(msg.Payload),
196 })
197 case permissions.PermissionResponseMsg:
198 switch msg.Action {
199 case permissions.PermissionAllow:
200 a.app.Permissions.Grant(msg.Permission)
201 case permissions.PermissionAllowForSession:
202 a.app.Permissions.GrantPersistent(msg.Permission)
203 case permissions.PermissionDeny:
204 a.app.Permissions.Deny(msg.Permission)
205 }
206 return a, nil
207 // Agent Events
208 case pubsub.Event[agent.AgentEvent]:
209 payload := msg.Payload
210
211 // Forward agent events to dialogs
212 if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() == compact.CompactDialogID {
213 u, dialogCmd := a.dialog.Update(payload)
214 a.dialog = u.(dialogs.DialogCmp)
215 cmds = append(cmds, dialogCmd)
216 }
217
218 // Handle auto-compact logic
219 if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSessionID != "" {
220 // Get current session to check token usage
221 session, err := a.app.Sessions.Get(context.Background(), a.selectedSessionID)
222 if err == nil {
223 model := a.app.CoderAgent.Model()
224 contextWindow := model.ContextWindow
225 tokens := session.CompletionTokens + session.PromptTokens
226 if (tokens >= int64(float64(contextWindow)*0.95)) && !config.Get().Options.DisableAutoSummarize {
227 // Show compact confirmation dialog
228 cmds = append(cmds, util.CmdHandler(dialogs.OpenDialogMsg{
229 Model: compact.NewCompactDialogCmp(a.app.CoderAgent, a.selectedSessionID, false),
230 }))
231 }
232 }
233 }
234
235 return a, tea.Batch(cmds...)
236 // Key Press Messages
237 case tea.KeyPressMsg:
238 return a, a.handleKeyPressMsg(msg)
239 }
240 s, _ := a.status.Update(msg)
241 a.status = s.(status.StatusCmp)
242 updated, cmd := a.pages[a.currentPage].Update(msg)
243 a.pages[a.currentPage] = updated.(util.Model)
244 if a.dialog.HasDialogs() {
245 u, dialogCmd := a.dialog.Update(msg)
246 a.dialog = u.(dialogs.DialogCmp)
247 cmds = append(cmds, dialogCmd)
248 }
249 cmds = append(cmds, cmd)
250 return a, tea.Batch(cmds...)
251}
252
253// handleWindowResize processes window resize events and updates all components.
254func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
255 var cmds []tea.Cmd
256 a.wWidth, a.wHeight = width, height
257 if a.showingFullHelp {
258 height -= 4
259 } else {
260 height -= 2
261 }
262 a.width, a.height = width, height
263 // Update status bar
264 s, cmd := a.status.Update(tea.WindowSizeMsg{Width: width, Height: height})
265 a.status = s.(status.StatusCmp)
266 cmds = append(cmds, cmd)
267
268 // Update the current page
269 for p, page := range a.pages {
270 updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
271 a.pages[p] = updated.(util.Model)
272 cmds = append(cmds, pageCmd)
273 }
274
275 // Update the dialogs
276 dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height})
277 a.dialog = dialog.(dialogs.DialogCmp)
278 cmds = append(cmds, cmd)
279
280 return tea.Batch(cmds...)
281}
282
283// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
284func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
285 switch {
286 // completions
287 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Up):
288 u, cmd := a.completions.Update(msg)
289 a.completions = u.(completions.Completions)
290 return cmd
291
292 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Down):
293 u, cmd := a.completions.Update(msg)
294 a.completions = u.(completions.Completions)
295 return cmd
296 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Select):
297 u, cmd := a.completions.Update(msg)
298 a.completions = u.(completions.Completions)
299 return cmd
300 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Cancel):
301 u, cmd := a.completions.Update(msg)
302 a.completions = u.(completions.Completions)
303 return cmd
304 // help
305 case key.Matches(msg, a.keyMap.Help):
306 a.status.ToggleFullHelp()
307 a.showingFullHelp = !a.showingFullHelp
308 return a.handleWindowResize(a.wWidth, a.wHeight)
309 // dialogs
310 case key.Matches(msg, a.keyMap.Quit):
311 if a.dialog.ActiveDialogID() == quit.QuitDialogID {
312 // if the quit dialog is already open, close the app
313 return tea.Quit
314 }
315 return util.CmdHandler(dialogs.OpenDialogMsg{
316 Model: quit.NewQuitDialog(),
317 })
318
319 case key.Matches(msg, a.keyMap.Commands):
320 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
321 // If the commands dialog is already open, close it
322 return util.CmdHandler(dialogs.CloseDialogMsg{})
323 }
324 return util.CmdHandler(dialogs.OpenDialogMsg{
325 Model: commands.NewCommandDialog(a.selectedSessionID),
326 })
327 case key.Matches(msg, a.keyMap.Sessions):
328 if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
329 // If the sessions dialog is already open, close it
330 return util.CmdHandler(dialogs.CloseDialogMsg{})
331 }
332 var cmds []tea.Cmd
333 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
334 // If the commands dialog is open, close it first
335 cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{}))
336 }
337 cmds = append(cmds,
338 func() tea.Msg {
339 allSessions, _ := a.app.Sessions.List(context.Background())
340 return dialogs.OpenDialogMsg{
341 Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
342 }
343 },
344 )
345 return tea.Sequence(cmds...)
346 // Page navigation
347 case key.Matches(msg, a.keyMap.Logs):
348 return a.moveToPage(logs.LogsPage)
349
350 default:
351 if a.dialog.HasDialogs() {
352 u, dialogCmd := a.dialog.Update(msg)
353 a.dialog = u.(dialogs.DialogCmp)
354 return dialogCmd
355 } else {
356 updated, cmd := a.pages[a.currentPage].Update(msg)
357 a.pages[a.currentPage] = updated.(util.Model)
358 return cmd
359 }
360 }
361}
362
363// moveToPage handles navigation between different pages in the application.
364func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
365 if a.app.CoderAgent.IsBusy() {
366 // TODO: maybe remove this : For now we don't move to any page if the agent is busy
367 return util.ReportWarn("Agent is busy, please wait...")
368 }
369
370 var cmds []tea.Cmd
371 if _, ok := a.loadedPages[pageID]; !ok {
372 cmd := a.pages[pageID].Init()
373 cmds = append(cmds, cmd)
374 a.loadedPages[pageID] = true
375 }
376 a.previousPage = a.currentPage
377 a.currentPage = pageID
378 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
379 cmd := sizable.SetSize(a.width, a.height)
380 cmds = append(cmds, cmd)
381 }
382
383 return tea.Batch(cmds...)
384}
385
386// View renders the complete application interface including pages, dialogs, and overlays.
387func (a *appModel) View() tea.View {
388 page := a.pages[a.currentPage]
389 if withHelp, ok := page.(layout.Help); ok {
390 a.keyMap.pageBindings = withHelp.Bindings()
391 }
392 a.status.SetKeyMap(a.keyMap)
393 pageView := page.View()
394 components := []string{
395 pageView.String(),
396 }
397 components = append(components, a.status.View().String())
398
399 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
400 layers := []*lipgloss.Layer{
401 lipgloss.NewLayer(appView),
402 }
403 if a.dialog.HasDialogs() {
404 layers = append(
405 layers,
406 a.dialog.GetLayers()...,
407 )
408 }
409
410 cursor := pageView.Cursor()
411 activeView := a.dialog.ActiveView()
412 if activeView != nil {
413 cursor = activeView.Cursor()
414 }
415
416 if a.completions.Open() && cursor != nil {
417 cmp := a.completions.View().String()
418 x, y := a.completions.Position()
419 layers = append(
420 layers,
421 lipgloss.NewLayer(cmp).X(x).Y(y),
422 )
423 }
424
425 canvas := lipgloss.NewCanvas(
426 layers...,
427 )
428
429 t := styles.CurrentTheme()
430 view := tea.NewView(canvas.Render())
431 view.SetBackgroundColor(t.BgBase)
432 view.SetCursor(cursor)
433 return view
434}
435
436// New creates and initializes a new TUI application model.
437func New(app *app.App) tea.Model {
438 chatPage := chat.NewChatPage(app)
439 keyMap := DefaultKeyMap()
440 keyMap.pageBindings = chatPage.Bindings()
441
442 model := &appModel{
443 currentPage: chat.ChatPageID,
444 app: app,
445 status: status.NewStatusCmp(keyMap),
446 loadedPages: make(map[page.PageID]bool),
447 keyMap: keyMap,
448
449 pages: map[page.PageID]util.Model{
450 chat.ChatPageID: chatPage,
451 logs.LogsPage: logs.NewLogsPage(),
452 },
453
454 dialog: dialogs.NewDialogCmp(),
455 completions: completions.New(),
456 }
457
458 return model
459}