tui.go

  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}