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