@@ -7,6 +7,7 @@ import (
77 tea "github.com/charmbracelet/bubbletea"
88 "github.com/charmbracelet/lipgloss"
99 "github.com/kujtimiihoxha/opencode/internal/app"
10+ "github.com/kujtimiihoxha/opencode/internal/config"
1011 "github.com/kujtimiihoxha/opencode/internal/logging"
1112 "github.com/kujtimiihoxha/opencode/internal/permission"
1213 "github.com/kujtimiihoxha/opencode/internal/pubsub"
@@ -23,6 +24,7 @@ type keyMap struct {
2324 Quit key.Binding
2425 Help key.Binding
2526 SwitchSession key.Binding
27+ Commands key.Binding
2628}
2729
2830var keys = keyMap{
@@ -44,6 +46,11 @@ var keys = keyMap{
4446 key.WithKeys("ctrl+a"),
4547 key.WithHelp("ctrl+a", "switch session"),
4648 ),
49+
50+ Commands: key.NewBinding(
51+ key.WithKeys("ctrl+k"),
52+ key.WithHelp("ctrl+K", "commands"),
53+ ),
4754}
4855
4956var helpEsc = key.NewBinding(
@@ -82,6 +89,13 @@ type appModel struct {
8289 showSessionDialog bool
8390 sessionDialog dialog.SessionDialog
8491
92+ showCommandDialog bool
93+ commandDialog dialog.CommandDialog
94+ commands []dialog.Command
95+
96+ showInitDialog bool
97+ initDialog dialog.InitDialogCmp
98+
8599 editingMode bool
86100}
87101
@@ -98,6 +112,23 @@ func (a appModel) Init() tea.Cmd {
98112 cmds = append(cmds, cmd)
99113 cmd = a.sessionDialog.Init()
100114 cmds = append(cmds, cmd)
115+ cmd = a.commandDialog.Init()
116+ cmds = append(cmds, cmd)
117+ cmd = a.initDialog.Init()
118+ cmds = append(cmds, cmd)
119+
120+ // Check if we should show the init dialog
121+ cmds = append(cmds, func() tea.Msg {
122+ shouldShow, err := config.ShouldShowInitDialog()
123+ if err != nil {
124+ return util.InfoMsg{
125+ Type: util.InfoTypeError,
126+ Msg: "Failed to check init status: " + err.Error(),
127+ }
128+ }
129+ return dialog.ShowInitDialogMsg{Show: shouldShow}
130+ })
131+
101132 return tea.Batch(cmds...)
102133}
103134
@@ -126,6 +157,12 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
126157 a.sessionDialog = session.(dialog.SessionDialog)
127158 cmds = append(cmds, sessionCmd)
128159
160+ command, commandCmd := a.commandDialog.Update(msg)
161+ a.commandDialog = command.(dialog.CommandDialog)
162+ cmds = append(cmds, commandCmd)
163+
164+ a.initDialog.SetSize(msg.Width, msg.Height)
165+
129166 return a, tea.Batch(cmds...)
130167 case chat.EditorFocusMsg:
131168 a.editingMode = bool(msg)
@@ -207,6 +244,35 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
207244 a.showSessionDialog = false
208245 return a, nil
209246
247+ case dialog.CloseCommandDialogMsg:
248+ a.showCommandDialog = false
249+ return a, nil
250+
251+ case dialog.ShowInitDialogMsg:
252+ a.showInitDialog = msg.Show
253+ return a, nil
254+
255+ case dialog.CloseInitDialogMsg:
256+ a.showInitDialog = false
257+ if msg.Initialize {
258+ // Run the initialization command
259+ for _, cmd := range a.commands {
260+ if cmd.ID == "init" {
261+ // Mark the project as initialized
262+ if err := config.MarkProjectInitialized(); err != nil {
263+ return a, util.ReportError(err)
264+ }
265+ return a, cmd.Handler(cmd)
266+ }
267+ }
268+ } else {
269+ // Mark the project as initialized without running the command
270+ if err := config.MarkProjectInitialized(); err != nil {
271+ return a, util.ReportError(err)
272+ }
273+ }
274+ return a, nil
275+
210276 case chat.SessionSelectedMsg:
211277 a.sessionDialog.SetSelectedSession(msg.ID)
212278 case dialog.SessionSelectedMsg:
@@ -216,6 +282,14 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
216282 }
217283 return a, nil
218284
285+ case dialog.CommandSelectedMsg:
286+ a.showCommandDialog = false
287+ // Execute the command handler if available
288+ if msg.Command.Handler != nil {
289+ return a, msg.Command.Handler(msg.Command)
290+ }
291+ return a, util.ReportInfo("Command selected: " + msg.Command.Title)
292+
219293 case tea.KeyMsg:
220294 switch {
221295 case key.Matches(msg, keys.Quit):
@@ -226,9 +300,12 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
226300 if a.showSessionDialog {
227301 a.showSessionDialog = false
228302 }
303+ if a.showCommandDialog {
304+ a.showCommandDialog = false
305+ }
229306 return a, nil
230307 case key.Matches(msg, keys.SwitchSession):
231− if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions {
308+ if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
232309 // Load sessions and show the dialog
233310 sessions, err := a.app.Sessions.List(context.Background())
234311 if err != nil {
@@ -242,6 +319,17 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
242319 return a, nil
243320 }
244321 return a, nil
322+ case key.Matches(msg, keys.Commands):
323+ if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog {
324+ // Show commands dialog
325+ if len(a.commands) == 0 {
326+ return a, util.ReportWarn("No commands available")
327+ }
328+ a.commandDialog.SetCommands(a.commands)
329+ a.showCommandDialog = true
330+ return a, nil
331+ }
332+ return a, nil
245333 case key.Matches(msg, logsKeyReturnKey):
246334 if a.currentPage == page.LogsPage {
247335 return a, a.moveToPage(page.ChatPage)
@@ -255,6 +343,14 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
255343 a.showHelp = !a.showHelp
256344 return a, nil
257345 }
346+ if a.showInitDialog {
347+ a.showInitDialog = false
348+ // Mark the project as initialized without running the command
349+ if err := config.MarkProjectInitialized(); err != nil {
350+ return a, util.ReportError(err)
351+ }
352+ return a, nil
353+ }
258354 case key.Matches(msg, keys.Logs):
259355 return a, a.moveToPage(page.LogsPage)
260356 case key.Matches(msg, keys.Help):
@@ -304,6 +400,26 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
304400 }
305401 }
306402
403+ if a.showCommandDialog {
404+ d, commandCmd := a.commandDialog.Update(msg)
405+ a.commandDialog = d.(dialog.CommandDialog)
406+ cmds = append(cmds, commandCmd)
407+ // Only block key messages send all other messages down
408+ if _, ok := msg.(tea.KeyMsg); ok {
409+ return a, tea.Batch(cmds...)
410+ }
411+ }
412+
413+ if a.showInitDialog {
414+ d, initCmd := a.initDialog.Update(msg)
415+ a.initDialog = d.(dialog.InitDialogCmp)
416+ cmds = append(cmds, initCmd)
417+ // Only block key messages send all other messages down
418+ if _, ok := msg.(tea.KeyMsg); ok {
419+ return a, tea.Batch(cmds...)
420+ }
421+ }
422+
307423 s, _ := a.status.Update(msg)
308424 a.status = s.(core.StatusCmp)
309425 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
@@ -311,6 +427,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
311427 return a, tea.Batch(cmds...)
312428}
313429
430+// RegisterCommand adds a command to the command dialog
431+func (a *appModel) RegisterCommand(cmd dialog.Command) {
432+ a.commands = append(a.commands, cmd)
433+}
434+
314435func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
315436 if a.app.CoderAgent.IsBusy() {
316437 // For now we don't move to any page if the agent is busy
@@ -422,24 +543,74 @@ func (a appModel) View() string {
422543 )
423544 }
424545
546+ if a.showCommandDialog {
547+ overlay := a.commandDialog.View()
548+ row := lipgloss.Height(appView) / 2
549+ row -= lipgloss.Height(overlay) / 2
550+ col := lipgloss.Width(appView) / 2
551+ col -= lipgloss.Width(overlay) / 2
552+ appView = layout.PlaceOverlay(
553+ col,
554+ row,
555+ overlay,
556+ appView,
557+ true,
558+ )
559+ }
560+
561+ if a.showInitDialog {
562+ overlay := a.initDialog.View()
563+ appView = layout.PlaceOverlay(
564+ a.width/2-lipgloss.Width(overlay)/2,
565+ a.height/2-lipgloss.Height(overlay)/2,
566+ overlay,
567+ appView,
568+ true,
569+ )
570+ }
571+
425572 return appView
426573}
427574
428575func New(app *app.App) tea.Model {
429576 startPage := page.ChatPage
430− return &appModel{
577+ model := &appModel{
431578 currentPage: startPage,
432579 loadedPages: make(map[page.PageID]bool),
433580 status: core.NewStatusCmp(app.LSPClients),
434581 help: dialog.NewHelpCmp(),
435582 quit: dialog.NewQuitCmp(),
436583 sessionDialog: dialog.NewSessionDialogCmp(),
584+ commandDialog: dialog.NewCommandDialogCmp(),
437585 permissions: dialog.NewPermissionDialogCmp(),
586+ initDialog: dialog.NewInitDialogCmp(),
438587 app: app,
439588 editingMode: true,
589+ commands: []dialog.Command{},
440590 pages: map[page.PageID]tea.Model{
441591 page.ChatPage: page.NewChatPage(app),
442592 page.LogsPage: page.NewLogsPage(),
443593 },
444594 }
595+
596+ model.RegisterCommand(dialog.Command{
597+ ID: "init",
598+ Title: "Initialize Project",
599+ Description: "Create/Update the OpenCode.md memory file",
600+ Handler: func(cmd dialog.Command) tea.Cmd {
601+ prompt := `Please analyze this codebase and create a OpenCode.md file containing:
602+1. Build/lint/test commands - especially for running a single test
603+2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
604+
605+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.
606+If there's already a opencode.md, improve it.
607+If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
608+ return tea.Batch(
609+ util.CmdHandler(chat.SendMsg{
610+ Text: prompt,
611+ }),
612+ )
613+ },
614+ })
615+ return model
445616}