package tui import ( "context" "fmt" "strings" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/opencode-ai/opencode/internal/app" "github.com/opencode-ai/opencode/internal/config" "github.com/opencode-ai/opencode/internal/llm/agent" "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/permission" "github.com/opencode-ai/opencode/internal/pubsub" "github.com/opencode-ai/opencode/internal/session" "github.com/opencode-ai/opencode/internal/tui/components/chat" "github.com/opencode-ai/opencode/internal/tui/components/core" "github.com/opencode-ai/opencode/internal/tui/components/dialog" "github.com/opencode-ai/opencode/internal/tui/layout" "github.com/opencode-ai/opencode/internal/tui/page" "github.com/opencode-ai/opencode/internal/tui/theme" "github.com/opencode-ai/opencode/internal/tui/util" ) type keyMap struct { Logs key.Binding Quit key.Binding Help key.Binding SwitchSession key.Binding Commands key.Binding Filepicker key.Binding Models key.Binding SwitchTheme key.Binding } type startCompactSessionMsg struct{} const ( quitKey = "q" ) var keys = keyMap{ Logs: key.NewBinding( key.WithKeys("ctrl+l"), key.WithHelp("ctrl+l", "logs"), ), Quit: key.NewBinding( key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "quit"), ), Help: key.NewBinding( key.WithKeys("ctrl+_"), key.WithHelp("ctrl+?", "toggle help"), ), SwitchSession: key.NewBinding( key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "switch session"), ), Commands: key.NewBinding( key.WithKeys("ctrl+k"), key.WithHelp("ctrl+k", "commands"), ), Filepicker: key.NewBinding( key.WithKeys("ctrl+f"), key.WithHelp("ctrl+f", "select files to upload"), ), Models: key.NewBinding( key.WithKeys("ctrl+o"), key.WithHelp("ctrl+o", "model selection"), ), SwitchTheme: key.NewBinding( key.WithKeys("ctrl+t"), key.WithHelp("ctrl+t", "switch theme"), ), } var helpEsc = key.NewBinding( key.WithKeys("?"), key.WithHelp("?", "toggle help"), ) var returnKey = key.NewBinding( key.WithKeys("esc"), key.WithHelp("esc", "close"), ) var logsKeyReturnKey = key.NewBinding( key.WithKeys("esc", "backspace", quitKey), key.WithHelp("esc/q", "go back"), ) type appModel struct { width, height int currentPage page.PageID previousPage page.PageID pages map[page.PageID]tea.Model loadedPages map[page.PageID]bool status core.StatusCmp app *app.App selectedSession session.Session showPermissions bool permissions dialog.PermissionDialogCmp showHelp bool help dialog.HelpCmp showQuit bool quit dialog.QuitDialog showSessionDialog bool sessionDialog dialog.SessionDialog showCommandDialog bool commandDialog dialog.CommandDialog commands []dialog.Command showModelDialog bool modelDialog dialog.ModelDialog showInitDialog bool initDialog dialog.InitDialogCmp showFilepicker bool filepicker dialog.FilepickerCmp showThemeDialog bool themeDialog dialog.ThemeDialog showArgumentsDialog bool argumentsDialog dialog.ArgumentsDialogCmp isCompacting bool compactingMessage string } func (a appModel) Init() tea.Cmd { var cmds []tea.Cmd cmd := a.pages[a.currentPage].Init() a.loadedPages[a.currentPage] = true cmds = append(cmds, cmd) cmd = a.status.Init() cmds = append(cmds, cmd) cmd = a.quit.Init() cmds = append(cmds, cmd) cmd = a.help.Init() cmds = append(cmds, cmd) cmd = a.sessionDialog.Init() cmds = append(cmds, cmd) cmd = a.commandDialog.Init() cmds = append(cmds, cmd) cmd = a.modelDialog.Init() cmds = append(cmds, cmd) cmd = a.initDialog.Init() cmds = append(cmds, cmd) cmd = a.filepicker.Init() cmds = append(cmds, cmd) cmd = a.themeDialog.Init() cmds = append(cmds, cmd) // Check if we should show the init dialog cmds = append(cmds, func() tea.Msg { shouldShow, err := config.ShouldShowInitDialog() if err != nil { return util.InfoMsg{ Type: util.InfoTypeError, Msg: "Failed to check init status: " + err.Error(), } } return dialog.ShowInitDialogMsg{Show: shouldShow} }) return tea.Batch(cmds...) } func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: msg.Height -= 1 // Make space for the status bar a.width, a.height = msg.Width, msg.Height s, _ := a.status.Update(msg) a.status = s.(core.StatusCmp) a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg) cmds = append(cmds, cmd) prm, permCmd := a.permissions.Update(msg) a.permissions = prm.(dialog.PermissionDialogCmp) cmds = append(cmds, permCmd) help, helpCmd := a.help.Update(msg) a.help = help.(dialog.HelpCmp) cmds = append(cmds, helpCmd) session, sessionCmd := a.sessionDialog.Update(msg) a.sessionDialog = session.(dialog.SessionDialog) cmds = append(cmds, sessionCmd) command, commandCmd := a.commandDialog.Update(msg) a.commandDialog = command.(dialog.CommandDialog) cmds = append(cmds, commandCmd) filepicker, filepickerCmd := a.filepicker.Update(msg) a.filepicker = filepicker.(dialog.FilepickerCmp) cmds = append(cmds, filepickerCmd) a.initDialog.SetSize(msg.Width, msg.Height) if a.showArgumentsDialog { a.argumentsDialog.SetSize(msg.Width, msg.Height) args, argsCmd := a.argumentsDialog.Update(msg) a.argumentsDialog = args.(dialog.ArgumentsDialogCmp) cmds = append(cmds, argsCmd, a.argumentsDialog.Init()) } return a, tea.Batch(cmds...) // Status case util.InfoMsg: s, cmd := a.status.Update(msg) a.status = s.(core.StatusCmp) cmds = append(cmds, cmd) return a, tea.Batch(cmds...) case pubsub.Event[logging.LogMessage]: if msg.Payload.Persist { switch msg.Payload.Level { case "error": s, cmd := a.status.Update(util.InfoMsg{ Type: util.InfoTypeError, Msg: msg.Payload.Message, TTL: msg.Payload.PersistTime, }) a.status = s.(core.StatusCmp) cmds = append(cmds, cmd) case "info": s, cmd := a.status.Update(util.InfoMsg{ Type: util.InfoTypeInfo, Msg: msg.Payload.Message, TTL: msg.Payload.PersistTime, }) a.status = s.(core.StatusCmp) cmds = append(cmds, cmd) case "warn": s, cmd := a.status.Update(util.InfoMsg{ Type: util.InfoTypeWarn, Msg: msg.Payload.Message, TTL: msg.Payload.PersistTime, }) a.status = s.(core.StatusCmp) cmds = append(cmds, cmd) default: s, cmd := a.status.Update(util.InfoMsg{ Type: util.InfoTypeInfo, Msg: msg.Payload.Message, TTL: msg.Payload.PersistTime, }) a.status = s.(core.StatusCmp) cmds = append(cmds, cmd) } } case util.ClearStatusMsg: s, _ := a.status.Update(msg) a.status = s.(core.StatusCmp) // Permission case pubsub.Event[permission.PermissionRequest]: a.showPermissions = true return a, a.permissions.SetPermissions(msg.Payload) case dialog.PermissionResponseMsg: var cmd tea.Cmd switch msg.Action { case dialog.PermissionAllow: a.app.Permissions.Grant(msg.Permission) case dialog.PermissionAllowForSession: a.app.Permissions.GrantPersistant(msg.Permission) case dialog.PermissionDeny: a.app.Permissions.Deny(msg.Permission) } a.showPermissions = false return a, cmd case page.PageChangeMsg: return a, a.moveToPage(msg.ID) case dialog.CloseQuitMsg: a.showQuit = false return a, nil case dialog.CloseSessionDialogMsg: a.showSessionDialog = false return a, nil case dialog.CloseCommandDialogMsg: a.showCommandDialog = false return a, nil case startCompactSessionMsg: // Start compacting the current session a.isCompacting = true a.compactingMessage = "Starting summarization..." if a.selectedSession.ID == "" { a.isCompacting = false return a, util.ReportWarn("No active session to summarize") } // Start the summarization process return a, func() tea.Msg { ctx := context.Background() a.app.CoderAgent.Summarize(ctx, a.selectedSession.ID) return nil } case pubsub.Event[agent.AgentEvent]: payload := msg.Payload if payload.Error != nil { a.isCompacting = false return a, util.ReportError(payload.Error) } a.compactingMessage = payload.Progress if payload.Done && payload.Type == agent.AgentEventTypeSummarize { a.isCompacting = false if payload.SessionID != "" { // Switch to the new session return a, func() tea.Msg { sessions, err := a.app.Sessions.List(context.Background()) if err != nil { return util.InfoMsg{ Type: util.InfoTypeError, Msg: "Failed to list sessions: " + err.Error(), } } for _, s := range sessions { if s.ID == payload.SessionID { return dialog.SessionSelectedMsg{Session: s} } } return util.InfoMsg{ Type: util.InfoTypeError, Msg: "Failed to find new session", } } } return a, util.ReportInfo("Session summarization complete") } else if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSession.ID != "" { model := a.app.CoderAgent.Model() contextWindow := model.ContextWindow tokens := a.selectedSession.CompletionTokens + a.selectedSession.PromptTokens if (tokens >= int64(float64(contextWindow)*0.95)) && config.Get().AutoCompact { return a, util.CmdHandler(startCompactSessionMsg{}) } } // Continue listening for events return a, nil case dialog.CloseThemeDialogMsg: a.showThemeDialog = false return a, nil case dialog.ThemeChangedMsg: a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg) a.showThemeDialog = false return a, tea.Batch(cmd, util.ReportInfo("Theme changed to: "+msg.ThemeName)) case dialog.CloseModelDialogMsg: a.showModelDialog = false return a, nil case dialog.ModelSelectedMsg: a.showModelDialog = false model, err := a.app.CoderAgent.Update(config.AgentCoder, msg.Model.ID) if err != nil { return a, util.ReportError(err) } return a, util.ReportInfo(fmt.Sprintf("Model changed to %s", model.Name)) case dialog.ShowInitDialogMsg: a.showInitDialog = msg.Show return a, nil case dialog.CloseInitDialogMsg: a.showInitDialog = false if msg.Initialize { // Run the initialization command for _, cmd := range a.commands { if cmd.ID == "init" { // Mark the project as initialized if err := config.MarkProjectInitialized(); err != nil { return a, util.ReportError(err) } return a, cmd.Handler(cmd) } } } else { // Mark the project as initialized without running the command if err := config.MarkProjectInitialized(); err != nil { return a, util.ReportError(err) } } return a, nil case chat.SessionSelectedMsg: a.selectedSession = msg a.sessionDialog.SetSelectedSession(msg.ID) case pubsub.Event[session.Session]: if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == a.selectedSession.ID { a.selectedSession = msg.Payload } case dialog.SessionSelectedMsg: a.showSessionDialog = false if a.currentPage == page.ChatPage { return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session)) } return a, nil case dialog.CommandSelectedMsg: a.showCommandDialog = false // Execute the command handler if available if msg.Command.Handler != nil { return a, msg.Command.Handler(msg.Command) } return a, util.ReportInfo("Command selected: " + msg.Command.Title) case dialog.ShowArgumentsDialogMsg: // Show arguments dialog a.argumentsDialog = dialog.NewArgumentsDialogCmp(msg.CommandID, msg.Content) a.showArgumentsDialog = true return a, a.argumentsDialog.Init() case dialog.CloseArgumentsDialogMsg: // Close arguments dialog a.showArgumentsDialog = false // If submitted, replace $ARGUMENTS and run the command if msg.Submit { // Replace $ARGUMENTS with the provided arguments content := strings.ReplaceAll(msg.Content, "$ARGUMENTS", msg.Arguments) // Execute the command with arguments return a, util.CmdHandler(dialog.CommandRunCustomMsg{ Content: content, }) } return a, nil case tea.KeyMsg: // If arguments dialog is open, let it handle the key press first if a.showArgumentsDialog { args, cmd := a.argumentsDialog.Update(msg) a.argumentsDialog = args.(dialog.ArgumentsDialogCmp) return a, cmd } switch { case key.Matches(msg, keys.Quit): a.showQuit = !a.showQuit if a.showHelp { a.showHelp = false } if a.showSessionDialog { a.showSessionDialog = false } if a.showCommandDialog { a.showCommandDialog = false } if a.showFilepicker { a.showFilepicker = false a.filepicker.ToggleFilepicker(a.showFilepicker) } if a.showModelDialog { a.showModelDialog = false } if a.showArgumentsDialog { a.showArgumentsDialog = false } return a, nil case key.Matches(msg, keys.SwitchSession): if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog { // Load sessions and show the dialog sessions, err := a.app.Sessions.List(context.Background()) if err != nil { return a, util.ReportError(err) } if len(sessions) == 0 { return a, util.ReportWarn("No sessions available") } a.sessionDialog.SetSessions(sessions) a.showSessionDialog = true return a, nil } return a, nil case key.Matches(msg, keys.Commands): if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker { // Show commands dialog if len(a.commands) == 0 { return a, util.ReportWarn("No commands available") } a.commandDialog.SetCommands(a.commands) a.showCommandDialog = true return a, nil } return a, nil case key.Matches(msg, keys.Models): if a.showModelDialog { a.showModelDialog = false return a, nil } if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog { a.showModelDialog = true return a, nil } return a, nil case key.Matches(msg, keys.SwitchTheme): if !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog { // Show theme switcher dialog a.showThemeDialog = true // Theme list is dynamically loaded by the dialog component return a, a.themeDialog.Init() } return a, nil case key.Matches(msg, returnKey) || key.Matches(msg): if msg.String() == quitKey { if a.currentPage == page.LogsPage { return a, a.moveToPage(page.ChatPage) } } else if !a.filepicker.IsCWDFocused() { if a.showQuit { a.showQuit = !a.showQuit return a, nil } if a.showHelp { a.showHelp = !a.showHelp return a, nil } if a.showInitDialog { a.showInitDialog = false // Mark the project as initialized without running the command if err := config.MarkProjectInitialized(); err != nil { return a, util.ReportError(err) } return a, nil } if a.showFilepicker { a.showFilepicker = false a.filepicker.ToggleFilepicker(a.showFilepicker) return a, nil } if a.currentPage == page.LogsPage { return a, a.moveToPage(page.ChatPage) } } case key.Matches(msg, keys.Logs): return a, a.moveToPage(page.LogsPage) case key.Matches(msg, keys.Help): if a.showQuit { return a, nil } a.showHelp = !a.showHelp return a, nil case key.Matches(msg, helpEsc): if a.app.CoderAgent.IsBusy() { if a.showQuit { return a, nil } a.showHelp = !a.showHelp return a, nil } case key.Matches(msg, keys.Filepicker): a.showFilepicker = !a.showFilepicker a.filepicker.ToggleFilepicker(a.showFilepicker) return a, nil } default: f, filepickerCmd := a.filepicker.Update(msg) a.filepicker = f.(dialog.FilepickerCmp) cmds = append(cmds, filepickerCmd) } if a.showFilepicker { f, filepickerCmd := a.filepicker.Update(msg) a.filepicker = f.(dialog.FilepickerCmp) cmds = append(cmds, filepickerCmd) // Only block key messages send all other messages down if _, ok := msg.(tea.KeyMsg); ok { return a, tea.Batch(cmds...) } } if a.showQuit { q, quitCmd := a.quit.Update(msg) a.quit = q.(dialog.QuitDialog) cmds = append(cmds, quitCmd) // Only block key messages send all other messages down if _, ok := msg.(tea.KeyMsg); ok { return a, tea.Batch(cmds...) } } if a.showPermissions { d, permissionsCmd := a.permissions.Update(msg) a.permissions = d.(dialog.PermissionDialogCmp) cmds = append(cmds, permissionsCmd) // Only block key messages send all other messages down if _, ok := msg.(tea.KeyMsg); ok { return a, tea.Batch(cmds...) } } if a.showSessionDialog { d, sessionCmd := a.sessionDialog.Update(msg) a.sessionDialog = d.(dialog.SessionDialog) cmds = append(cmds, sessionCmd) // Only block key messages send all other messages down if _, ok := msg.(tea.KeyMsg); ok { return a, tea.Batch(cmds...) } } if a.showCommandDialog { d, commandCmd := a.commandDialog.Update(msg) a.commandDialog = d.(dialog.CommandDialog) cmds = append(cmds, commandCmd) // Only block key messages send all other messages down if _, ok := msg.(tea.KeyMsg); ok { return a, tea.Batch(cmds...) } } if a.showModelDialog { d, modelCmd := a.modelDialog.Update(msg) a.modelDialog = d.(dialog.ModelDialog) cmds = append(cmds, modelCmd) // Only block key messages send all other messages down if _, ok := msg.(tea.KeyMsg); ok { return a, tea.Batch(cmds...) } } if a.showInitDialog { d, initCmd := a.initDialog.Update(msg) a.initDialog = d.(dialog.InitDialogCmp) cmds = append(cmds, initCmd) // Only block key messages send all other messages down if _, ok := msg.(tea.KeyMsg); ok { return a, tea.Batch(cmds...) } } if a.showThemeDialog { d, themeCmd := a.themeDialog.Update(msg) a.themeDialog = d.(dialog.ThemeDialog) cmds = append(cmds, themeCmd) // Only block key messages send all other messages down if _, ok := msg.(tea.KeyMsg); ok { return a, tea.Batch(cmds...) } } s, _ := a.status.Update(msg) a.status = s.(core.StatusCmp) a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg) cmds = append(cmds, cmd) return a, tea.Batch(cmds...) } // RegisterCommand adds a command to the command dialog func (a *appModel) RegisterCommand(cmd dialog.Command) { a.commands = append(a.commands, cmd) } func (a *appModel) findCommand(id string) (dialog.Command, bool) { for _, cmd := range a.commands { if cmd.ID == id { return cmd, true } } return dialog.Command{}, false } func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd { if a.app.CoderAgent.IsBusy() { // For now we don't move to any page if the agent is busy return util.ReportWarn("Agent is busy, please wait...") } var cmds []tea.Cmd if _, ok := a.loadedPages[pageID]; !ok { cmd := a.pages[pageID].Init() cmds = append(cmds, cmd) a.loadedPages[pageID] = true } a.previousPage = a.currentPage a.currentPage = pageID if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok { cmd := sizable.SetSize(a.width, a.height) cmds = append(cmds, cmd) } return tea.Batch(cmds...) } func (a appModel) View() string { components := []string{ a.pages[a.currentPage].View(), } components = append(components, a.status.View()) appView := lipgloss.JoinVertical(lipgloss.Top, components...) if a.showPermissions { overlay := a.permissions.View() row := lipgloss.Height(appView) / 2 row -= lipgloss.Height(overlay) / 2 col := lipgloss.Width(appView) / 2 col -= lipgloss.Width(overlay) / 2 appView = layout.PlaceOverlay( col, row, overlay, appView, true, ) } if a.showFilepicker { overlay := a.filepicker.View() row := lipgloss.Height(appView) / 2 row -= lipgloss.Height(overlay) / 2 col := lipgloss.Width(appView) / 2 col -= lipgloss.Width(overlay) / 2 appView = layout.PlaceOverlay( col, row, overlay, appView, true, ) } // Show compacting status overlay if a.isCompacting { t := theme.CurrentTheme() style := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(t.BorderFocused()). BorderBackground(t.Background()). Padding(1, 2). Background(t.Background()). Foreground(t.Text()) overlay := style.Render("Summarizing\n" + a.compactingMessage) row := lipgloss.Height(appView) / 2 row -= lipgloss.Height(overlay) / 2 col := lipgloss.Width(appView) / 2 col -= lipgloss.Width(overlay) / 2 appView = layout.PlaceOverlay( col, row, overlay, appView, true, ) } if a.showHelp { bindings := layout.KeyMapToSlice(keys) if p, ok := a.pages[a.currentPage].(layout.Bindings); ok { bindings = append(bindings, p.BindingKeys()...) } if a.showPermissions { bindings = append(bindings, a.permissions.BindingKeys()...) } if a.currentPage == page.LogsPage { bindings = append(bindings, logsKeyReturnKey) } if !a.app.CoderAgent.IsBusy() { bindings = append(bindings, helpEsc) } a.help.SetBindings(bindings) overlay := a.help.View() row := lipgloss.Height(appView) / 2 row -= lipgloss.Height(overlay) / 2 col := lipgloss.Width(appView) / 2 col -= lipgloss.Width(overlay) / 2 appView = layout.PlaceOverlay( col, row, overlay, appView, true, ) } if a.showQuit { overlay := a.quit.View() row := lipgloss.Height(appView) / 2 row -= lipgloss.Height(overlay) / 2 col := lipgloss.Width(appView) / 2 col -= lipgloss.Width(overlay) / 2 appView = layout.PlaceOverlay( col, row, overlay, appView, true, ) } if a.showSessionDialog { overlay := a.sessionDialog.View() row := lipgloss.Height(appView) / 2 row -= lipgloss.Height(overlay) / 2 col := lipgloss.Width(appView) / 2 col -= lipgloss.Width(overlay) / 2 appView = layout.PlaceOverlay( col, row, overlay, appView, true, ) } if a.showModelDialog { overlay := a.modelDialog.View() row := lipgloss.Height(appView) / 2 row -= lipgloss.Height(overlay) / 2 col := lipgloss.Width(appView) / 2 col -= lipgloss.Width(overlay) / 2 appView = layout.PlaceOverlay( col, row, overlay, appView, true, ) } if a.showCommandDialog { overlay := a.commandDialog.View() row := lipgloss.Height(appView) / 2 row -= lipgloss.Height(overlay) / 2 col := lipgloss.Width(appView) / 2 col -= lipgloss.Width(overlay) / 2 appView = layout.PlaceOverlay( col, row, overlay, appView, true, ) } if a.showInitDialog { overlay := a.initDialog.View() appView = layout.PlaceOverlay( a.width/2-lipgloss.Width(overlay)/2, a.height/2-lipgloss.Height(overlay)/2, overlay, appView, true, ) } if a.showThemeDialog { overlay := a.themeDialog.View() row := lipgloss.Height(appView) / 2 row -= lipgloss.Height(overlay) / 2 col := lipgloss.Width(appView) / 2 col -= lipgloss.Width(overlay) / 2 appView = layout.PlaceOverlay( col, row, overlay, appView, true, ) } if a.showArgumentsDialog { overlay := a.argumentsDialog.View() row := lipgloss.Height(appView) / 2 row -= lipgloss.Height(overlay) / 2 col := lipgloss.Width(appView) / 2 col -= lipgloss.Width(overlay) / 2 appView = layout.PlaceOverlay( col, row, overlay, appView, true, ) } return appView } func New(app *app.App) tea.Model { startPage := page.ChatPage model := &appModel{ currentPage: startPage, loadedPages: make(map[page.PageID]bool), status: core.NewStatusCmp(app.LSPClients), help: dialog.NewHelpCmp(), quit: dialog.NewQuitCmp(), sessionDialog: dialog.NewSessionDialogCmp(), commandDialog: dialog.NewCommandDialogCmp(), modelDialog: dialog.NewModelDialogCmp(), permissions: dialog.NewPermissionDialogCmp(), initDialog: dialog.NewInitDialogCmp(), themeDialog: dialog.NewThemeDialogCmp(), app: app, commands: []dialog.Command{}, pages: map[page.PageID]tea.Model{ page.ChatPage: page.NewChatPage(app), page.LogsPage: page.NewLogsPage(), }, filepicker: dialog.NewFilepickerCmp(app), } model.RegisterCommand(dialog.Command{ ID: "init", Title: "Initialize Project", Description: "Create/Update the OpenCode.md memory file", Handler: func(cmd dialog.Command) tea.Cmd { prompt := `Please analyze this codebase and create a OpenCode.md file containing: 1. Build/lint/test commands - especially for running a single test 2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc. The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long. If there's already a opencode.md, improve it. If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.` return tea.Batch( util.CmdHandler(chat.SendMsg{ Text: prompt, }), ) }, }) model.RegisterCommand(dialog.Command{ ID: "compact", Title: "Compact Session", Description: "Summarize the current session and create a new one with the summary", Handler: func(cmd dialog.Command) tea.Cmd { return func() tea.Msg { return startCompactSessionMsg{} } }, }) // Load custom commands customCommands, err := dialog.LoadCustomCommands() if err != nil { logging.Warn("Failed to load custom commands", "error", err) } else { for _, cmd := range customCommands { model.RegisterCommand(cmd) } } return model }