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