tui.go

  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}