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