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