on main help sign in
init command
3a6a269Kujtim Hoxha
README.md+30−25
@@ -104,6 +104,7 @@ You can configure OpenCode using environment variables:
104OpenCode supports a variety of AI models from different providers:
104OpenCode supports a variety of AI models from different providers:
105
105
106### OpenAI
106### OpenAI
107+
107- GPT-4.1 family (gpt-4.1, gpt-4.1-mini, gpt-4.1-nano)
108- GPT-4.1 family (gpt-4.1, gpt-4.1-mini, gpt-4.1-nano)
108- GPT-4.5 Preview
109- GPT-4.5 Preview
109- GPT-4o family (gpt-4o, gpt-4o-mini)
110- GPT-4o family (gpt-4o, gpt-4o-mini)
@@ -112,6 +113,7 @@ OpenCode supports a variety of AI models from different providers:
112- O4 Mini
113- O4 Mini
113
114
114### Anthropic
115### Anthropic
116+
115- Claude 3.5 Sonnet
117- Claude 3.5 Sonnet
116- Claude 3.5 Haiku
118- Claude 3.5 Haiku
117- Claude 3.7 Sonnet
119- Claude 3.7 Sonnet
@@ -119,12 +121,14 @@ OpenCode supports a variety of AI models from different providers:
119- Claude 3 Opus
121- Claude 3 Opus
120
122
121### Google
123### Google
124+
122- Gemini 2.5
125- Gemini 2.5
123- Gemini 2.5 Flash
126- Gemini 2.5 Flash
124- Gemini 2.0 Flash
127- Gemini 2.0 Flash
125- Gemini 2.0 Flash Lite
128- Gemini 2.0 Flash Lite
126
129
127### AWS Bedrock
130### AWS Bedrock
131+
128- Claude 3.7 Sonnet
132- Claude 3.7 Sonnet
129
133
130## Usage
134## Usage
@@ -152,14 +156,15 @@ opencode -c /path/to/project
152
156
153### Global Shortcuts
157### Global Shortcuts
154
158
155−| Shortcut | Action |
159+| Shortcut | Action |
156−| --------- | ------------------------------------------------------- |
160+| -------- | ------------------------------------------------------- |
157−| `Ctrl+C` | Quit application |
161+| `Ctrl+C` | Quit application |
158−| `Ctrl+?` | Toggle help dialog |
162+| `Ctrl+?` | Toggle help dialog |
159−| `?` | Toggle help dialog (when not in editing mode) |
163+| `?` | Toggle help dialog (when not in editing mode) |
160−| `Ctrl+L` | View logs |
164+| `Ctrl+L` | View logs |
161−| `Ctrl+A` | Switch session |
165+| `Ctrl+A` | Switch session |
166+| `Ctrl+K` | Command dialog |
162−| `Esc` | Close current overlay/dialog or return to previous mode |
167+| `Esc` | Close current overlay/dialog or return to previous mode |
163
168
164### Chat Page Shortcuts
169### Chat Page Shortcuts
165
170
@@ -181,28 +186,28 @@ opencode -c /path/to/project
181
186
182### Session Dialog Shortcuts
187### Session Dialog Shortcuts
183
188
184−| Shortcut | Action |
189+| Shortcut | Action |
185−| ------------- | ---------------- |
190+| ---------- | ---------------- |
186−| `↑` or `k` | Previous session |
191+| `↑` or `k` | Previous session |
187−| `↓` or `j` | Next session |
192+| `↓` or `j` | Next session |
188−| `Enter` | Select session |
193+| `Enter` | Select session |
189−| `Esc` | Close dialog |
194+| `Esc` | Close dialog |
190
195
191### Permission Dialog Shortcuts
196### Permission Dialog Shortcuts
192
197
193−| Shortcut | Action |
198+| Shortcut | Action |
194−| ------------------------- | ----------------------- |
199+| ----------------------- | ---------------------------- |
195−| `←` or `left` | Switch options left |
200+| `←` or `left` | Switch options left |
196−| `→` or `right` or `tab` | Switch options right |
201+| `→` or `right` or `tab` | Switch options right |
197−| `Enter` or `space` | Confirm selection |
202+| `Enter` or `space` | Confirm selection |
198−| `a` | Allow permission |
203+| `a` | Allow permission |
199−| `A` | Allow permission for session |
204+| `A` | Allow permission for session |
200−| `d` | Deny permission |
205+| `d` | Deny permission |
201
206
202### Logs Page Shortcuts
207### Logs Page Shortcuts
203
208
204−| Shortcut | Action |
209+| Shortcut | Action |
205−| ---------------- | ------------------- |
210+| ------------------ | ------------------- |
206| `Backspace` or `q` | Return to chat page |
211| `Backspace` or `q` | Return to chat page |
207
212
208## AI Assistant Tools
213## AI Assistant Tools
@@ -369,4 +374,4 @@ Contributions are welcome! Here's how you can contribute:
3694. Push to the branch (`git push origin feature/amazing-feature`)
3744. Push to the branch (`git push origin feature/amazing-feature`)
3705. Open a Pull Request
3755. Open a Pull Request
371
376
372−Please make sure to update tests as appropriate and follow the existing code style.
377+Please make sure to update tests as appropriate and follow the existing code style.
internal/config/init.gonew file+61
@@ -0,0 +1,61 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+)
8+
9+const (
10+ // InitFlagFilename is the name of the file that indicates whether the project has been initialized
11+ InitFlagFilename = "init"
12+)
13+
14+// ProjectInitFlag represents the initialization status for a project directory
15+type ProjectInitFlag struct {
16+ Initialized bool `json:"initialized"`
17+}
18+
19+// ShouldShowInitDialog checks if the initialization dialog should be shown for the current directory
20+func ShouldShowInitDialog() (bool, error) {
21+ if cfg == nil {
22+ return false, fmt.Errorf("config not loaded")
23+ }
24+
25+ // Create the flag file path
26+ flagFilePath := filepath.Join(cfg.Data.Directory, InitFlagFilename)
27+
28+ // Check if the flag file exists
29+ _, err := os.Stat(flagFilePath)
30+ if err == nil {
31+ // File exists, don't show the dialog
32+ return false, nil
33+ }
34+
35+ // If the error is not "file not found", return the error
36+ if !os.IsNotExist(err) {
37+ return false, fmt.Errorf("failed to check init flag file: %w", err)
38+ }
39+
40+ // File doesn't exist, show the dialog
41+ return true, nil
42+}
43+
44+// MarkProjectInitialized marks the current project as initialized
45+func MarkProjectInitialized() error {
46+ if cfg == nil {
47+ return fmt.Errorf("config not loaded")
48+ }
49+ // Create the flag file path
50+ flagFilePath := filepath.Join(cfg.Data.Directory, InitFlagFilename)
51+
52+ // Create an empty file to mark the project as initialized
53+ file, err := os.Create(flagFilePath)
54+ if err != nil {
55+ return fmt.Errorf("failed to create init flag file: %w", err)
56+ }
57+ defer file.Close()
58+
59+ return nil
60+}
61+
internal/llm/agent/mcp-tools.go+5
@@ -80,9 +80,14 @@ func runTool(ctx context.Context, c MCPClient, toolName string, input string) (t
80}
80}
81
81
82func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolResponse, error) {
82func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolResponse, error) {
83+ sessionID, messageID := tools.GetContextValues(ctx)
84+ if sessionID == "" || messageID == "" {
85+ return tools.ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
86+ }
83 permissionDescription := fmt.Sprintf("execute %s with the following parameters: %s", b.Info().Name, params.Input)
87 permissionDescription := fmt.Sprintf("execute %s with the following parameters: %s", b.Info().Name, params.Input)
84 p := b.permissions.Request(
88 p := b.permissions.Request(
85 permission.CreatePermissionRequest{
89 permission.CreatePermissionRequest{
90+ SessionID: sessionID,
86 Path: config.WorkingDirectory(),
91 Path: config.WorkingDirectory(),
87 ToolName: b.Info().Name,
92 ToolName: b.Info().Name,
88 Action: "execute",
93 Action: "execute",
internal/llm/prompt/prompt.go+11−5
@@ -14,8 +14,13 @@ var contextFiles = []string{
14 ".github/copilot-instructions.md",
14 ".github/copilot-instructions.md",
15 ".cursorrules",
15 ".cursorrules",
16 "CLAUDE.md",
16 "CLAUDE.md",
17+ "CLAUDE.local.md",
17 "opencode.md",
18 "opencode.md",
19+ "opencode.local.md",
18 "OpenCode.md",
20 "OpenCode.md",
21+ "OpenCode.local.md",
22+ "OPENCODE.md",
23+ "OPENCODE.local.md",
19}
24}
20
25
21func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) string {
26func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) string {
@@ -31,12 +36,13 @@ func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) s
31 basePrompt = "You are a helpful assistant"
36 basePrompt = "You are a helpful assistant"
32 }
37 }
33
38
39+ if agentName == config.AgentCoder || agentName == config.AgentTask {
34− // Add context from project-specific instruction files if they exist
40+ // Add context from project-specific instruction files if they exist
35− contextContent := getContextFromFiles()
41+ contextContent := getContextFromFiles()
36− if contextContent != "" {
42+ if contextContent != "" {
37− return fmt.Sprintf("%s\n\n# Project-Specific Context\n%s", basePrompt, contextContent)
43+ return fmt.Sprintf("%s\n\n# Project-Specific Context\n%s", basePrompt, contextContent)
44+ }
38 }
45 }
39−
40 return basePrompt
46 return basePrompt
41}
47}
42
48
internal/llm/prompt/title.go+1
@@ -6,6 +6,7 @@ func TitlePrompt(_ models.ModelProvider) string {
6 return `you will generate a short title based on the first message a user begins a conversation with
6 return `you will generate a short title based on the first message a user begins a conversation with
7- ensure it is not more than 50 characters long
7- ensure it is not more than 50 characters long
8- the title should be a summary of the user's message
8- the title should be a summary of the user's message
9+- it should be one line long
9- do not use quotes or colons
10- do not use quotes or colons
10- the entire text you return will be used as the title`
11- the entire text you return will be used as the title`
11}
12}
internal/llm/tools/bash.go+7−1
@@ -51,7 +51,7 @@ var safeReadOnlyCommands = []string{
51 "git status", "git log", "git diff", "git show", "git branch", "git tag", "git remote", "git ls-files", "git ls-remote",
51 "git status", "git log", "git diff", "git show", "git branch", "git tag", "git remote", "git ls-files", "git ls-remote",
52 "git rev-parse", "git config --get", "git config --list", "git describe", "git blame", "git grep", "git shortlog",
52 "git rev-parse", "git config --get", "git config --list", "git describe", "git blame", "git grep", "git shortlog",
53
53
54− "go version", "go list", "go env", "go doc", "go vet", "go fmt", "go mod", "go test", "go build", "go run", "go install", "go clean",
54+ "go version", "go help", "go list", "go env", "go doc", "go vet", "go fmt", "go mod", "go test", "go build", "go run", "go install", "go clean",
55}
55}
56
56
57func bashDescription() string {
57func bashDescription() string {
@@ -261,9 +261,15 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
261 }
261 }
262 }
262 }
263 }
263 }
264+
265+ sessionID, messageID := GetContextValues(ctx)
266+ if sessionID == "" || messageID == "" {
267+ return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
268+ }
264 if !isSafeReadOnly {
269 if !isSafeReadOnly {
265 p := b.permissions.Request(
270 p := b.permissions.Request(
266 permission.CreatePermissionRequest{
271 permission.CreatePermissionRequest{
272+ SessionID: sessionID,
267 Path: config.WorkingDirectory(),
273 Path: config.WorkingDirectory(),
268 ToolName: BashToolName,
274 ToolName: BashToolName,
269 Action: "execute",
275 Action: "execute",
internal/llm/tools/edit.go+3
@@ -203,6 +203,7 @@ func (e *editTool) createNewFile(ctx context.Context, filePath, content string)
203 }
203 }
204 p := e.permissions.Request(
204 p := e.permissions.Request(
205 permission.CreatePermissionRequest{
205 permission.CreatePermissionRequest{
206+ SessionID: sessionID,
206 Path: permissionPath,
207 Path: permissionPath,
207 ToolName: EditToolName,
208 ToolName: EditToolName,
208 Action: "write",
209 Action: "write",
@@ -313,6 +314,7 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string
313 }
314 }
314 p := e.permissions.Request(
315 p := e.permissions.Request(
315 permission.CreatePermissionRequest{
316 permission.CreatePermissionRequest{
317+ SessionID: sessionID,
316 Path: permissionPath,
318 Path: permissionPath,
317 ToolName: EditToolName,
319 ToolName: EditToolName,
318 Action: "write",
320 Action: "write",
@@ -432,6 +434,7 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
432 }
434 }
433 p := e.permissions.Request(
435 p := e.permissions.Request(
434 permission.CreatePermissionRequest{
436 permission.CreatePermissionRequest{
437+ SessionID: sessionID,
435 Path: permissionPath,
438 Path: permissionPath,
436 ToolName: EditToolName,
439 ToolName: EditToolName,
437 Action: "write",
440 Action: "write",
internal/llm/tools/fetch.go+6
@@ -116,8 +116,14 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
116 return NewTextErrorResponse("URL must start with http:// or https://"), nil
116 return NewTextErrorResponse("URL must start with http:// or https://"), nil
117 }
117 }
118
118
119+ sessionID, messageID := GetContextValues(ctx)
120+ if sessionID == "" || messageID == "" {
121+ return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
122+ }
123+
119 p := t.permissions.Request(
124 p := t.permissions.Request(
120 permission.CreatePermissionRequest{
125 permission.CreatePermissionRequest{
126+ SessionID: sessionID,
121 Path: config.WorkingDirectory(),
127 Path: config.WorkingDirectory(),
122 ToolName: FetchToolName,
128 ToolName: FetchToolName,
123 Action: "fetch",
129 Action: "fetch",
internal/llm/tools/patch.go+3
@@ -194,6 +194,7 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
194 patchDiff, _, _ := diff.GenerateDiff("", *change.NewContent, path)
194 patchDiff, _, _ := diff.GenerateDiff("", *change.NewContent, path)
195 p := p.permissions.Request(
195 p := p.permissions.Request(
196 permission.CreatePermissionRequest{
196 permission.CreatePermissionRequest{
197+ SessionID: sessionID,
197 Path: dir,
198 Path: dir,
198 ToolName: PatchToolName,
199 ToolName: PatchToolName,
199 Action: "create",
200 Action: "create",
@@ -220,6 +221,7 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
220 dir := filepath.Dir(path)
221 dir := filepath.Dir(path)
221 p := p.permissions.Request(
222 p := p.permissions.Request(
222 permission.CreatePermissionRequest{
223 permission.CreatePermissionRequest{
224+ SessionID: sessionID,
223 Path: dir,
225 Path: dir,
224 ToolName: PatchToolName,
226 ToolName: PatchToolName,
225 Action: "update",
227 Action: "update",
@@ -238,6 +240,7 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
238 patchDiff, _, _ := diff.GenerateDiff(*change.OldContent, "", path)
240 patchDiff, _, _ := diff.GenerateDiff(*change.OldContent, "", path)
239 p := p.permissions.Request(
241 p := p.permissions.Request(
240 permission.CreatePermissionRequest{
242 permission.CreatePermissionRequest{
243+ SessionID: sessionID,
241 Path: dir,
244 Path: dir,
242 ToolName: PatchToolName,
245 ToolName: PatchToolName,
243 Action: "delete",
246 Action: "delete",
internal/llm/tools/write.go+1
@@ -168,6 +168,7 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
168 }
168 }
169 p := w.permissions.Request(
169 p := w.permissions.Request(
170 permission.CreatePermissionRequest{
170 permission.CreatePermissionRequest{
171+ SessionID: sessionID,
171 Path: permissionPath,
172 Path: permissionPath,
172 ToolName: WriteToolName,
173 ToolName: WriteToolName,
173 Action: "write",
174 Action: "write",
internal/permission/permission.go+14−2
@@ -3,6 +3,7 @@ package permission
3import (
3import (
4 "errors"
4 "errors"
5 "path/filepath"
5 "path/filepath"
6+ "slices"
6 "sync"
7 "sync"
7 "time"
8 "time"
8
9
@@ -14,6 +15,7 @@ import (
14var ErrorPermissionDenied = errors.New("permission denied")
15var ErrorPermissionDenied = errors.New("permission denied")
15
16
16type CreatePermissionRequest struct {
17type CreatePermissionRequest struct {
18+ SessionID string `json:"session_id"`
17 ToolName string `json:"tool_name"`
19 ToolName string `json:"tool_name"`
18 Description string `json:"description"`
20 Description string `json:"description"`
19 Action string `json:"action"`
21 Action string `json:"action"`
@@ -37,13 +39,15 @@ type Service interface {
37 Grant(permission PermissionRequest)
39 Grant(permission PermissionRequest)
38 Deny(permission PermissionRequest)
40 Deny(permission PermissionRequest)
39 Request(opts CreatePermissionRequest) bool
41 Request(opts CreatePermissionRequest) bool
42+ AutoApproveSession(sessionID string)
40}
43}
41
44
42type permissionService struct {
45type permissionService struct {
43 *pubsub.Broker[PermissionRequest]
46 *pubsub.Broker[PermissionRequest]
44
47
45− sessionPermissions []PermissionRequest
48+ sessionPermissions []PermissionRequest
46− pendingRequests sync.Map
49+ pendingRequests sync.Map
50+ autoApproveSessions []string
47}
51}
48
52
49func (s *permissionService) GrantPersistant(permission PermissionRequest) {
53func (s *permissionService) GrantPersistant(permission PermissionRequest) {
@@ -69,6 +73,9 @@ func (s *permissionService) Deny(permission PermissionRequest) {
69}
73}
70
74
71func (s *permissionService) Request(opts CreatePermissionRequest) bool {
75func (s *permissionService) Request(opts CreatePermissionRequest) bool {
76+ if slices.Contains(s.autoApproveSessions, opts.SessionID) {
77+ return true
78+ }
72 dir := filepath.Dir(opts.Path)
79 dir := filepath.Dir(opts.Path)
73 if dir == "." {
80 if dir == "." {
74 dir = config.WorkingDirectory()
81 dir = config.WorkingDirectory()
@@ -76,6 +83,7 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
76 permission := PermissionRequest{
83 permission := PermissionRequest{
77 ID: uuid.New().String(),
84 ID: uuid.New().String(),
78 Path: dir,
85 Path: dir,
86+ SessionID: opts.SessionID,
79 ToolName: opts.ToolName,
87 ToolName: opts.ToolName,
80 Description: opts.Description,
88 Description: opts.Description,
81 Action: opts.Action,
89 Action: opts.Action,
@@ -104,6 +112,10 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
104 }
112 }
105}
113}
106
114
115+func (s *permissionService) AutoApproveSession(sessionID string) {
116+ s.autoApproveSessions = append(s.autoApproveSessions, sessionID)
117+}
118+
107func NewPermissionService() Service {
119func NewPermissionService() Service {
108 return &permissionService{
120 return &permissionService{
109 Broker: pubsub.NewBroker[PermissionRequest](),
121 Broker: pubsub.NewBroker[PermissionRequest](),
internal/tui/components/dialog/commands.gonew file+247
@@ -0,0 +1,247 @@
1+package dialog
2+
3+import (
4+ "github.com/charmbracelet/bubbles/key"
5+ tea "github.com/charmbracelet/bubbletea"
6+ "github.com/charmbracelet/lipgloss"
7+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
8+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
9+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
10+)
11+
12+// Command represents a command that can be executed
13+type Command struct {
14+ ID string
15+ Title string
16+ Description string
17+ Handler func(cmd Command) tea.Cmd
18+}
19+
20+// CommandSelectedMsg is sent when a command is selected
21+type CommandSelectedMsg struct {
22+ Command Command
23+}
24+
25+// CloseCommandDialogMsg is sent when the command dialog is closed
26+type CloseCommandDialogMsg struct{}
27+
28+// CommandDialog interface for the command selection dialog
29+type CommandDialog interface {
30+ tea.Model
31+ layout.Bindings
32+ SetCommands(commands []Command)
33+ SetSelectedCommand(commandID string)
34+}
35+
36+type commandDialogCmp struct {
37+ commands []Command
38+ selectedIdx int
39+ width int
40+ height int
41+ selectedCommandID string
42+}
43+
44+type commandKeyMap struct {
45+ Up key.Binding
46+ Down key.Binding
47+ Enter key.Binding
48+ Escape key.Binding
49+ J key.Binding
50+ K key.Binding
51+}
52+
53+var commandKeys = commandKeyMap{
54+ Up: key.NewBinding(
55+ key.WithKeys("up"),
56+ key.WithHelp("↑", "previous command"),
57+ ),
58+ Down: key.NewBinding(
59+ key.WithKeys("down"),
60+ key.WithHelp("↓", "next command"),
61+ ),
62+ Enter: key.NewBinding(
63+ key.WithKeys("enter"),
64+ key.WithHelp("enter", "select command"),
65+ ),
66+ Escape: key.NewBinding(
67+ key.WithKeys("esc"),
68+ key.WithHelp("esc", "close"),
69+ ),
70+ J: key.NewBinding(
71+ key.WithKeys("j"),
72+ key.WithHelp("j", "next command"),
73+ ),
74+ K: key.NewBinding(
75+ key.WithKeys("k"),
76+ key.WithHelp("k", "previous command"),
77+ ),
78+}
79+
80+func (c *commandDialogCmp) Init() tea.Cmd {
81+ return nil
82+}
83+
84+func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
85+ switch msg := msg.(type) {
86+ case tea.KeyMsg:
87+ switch {
88+ case key.Matches(msg, commandKeys.Up) || key.Matches(msg, commandKeys.K):
89+ if c.selectedIdx > 0 {
90+ c.selectedIdx--
91+ }
92+ return c, nil
93+ case key.Matches(msg, commandKeys.Down) || key.Matches(msg, commandKeys.J):
94+ if c.selectedIdx < len(c.commands)-1 {
95+ c.selectedIdx++
96+ }
97+ return c, nil
98+ case key.Matches(msg, commandKeys.Enter):
99+ if len(c.commands) > 0 {
100+ return c, util.CmdHandler(CommandSelectedMsg{
101+ Command: c.commands[c.selectedIdx],
102+ })
103+ }
104+ case key.Matches(msg, commandKeys.Escape):
105+ return c, util.CmdHandler(CloseCommandDialogMsg{})
106+ }
107+ case tea.WindowSizeMsg:
108+ c.width = msg.Width
109+ c.height = msg.Height
110+ }
111+ return c, nil
112+}
113+
114+func (c *commandDialogCmp) View() string {
115+ if len(c.commands) == 0 {
116+ return styles.BaseStyle.Padding(1, 2).
117+ Border(lipgloss.RoundedBorder()).
118+ BorderBackground(styles.Background).
119+ BorderForeground(styles.ForgroundDim).
120+ Width(40).
121+ Render("No commands available")
122+ }
123+
124+ // Calculate max width needed for command titles
125+ maxWidth := 40 // Minimum width
126+ for _, cmd := range c.commands {
127+ if len(cmd.Title) > maxWidth-4 { // Account for padding
128+ maxWidth = len(cmd.Title) + 4
129+ }
130+ if len(cmd.Description) > maxWidth-4 {
131+ maxWidth = len(cmd.Description) + 4
132+ }
133+ }
134+
135+ // Limit height to avoid taking up too much screen space
136+ maxVisibleCommands := min(10, len(c.commands))
137+
138+ // Build the command list
139+ commandItems := make([]string, 0, maxVisibleCommands)
140+ startIdx := 0
141+
142+ // If we have more commands than can be displayed, adjust the start index
143+ if len(c.commands) > maxVisibleCommands {
144+ // Center the selected item when possible
145+ halfVisible := maxVisibleCommands / 2
146+ if c.selectedIdx >= halfVisible && c.selectedIdx < len(c.commands)-halfVisible {
147+ startIdx = c.selectedIdx - halfVisible
148+ } else if c.selectedIdx >= len(c.commands)-halfVisible {
149+ startIdx = len(c.commands) - maxVisibleCommands
150+ }
151+ }
152+
153+ endIdx := min(startIdx+maxVisibleCommands, len(c.commands))
154+
155+ for i := startIdx; i < endIdx; i++ {
156+ cmd := c.commands[i]
157+ itemStyle := styles.BaseStyle.Width(maxWidth)
158+ descStyle := styles.BaseStyle.Width(maxWidth).Foreground(styles.ForgroundDim)
159+
160+ if i == c.selectedIdx {
161+ itemStyle = itemStyle.
162+ Background(styles.PrimaryColor).
163+ Foreground(styles.Background).
164+ Bold(true)
165+ descStyle = descStyle.
166+ Background(styles.PrimaryColor).
167+ Foreground(styles.Background)
168+ }
169+
170+ title := itemStyle.Padding(0, 1).Render(cmd.Title)
171+ description := ""
172+ if cmd.Description != "" {
173+ description = descStyle.Padding(0, 1).Render(cmd.Description)
174+ commandItems = append(commandItems, lipgloss.JoinVertical(lipgloss.Left, title, description))
175+ } else {
176+ commandItems = append(commandItems, title)
177+ }
178+ }
179+
180+ title := styles.BaseStyle.
181+ Foreground(styles.PrimaryColor).
182+ Bold(true).
183+ Width(maxWidth).
184+ Padding(0, 1).
185+ Render("Commands")
186+
187+ content := lipgloss.JoinVertical(
188+ lipgloss.Left,
189+ title,
190+ styles.BaseStyle.Width(maxWidth).Render(""),
191+ styles.BaseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, commandItems...)),
192+ styles.BaseStyle.Width(maxWidth).Render(""),
193+ styles.BaseStyle.Width(maxWidth).Padding(0, 1).Foreground(styles.ForgroundDim).Render("↑/k: up ↓/j: down enter: select esc: cancel"),
194+ )
195+
196+ return styles.BaseStyle.Padding(1, 2).
197+ Border(lipgloss.RoundedBorder()).
198+ BorderBackground(styles.Background).
199+ BorderForeground(styles.ForgroundDim).
200+ Width(lipgloss.Width(content) + 4).
201+ Render(content)
202+}
203+
204+func (c *commandDialogCmp) BindingKeys() []key.Binding {
205+ return layout.KeyMapToSlice(commandKeys)
206+}
207+
208+func (c *commandDialogCmp) SetCommands(commands []Command) {
209+ c.commands = commands
210+
211+ // If we have a selected command ID, find its index
212+ if c.selectedCommandID != "" {
213+ for i, cmd := range commands {
214+ if cmd.ID == c.selectedCommandID {
215+ c.selectedIdx = i
216+ return
217+ }
218+ }
219+ }
220+
221+ // Default to first command if selected not found
222+ c.selectedIdx = 0
223+}
224+
225+func (c *commandDialogCmp) SetSelectedCommand(commandID string) {
226+ c.selectedCommandID = commandID
227+
228+ // Update the selected index if commands are already loaded
229+ if len(c.commands) > 0 {
230+ for i, cmd := range c.commands {
231+ if cmd.ID == commandID {
232+ c.selectedIdx = i
233+ return
234+ }
235+ }
236+ }
237+}
238+
239+// NewCommandDialogCmp creates a new command selection dialog
240+func NewCommandDialogCmp() CommandDialog {
241+ return &commandDialogCmp{
242+ commands: []Command{},
243+ selectedIdx: 0,
244+ selectedCommandID: "",
245+ }
246+}
247+
internal/tui/components/dialog/init.gonew file+191
@@ -0,0 +1,191 @@
1+package dialog
2+
3+import (
4+ "github.com/charmbracelet/bubbles/key"
5+ tea "github.com/charmbracelet/bubbletea"
6+ "github.com/charmbracelet/lipgloss"
7+
8+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
9+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
10+)
11+
12+// InitDialogCmp is a component that asks the user if they want to initialize the project.
13+type InitDialogCmp struct {
14+ width, height int
15+ selected int
16+ keys initDialogKeyMap
17+}
18+
19+// NewInitDialogCmp creates a new InitDialogCmp.
20+func NewInitDialogCmp() InitDialogCmp {
21+ return InitDialogCmp{
22+ selected: 0,
23+ keys: initDialogKeyMap{},
24+ }
25+}
26+
27+type initDialogKeyMap struct {
28+ Tab key.Binding
29+ Left key.Binding
30+ Right key.Binding
31+ Enter key.Binding
32+ Escape key.Binding
33+ Y key.Binding
34+ N key.Binding
35+}
36+
37+// ShortHelp implements key.Map.
38+func (k initDialogKeyMap) ShortHelp() []key.Binding {
39+ return []key.Binding{
40+ key.NewBinding(
41+ key.WithKeys("tab", "left", "right"),
42+ key.WithHelp("tab/←/→", "toggle selection"),
43+ ),
44+ key.NewBinding(
45+ key.WithKeys("enter"),
46+ key.WithHelp("enter", "confirm"),
47+ ),
48+ key.NewBinding(
49+ key.WithKeys("esc"),
50+ key.WithHelp("esc", "cancel"),
51+ ),
52+ key.NewBinding(
53+ key.WithKeys("y", "n"),
54+ key.WithHelp("y/n", "yes/no"),
55+ ),
56+ }
57+}
58+
59+// FullHelp implements key.Map.
60+func (k initDialogKeyMap) FullHelp() [][]key.Binding {
61+ return [][]key.Binding{k.ShortHelp()}
62+}
63+
64+// Init implements tea.Model.
65+func (m InitDialogCmp) Init() tea.Cmd {
66+ return nil
67+}
68+
69+// Update implements tea.Model.
70+func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
71+ switch msg := msg.(type) {
72+ case tea.KeyMsg:
73+ switch {
74+ case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
75+ return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
76+ case key.Matches(msg, key.NewBinding(key.WithKeys("tab", "left", "right", "h", "l"))):
77+ m.selected = (m.selected + 1) % 2
78+ return m, nil
79+ case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
80+ return m, util.CmdHandler(CloseInitDialogMsg{Initialize: m.selected == 0})
81+ case key.Matches(msg, key.NewBinding(key.WithKeys("y"))):
82+ return m, util.CmdHandler(CloseInitDialogMsg{Initialize: true})
83+ case key.Matches(msg, key.NewBinding(key.WithKeys("n"))):
84+ return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
85+ }
86+ case tea.WindowSizeMsg:
87+ m.width = msg.Width
88+ m.height = msg.Height
89+ }
90+ return m, nil
91+}
92+
93+// View implements tea.Model.
94+func (m InitDialogCmp) View() string {
95+ // Calculate width needed for content
96+ maxWidth := 60 // Width for explanation text
97+
98+ title := styles.BaseStyle.
99+ Foreground(styles.PrimaryColor).
100+ Bold(true).
101+ Width(maxWidth).
102+ Padding(0, 1).
103+ Render("Initialize Project")
104+
105+ explanation := styles.BaseStyle.
106+ Foreground(styles.Forground).
107+ Width(maxWidth).
108+ Padding(0, 1).
109+ Render("Initialization generates a new OpenCode.md file that contains information about your codebase, this file serves as memory for each project, you can freely add to it to help the agents be better at their job.")
110+
111+ question := styles.BaseStyle.
112+ Foreground(styles.Forground).
113+ Width(maxWidth).
114+ Padding(1, 1).
115+ Render("Would you like to initialize this project?")
116+
117+ yesStyle := styles.BaseStyle
118+ noStyle := styles.BaseStyle
119+
120+ if m.selected == 0 {
121+ yesStyle = yesStyle.
122+ Background(styles.PrimaryColor).
123+ Foreground(styles.Background).
124+ Bold(true)
125+ noStyle = noStyle.
126+ Background(styles.Background).
127+ Foreground(styles.PrimaryColor)
128+ } else {
129+ noStyle = noStyle.
130+ Background(styles.PrimaryColor).
131+ Foreground(styles.Background).
132+ Bold(true)
133+ yesStyle = yesStyle.
134+ Background(styles.Background).
135+ Foreground(styles.PrimaryColor)
136+ }
137+
138+ yes := yesStyle.Padding(0, 3).Render("Yes")
139+ no := noStyle.Padding(0, 3).Render("No")
140+
141+ buttons := lipgloss.JoinHorizontal(lipgloss.Center, yes, styles.BaseStyle.Render(" "), no)
142+ buttons = styles.BaseStyle.
143+ Width(maxWidth).
144+ Padding(1, 0).
145+ Render(buttons)
146+
147+ help := styles.BaseStyle.
148+ Width(maxWidth).
149+ Padding(0, 1).
150+ Foreground(styles.ForgroundDim).
151+ Render("tab/←/→: toggle y/n: yes/no enter: confirm esc: cancel")
152+
153+ content := lipgloss.JoinVertical(
154+ lipgloss.Left,
155+ title,
156+ styles.BaseStyle.Width(maxWidth).Render(""),
157+ explanation,
158+ question,
159+ buttons,
160+ styles.BaseStyle.Width(maxWidth).Render(""),
161+ help,
162+ )
163+
164+ return styles.BaseStyle.Padding(1, 2).
165+ Border(lipgloss.RoundedBorder()).
166+ BorderBackground(styles.Background).
167+ BorderForeground(styles.ForgroundDim).
168+ Width(lipgloss.Width(content) + 4).
169+ Render(content)
170+}
171+
172+// SetSize sets the size of the component.
173+func (m *InitDialogCmp) SetSize(width, height int) {
174+ m.width = width
175+ m.height = height
176+}
177+
178+// Bindings implements layout.Bindings.
179+func (m InitDialogCmp) Bindings() []key.Binding {
180+ return m.keys.ShortHelp()
181+}
182+
183+// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
184+type CloseInitDialogMsg struct {
185+ Initialize bool
186+}
187+
188+// ShowInitDialogMsg is a message that is sent to show the init dialog.
189+type ShowInitDialogMsg struct {
190+ Show bool
191+}
internal/tui/tui.go+173−2
@@ -7,6 +7,7 @@ import (
7 tea "github.com/charmbracelet/bubbletea"
7 tea "github.com/charmbracelet/bubbletea"
8 "github.com/charmbracelet/lipgloss"
8 "github.com/charmbracelet/lipgloss"
9 "github.com/kujtimiihoxha/opencode/internal/app"
9 "github.com/kujtimiihoxha/opencode/internal/app"
10+ "github.com/kujtimiihoxha/opencode/internal/config"
10 "github.com/kujtimiihoxha/opencode/internal/logging"
11 "github.com/kujtimiihoxha/opencode/internal/logging"
11 "github.com/kujtimiihoxha/opencode/internal/permission"
12 "github.com/kujtimiihoxha/opencode/internal/permission"
12 "github.com/kujtimiihoxha/opencode/internal/pubsub"
13 "github.com/kujtimiihoxha/opencode/internal/pubsub"
@@ -23,6 +24,7 @@ type keyMap struct {
23 Quit key.Binding
24 Quit key.Binding
24 Help key.Binding
25 Help key.Binding
25 SwitchSession key.Binding
26 SwitchSession key.Binding
27+ Commands key.Binding
26}
28}
27
29
28var keys = keyMap{
30var keys = keyMap{
@@ -44,6 +46,11 @@ var keys = keyMap{
44 key.WithKeys("ctrl+a"),
46 key.WithKeys("ctrl+a"),
45 key.WithHelp("ctrl+a", "switch session"),
47 key.WithHelp("ctrl+a", "switch session"),
46 ),
48 ),
49+
50+ Commands: key.NewBinding(
51+ key.WithKeys("ctrl+k"),
52+ key.WithHelp("ctrl+K", "commands"),
53+ ),
47}
54}
48
55
49var helpEsc = key.NewBinding(
56var helpEsc = key.NewBinding(
@@ -82,6 +89,13 @@ type appModel struct {
82 showSessionDialog bool
89 showSessionDialog bool
83 sessionDialog dialog.SessionDialog
90 sessionDialog dialog.SessionDialog
84
91
92+ showCommandDialog bool
93+ commandDialog dialog.CommandDialog
94+ commands []dialog.Command
95+
96+ showInitDialog bool
97+ initDialog dialog.InitDialogCmp
98+
85 editingMode bool
99 editingMode bool
86}
100}
87
101
@@ -98,6 +112,23 @@ func (a appModel) Init() tea.Cmd {
98 cmds = append(cmds, cmd)
112 cmds = append(cmds, cmd)
99 cmd = a.sessionDialog.Init()
113 cmd = a.sessionDialog.Init()
100 cmds = append(cmds, cmd)
114 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+
101 return tea.Batch(cmds...)
132 return tea.Batch(cmds...)
102}
133}
103
134
@@ -126,6 +157,12 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
126 a.sessionDialog = session.(dialog.SessionDialog)
157 a.sessionDialog = session.(dialog.SessionDialog)
127 cmds = append(cmds, sessionCmd)
158 cmds = append(cmds, sessionCmd)
128
159
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+
129 return a, tea.Batch(cmds...)
166 return a, tea.Batch(cmds...)
130 case chat.EditorFocusMsg:
167 case chat.EditorFocusMsg:
131 a.editingMode = bool(msg)
168 a.editingMode = bool(msg)
@@ -207,6 +244,35 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
207 a.showSessionDialog = false
244 a.showSessionDialog = false
208 return a, nil
245 return a, nil
209
246
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+
210 case chat.SessionSelectedMsg:
276 case chat.SessionSelectedMsg:
211 a.sessionDialog.SetSelectedSession(msg.ID)
277 a.sessionDialog.SetSelectedSession(msg.ID)
212 case dialog.SessionSelectedMsg:
278 case dialog.SessionSelectedMsg:
@@ -216,6 +282,14 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
216 }
282 }
217 return a, nil
283 return a, nil
218
284
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+
219 case tea.KeyMsg:
293 case tea.KeyMsg:
220 switch {
294 switch {
221 case key.Matches(msg, keys.Quit):
295 case key.Matches(msg, keys.Quit):
@@ -226,9 +300,12 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
226 if a.showSessionDialog {
300 if a.showSessionDialog {
227 a.showSessionDialog = false
301 a.showSessionDialog = false
228 }
302 }
303+ if a.showCommandDialog {
304+ a.showCommandDialog = false
305+ }
229 return a, nil
306 return a, nil
230 case key.Matches(msg, keys.SwitchSession):
307 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 {
232 // Load sessions and show the dialog
309 // Load sessions and show the dialog
233 sessions, err := a.app.Sessions.List(context.Background())
310 sessions, err := a.app.Sessions.List(context.Background())
234 if err != nil {
311 if err != nil {
@@ -242,6 +319,17 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
242 return a, nil
319 return a, nil
243 }
320 }
244 return a, nil
321 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
245 case key.Matches(msg, logsKeyReturnKey):
333 case key.Matches(msg, logsKeyReturnKey):
246 if a.currentPage == page.LogsPage {
334 if a.currentPage == page.LogsPage {
247 return a, a.moveToPage(page.ChatPage)
335 return a, a.moveToPage(page.ChatPage)
@@ -255,6 +343,14 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
255 a.showHelp = !a.showHelp
343 a.showHelp = !a.showHelp
256 return a, nil
344 return a, nil
257 }
345 }
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+ }
258 case key.Matches(msg, keys.Logs):
354 case key.Matches(msg, keys.Logs):
259 return a, a.moveToPage(page.LogsPage)
355 return a, a.moveToPage(page.LogsPage)
260 case key.Matches(msg, keys.Help):
356 case key.Matches(msg, keys.Help):
@@ -304,6 +400,26 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
304 }
400 }
305 }
401 }
306
402
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+
307 s, _ := a.status.Update(msg)
423 s, _ := a.status.Update(msg)
308 a.status = s.(core.StatusCmp)
424 a.status = s.(core.StatusCmp)
309 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
425 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) {
311 return a, tea.Batch(cmds...)
427 return a, tea.Batch(cmds...)
312}
428}
313
429
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+
314func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
435func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
315 if a.app.CoderAgent.IsBusy() {
436 if a.app.CoderAgent.IsBusy() {
316 // For now we don't move to any page if the agent is busy
437 // For now we don't move to any page if the agent is busy
@@ -422,24 +543,74 @@ func (a appModel) View() string {
422 )
543 )
423 }
544 }
424
545
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+
425 return appView
572 return appView
426}
573}
427
574
428func New(app *app.App) tea.Model {
575func New(app *app.App) tea.Model {
429 startPage := page.ChatPage
576 startPage := page.ChatPage
430− return &appModel{
577+ model := &appModel{
431 currentPage: startPage,
578 currentPage: startPage,
432 loadedPages: make(map[page.PageID]bool),
579 loadedPages: make(map[page.PageID]bool),
433 status: core.NewStatusCmp(app.LSPClients),
580 status: core.NewStatusCmp(app.LSPClients),
434 help: dialog.NewHelpCmp(),
581 help: dialog.NewHelpCmp(),
435 quit: dialog.NewQuitCmp(),
582 quit: dialog.NewQuitCmp(),
436 sessionDialog: dialog.NewSessionDialogCmp(),
583 sessionDialog: dialog.NewSessionDialogCmp(),
584+ commandDialog: dialog.NewCommandDialogCmp(),
437 permissions: dialog.NewPermissionDialogCmp(),
585 permissions: dialog.NewPermissionDialogCmp(),
586+ initDialog: dialog.NewInitDialogCmp(),
438 app: app,
587 app: app,
439 editingMode: true,
588 editingMode: true,
589+ commands: []dialog.Command{},
440 pages: map[page.PageID]tea.Model{
590 pages: map[page.PageID]tea.Model{
441 page.ChatPage: page.NewChatPage(app),
591 page.ChatPage: page.NewChatPage(app),
442 page.LogsPage: page.NewLogsPage(),
592 page.LogsPage: page.NewLogsPage(),
443 },
593 },
444 }
594 }
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
445}
616}