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 switch msg.Action {
167 case dialog.PermissionAllow:
168 a.app.Permissions.Grant(msg.Permission)
169 case dialog.PermissionAllowForSession:
170 a.app.Permissions.GrantPersistant(msg.Permission)
171 case dialog.PermissionDeny:
172 a.app.Permissions.Deny(msg.Permission)
173 }
174 a.showPermissions = false
175 return a, nil
176
177 case page.PageChangeMsg:
178 return a, a.moveToPage(msg.ID)
179
180 case dialog.CloseQuitMsg:
181 a.showQuit = false
182 return a, nil
183
184 case dialog.CloseSessionDialogMsg:
185 a.showSessionDialog = false
186 return a, nil
187
188 case chat.SessionSelectedMsg:
189 a.sessionDialog.SetSelectedSession(msg.ID)
190 case dialog.SessionSelectedMsg:
191 a.showSessionDialog = false
192 if a.currentPage == page.ChatPage {
193 return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
194 }
195 return a, nil
196
197 case tea.KeyMsg:
198 switch {
199 case key.Matches(msg, keys.Quit):
200 a.showQuit = !a.showQuit
201 if a.showHelp {
202 a.showHelp = false
203 }
204 if a.showSessionDialog {
205 a.showSessionDialog = false
206 }
207 return a, nil
208 case key.Matches(msg, keys.SwitchSession):
209 if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions {
210 // Load sessions and show the dialog
211 sessions, err := a.app.Sessions.List(context.Background())
212 if err != nil {
213 return a, util.ReportError(err)
214 }
215 if len(sessions) == 0 {
216 return a, util.ReportWarn("No sessions available")
217 }
218 a.sessionDialog.SetSessions(sessions)
219 a.showSessionDialog = true
220 return a, nil
221 }
222 return a, nil
223 case key.Matches(msg, logsKeyReturnKey):
224 if a.currentPage == page.LogsPage {
225 return a, a.moveToPage(page.ChatPage)
226 }
227 case key.Matches(msg, returnKey):
228 if a.showQuit {
229 a.showQuit = !a.showQuit
230 return a, nil
231 }
232 if a.showHelp {
233 a.showHelp = !a.showHelp
234 return a, nil
235 }
236 case key.Matches(msg, keys.Logs):
237 return a, a.moveToPage(page.LogsPage)
238 case key.Matches(msg, keys.Help):
239 if a.showQuit {
240 return a, nil
241 }
242 a.showHelp = !a.showHelp
243 return a, nil
244 }
245 }
246
247 if a.showQuit {
248 q, quitCmd := a.quit.Update(msg)
249 a.quit = q.(dialog.QuitDialog)
250 cmds = append(cmds, quitCmd)
251 // Only block key messages send all other messages down
252 if _, ok := msg.(tea.KeyMsg); ok {
253 return a, tea.Batch(cmds...)
254 }
255 }
256 if a.showPermissions {
257 d, permissionsCmd := a.permissions.Update(msg)
258 a.permissions = d.(dialog.PermissionDialogCmp)
259 cmds = append(cmds, permissionsCmd)
260 // Only block key messages send all other messages down
261 if _, ok := msg.(tea.KeyMsg); ok {
262 return a, tea.Batch(cmds...)
263 }
264 }
265
266 if a.showSessionDialog {
267 d, sessionCmd := a.sessionDialog.Update(msg)
268 a.sessionDialog = d.(dialog.SessionDialog)
269 cmds = append(cmds, sessionCmd)
270 // Only block key messages send all other messages down
271 if _, ok := msg.(tea.KeyMsg); ok {
272 return a, tea.Batch(cmds...)
273 }
274 }
275
276 a.status, _ = a.status.Update(msg)
277 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
278 cmds = append(cmds, cmd)
279 return a, tea.Batch(cmds...)
280}
281
282func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
283 if a.app.CoderAgent.IsBusy() {
284 // For now we don't move to any page if the agent is busy
285 return util.ReportWarn("Agent is busy, please wait...")
286 }
287 var cmds []tea.Cmd
288 if _, ok := a.loadedPages[pageID]; !ok {
289 cmd := a.pages[pageID].Init()
290 cmds = append(cmds, cmd)
291 a.loadedPages[pageID] = true
292 }
293 a.previousPage = a.currentPage
294 a.currentPage = pageID
295 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
296 cmd := sizable.SetSize(a.width, a.height)
297 cmds = append(cmds, cmd)
298 }
299
300 return tea.Batch(cmds...)
301}
302
303func (a appModel) View() string {
304 components := []string{
305 a.pages[a.currentPage].View(),
306 }
307
308 components = append(components, a.status.View())
309
310 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
311
312 if a.showPermissions {
313 overlay := a.permissions.View()
314 row := lipgloss.Height(appView) / 2
315 row -= lipgloss.Height(overlay) / 2
316 col := lipgloss.Width(appView) / 2
317 col -= lipgloss.Width(overlay) / 2
318 appView = layout.PlaceOverlay(
319 col,
320 row,
321 overlay,
322 appView,
323 true,
324 )
325 }
326
327 if a.showHelp {
328 bindings := layout.KeyMapToSlice(keys)
329 if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
330 bindings = append(bindings, p.BindingKeys()...)
331 }
332 if a.showPermissions {
333 bindings = append(bindings, a.permissions.BindingKeys()...)
334 }
335 if a.currentPage == page.LogsPage {
336 bindings = append(bindings, logsKeyReturnKey)
337 }
338
339 a.help.SetBindings(bindings)
340
341 overlay := a.help.View()
342 row := lipgloss.Height(appView) / 2
343 row -= lipgloss.Height(overlay) / 2
344 col := lipgloss.Width(appView) / 2
345 col -= lipgloss.Width(overlay) / 2
346 appView = layout.PlaceOverlay(
347 col,
348 row,
349 overlay,
350 appView,
351 true,
352 )
353 }
354
355 if a.showQuit {
356 overlay := a.quit.View()
357 row := lipgloss.Height(appView) / 2
358 row -= lipgloss.Height(overlay) / 2
359 col := lipgloss.Width(appView) / 2
360 col -= lipgloss.Width(overlay) / 2
361 appView = layout.PlaceOverlay(
362 col,
363 row,
364 overlay,
365 appView,
366 true,
367 )
368 }
369
370 if a.showSessionDialog {
371 overlay := a.sessionDialog.View()
372 row := lipgloss.Height(appView) / 2
373 row -= lipgloss.Height(overlay) / 2
374 col := lipgloss.Width(appView) / 2
375 col -= lipgloss.Width(overlay) / 2
376 appView = layout.PlaceOverlay(
377 col,
378 row,
379 overlay,
380 appView,
381 true,
382 )
383 }
384
385 return appView
386}
387
388func New(app *app.App) tea.Model {
389 startPage := page.ChatPage
390 return &appModel{
391 currentPage: startPage,
392 loadedPages: make(map[page.PageID]bool),
393 status: core.NewStatusCmp(app.LSPClients),
394 help: dialog.NewHelpCmp(),
395 quit: dialog.NewQuitCmp(),
396 sessionDialog: dialog.NewSessionDialogCmp(),
397 permissions: dialog.NewPermissionDialogCmp(),
398 app: app,
399 pages: map[page.PageID]tea.Model{
400 page.ChatPage: page.NewChatPage(app),
401 page.LogsPage: page.NewLogsPage(),
402 },
403 }
404}