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