1package tui
2
3import (
4 "context"
5
6 "github.com/charmbracelet/bubbles/v2/key"
7 tea "github.com/charmbracelet/bubbletea/v2"
8 "github.com/charmbracelet/crush/internal/app"
9 "github.com/charmbracelet/crush/internal/config"
10 "github.com/charmbracelet/crush/internal/llm/tools"
11 "github.com/charmbracelet/crush/internal/logging"
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/status"
17 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
18 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
20 initDialog "github.com/charmbracelet/crush/internal/tui/components/dialogs/init"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
22 "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
23 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
24 "github.com/charmbracelet/crush/internal/tui/components/dialogs/sessions"
25 "github.com/charmbracelet/crush/internal/tui/layout"
26 "github.com/charmbracelet/crush/internal/tui/page"
27 "github.com/charmbracelet/crush/internal/tui/page/chat"
28 "github.com/charmbracelet/crush/internal/tui/styles"
29 "github.com/charmbracelet/crush/internal/tui/util"
30 "github.com/charmbracelet/lipgloss/v2"
31)
32
33// appModel represents the main application model that manages pages, dialogs, and UI state.
34type appModel struct {
35 width, height int
36 keyMap KeyMap
37
38 currentPage page.PageID
39 previousPage page.PageID
40 pages map[page.PageID]util.Model
41 loadedPages map[page.PageID]bool
42
43 status status.StatusCmp
44
45 app *app.App
46
47 dialog dialogs.DialogCmp
48 completions completions.Completions
49
50 // Session
51 selectedSessionID string // The ID of the currently selected session
52}
53
54// Init initializes the application model and returns initial commands.
55func (a appModel) Init() tea.Cmd {
56 var cmds []tea.Cmd
57 cmd := a.pages[a.currentPage].Init()
58 cmds = append(cmds, cmd)
59 a.loadedPages[a.currentPage] = true
60
61 cmd = a.status.Init()
62 cmds = append(cmds, cmd)
63
64 // Check if we should show the init dialog
65 cmds = append(cmds, func() tea.Msg {
66 shouldShow, err := config.ShouldShowInitDialog()
67 if err != nil {
68 return util.InfoMsg{
69 Type: util.InfoTypeError,
70 Msg: "Failed to check init status: " + err.Error(),
71 }
72 }
73 if shouldShow {
74 return dialogs.OpenDialogMsg{
75 Model: initDialog.NewInitDialogCmp(),
76 }
77 }
78 return nil
79 })
80
81 return tea.Batch(cmds...)
82}
83
84// Update handles incoming messages and updates the application state.
85func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
86 logging.Info("TUI Update", "msg", msg)
87 var cmds []tea.Cmd
88 var cmd tea.Cmd
89
90 switch msg := msg.(type) {
91 case tea.WindowSizeMsg:
92 return a, a.handleWindowResize(msg)
93
94 // Completions messages
95 case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, completions.CloseCompletionsMsg:
96 u, completionCmd := a.completions.Update(msg)
97 a.completions = u.(completions.Completions)
98 return a, completionCmd
99
100 // Dialog messages
101 case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
102 u, dialogCmd := a.dialog.Update(msg)
103 a.dialog = u.(dialogs.DialogCmp)
104 return a, dialogCmd
105 case commands.ShowArgumentsDialogMsg:
106 return a, util.CmdHandler(
107 dialogs.OpenDialogMsg{
108 Model: commands.NewCommandArgumentsDialog(
109 msg.CommandID,
110 msg.Content,
111 msg.ArgNames,
112 ),
113 },
114 )
115 // Page change messages
116 case page.PageChangeMsg:
117 return a, a.moveToPage(msg.ID)
118
119 // Status Messages
120 case util.InfoMsg, util.ClearStatusMsg:
121 s, statusCmd := a.status.Update(msg)
122 a.status = s.(status.StatusCmp)
123 cmds = append(cmds, statusCmd)
124 return a, tea.Batch(cmds...)
125
126 // Session
127 case cmpChat.SessionSelectedMsg:
128 a.selectedSessionID = msg.ID
129 case cmpChat.SessionClearedMsg:
130 a.selectedSessionID = ""
131 // Logs
132 case pubsub.Event[logging.LogMessage]:
133 // Send to the status component
134 s, statusCmd := a.status.Update(msg)
135 a.status = s.(status.StatusCmp)
136 cmds = append(cmds, statusCmd)
137
138 // If the current page is logs, update the logs view
139 if a.currentPage == page.LogsPage {
140 updated, pageCmd := a.pages[a.currentPage].Update(msg)
141 a.pages[a.currentPage] = updated.(util.Model)
142 cmds = append(cmds, pageCmd)
143 }
144 return a, tea.Batch(cmds...)
145 // Commands
146 case commands.SwitchSessionsMsg:
147 return a, func() tea.Msg {
148 allSessions, _ := a.app.Sessions.List(context.Background())
149 return dialogs.OpenDialogMsg{
150 Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
151 }
152 }
153
154 case commands.SwitchModelMsg:
155 return a, util.CmdHandler(
156 dialogs.OpenDialogMsg{
157 Model: models.NewModelDialogCmp(),
158 },
159 )
160 // File Picker
161 case chat.OpenFilePickerMsg:
162 if a.dialog.ActiveDialogId() == filepicker.FilePickerID {
163 // If the commands dialog is already open, close it
164 return a, util.CmdHandler(dialogs.CloseDialogMsg{})
165 }
166 return a, util.CmdHandler(dialogs.OpenDialogMsg{
167 Model: filepicker.NewFilePickerCmp(),
168 })
169 // Permissions
170 case pubsub.Event[permission.PermissionRequest]:
171 return a, util.CmdHandler(dialogs.OpenDialogMsg{
172 Model: permissions.NewPermissionDialogCmp(msg.Payload),
173 })
174 case permissions.PermissionResponseMsg:
175 switch msg.Action {
176 case permissions.PermissionAllow:
177 a.app.Permissions.Grant(msg.Permission)
178 case permissions.PermissionAllowForSession:
179 a.app.Permissions.GrantPersistent(msg.Permission)
180 case permissions.PermissionDeny:
181 a.app.Permissions.Deny(msg.Permission)
182 }
183 return a, nil
184 // Init Dialog
185 case initDialog.CloseInitDialogMsg:
186 if msg.Initialize {
187 // Run the initialization command
188 prompt := `Please analyze this codebase and create a Crush.md file containing:
1891. Build/lint/test commands - especially for running a single test
1902. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
191
192The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
193If there's already a crush.md, improve it.
194If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
195
196 // Mark the project as initialized
197 if err := config.MarkProjectInitialized(); err != nil {
198 return a, util.ReportError(err)
199 }
200
201 return a, util.CmdHandler(cmpChat.SendMsg{
202 Text: prompt,
203 })
204 } else {
205 // Mark the project as initialized without running the command
206 if err := config.MarkProjectInitialized(); err != nil {
207 return a, util.ReportError(err)
208 }
209 }
210 return a, nil
211 // Key Press Messages
212 case tea.KeyPressMsg:
213 if msg.String() == "ctrl+t" {
214 go a.app.Permissions.Request(permission.CreatePermissionRequest{
215 SessionID: "123",
216 ToolName: "bash",
217 Action: "execute",
218 Params: tools.BashPermissionsParams{
219 Command: "ls -la",
220 },
221 })
222 }
223 return a, a.handleKeyPressMsg(msg)
224 }
225 s, _ := a.status.Update(msg)
226 a.status = s.(status.StatusCmp)
227 updated, cmd := a.pages[a.currentPage].Update(msg)
228 a.pages[a.currentPage] = updated.(util.Model)
229 if a.dialog.HasDialogs() {
230 u, dialogCmd := a.dialog.Update(msg)
231 a.dialog = u.(dialogs.DialogCmp)
232 cmds = append(cmds, dialogCmd)
233 }
234 cmds = append(cmds, cmd)
235 return a, tea.Batch(cmds...)
236}
237
238// handleWindowResize processes window resize events and updates all components.
239func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
240 var cmds []tea.Cmd
241 msg.Height -= 1 // Make space for the status bar
242 a.width, a.height = msg.Width, msg.Height
243
244 // Update status bar
245 s, cmd := a.status.Update(msg)
246 a.status = s.(status.StatusCmp)
247 cmds = append(cmds, cmd)
248
249 // Update the current page
250 updated, cmd := a.pages[a.currentPage].Update(msg)
251 a.pages[a.currentPage] = updated.(util.Model)
252 cmds = append(cmds, cmd)
253
254 // Update the dialogs
255 dialog, cmd := a.dialog.Update(msg)
256 a.dialog = dialog.(dialogs.DialogCmp)
257 cmds = append(cmds, cmd)
258
259 return tea.Batch(cmds...)
260}
261
262// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
263func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
264 switch {
265 // completions
266 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Up):
267 u, cmd := a.completions.Update(msg)
268 a.completions = u.(completions.Completions)
269 return cmd
270
271 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Down):
272 u, cmd := a.completions.Update(msg)
273 a.completions = u.(completions.Completions)
274 return cmd
275 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Select):
276 u, cmd := a.completions.Update(msg)
277 a.completions = u.(completions.Completions)
278 return cmd
279 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Cancel):
280 u, cmd := a.completions.Update(msg)
281 a.completions = u.(completions.Completions)
282 return cmd
283 // dialogs
284 case key.Matches(msg, a.keyMap.Quit):
285 if a.dialog.ActiveDialogId() == quit.QuitDialogID {
286 // if the quit dialog is already open, close the app
287 return tea.Quit
288 }
289 return util.CmdHandler(dialogs.OpenDialogMsg{
290 Model: quit.NewQuitDialog(),
291 })
292
293 case key.Matches(msg, a.keyMap.Commands):
294 if a.dialog.ActiveDialogId() == commands.CommandsDialogID {
295 // If the commands dialog is already open, close it
296 return util.CmdHandler(dialogs.CloseDialogMsg{})
297 }
298 return util.CmdHandler(dialogs.OpenDialogMsg{
299 Model: commands.NewCommandDialog(),
300 })
301 case key.Matches(msg, a.keyMap.Sessions):
302 if a.dialog.ActiveDialogId() == sessions.SessionsDialogID {
303 // If the sessions dialog is already open, close it
304 return util.CmdHandler(dialogs.CloseDialogMsg{})
305 }
306 var cmds []tea.Cmd
307 if a.dialog.ActiveDialogId() == commands.CommandsDialogID {
308 // If the commands dialog is open, close it first
309 cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{}))
310 }
311 cmds = append(cmds,
312 func() tea.Msg {
313 allSessions, _ := a.app.Sessions.List(context.Background())
314 return dialogs.OpenDialogMsg{
315 Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
316 }
317 },
318 )
319 return tea.Sequence(cmds...)
320 // Page navigation
321 case key.Matches(msg, a.keyMap.Logs):
322 return a.moveToPage(page.LogsPage)
323
324 default:
325 if a.dialog.HasDialogs() {
326 u, dialogCmd := a.dialog.Update(msg)
327 a.dialog = u.(dialogs.DialogCmp)
328 return dialogCmd
329 } else {
330 updated, cmd := a.pages[a.currentPage].Update(msg)
331 a.pages[a.currentPage] = updated.(util.Model)
332 return cmd
333 }
334 }
335}
336
337// moveToPage handles navigation between different pages in the application.
338func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
339 if a.app.CoderAgent.IsBusy() {
340 // For now we don't move to any page if the agent is busy
341 return util.ReportWarn("Agent is busy, please wait...")
342 }
343
344 var cmds []tea.Cmd
345 if _, ok := a.loadedPages[pageID]; !ok {
346 cmd := a.pages[pageID].Init()
347 cmds = append(cmds, cmd)
348 a.loadedPages[pageID] = true
349 }
350 a.previousPage = a.currentPage
351 a.currentPage = pageID
352 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
353 cmd := sizable.SetSize(a.width, a.height)
354 cmds = append(cmds, cmd)
355 }
356
357 return tea.Batch(cmds...)
358}
359
360// View renders the complete application interface including pages, dialogs, and overlays.
361func (a *appModel) View() tea.View {
362 pageView := a.pages[a.currentPage].View()
363 components := []string{
364 pageView.String(),
365 }
366 components = append(components, a.status.View().String())
367
368 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
369 layers := []*lipgloss.Layer{
370 lipgloss.NewLayer(appView),
371 }
372 if a.dialog.HasDialogs() {
373 logging.Info("Rendering dialogs")
374 layers = append(
375 layers,
376 a.dialog.GetLayers()...,
377 )
378 }
379
380 cursor := pageView.Cursor()
381 activeView := a.dialog.ActiveView()
382 if activeView != nil {
383 cursor = activeView.Cursor()
384 }
385
386 if a.completions.Open() && cursor != nil {
387 cmp := a.completions.View().String()
388 x, y := a.completions.Position()
389 layers = append(
390 layers,
391 lipgloss.NewLayer(cmp).X(x).Y(y),
392 )
393 }
394
395 canvas := lipgloss.NewCanvas(
396 layers...,
397 )
398
399 t := styles.CurrentTheme()
400 view := tea.NewView(canvas.Render())
401 view.SetBackgroundColor(t.BgBase)
402 view.SetCursor(cursor)
403 return view
404}
405
406// New creates and initializes a new TUI application model.
407func New(app *app.App) tea.Model {
408 startPage := chat.ChatPage
409 model := &appModel{
410 currentPage: startPage,
411 app: app,
412 status: status.NewStatusCmp(),
413 loadedPages: make(map[page.PageID]bool),
414 keyMap: DefaultKeyMap(),
415
416 pages: map[page.PageID]util.Model{
417 chat.ChatPage: chat.NewChatPage(app),
418 page.LogsPage: page.NewLogsPage(),
419 },
420
421 dialog: dialogs.NewDialogCmp(),
422 completions: completions.New(),
423 }
424
425 return model
426}