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