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/config"
11 "github.com/kujtimiihoxha/opencode/internal/logging"
12 "github.com/kujtimiihoxha/opencode/internal/permission"
13 "github.com/kujtimiihoxha/opencode/internal/pubsub"
14 "github.com/kujtimiihoxha/opencode/internal/tui/components/chat"
15 "github.com/kujtimiihoxha/opencode/internal/tui/components/core"
16 "github.com/kujtimiihoxha/opencode/internal/tui/components/dialog"
17 "github.com/kujtimiihoxha/opencode/internal/tui/layout"
18 "github.com/kujtimiihoxha/opencode/internal/tui/page"
19 "github.com/kujtimiihoxha/opencode/internal/tui/util"
20)
21
22type keyMap struct {
23 Logs key.Binding
24 Quit key.Binding
25 Help key.Binding
26 SwitchSession key.Binding
27 Commands key.Binding
28}
29
30var keys = keyMap{
31 Logs: key.NewBinding(
32 key.WithKeys("ctrl+l"),
33 key.WithHelp("ctrl+L", "logs"),
34 ),
35
36 Quit: key.NewBinding(
37 key.WithKeys("ctrl+c"),
38 key.WithHelp("ctrl+c", "quit"),
39 ),
40 Help: key.NewBinding(
41 key.WithKeys("ctrl+_"),
42 key.WithHelp("ctrl+?", "toggle help"),
43 ),
44
45 SwitchSession: key.NewBinding(
46 key.WithKeys("ctrl+a"),
47 key.WithHelp("ctrl+a", "switch session"),
48 ),
49
50 Commands: key.NewBinding(
51 key.WithKeys("ctrl+k"),
52 key.WithHelp("ctrl+K", "commands"),
53 ),
54}
55
56var helpEsc = key.NewBinding(
57 key.WithKeys("?"),
58 key.WithHelp("?", "toggle help"),
59)
60
61var returnKey = key.NewBinding(
62 key.WithKeys("esc"),
63 key.WithHelp("esc", "close"),
64)
65
66var logsKeyReturnKey = key.NewBinding(
67 key.WithKeys("backspace", "q"),
68 key.WithHelp("backspace/q", "go back"),
69)
70
71type appModel struct {
72 width, height int
73 currentPage page.PageID
74 previousPage page.PageID
75 pages map[page.PageID]tea.Model
76 loadedPages map[page.PageID]bool
77 status core.StatusCmp
78 app *app.App
79
80 showPermissions bool
81 permissions dialog.PermissionDialogCmp
82
83 showHelp bool
84 help dialog.HelpCmp
85
86 showQuit bool
87 quit dialog.QuitDialog
88
89 showSessionDialog bool
90 sessionDialog dialog.SessionDialog
91
92 showCommandDialog bool
93 commandDialog dialog.CommandDialog
94 commands []dialog.Command
95
96 showInitDialog bool
97 initDialog dialog.InitDialogCmp
98
99 editingMode bool
100}
101
102func (a appModel) Init() tea.Cmd {
103 var cmds []tea.Cmd
104 cmd := a.pages[a.currentPage].Init()
105 a.loadedPages[a.currentPage] = true
106 cmds = append(cmds, cmd)
107 cmd = a.status.Init()
108 cmds = append(cmds, cmd)
109 cmd = a.quit.Init()
110 cmds = append(cmds, cmd)
111 cmd = a.help.Init()
112 cmds = append(cmds, cmd)
113 cmd = a.sessionDialog.Init()
114 cmds = append(cmds, cmd)
115 cmd = a.commandDialog.Init()
116 cmds = append(cmds, cmd)
117 cmd = a.initDialog.Init()
118 cmds = append(cmds, cmd)
119
120 // Check if we should show the init dialog
121 cmds = append(cmds, func() tea.Msg {
122 shouldShow, err := config.ShouldShowInitDialog()
123 if err != nil {
124 return util.InfoMsg{
125 Type: util.InfoTypeError,
126 Msg: "Failed to check init status: " + err.Error(),
127 }
128 }
129 return dialog.ShowInitDialogMsg{Show: shouldShow}
130 })
131
132 return tea.Batch(cmds...)
133}
134
135func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
136 var cmds []tea.Cmd
137 var cmd tea.Cmd
138 switch msg := msg.(type) {
139 case tea.WindowSizeMsg:
140 msg.Height -= 1 // Make space for the status bar
141 a.width, a.height = msg.Width, msg.Height
142
143 s, _ := a.status.Update(msg)
144 a.status = s.(core.StatusCmp)
145 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
146 cmds = append(cmds, cmd)
147
148 prm, permCmd := a.permissions.Update(msg)
149 a.permissions = prm.(dialog.PermissionDialogCmp)
150 cmds = append(cmds, permCmd)
151
152 help, helpCmd := a.help.Update(msg)
153 a.help = help.(dialog.HelpCmp)
154 cmds = append(cmds, helpCmd)
155
156 session, sessionCmd := a.sessionDialog.Update(msg)
157 a.sessionDialog = session.(dialog.SessionDialog)
158 cmds = append(cmds, sessionCmd)
159
160 command, commandCmd := a.commandDialog.Update(msg)
161 a.commandDialog = command.(dialog.CommandDialog)
162 cmds = append(cmds, commandCmd)
163
164 a.initDialog.SetSize(msg.Width, msg.Height)
165
166 return a, tea.Batch(cmds...)
167 case chat.EditorFocusMsg:
168 a.editingMode = bool(msg)
169 // Status
170 case util.InfoMsg:
171 s, cmd := a.status.Update(msg)
172 a.status = s.(core.StatusCmp)
173 cmds = append(cmds, cmd)
174 return a, tea.Batch(cmds...)
175 case pubsub.Event[logging.LogMessage]:
176 if msg.Payload.Persist {
177 switch msg.Payload.Level {
178 case "error":
179 s, cmd := a.status.Update(util.InfoMsg{
180 Type: util.InfoTypeError,
181 Msg: msg.Payload.Message,
182 TTL: msg.Payload.PersistTime,
183 })
184 a.status = s.(core.StatusCmp)
185 cmds = append(cmds, cmd)
186 case "info":
187 s, cmd := a.status.Update(util.InfoMsg{
188 Type: util.InfoTypeInfo,
189 Msg: msg.Payload.Message,
190 TTL: msg.Payload.PersistTime,
191 })
192 a.status = s.(core.StatusCmp)
193 cmds = append(cmds, cmd)
194
195 case "warn":
196 s, cmd := a.status.Update(util.InfoMsg{
197 Type: util.InfoTypeWarn,
198 Msg: msg.Payload.Message,
199 TTL: msg.Payload.PersistTime,
200 })
201
202 a.status = s.(core.StatusCmp)
203 cmds = append(cmds, cmd)
204 default:
205 s, cmd := a.status.Update(util.InfoMsg{
206 Type: util.InfoTypeInfo,
207 Msg: msg.Payload.Message,
208 TTL: msg.Payload.PersistTime,
209 })
210 a.status = s.(core.StatusCmp)
211 cmds = append(cmds, cmd)
212 }
213 }
214 case util.ClearStatusMsg:
215 s, _ := a.status.Update(msg)
216 a.status = s.(core.StatusCmp)
217
218 // Permission
219 case pubsub.Event[permission.PermissionRequest]:
220 a.showPermissions = true
221 return a, a.permissions.SetPermissions(msg.Payload)
222 case dialog.PermissionResponseMsg:
223 var cmd tea.Cmd
224 switch msg.Action {
225 case dialog.PermissionAllow:
226 a.app.Permissions.Grant(msg.Permission)
227 case dialog.PermissionAllowForSession:
228 a.app.Permissions.GrantPersistant(msg.Permission)
229 case dialog.PermissionDeny:
230 a.app.Permissions.Deny(msg.Permission)
231 cmd = util.CmdHandler(chat.FocusEditorMsg(true))
232 }
233 a.showPermissions = false
234 return a, cmd
235
236 case page.PageChangeMsg:
237 return a, a.moveToPage(msg.ID)
238
239 case dialog.CloseQuitMsg:
240 a.showQuit = false
241 return a, nil
242
243 case dialog.CloseSessionDialogMsg:
244 a.showSessionDialog = false
245 return a, nil
246
247 case dialog.CloseCommandDialogMsg:
248 a.showCommandDialog = false
249 return a, nil
250
251 case dialog.ShowInitDialogMsg:
252 a.showInitDialog = msg.Show
253 return a, nil
254
255 case dialog.CloseInitDialogMsg:
256 a.showInitDialog = false
257 if msg.Initialize {
258 // Run the initialization command
259 for _, cmd := range a.commands {
260 if cmd.ID == "init" {
261 // Mark the project as initialized
262 if err := config.MarkProjectInitialized(); err != nil {
263 return a, util.ReportError(err)
264 }
265 return a, cmd.Handler(cmd)
266 }
267 }
268 } else {
269 // Mark the project as initialized without running the command
270 if err := config.MarkProjectInitialized(); err != nil {
271 return a, util.ReportError(err)
272 }
273 }
274 return a, nil
275
276 case chat.SessionSelectedMsg:
277 a.sessionDialog.SetSelectedSession(msg.ID)
278 case dialog.SessionSelectedMsg:
279 a.showSessionDialog = false
280 if a.currentPage == page.ChatPage {
281 return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
282 }
283 return a, nil
284
285 case dialog.CommandSelectedMsg:
286 a.showCommandDialog = false
287 // Execute the command handler if available
288 if msg.Command.Handler != nil {
289 return a, msg.Command.Handler(msg.Command)
290 }
291 return a, util.ReportInfo("Command selected: " + msg.Command.Title)
292
293 case tea.KeyMsg:
294 switch {
295 case key.Matches(msg, keys.Quit):
296 a.showQuit = !a.showQuit
297 if a.showHelp {
298 a.showHelp = false
299 }
300 if a.showSessionDialog {
301 a.showSessionDialog = false
302 }
303 if a.showCommandDialog {
304 a.showCommandDialog = false
305 }
306 return a, nil
307 case key.Matches(msg, keys.SwitchSession):
308 if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
309 // Load sessions and show the dialog
310 sessions, err := a.app.Sessions.List(context.Background())
311 if err != nil {
312 return a, util.ReportError(err)
313 }
314 if len(sessions) == 0 {
315 return a, util.ReportWarn("No sessions available")
316 }
317 a.sessionDialog.SetSessions(sessions)
318 a.showSessionDialog = true
319 return a, nil
320 }
321 return a, nil
322 case key.Matches(msg, keys.Commands):
323 if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog {
324 // Show commands dialog
325 if len(a.commands) == 0 {
326 return a, util.ReportWarn("No commands available")
327 }
328 a.commandDialog.SetCommands(a.commands)
329 a.showCommandDialog = true
330 return a, nil
331 }
332 return a, nil
333 case key.Matches(msg, logsKeyReturnKey):
334 if a.currentPage == page.LogsPage {
335 return a, a.moveToPage(page.ChatPage)
336 }
337 case key.Matches(msg, returnKey):
338 if a.showQuit {
339 a.showQuit = !a.showQuit
340 return a, nil
341 }
342 if a.showHelp {
343 a.showHelp = !a.showHelp
344 return a, nil
345 }
346 if a.showInitDialog {
347 a.showInitDialog = false
348 // Mark the project as initialized without running the command
349 if err := config.MarkProjectInitialized(); err != nil {
350 return a, util.ReportError(err)
351 }
352 return a, nil
353 }
354 case key.Matches(msg, keys.Logs):
355 return a, a.moveToPage(page.LogsPage)
356 case key.Matches(msg, keys.Help):
357 if a.showQuit {
358 return a, nil
359 }
360 a.showHelp = !a.showHelp
361 return a, nil
362 case key.Matches(msg, helpEsc):
363 if !a.editingMode {
364 if a.showQuit {
365 return a, nil
366 }
367 a.showHelp = !a.showHelp
368 return a, nil
369 }
370 }
371
372 }
373
374 if a.showQuit {
375 q, quitCmd := a.quit.Update(msg)
376 a.quit = q.(dialog.QuitDialog)
377 cmds = append(cmds, quitCmd)
378 // Only block key messages send all other messages down
379 if _, ok := msg.(tea.KeyMsg); ok {
380 return a, tea.Batch(cmds...)
381 }
382 }
383 if a.showPermissions {
384 d, permissionsCmd := a.permissions.Update(msg)
385 a.permissions = d.(dialog.PermissionDialogCmp)
386 cmds = append(cmds, permissionsCmd)
387 // Only block key messages send all other messages down
388 if _, ok := msg.(tea.KeyMsg); ok {
389 return a, tea.Batch(cmds...)
390 }
391 }
392
393 if a.showSessionDialog {
394 d, sessionCmd := a.sessionDialog.Update(msg)
395 a.sessionDialog = d.(dialog.SessionDialog)
396 cmds = append(cmds, sessionCmd)
397 // Only block key messages send all other messages down
398 if _, ok := msg.(tea.KeyMsg); ok {
399 return a, tea.Batch(cmds...)
400 }
401 }
402
403 if a.showCommandDialog {
404 d, commandCmd := a.commandDialog.Update(msg)
405 a.commandDialog = d.(dialog.CommandDialog)
406 cmds = append(cmds, commandCmd)
407 // Only block key messages send all other messages down
408 if _, ok := msg.(tea.KeyMsg); ok {
409 return a, tea.Batch(cmds...)
410 }
411 }
412
413 if a.showInitDialog {
414 d, initCmd := a.initDialog.Update(msg)
415 a.initDialog = d.(dialog.InitDialogCmp)
416 cmds = append(cmds, initCmd)
417 // Only block key messages send all other messages down
418 if _, ok := msg.(tea.KeyMsg); ok {
419 return a, tea.Batch(cmds...)
420 }
421 }
422
423 s, _ := a.status.Update(msg)
424 a.status = s.(core.StatusCmp)
425 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
426 cmds = append(cmds, cmd)
427 return a, tea.Batch(cmds...)
428}
429
430// RegisterCommand adds a command to the command dialog
431func (a *appModel) RegisterCommand(cmd dialog.Command) {
432 a.commands = append(a.commands, cmd)
433}
434
435func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
436 if a.app.CoderAgent.IsBusy() {
437 // For now we don't move to any page if the agent is busy
438 return util.ReportWarn("Agent is busy, please wait...")
439 }
440 var cmds []tea.Cmd
441 if _, ok := a.loadedPages[pageID]; !ok {
442 cmd := a.pages[pageID].Init()
443 cmds = append(cmds, cmd)
444 a.loadedPages[pageID] = true
445 }
446 a.previousPage = a.currentPage
447 a.currentPage = pageID
448 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
449 cmd := sizable.SetSize(a.width, a.height)
450 cmds = append(cmds, cmd)
451 }
452
453 return tea.Batch(cmds...)
454}
455
456func (a appModel) View() string {
457 components := []string{
458 a.pages[a.currentPage].View(),
459 }
460
461 components = append(components, a.status.View())
462
463 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
464
465 if a.showPermissions {
466 overlay := a.permissions.View()
467 row := lipgloss.Height(appView) / 2
468 row -= lipgloss.Height(overlay) / 2
469 col := lipgloss.Width(appView) / 2
470 col -= lipgloss.Width(overlay) / 2
471 appView = layout.PlaceOverlay(
472 col,
473 row,
474 overlay,
475 appView,
476 true,
477 )
478 }
479
480 if a.editingMode {
481 a.status.SetHelpMsg("ctrl+? help")
482 } else {
483 a.status.SetHelpMsg("? help")
484 }
485
486 if a.showHelp {
487 bindings := layout.KeyMapToSlice(keys)
488 if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
489 bindings = append(bindings, p.BindingKeys()...)
490 }
491 if a.showPermissions {
492 bindings = append(bindings, a.permissions.BindingKeys()...)
493 }
494 if a.currentPage == page.LogsPage {
495 bindings = append(bindings, logsKeyReturnKey)
496 }
497 if !a.editingMode {
498 bindings = append(bindings, helpEsc)
499 }
500 a.help.SetBindings(bindings)
501
502 overlay := a.help.View()
503 row := lipgloss.Height(appView) / 2
504 row -= lipgloss.Height(overlay) / 2
505 col := lipgloss.Width(appView) / 2
506 col -= lipgloss.Width(overlay) / 2
507 appView = layout.PlaceOverlay(
508 col,
509 row,
510 overlay,
511 appView,
512 true,
513 )
514 }
515
516 if a.showQuit {
517 overlay := a.quit.View()
518 row := lipgloss.Height(appView) / 2
519 row -= lipgloss.Height(overlay) / 2
520 col := lipgloss.Width(appView) / 2
521 col -= lipgloss.Width(overlay) / 2
522 appView = layout.PlaceOverlay(
523 col,
524 row,
525 overlay,
526 appView,
527 true,
528 )
529 }
530
531 if a.showSessionDialog {
532 overlay := a.sessionDialog.View()
533 row := lipgloss.Height(appView) / 2
534 row -= lipgloss.Height(overlay) / 2
535 col := lipgloss.Width(appView) / 2
536 col -= lipgloss.Width(overlay) / 2
537 appView = layout.PlaceOverlay(
538 col,
539 row,
540 overlay,
541 appView,
542 true,
543 )
544 }
545
546 if a.showCommandDialog {
547 overlay := a.commandDialog.View()
548 row := lipgloss.Height(appView) / 2
549 row -= lipgloss.Height(overlay) / 2
550 col := lipgloss.Width(appView) / 2
551 col -= lipgloss.Width(overlay) / 2
552 appView = layout.PlaceOverlay(
553 col,
554 row,
555 overlay,
556 appView,
557 true,
558 )
559 }
560
561 if a.showInitDialog {
562 overlay := a.initDialog.View()
563 appView = layout.PlaceOverlay(
564 a.width/2-lipgloss.Width(overlay)/2,
565 a.height/2-lipgloss.Height(overlay)/2,
566 overlay,
567 appView,
568 true,
569 )
570 }
571
572 return appView
573}
574
575func New(app *app.App) tea.Model {
576 startPage := page.ChatPage
577 model := &appModel{
578 currentPage: startPage,
579 loadedPages: make(map[page.PageID]bool),
580 status: core.NewStatusCmp(app.LSPClients),
581 help: dialog.NewHelpCmp(),
582 quit: dialog.NewQuitCmp(),
583 sessionDialog: dialog.NewSessionDialogCmp(),
584 commandDialog: dialog.NewCommandDialogCmp(),
585 permissions: dialog.NewPermissionDialogCmp(),
586 initDialog: dialog.NewInitDialogCmp(),
587 app: app,
588 editingMode: true,
589 commands: []dialog.Command{},
590 pages: map[page.PageID]tea.Model{
591 page.ChatPage: page.NewChatPage(app),
592 page.LogsPage: page.NewLogsPage(),
593 },
594 }
595
596 model.RegisterCommand(dialog.Command{
597 ID: "init",
598 Title: "Initialize Project",
599 Description: "Create/Update the OpenCode.md memory file",
600 Handler: func(cmd dialog.Command) tea.Cmd {
601 prompt := `Please analyze this codebase and create a OpenCode.md file containing:
6021. Build/lint/test commands - especially for running a single test
6032. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
604
605The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
606If there's already a opencode.md, improve it.
607If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
608 return tea.Batch(
609 util.CmdHandler(chat.SendMsg{
610 Text: prompt,
611 }),
612 )
613 },
614 })
615 return model
616}