on main help sign in
init command
commit3a6a26981a8074b6ab0eaadb520db986e04799ff authorKujtim Hoxha <kujtimii.h@gmail.com> dateMon 21 Apr 2025, 19:48 (2 years ago) parentd7569d7
14 files changed+753−35
README.md+30−25 internal/config/init.go+61 internal/llm/agent/mcp-tools.go+5 internal/llm/prompt/prompt.go+11−5 internal/llm/prompt/title.go+1 internal/llm/tools/bash.go+7−1 internal/llm/tools/edit.go+3 internal/llm/tools/fetch.go+6 internal/llm/tools/patch.go+3 internal/llm/tools/write.go+1 internal/permission/permission.go+14−2 internal/tui/components/dialog/commands.go+247 internal/tui/components/dialog/init.go+191 internal/tui/tui.go+173−2
README.md+30−25
@@ -104,6 +104,7 @@ You can configure OpenCode using environment variables:
104104OpenCode supports a variety of AI models from different providers:
105105
106106### OpenAI
107+
107108- GPT-4.1 family (gpt-4.1, gpt-4.1-mini, gpt-4.1-nano)
108109- GPT-4.5 Preview
109110- GPT-4o family (gpt-4o, gpt-4o-mini)
@@ -112,6 +113,7 @@ OpenCode supports a variety of AI models from different providers:
112113- O4 Mini
113114
114115### Anthropic
116+
115117- Claude 3.5 Sonnet
116118- Claude 3.5 Haiku
117119- Claude 3.7 Sonnet
@@ -119,12 +121,14 @@ OpenCode supports a variety of AI models from different providers:
119121- Claude 3 Opus
120122
121123### Google
124+
122125- Gemini 2.5
123126- Gemini 2.5 Flash
124127- Gemini 2.0 Flash
125128- Gemini 2.0 Flash Lite
126129
127130### AWS Bedrock
131+
128132- Claude 3.7 Sonnet
129133
130134## Usage
@@ -152,14 +156,15 @@ opencode -c /path/to/project
152156
153157### Global Shortcuts
154158
155−| Shortcut | Action |
156−| --------- | ------------------------------------------------------- |
157−| `Ctrl+C` | Quit application |
158−| `Ctrl+?` | Toggle help dialog |
159−| `?` | Toggle help dialog (when not in editing mode) |
160−| `Ctrl+L` | View logs |
161−| `Ctrl+A` | Switch session |
162−| `Esc` | Close current overlay/dialog or return to previous mode |
159+| Shortcut | Action |
160+| -------- | ------------------------------------------------------- |
161+| `Ctrl+C` | Quit application |
162+| `Ctrl+?` | Toggle help dialog |
163+| `?` | Toggle help dialog (when not in editing mode) |
164+| `Ctrl+L` | View logs |
165+| `Ctrl+A` | Switch session |
166+| `Ctrl+K` | Command dialog |
167+| `Esc` | Close current overlay/dialog or return to previous mode |
163168
164169### Chat Page Shortcuts
165170
@@ -181,28 +186,28 @@ opencode -c /path/to/project
181186
182187### Session Dialog Shortcuts
183188
184−| Shortcut | Action |
185−| ------------- | ---------------- |
186−| `↑` or `k` | Previous session |
187−| `↓` or `j` | Next session |
188−| `Enter` | Select session |
189−| `Esc` | Close dialog |
189+| Shortcut | Action |
190+| ---------- | ---------------- |
191+| `↑` or `k` | Previous session |
192+| `↓` or `j` | Next session |
193+| `Enter` | Select session |
194+| `Esc` | Close dialog |
190195
191196### Permission Dialog Shortcuts
192197
193−| Shortcut | Action |
194−| ------------------------- | ----------------------- |
195−| `←` or `left` | Switch options left |
196−| `→` or `right` or `tab` | Switch options right |
197−| `Enter` or `space` | Confirm selection |
198−| `a` | Allow permission |
199−| `A` | Allow permission for session |
200−| `d` | Deny permission |
198+| Shortcut | Action |
199+| ----------------------- | ---------------------------- |
200+| `←` or `left` | Switch options left |
201+| `→` or `right` or `tab` | Switch options right |
202+| `Enter` or `space` | Confirm selection |
203+| `a` | Allow permission |
204+| `A` | Allow permission for session |
205+| `d` | Deny permission |
201206
202207### Logs Page Shortcuts
203208
204−| Shortcut | Action |
205−| ---------------- | ------------------- |
209+| Shortcut | Action |
210+| ------------------ | ------------------- |
206211| `Backspace` or `q` | Return to chat page |
207212
208213## AI Assistant Tools
@@ -369,4 +374,4 @@ Contributions are welcome! Here's how you can contribute:
3693744. Push to the branch (`git push origin feature/amazing-feature`)
3703755. Open a Pull Request
371376
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
8080}
8181
8282func (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+ }
8387 permissionDescription := fmt.Sprintf("execute %s with the following parameters: %s", b.Info().Name, params.Input)
8488 p := b.permissions.Request(
8589 permission.CreatePermissionRequest{
90+ SessionID: sessionID,
8691 Path: config.WorkingDirectory(),
8792 ToolName: b.Info().Name,
8893 Action: "execute",
internal/llm/prompt/prompt.go+11−5
@@ -14,8 +14,13 @@ var contextFiles = []string{
1414 ".github/copilot-instructions.md",
1515 ".cursorrules",
1616 "CLAUDE.md",
17+ "CLAUDE.local.md",
1718 "opencode.md",
19+ "opencode.local.md",
1820 "OpenCode.md",
21+ "OpenCode.local.md",
22+ "OPENCODE.md",
23+ "OPENCODE.local.md",
1924}
2025
2126func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) string {
@@ -31,12 +36,13 @@ func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) s
3136 basePrompt = "You are a helpful assistant"
3237 }
3338
34− // Add context from project-specific instruction files if they exist
35− contextContent := getContextFromFiles()
36− if contextContent != "" {
37− return fmt.Sprintf("%s\n\n# Project-Specific Context\n%s", basePrompt, contextContent)
39+ if agentName == config.AgentCoder || agentName == config.AgentTask {
40+ // Add context from project-specific instruction files if they exist
41+ contextContent := getContextFromFiles()
42+ if contextContent != "" {
43+ return fmt.Sprintf("%s\n\n# Project-Specific Context\n%s", basePrompt, contextContent)
44+ }
3845 }
39−
4046 return basePrompt
4147}
4248
internal/llm/prompt/title.go+1
@@ -6,6 +6,7 @@ func TitlePrompt(_ models.ModelProvider) string {
66 return `you will generate a short title based on the first message a user begins a conversation with
77- ensure it is not more than 50 characters long
88- the title should be a summary of the user's message
9+- it should be one line long
910- do not use quotes or colons
1011- the entire text you return will be used as the title`
1112}
internal/llm/tools/bash.go+7−1
@@ -51,7 +51,7 @@ var safeReadOnlyCommands = []string{
5151 "git status", "git log", "git diff", "git show", "git branch", "git tag", "git remote", "git ls-files", "git ls-remote",
5252 "git rev-parse", "git config --get", "git config --list", "git describe", "git blame", "git grep", "git shortlog",
5353
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",
5555}
5656
5757func bashDescription() string {
@@ -261,9 +261,15 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
261261 }
262262 }
263263 }
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+ }
264269 if !isSafeReadOnly {
265270 p := b.permissions.Request(
266271 permission.CreatePermissionRequest{
272+ SessionID: sessionID,
267273 Path: config.WorkingDirectory(),
268274 ToolName: BashToolName,
269275 Action: "execute",
internal/llm/tools/edit.go+3
@@ -203,6 +203,7 @@ func (e *editTool) createNewFile(ctx context.Context, filePath, content string)
203203 }
204204 p := e.permissions.Request(
205205 permission.CreatePermissionRequest{
206+ SessionID: sessionID,
206207 Path: permissionPath,
207208 ToolName: EditToolName,
208209 Action: "write",
@@ -313,6 +314,7 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string
313314 }
314315 p := e.permissions.Request(
315316 permission.CreatePermissionRequest{
317+ SessionID: sessionID,
316318 Path: permissionPath,
317319 ToolName: EditToolName,
318320 Action: "write",
@@ -432,6 +434,7 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
432434 }
433435 p := e.permissions.Request(
434436 permission.CreatePermissionRequest{
437+ SessionID: sessionID,
435438 Path: permissionPath,
436439 ToolName: EditToolName,
437440 Action: "write",
internal/llm/tools/fetch.go+6
@@ -116,8 +116,14 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
116116 return NewTextErrorResponse("URL must start with http:// or https://"), nil
117117 }
118118
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+
119124 p := t.permissions.Request(
120125 permission.CreatePermissionRequest{
126+ SessionID: sessionID,
121127 Path: config.WorkingDirectory(),
122128 ToolName: FetchToolName,
123129 Action: "fetch",
internal/llm/tools/patch.go+3
@@ -194,6 +194,7 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
194194 patchDiff, _, _ := diff.GenerateDiff("", *change.NewContent, path)
195195 p := p.permissions.Request(
196196 permission.CreatePermissionRequest{
197+ SessionID: sessionID,
197198 Path: dir,
198199 ToolName: PatchToolName,
199200 Action: "create",
@@ -220,6 +221,7 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
220221 dir := filepath.Dir(path)
221222 p := p.permissions.Request(
222223 permission.CreatePermissionRequest{
224+ SessionID: sessionID,
223225 Path: dir,
224226 ToolName: PatchToolName,
225227 Action: "update",
@@ -238,6 +240,7 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
238240 patchDiff, _, _ := diff.GenerateDiff(*change.OldContent, "", path)
239241 p := p.permissions.Request(
240242 permission.CreatePermissionRequest{
243+ SessionID: sessionID,
241244 Path: dir,
242245 ToolName: PatchToolName,
243246 Action: "delete",
internal/llm/tools/write.go+1
@@ -168,6 +168,7 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
168168 }
169169 p := w.permissions.Request(
170170 permission.CreatePermissionRequest{
171+ SessionID: sessionID,
171172 Path: permissionPath,
172173 ToolName: WriteToolName,
173174 Action: "write",
internal/permission/permission.go+14−2
@@ -3,6 +3,7 @@ package permission
33import (
44 "errors"
55 "path/filepath"
6+ "slices"
67 "sync"
78 "time"
89
@@ -14,6 +15,7 @@ import (
1415var ErrorPermissionDenied = errors.New("permission denied")
1516
1617type CreatePermissionRequest struct {
18+ SessionID string `json:"session_id"`
1719 ToolName string `json:"tool_name"`
1820 Description string `json:"description"`
1921 Action string `json:"action"`
@@ -37,13 +39,15 @@ type Service interface {
3739 Grant(permission PermissionRequest)
3840 Deny(permission PermissionRequest)
3941 Request(opts CreatePermissionRequest) bool
42+ AutoApproveSession(sessionID string)
4043}
4144
4245type permissionService struct {
4346 *pubsub.Broker[PermissionRequest]
4447
45− sessionPermissions []PermissionRequest
46− pendingRequests sync.Map
48+ sessionPermissions []PermissionRequest
49+ pendingRequests sync.Map
50+ autoApproveSessions []string
4751}
4852
4953func (s *permissionService) GrantPersistant(permission PermissionRequest) {
@@ -69,6 +73,9 @@ func (s *permissionService) Deny(permission PermissionRequest) {
6973}
7074
7175func (s *permissionService) Request(opts CreatePermissionRequest) bool {
76+ if slices.Contains(s.autoApproveSessions, opts.SessionID) {
77+ return true
78+ }
7279 dir := filepath.Dir(opts.Path)
7380 if dir == "." {
7481 dir = config.WorkingDirectory()
@@ -76,6 +83,7 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
7683 permission := PermissionRequest{
7784 ID: uuid.New().String(),
7885 Path: dir,
86+ SessionID: opts.SessionID,
7987 ToolName: opts.ToolName,
8088 Description: opts.Description,
8189 Action: opts.Action,
@@ -104,6 +112,10 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
104112 }
105113}
106114
115+func (s *permissionService) AutoApproveSession(sessionID string) {
116+ s.autoApproveSessions = append(s.autoApproveSessions, sessionID)
117+}
118+
107119func NewPermissionService() Service {
108120 return &permissionService{
109121 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 (
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}