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