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