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/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/compact"
20 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
21 initDialog "github.com/charmbracelet/crush/internal/tui/components/dialogs/init"
22 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
23 "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
24 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
25 "github.com/charmbracelet/crush/internal/tui/components/dialogs/sessions"
26 "github.com/charmbracelet/crush/internal/tui/layout"
27 "github.com/charmbracelet/crush/internal/tui/page"
28 "github.com/charmbracelet/crush/internal/tui/page/chat"
29 "github.com/charmbracelet/crush/internal/tui/page/logs"
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 == logs.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 return a, a.handleKeyPressMsg(msg)
223 }
224 s, _ := a.status.Update(msg)
225 a.status = s.(status.StatusCmp)
226 updated, cmd := a.pages[a.currentPage].Update(msg)
227 a.pages[a.currentPage] = updated.(util.Model)
228 if a.dialog.HasDialogs() {
229 u, dialogCmd := a.dialog.Update(msg)
230 a.dialog = u.(dialogs.DialogCmp)
231 cmds = append(cmds, dialogCmd)
232 }
233 cmds = append(cmds, cmd)
234 return a, tea.Batch(cmds...)
235}
236
237// handleWindowResize processes window resize events and updates all components.
238func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
239 var cmds []tea.Cmd
240 msg.Height -= 2 // Make space for the status bar
241 a.width, a.height = msg.Width, msg.Height
242
243 // Update status bar
244 s, cmd := a.status.Update(msg)
245 a.status = s.(status.StatusCmp)
246 cmds = append(cmds, cmd)
247
248 // Update the current page
249 updated, cmd := a.pages[a.currentPage].Update(msg)
250 a.pages[a.currentPage] = updated.(util.Model)
251 cmds = append(cmds, cmd)
252
253 // Update the dialogs
254 dialog, cmd := a.dialog.Update(msg)
255 a.dialog = dialog.(dialogs.DialogCmp)
256 cmds = append(cmds, cmd)
257
258 return tea.Batch(cmds...)
259}
260
261// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
262func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
263 switch {
264 // completions
265 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Up):
266 u, cmd := a.completions.Update(msg)
267 a.completions = u.(completions.Completions)
268 return cmd
269
270 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Down):
271 u, cmd := a.completions.Update(msg)
272 a.completions = u.(completions.Completions)
273 return cmd
274 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Select):
275 u, cmd := a.completions.Update(msg)
276 a.completions = u.(completions.Completions)
277 return cmd
278 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Cancel):
279 u, cmd := a.completions.Update(msg)
280 a.completions = u.(completions.Completions)
281 return cmd
282 // dialogs
283 case key.Matches(msg, a.keyMap.Quit):
284 if a.dialog.ActiveDialogId() == quit.QuitDialogID {
285 // if the quit dialog is already open, close the app
286 return tea.Quit
287 }
288 return util.CmdHandler(dialogs.OpenDialogMsg{
289 Model: quit.NewQuitDialog(),
290 })
291
292 case key.Matches(msg, a.keyMap.Commands):
293 if a.dialog.ActiveDialogId() == commands.CommandsDialogID {
294 // If the commands dialog is already open, close it
295 return util.CmdHandler(dialogs.CloseDialogMsg{})
296 }
297 return util.CmdHandler(dialogs.OpenDialogMsg{
298 Model: commands.NewCommandDialog(a.selectedSessionID),
299 })
300 case key.Matches(msg, a.keyMap.Sessions):
301 if a.dialog.ActiveDialogId() == sessions.SessionsDialogID {
302 // If the sessions dialog is already open, close it
303 return util.CmdHandler(dialogs.CloseDialogMsg{})
304 }
305 var cmds []tea.Cmd
306 if a.dialog.ActiveDialogId() == commands.CommandsDialogID {
307 // If the commands dialog is open, close it first
308 cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{}))
309 }
310 cmds = append(cmds,
311 func() tea.Msg {
312 allSessions, _ := a.app.Sessions.List(context.Background())
313 return dialogs.OpenDialogMsg{
314 Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
315 }
316 },
317 )
318 return tea.Sequence(cmds...)
319 // Page navigation
320 case key.Matches(msg, a.keyMap.Logs):
321 return a.moveToPage(logs.LogsPage)
322
323 default:
324 if a.dialog.HasDialogs() {
325 u, dialogCmd := a.dialog.Update(msg)
326 a.dialog = u.(dialogs.DialogCmp)
327 return dialogCmd
328 } else {
329 updated, cmd := a.pages[a.currentPage].Update(msg)
330 a.pages[a.currentPage] = updated.(util.Model)
331 return cmd
332 }
333 }
334}
335
336// moveToPage handles navigation between different pages in the application.
337func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
338 if a.app.CoderAgent.IsBusy() {
339 // For now we don't move to any page if the agent is busy
340 return util.ReportWarn("Agent is busy, please wait...")
341 }
342
343 var cmds []tea.Cmd
344 if _, ok := a.loadedPages[pageID]; !ok {
345 cmd := a.pages[pageID].Init()
346 cmds = append(cmds, cmd)
347 a.loadedPages[pageID] = true
348 }
349 a.previousPage = a.currentPage
350 a.currentPage = pageID
351 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
352 cmd := sizable.SetSize(a.width, a.height)
353 cmds = append(cmds, cmd)
354 }
355
356 return tea.Batch(cmds...)
357}
358
359// View renders the complete application interface including pages, dialogs, and overlays.
360func (a *appModel) View() tea.View {
361 pageView := a.pages[a.currentPage].View()
362 components := []string{
363 pageView.String(),
364 }
365 components = append(components, a.status.View().String())
366
367 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
368 layers := []*lipgloss.Layer{
369 lipgloss.NewLayer(appView),
370 }
371 if a.dialog.HasDialogs() {
372 layers = append(
373 layers,
374 a.dialog.GetLayers()...,
375 )
376 }
377
378 cursor := pageView.Cursor()
379 activeView := a.dialog.ActiveView()
380 if activeView != nil {
381 cursor = activeView.Cursor()
382 }
383
384 if a.completions.Open() && cursor != nil {
385 cmp := a.completions.View().String()
386 x, y := a.completions.Position()
387 layers = append(
388 layers,
389 lipgloss.NewLayer(cmp).X(x).Y(y),
390 )
391 }
392
393 canvas := lipgloss.NewCanvas(
394 layers...,
395 )
396
397 t := styles.CurrentTheme()
398 view := tea.NewView(canvas.Render())
399 view.SetBackgroundColor(t.BgBase)
400 view.SetCursor(cursor)
401 return view
402}
403
404// New creates and initializes a new TUI application model.
405func New(app *app.App) tea.Model {
406 startPage := chat.ChatPage
407 model := &appModel{
408 currentPage: startPage,
409 app: app,
410 status: status.NewStatusCmp(),
411 loadedPages: make(map[page.PageID]bool),
412 keyMap: DefaultKeyMap(),
413
414 pages: map[page.PageID]util.Model{
415 chat.ChatPage: chat.NewChatPage(app),
416 logs.LogsPage: logs.NewLogsPage(),
417 },
418
419 dialog: dialogs.NewDialogCmp(),
420 completions: completions.New(),
421 }
422
423 return model
424}