1package tui
2
3import (
4 "context"
5
6 "github.com/charmbracelet/bubbles/key"
7 tea "github.com/charmbracelet/bubbletea"
8 "github.com/charmbracelet/lipgloss"
9 "github.com/kujtimiihoxha/opencode/internal/app"
10 "github.com/kujtimiihoxha/opencode/internal/logging"
11 "github.com/kujtimiihoxha/opencode/internal/permission"
12 "github.com/kujtimiihoxha/opencode/internal/pubsub"
13 "github.com/kujtimiihoxha/opencode/internal/tui/components/chat"
14 "github.com/kujtimiihoxha/opencode/internal/tui/components/core"
15 "github.com/kujtimiihoxha/opencode/internal/tui/components/dialog"
16 "github.com/kujtimiihoxha/opencode/internal/tui/layout"
17 "github.com/kujtimiihoxha/opencode/internal/tui/page"
18 "github.com/kujtimiihoxha/opencode/internal/tui/util"
19)
20
21type keyMap struct {
22 Logs key.Binding
23 Quit key.Binding
24 Help key.Binding
25 SwitchSession key.Binding
26}
27
28var keys = keyMap{
29 Logs: key.NewBinding(
30 key.WithKeys("ctrl+l"),
31 key.WithHelp("ctrl+L", "logs"),
32 ),
33
34 Quit: key.NewBinding(
35 key.WithKeys("ctrl+c"),
36 key.WithHelp("ctrl+c", "quit"),
37 ),
38 Help: key.NewBinding(
39 key.WithKeys("ctrl+_"),
40 key.WithHelp("ctrl+?", "toggle help"),
41 ),
42
43 SwitchSession: key.NewBinding(
44 key.WithKeys("ctrl+a"),
45 key.WithHelp("ctrl+a", "switch session"),
46 ),
47}
48
49var helpEsc = key.NewBinding(
50 key.WithKeys("?"),
51 key.WithHelp("?", "toggle help"),
52)
53
54var returnKey = key.NewBinding(
55 key.WithKeys("esc"),
56 key.WithHelp("esc", "close"),
57)
58
59var logsKeyReturnKey = key.NewBinding(
60 key.WithKeys("backspace", "q"),
61 key.WithHelp("backspace/q", "go back"),
62)
63
64type appModel struct {
65 width, height int
66 currentPage page.PageID
67 previousPage page.PageID
68 pages map[page.PageID]tea.Model
69 loadedPages map[page.PageID]bool
70 status core.StatusCmp
71 app *app.App
72
73 showPermissions bool
74 permissions dialog.PermissionDialogCmp
75
76 showHelp bool
77 help dialog.HelpCmp
78
79 showQuit bool
80 quit dialog.QuitDialog
81
82 showSessionDialog bool
83 sessionDialog dialog.SessionDialog
84
85 editingMode bool
86}
87
88func (a appModel) Init() tea.Cmd {
89 var cmds []tea.Cmd
90 cmd := a.pages[a.currentPage].Init()
91 a.loadedPages[a.currentPage] = true
92 cmds = append(cmds, cmd)
93 cmd = a.status.Init()
94 cmds = append(cmds, cmd)
95 cmd = a.quit.Init()
96 cmds = append(cmds, cmd)
97 cmd = a.help.Init()
98 cmds = append(cmds, cmd)
99 cmd = a.sessionDialog.Init()
100 cmds = append(cmds, cmd)
101 return tea.Batch(cmds...)
102}
103
104func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
105 var cmds []tea.Cmd
106 var cmd tea.Cmd
107 switch msg := msg.(type) {
108 case tea.WindowSizeMsg:
109 msg.Height -= 1 // Make space for the status bar
110 a.width, a.height = msg.Width, msg.Height
111
112 s, _ := a.status.Update(msg)
113 a.status = s.(core.StatusCmp)
114 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
115 cmds = append(cmds, cmd)
116
117 prm, permCmd := a.permissions.Update(msg)
118 a.permissions = prm.(dialog.PermissionDialogCmp)
119 cmds = append(cmds, permCmd)
120
121 help, helpCmd := a.help.Update(msg)
122 a.help = help.(dialog.HelpCmp)
123 cmds = append(cmds, helpCmd)
124
125 session, sessionCmd := a.sessionDialog.Update(msg)
126 a.sessionDialog = session.(dialog.SessionDialog)
127 cmds = append(cmds, sessionCmd)
128
129 return a, tea.Batch(cmds...)
130 case chat.EditorFocusMsg:
131 a.editingMode = bool(msg)
132 // Status
133 case util.InfoMsg:
134 s, cmd := a.status.Update(msg)
135 a.status = s.(core.StatusCmp)
136 cmds = append(cmds, cmd)
137 return a, tea.Batch(cmds...)
138 case pubsub.Event[logging.LogMessage]:
139 if msg.Payload.Persist {
140 switch msg.Payload.Level {
141 case "error":
142 s, cmd := a.status.Update(util.InfoMsg{
143 Type: util.InfoTypeError,
144 Msg: msg.Payload.Message,
145 TTL: msg.Payload.PersistTime,
146 })
147 a.status = s.(core.StatusCmp)
148 cmds = append(cmds, cmd)
149 case "info":
150 s, cmd := a.status.Update(util.InfoMsg{
151 Type: util.InfoTypeInfo,
152 Msg: msg.Payload.Message,
153 TTL: msg.Payload.PersistTime,
154 })
155 a.status = s.(core.StatusCmp)
156 cmds = append(cmds, cmd)
157
158 case "warn":
159 s, cmd := a.status.Update(util.InfoMsg{
160 Type: util.InfoTypeWarn,
161 Msg: msg.Payload.Message,
162 TTL: msg.Payload.PersistTime,
163 })
164
165 a.status = s.(core.StatusCmp)
166 cmds = append(cmds, cmd)
167 default:
168 s, cmd := a.status.Update(util.InfoMsg{
169 Type: util.InfoTypeInfo,
170 Msg: msg.Payload.Message,
171 TTL: msg.Payload.PersistTime,
172 })
173 a.status = s.(core.StatusCmp)
174 cmds = append(cmds, cmd)
175 }
176 }
177 case util.ClearStatusMsg:
178 s, _ := a.status.Update(msg)
179 a.status = s.(core.StatusCmp)
180
181 // Permission
182 case pubsub.Event[permission.PermissionRequest]:
183 a.showPermissions = true
184 return a, a.permissions.SetPermissions(msg.Payload)
185 case dialog.PermissionResponseMsg:
186 var cmd tea.Cmd
187 switch msg.Action {
188 case dialog.PermissionAllow:
189 a.app.Permissions.Grant(msg.Permission)
190 case dialog.PermissionAllowForSession:
191 a.app.Permissions.GrantPersistant(msg.Permission)
192 case dialog.PermissionDeny:
193 a.app.Permissions.Deny(msg.Permission)
194 cmd = util.CmdHandler(chat.FocusEditorMsg(true))
195 }
196 a.showPermissions = false
197 return a, cmd
198
199 case page.PageChangeMsg:
200 return a, a.moveToPage(msg.ID)
201
202 case dialog.CloseQuitMsg:
203 a.showQuit = false
204 return a, nil
205
206 case dialog.CloseSessionDialogMsg:
207 a.showSessionDialog = false
208 return a, nil
209
210 case chat.SessionSelectedMsg:
211 a.sessionDialog.SetSelectedSession(msg.ID)
212 case dialog.SessionSelectedMsg:
213 a.showSessionDialog = false
214 if a.currentPage == page.ChatPage {
215 return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
216 }
217 return a, nil
218
219 case tea.KeyMsg:
220 switch {
221 case key.Matches(msg, keys.Quit):
222 a.showQuit = !a.showQuit
223 if a.showHelp {
224 a.showHelp = false
225 }
226 if a.showSessionDialog {
227 a.showSessionDialog = false
228 }
229 return a, nil
230 case key.Matches(msg, keys.SwitchSession):
231 if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions {
232 // Load sessions and show the dialog
233 sessions, err := a.app.Sessions.List(context.Background())
234 if err != nil {
235 return a, util.ReportError(err)
236 }
237 if len(sessions) == 0 {
238 return a, util.ReportWarn("No sessions available")
239 }
240 a.sessionDialog.SetSessions(sessions)
241 a.showSessionDialog = true
242 return a, nil
243 }
244 return a, nil
245 case key.Matches(msg, logsKeyReturnKey):
246 if a.currentPage == page.LogsPage {
247 return a, a.moveToPage(page.ChatPage)
248 }
249 case key.Matches(msg, returnKey):
250 if a.showQuit {
251 a.showQuit = !a.showQuit
252 return a, nil
253 }
254 if a.showHelp {
255 a.showHelp = !a.showHelp
256 return a, nil
257 }
258 case key.Matches(msg, keys.Logs):
259 return a, a.moveToPage(page.LogsPage)
260 case key.Matches(msg, keys.Help):
261 if a.showQuit {
262 return a, nil
263 }
264 a.showHelp = !a.showHelp
265 return a, nil
266 case key.Matches(msg, helpEsc):
267 if !a.editingMode {
268 if a.showQuit {
269 return a, nil
270 }
271 a.showHelp = !a.showHelp
272 return a, nil
273 }
274 }
275
276 }
277
278 if a.showQuit {
279 q, quitCmd := a.quit.Update(msg)
280 a.quit = q.(dialog.QuitDialog)
281 cmds = append(cmds, quitCmd)
282 // Only block key messages send all other messages down
283 if _, ok := msg.(tea.KeyMsg); ok {
284 return a, tea.Batch(cmds...)
285 }
286 }
287 if a.showPermissions {
288 d, permissionsCmd := a.permissions.Update(msg)
289 a.permissions = d.(dialog.PermissionDialogCmp)
290 cmds = append(cmds, permissionsCmd)
291 // Only block key messages send all other messages down
292 if _, ok := msg.(tea.KeyMsg); ok {
293 return a, tea.Batch(cmds...)
294 }
295 }
296
297 if a.showSessionDialog {
298 d, sessionCmd := a.sessionDialog.Update(msg)
299 a.sessionDialog = d.(dialog.SessionDialog)
300 cmds = append(cmds, sessionCmd)
301 // Only block key messages send all other messages down
302 if _, ok := msg.(tea.KeyMsg); ok {
303 return a, tea.Batch(cmds...)
304 }
305 }
306
307 s, _ := a.status.Update(msg)
308 a.status = s.(core.StatusCmp)
309 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
310 cmds = append(cmds, cmd)
311 return a, tea.Batch(cmds...)
312}
313
314func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
315 if a.app.CoderAgent.IsBusy() {
316 // For now we don't move to any page if the agent is busy
317 return util.ReportWarn("Agent is busy, please wait...")
318 }
319 var cmds []tea.Cmd
320 if _, ok := a.loadedPages[pageID]; !ok {
321 cmd := a.pages[pageID].Init()
322 cmds = append(cmds, cmd)
323 a.loadedPages[pageID] = true
324 }
325 a.previousPage = a.currentPage
326 a.currentPage = pageID
327 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
328 cmd := sizable.SetSize(a.width, a.height)
329 cmds = append(cmds, cmd)
330 }
331
332 return tea.Batch(cmds...)
333}
334
335func (a appModel) View() string {
336 components := []string{
337 a.pages[a.currentPage].View(),
338 }
339
340 components = append(components, a.status.View())
341
342 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
343
344 if a.showPermissions {
345 overlay := a.permissions.View()
346 row := lipgloss.Height(appView) / 2
347 row -= lipgloss.Height(overlay) / 2
348 col := lipgloss.Width(appView) / 2
349 col -= lipgloss.Width(overlay) / 2
350 appView = layout.PlaceOverlay(
351 col,
352 row,
353 overlay,
354 appView,
355 true,
356 )
357 }
358
359 if a.editingMode {
360 a.status.SetHelpMsg("ctrl+? help")
361 } else {
362 a.status.SetHelpMsg("? help")
363 }
364
365 if a.showHelp {
366 bindings := layout.KeyMapToSlice(keys)
367 if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
368 bindings = append(bindings, p.BindingKeys()...)
369 }
370 if a.showPermissions {
371 bindings = append(bindings, a.permissions.BindingKeys()...)
372 }
373 if a.currentPage == page.LogsPage {
374 bindings = append(bindings, logsKeyReturnKey)
375 }
376 if !a.editingMode {
377 bindings = append(bindings, helpEsc)
378 }
379 a.help.SetBindings(bindings)
380
381 overlay := a.help.View()
382 row := lipgloss.Height(appView) / 2
383 row -= lipgloss.Height(overlay) / 2
384 col := lipgloss.Width(appView) / 2
385 col -= lipgloss.Width(overlay) / 2
386 appView = layout.PlaceOverlay(
387 col,
388 row,
389 overlay,
390 appView,
391 true,
392 )
393 }
394
395 if a.showQuit {
396 overlay := a.quit.View()
397 row := lipgloss.Height(appView) / 2
398 row -= lipgloss.Height(overlay) / 2
399 col := lipgloss.Width(appView) / 2
400 col -= lipgloss.Width(overlay) / 2
401 appView = layout.PlaceOverlay(
402 col,
403 row,
404 overlay,
405 appView,
406 true,
407 )
408 }
409
410 if a.showSessionDialog {
411 overlay := a.sessionDialog.View()
412 row := lipgloss.Height(appView) / 2
413 row -= lipgloss.Height(overlay) / 2
414 col := lipgloss.Width(appView) / 2
415 col -= lipgloss.Width(overlay) / 2
416 appView = layout.PlaceOverlay(
417 col,
418 row,
419 overlay,
420 appView,
421 true,
422 )
423 }
424
425 return appView
426}
427
428func New(app *app.App) tea.Model {
429 startPage := page.ChatPage
430 return &appModel{
431 currentPage: startPage,
432 loadedPages: make(map[page.PageID]bool),
433 status: core.NewStatusCmp(app.LSPClients),
434 help: dialog.NewHelpCmp(),
435 quit: dialog.NewQuitCmp(),
436 sessionDialog: dialog.NewSessionDialogCmp(),
437 permissions: dialog.NewPermissionDialogCmp(),
438 app: app,
439 editingMode: true,
440 pages: map[page.PageID]tea.Model{
441 page.ChatPage: page.NewChatPage(app),
442 page.LogsPage: page.NewLogsPage(),
443 },
444 }
445}