on main help sign in
refactor: new init dialog
6e526f2Kujtim Hoxha
crush.md+17−17
@@ -1,22 +1,22 @@
1# Crush Development Guide
1# Crush Development Guide
2
2
3## Build/Test/Lint Commands
3## Build/Test/Lint Commands
4−
5−- **Build**: `go build ./...` or `go build .` (for main binary)
4+- **Build**: `go build .` or `go run .`
6−- **Test**: `task test` or `go test ./...`
5+- **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
7−- **Single test**: `go test ./internal/path/to/package -run TestName`
8−- **Lint**: `task lint` or `golangci-lint run`
6+- **Lint**: `task lint` (golangci-lint run) or `task lint-fix` (with --fix)
9−- **Format**: `task fmt` or `gofumpt -w .`
7+- **Format**: `task fmt` (gofumpt -w .)
8+- **Dev**: `task dev` (runs with profiling enabled)
10
9
11## Code Style Guidelines
10## Code Style Guidelines
12−
11+- **Imports**: Use goimports formatting, group stdlib, external, internal packages
13−- **Imports**: Standard library first, then third-party, then internal packages (separated by blank lines)
12+- **Formatting**: Use gofumpt (stricter than gofmt), enabled in golangci-lint
14−- **Types**: Use `any` instead of `interface{}`, prefer concrete types over interfaces when possible
13+- **Naming**: Standard Go conventions - PascalCase for exported, camelCase for unexported
15−- **Naming**: Use camelCase for private, PascalCase for public, descriptive names (e.g., `messageListCmp`, `handleNewUserMessage`)
14+- **Types**: Prefer explicit types, use type aliases for clarity (e.g., `type AgentName string`)
15+- **Error handling**: Return errors explicitly, use `fmt.Errorf` for wrapping
16+- **Context**: Always pass context.Context as first parameter for operations
17+- **Interfaces**: Define interfaces in consuming packages, keep them small and focused
18+- **Structs**: Use struct embedding for composition, group related fields
16−- **Constants**: Use `const` blocks with descriptive names (e.g., `NotFound = -1`)
19+- **Constants**: Use typed constants with iota for enums, group in const blocks
17−- **Error handling**: Always check errors, use `require.NoError()` in tests, return errors up the stack
18−- **Documentation**: Add comments for all public types/methods, explain complex logic in private methods
19−- **Testing**: Use testify/assert and testify/require, table-driven tests with `t.Run()`, mark helpers with `t.Helper()`
20+- **Testing**: Use testify/assert and testify/require, parallel tests with `t.Parallel()`
20−- **File organization**: Group related functionality, extract helper methods for complex logic, use meaningful method names
21+- **JSON tags**: Use snake_case for JSON field names
21−- **TUI components**: Implement interfaces (util.Model, layout.Sizeable), document component purpose and behavior
22+- **File permissions**: Use octal notation (0o755, 0o644) for file permissions
22−- **Message handling**: Use pubsub events, handle different message roles (User/Assistant/Tool), manage tool calls separately
internal/config/init.go+32
@@ -4,6 +4,7 @@ import (
4 "fmt"
4 "fmt"
5 "os"
5 "os"
6 "path/filepath"
6 "path/filepath"
7+ "strings"
7)
8)
8
9
9const (
10const (
@@ -37,10 +38,41 @@ func ShouldShowInitDialog() (bool, error) {
37 return false, fmt.Errorf("failed to check init flag file: %w", err)
38 return false, fmt.Errorf("failed to check init flag file: %w", err)
38 }
39 }
39
40
41+ // Check if any variation of crush.md already exists in working directory
42+ crushExists, err := crushMdExists(WorkingDirectory())
43+ if err != nil {
44+ return false, fmt.Errorf("failed to check for crush.md files: %w", err)
45+ }
46+ if crushExists {
47+ // Crush.md already exists, don't show the dialog
48+ return false, nil
49+ }
50+
40 // File doesn't exist, show the dialog
51 // File doesn't exist, show the dialog
41 return true, nil
52 return true, nil
42}
53}
43
54
55+// crushMdExists checks if any case variation of crush.md exists in the directory
56+func crushMdExists(dir string) (bool, error) {
57+ entries, err := os.ReadDir(dir)
58+ if err != nil {
59+ return false, err
60+ }
61+
62+ for _, entry := range entries {
63+ if entry.IsDir() {
64+ continue
65+ }
66+
67+ name := strings.ToLower(entry.Name())
68+ if name == "crush.md" {
69+ return true, nil
70+ }
71+ }
72+
73+ return false, nil
74+}
75+
44// MarkProjectInitialized marks the current project as initialized
76// MarkProjectInitialized marks the current project as initialized
45func MarkProjectInitialized() error {
77func MarkProjectInitialized() error {
46 if cfg == nil {
78 if cfg == nil {
internal/tui/components/core/helpers.go+55
@@ -76,3 +76,58 @@ func Status(ops StatusOpts, width int) string {
76 description,
76 description,
77 }, " ")
77 }, " ")
78}
78}
79+
80+type ButtonOpts struct {
81+ Text string
82+ UnderlineIndex int // Index of character to underline (0-based)
83+ Selected bool // Whether this button is selected
84+}
85+
86+// SelectableButton creates a button with an underlined character and selection state
87+func SelectableButton(opts ButtonOpts) string {
88+ t := styles.CurrentTheme()
89+
90+ // Base style for the button
91+ buttonStyle := t.S().Text
92+
93+ // Apply selection styling
94+ if opts.Selected {
95+ buttonStyle = buttonStyle.Foreground(t.White).Background(t.Secondary)
96+ } else {
97+ buttonStyle = buttonStyle.Background(t.BgSubtle)
98+ }
99+
100+ // Create the button text with underlined character
101+ text := opts.Text
102+ if opts.UnderlineIndex >= 0 && opts.UnderlineIndex < len(text) {
103+ before := text[:opts.UnderlineIndex]
104+ underlined := text[opts.UnderlineIndex : opts.UnderlineIndex+1]
105+ after := text[opts.UnderlineIndex+1:]
106+
107+ message := buttonStyle.Render(before) +
108+ buttonStyle.Underline(true).Render(underlined) +
109+ buttonStyle.Render(after)
110+
111+ return buttonStyle.Padding(0, 2).Render(message)
112+ }
113+
114+ // Fallback if no underline index specified
115+ return buttonStyle.Padding(0, 2).Render(text)
116+}
117+
118+// SelectableButtons creates a horizontal row of selectable buttons
119+func SelectableButtons(buttons []ButtonOpts, spacing string) string {
120+ if spacing == "" {
121+ spacing = " "
122+ }
123+
124+ var parts []string
125+ for i, button := range buttons {
126+ parts = append(parts, SelectableButton(button))
127+ if i < len(buttons)-1 {
128+ parts = append(parts, spacing)
129+ }
130+ }
131+
132+ return lipgloss.JoinHorizontal(lipgloss.Left, parts...)
133+}
internal/tui/components/dialog/init.godeleted−181
@@ -1,181 +0,0 @@
1−package dialog
2−
3−import (
4− "github.com/charmbracelet/bubbles/v2/key"
5− tea "github.com/charmbracelet/bubbletea/v2"
6− "github.com/charmbracelet/lipgloss/v2"
7−
8− "github.com/charmbracelet/crush/internal/tui/styles"
9− "github.com/charmbracelet/crush/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", "q"),
50− key.WithHelp("esc/q", "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.KeyPressMsg:
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− t := styles.CurrentTheme()
96− baseStyle := t.S().Base
97−
98− // Calculate width needed for content
99− maxWidth := 60 // Width for explanation text
100−
101− title := baseStyle.
102− Foreground(t.Primary).
103− Bold(true).
104− Width(maxWidth).
105− Padding(0, 1).
106− Render("Initialize Project")
107−
108− explanation := t.S().Text.
109− Width(maxWidth).
110− Padding(0, 1).
111− Render("Initialization generates a new Crush.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.")
112−
113− question := t.S().Text.
114− Width(maxWidth).
115− Padding(1, 1).
116− Render("Would you like to initialize this project?")
117−
118− maxWidth = min(maxWidth, m.width-10)
119− yesStyle := t.S().Text
120− noStyle := yesStyle
121−
122− if m.selected == 0 {
123− yesStyle = yesStyle.
124− Background(t.Primary).
125− Bold(true)
126− noStyle = noStyle.
127− Background(t.BgSubtle)
128− } else {
129− noStyle = noStyle.
130− Background(t.Primary).
131− Bold(true)
132− yesStyle = yesStyle.
133− Background(t.BgSubtle)
134− }
135−
136− yes := yesStyle.Padding(0, 3).Render("Yes")
137− no := noStyle.Padding(0, 3).Render("No")
138−
139− buttons := lipgloss.JoinHorizontal(lipgloss.Center, yes, baseStyle.Render(" "), no)
140− buttons = baseStyle.
141− Width(maxWidth).
142− Padding(1, 0).
143− Render(buttons)
144−
145− content := lipgloss.JoinVertical(
146− lipgloss.Left,
147− title,
148− baseStyle.Width(maxWidth).Render(""),
149− explanation,
150− question,
151− buttons,
152− baseStyle.Width(maxWidth).Render(""),
153− )
154−
155− return baseStyle.Padding(1, 2).
156− Border(lipgloss.RoundedBorder()).
157− BorderForeground(t.BorderFocus).
158− Width(lipgloss.Width(content) + 4).
159− Render(content)
160−}
161−
162−// SetSize sets the size of the component.
163−func (m *InitDialogCmp) SetSize(width, height int) {
164− m.width = width
165− m.height = height
166−}
167−
168−// Bindings implements layout.Bindings.
169−func (m InitDialogCmp) Bindings() []key.Binding {
170− return m.keys.ShortHelp()
171−}
172−
173−// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
174−type CloseInitDialogMsg struct {
175− Initialize bool
176−}
177−
178−// ShowInitDialogMsg is a message that is sent to show the init dialog.
179−type ShowInitDialogMsg struct {
180− Show bool
181−}
internal/tui/components/dialog/permission.godeleted−507
@@ -1,507 +0,0 @@
1−package dialog
2−
3−import (
4− "fmt"
5− "strings"
6−
7− "github.com/charmbracelet/bubbles/v2/key"
8− "github.com/charmbracelet/bubbles/v2/viewport"
9− tea "github.com/charmbracelet/bubbletea/v2"
10− "github.com/charmbracelet/crush/internal/diff"
11− "github.com/charmbracelet/crush/internal/llm/tools"
12− "github.com/charmbracelet/crush/internal/permission"
13− "github.com/charmbracelet/crush/internal/tui/layout"
14− "github.com/charmbracelet/crush/internal/tui/styles"
15− "github.com/charmbracelet/crush/internal/tui/util"
16− "github.com/charmbracelet/lipgloss/v2"
17−)
18−
19−type PermissionAction string
20−
21−// Permission responses
22−const (
23− PermissionAllow PermissionAction = "allow"
24− PermissionAllowForSession PermissionAction = "allow_session"
25− PermissionDeny PermissionAction = "deny"
26−)
27−
28−// PermissionResponseMsg represents the user's response to a permission request
29−type PermissionResponseMsg struct {
30− Permission permission.PermissionRequest
31− Action PermissionAction
32−}
33−
34−// PermissionDialogCmp interface for permission dialog component
35−type PermissionDialogCmp interface {
36− util.Model
37− layout.Bindings
38− SetPermissions(permission permission.PermissionRequest) tea.Cmd
39−}
40−
41−type permissionsMapping struct {
42− Left key.Binding
43− Right key.Binding
44− EnterSpace key.Binding
45− Allow key.Binding
46− AllowSession key.Binding
47− Deny key.Binding
48− Tab key.Binding
49−}
50−
51−var permissionsKeys = permissionsMapping{
52− Left: key.NewBinding(
53− key.WithKeys("left"),
54− key.WithHelp("←", "switch options"),
55− ),
56− Right: key.NewBinding(
57− key.WithKeys("right"),
58− key.WithHelp("→", "switch options"),
59− ),
60− EnterSpace: key.NewBinding(
61− key.WithKeys("enter", " "),
62− key.WithHelp("enter/space", "confirm"),
63− ),
64− Allow: key.NewBinding(
65− key.WithKeys("a"),
66− key.WithHelp("a", "allow"),
67− ),
68− AllowSession: key.NewBinding(
69− key.WithKeys("s"),
70− key.WithHelp("s", "allow for session"),
71− ),
72− Deny: key.NewBinding(
73− key.WithKeys("d"),
74− key.WithHelp("d", "deny"),
75− ),
76− Tab: key.NewBinding(
77− key.WithKeys("tab"),
78− key.WithHelp("tab", "switch options"),
79− ),
80−}
81−
82−// permissionDialogCmp is the implementation of PermissionDialog
83−type permissionDialogCmp struct {
84− width int
85− height int
86− permission permission.PermissionRequest
87− windowSize tea.WindowSizeMsg
88− contentViewPort viewport.Model
89− selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
90−
91− diffCache map[string]string
92− markdownCache map[string]string
93−}
94−
95−func (p *permissionDialogCmp) Init() tea.Cmd {
96− return p.contentViewPort.Init()
97−}
98−
99−func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
100− var cmds []tea.Cmd
101−
102− switch msg := msg.(type) {
103− case tea.WindowSizeMsg:
104− p.windowSize = msg
105− cmd := p.SetSize()
106− cmds = append(cmds, cmd)
107− p.markdownCache = make(map[string]string)
108− p.diffCache = make(map[string]string)
109− case tea.KeyPressMsg:
110− switch {
111− case key.Matches(msg, permissionsKeys.Right) || key.Matches(msg, permissionsKeys.Tab):
112− p.selectedOption = (p.selectedOption + 1) % 3
113− return p, nil
114− case key.Matches(msg, permissionsKeys.Left):
115− p.selectedOption = (p.selectedOption + 2) % 3
116− case key.Matches(msg, permissionsKeys.EnterSpace):
117− return p, p.selectCurrentOption()
118− case key.Matches(msg, permissionsKeys.Allow):
119− return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission})
120− case key.Matches(msg, permissionsKeys.AllowSession):
121− return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission})
122− case key.Matches(msg, permissionsKeys.Deny):
123− return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission})
124− default:
125− // Pass other keys to viewport
126− viewPort, cmd := p.contentViewPort.Update(msg)
127− p.contentViewPort = viewPort
128− cmds = append(cmds, cmd)
129− }
130− }
131−
132− return p, tea.Batch(cmds...)
133−}
134−
135−func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
136− var action PermissionAction
137−
138− switch p.selectedOption {
139− case 0:
140− action = PermissionAllow
141− case 1:
142− action = PermissionAllowForSession
143− case 2:
144− action = PermissionDeny
145− }
146−
147− return util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission})
148−}
149−
150−func (p *permissionDialogCmp) renderButtons() string {
151− t := styles.CurrentTheme()
152−
153− allowStyle := t.S().Text
154− allowSessionStyle := allowStyle
155− denyStyle := allowStyle
156−
157− // Style the selected button
158− switch p.selectedOption {
159− case 0:
160− allowStyle = allowStyle.Background(t.Secondary)
161− allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
162− denyStyle = denyStyle.Background(t.BgSubtle)
163− case 1:
164− allowStyle = allowStyle.Background(t.BgSubtle)
165− allowSessionStyle = allowSessionStyle.Background(t.Secondary)
166− denyStyle = denyStyle.Background(t.BgSubtle)
167− case 2:
168− allowStyle = allowStyle.Background(t.BgSubtle)
169− allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
170− denyStyle = denyStyle.Background(t.Secondary)
171− }
172−
173− allowButton := allowStyle.Padding(0, 1).Render("Allow (a)")
174− allowSessionButton := allowSessionStyle.Padding(0, 1).Render("Allow for session (s)")
175− denyButton := denyStyle.Padding(0, 1).Render("Deny (d)")
176−
177− content := lipgloss.JoinHorizontal(
178− lipgloss.Left,
179− allowButton,
180− " ",
181− allowSessionButton,
182− " ",
183− denyButton,
184− " ",
185− )
186−
187− remainingWidth := p.width - lipgloss.Width(content)
188− if remainingWidth > 0 {
189− content = strings.Repeat(" ", remainingWidth) + content
190− }
191− return content
192−}
193−
194−func (p *permissionDialogCmp) renderHeader() string {
195− t := styles.CurrentTheme()
196− baseStyle := t.S().Base
197−
198− toolKey := t.S().Muted.Bold(true).Render("Tool")
199− toolValue := t.S().Text.
200− Width(p.width - lipgloss.Width(toolKey)).
201− Render(fmt.Sprintf(": %s", p.permission.ToolName))
202−
203− pathKey := t.S().Muted.Bold(true).Render("Path")
204− pathValue := t.S().Text.
205− Width(p.width - lipgloss.Width(pathKey)).
206− Render(fmt.Sprintf(": %s", p.permission.Path))
207−
208− headerParts := []string{
209− lipgloss.JoinHorizontal(
210− lipgloss.Left,
211− toolKey,
212− toolValue,
213− ),
214− baseStyle.Render(strings.Repeat(" ", p.width)),
215− lipgloss.JoinHorizontal(
216− lipgloss.Left,
217− pathKey,
218− pathValue,
219− ),
220− baseStyle.Render(strings.Repeat(" ", p.width)),
221− }
222−
223− // Add tool-specific header information
224− switch p.permission.ToolName {
225− case tools.BashToolName:
226− headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("Command"))
227− case tools.EditToolName:
228− params := p.permission.Params.(tools.EditPermissionsParams)
229− fileKey := t.S().Muted.Bold(true).Render("File")
230− filePath := t.S().Text.
231− Width(p.width - lipgloss.Width(fileKey)).
232− Render(fmt.Sprintf(": %s", params.FilePath))
233− headerParts = append(headerParts,
234− lipgloss.JoinHorizontal(
235− lipgloss.Left,
236− fileKey,
237− filePath,
238− ),
239− baseStyle.Render(strings.Repeat(" ", p.width)),
240− )
241−
242− case tools.WriteToolName:
243− params := p.permission.Params.(tools.WritePermissionsParams)
244− fileKey := t.S().Muted.Bold(true).Render("File")
245− filePath := t.S().Text.
246− Width(p.width - lipgloss.Width(fileKey)).
247− Render(fmt.Sprintf(": %s", params.FilePath))
248− headerParts = append(headerParts,
249− lipgloss.JoinHorizontal(
250− lipgloss.Left,
251− fileKey,
252− filePath,
253− ),
254− baseStyle.Render(strings.Repeat(" ", p.width)),
255− )
256− case tools.FetchToolName:
257− headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
258− }
259−
260− return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
261−}
262−
263−func (p *permissionDialogCmp) renderBashContent() string {
264− baseStyle := styles.CurrentTheme().S().Base
265− if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
266− content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
267−
268− // Use the cache for markdown rendering
269− renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
270− r := styles.GetMarkdownRenderer(p.width - 10)
271− s, err := r.Render(content)
272− return s, err
273− })
274−
275− finalContent := baseStyle.
276− Width(p.contentViewPort.Width()).
277− Render(renderedContent)
278− p.contentViewPort.SetContent(finalContent)
279− return p.styleViewport()
280− }
281− return ""
282−}
283−
284−func (p *permissionDialogCmp) renderEditContent() string {
285− if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
286− diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
287− return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
288− })
289−
290− p.contentViewPort.SetContent(diff)
291− return p.styleViewport()
292− }
293− return ""
294−}
295−
296−func (p *permissionDialogCmp) renderPatchContent() string {
297− if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
298− diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
299− return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
300− })
301−
302− p.contentViewPort.SetContent(diff)
303− return p.styleViewport()
304− }
305− return ""
306−}
307−
308−func (p *permissionDialogCmp) renderWriteContent() string {
309− if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
310− // Use the cache for diff rendering
311− diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
312− return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
313− })
314−
315− p.contentViewPort.SetContent(diff)
316− return p.styleViewport()
317− }
318− return ""
319−}
320−
321−func (p *permissionDialogCmp) renderFetchContent() string {
322− baseStyle := styles.CurrentTheme().S().Base
323− if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
324− content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
325−
326− // Use the cache for markdown rendering
327− renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
328− r := styles.GetMarkdownRenderer(p.width - 10)
329− s, err := r.Render(content)
330− return s, err
331− })
332−
333− finalContent := baseStyle.
334− Width(p.contentViewPort.Width()).
335− Render(renderedContent)
336− p.contentViewPort.SetContent(finalContent)
337− return p.styleViewport()
338− }
339− return ""
340−}
341−
342−func (p *permissionDialogCmp) renderDefaultContent() string {
343− baseStyle := styles.CurrentTheme().S().Base
344−
345− content := p.permission.Description
346−
347− // Use the cache for markdown rendering
348− renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
349− r := styles.GetMarkdownRenderer(p.width - 10)
350− s, err := r.Render(content)
351− return s, err
352− })
353−
354− finalContent := baseStyle.
355− Width(p.contentViewPort.Width()).
356− Render(renderedContent)
357− p.contentViewPort.SetContent(finalContent)
358−
359− if renderedContent == "" {
360− return ""
361− }
362−
363− return p.styleViewport()
364−}
365−
366−func (p *permissionDialogCmp) styleViewport() string {
367− t := styles.CurrentTheme()
368−
369− return t.S().Base.Render(p.contentViewPort.View())
370−}
371−
372−func (p *permissionDialogCmp) render() string {
373− t := styles.CurrentTheme()
374− baseStyle := t.S().Base
375−
376− title := baseStyle.
377− Bold(true).
378− Width(p.width - 4).
379− Foreground(t.Primary).
380− Render("Permission Required")
381− // Render header
382− headerContent := p.renderHeader()
383− // Render buttons
384− buttons := p.renderButtons()
385−
386− // Calculate content height dynamically based on window size
387− p.contentViewPort.SetHeight(p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title))
388− p.contentViewPort.SetWidth(p.width - 4)
389−
390− // Render content based on tool type
391− var contentFinal string
392− switch p.permission.ToolName {
393− case tools.BashToolName:
394− contentFinal = p.renderBashContent()
395− case tools.EditToolName:
396− contentFinal = p.renderEditContent()
397− case tools.PatchToolName:
398− contentFinal = p.renderPatchContent()
399− case tools.WriteToolName:
400− contentFinal = p.renderWriteContent()
401− case tools.FetchToolName:
402− contentFinal = p.renderFetchContent()
403− default:
404− contentFinal = p.renderDefaultContent()
405− }
406−
407− content := lipgloss.JoinVertical(
408− lipgloss.Top,
409− title,
410− baseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
411− headerContent,
412− contentFinal,
413− buttons,
414− baseStyle.Render(strings.Repeat(" ", p.width-4)),
415− )
416−
417− return baseStyle.
418− Padding(1, 0, 0, 1).
419− Border(lipgloss.RoundedBorder()).
420− BorderForeground(t.BorderFocus).
421− Width(p.width).
422− Height(p.height).
423− Render(
424− content,
425− )
426−}
427−
428−func (p *permissionDialogCmp) View() tea.View {
429− return tea.NewView(p.render())
430−}
431−
432−func (p *permissionDialogCmp) BindingKeys() []key.Binding {
433− return layout.KeyMapToSlice(permissionsKeys)
434−}
435−
436−func (p *permissionDialogCmp) SetSize() tea.Cmd {
437− if p.permission.ID == "" {
438− return nil
439− }
440− switch p.permission.ToolName {
441− case tools.BashToolName:
442− p.width = int(float64(p.windowSize.Width) * 0.4)
443− p.height = int(float64(p.windowSize.Height) * 0.3)
444− case tools.EditToolName:
445− p.width = int(float64(p.windowSize.Width) * 0.8)
446− p.height = int(float64(p.windowSize.Height) * 0.8)
447− case tools.WriteToolName:
448− p.width = int(float64(p.windowSize.Width) * 0.8)
449− p.height = int(float64(p.windowSize.Height) * 0.8)
450− case tools.FetchToolName:
451− p.width = int(float64(p.windowSize.Width) * 0.4)
452− p.height = int(float64(p.windowSize.Height) * 0.3)
453− default:
454− p.width = int(float64(p.windowSize.Width) * 0.7)
455− p.height = int(float64(p.windowSize.Height) * 0.5)
456− }
457− return nil
458−}
459−
460−func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd {
461− p.permission = permission
462− return p.SetSize()
463−}
464−
465−// Helper to get or set cached diff content
466−func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
467− if cached, ok := c.diffCache[key]; ok {
468− return cached
469− }
470−
471− content, err := generator()
472− if err != nil {
473− return fmt.Sprintf("Error formatting diff: %v", err)
474− }
475−
476− c.diffCache[key] = content
477−
478− return content
479−}
480−
481−// Helper to get or set cached markdown content
482−func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
483− if cached, ok := c.markdownCache[key]; ok {
484− return cached
485− }
486−
487− content, err := generator()
488− if err != nil {
489− return fmt.Sprintf("Error rendering markdown: %v", err)
490− }
491−
492− c.markdownCache[key] = content
493−
494− return content
495−}
496−
497−func NewPermissionDialogCmp() PermissionDialogCmp {
498− // Create viewport for content
499− contentViewport := viewport.New()
500−
501− return &permissionDialogCmp{
502− contentViewPort: contentViewport,
503− selectedOption: 0, // Default to "Allow"
504− diffCache: make(map[string]string),
505− markdownCache: make(map[string]string),
506− }
507−}
internal/tui/components/dialogs/init/init.gonew file+179
@@ -0,0 +1,179 @@
1+package init
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ tea "github.com/charmbracelet/bubbletea/v2"
6+ "github.com/charmbracelet/lipgloss/v2"
7+
8+ "github.com/charmbracelet/crush/internal/tui/components/core"
9+ "github.com/charmbracelet/crush/internal/tui/components/dialogs"
10+ "github.com/charmbracelet/crush/internal/tui/styles"
11+ "github.com/charmbracelet/crush/internal/tui/util"
12+)
13+
14+const InitDialogID dialogs.DialogID = "init"
15+
16+// InitDialogCmp is a component that asks the user if they want to initialize the project.
17+type InitDialogCmp interface {
18+ dialogs.DialogModel
19+}
20+
21+type initDialogCmp struct {
22+ wWidth, wHeight int
23+ width, height int
24+ selected int
25+ keyMap KeyMap
26+}
27+
28+// NewInitDialogCmp creates a new InitDialogCmp.
29+func NewInitDialogCmp() InitDialogCmp {
30+ return &initDialogCmp{
31+ selected: 0,
32+ keyMap: DefaultKeyMap(),
33+ }
34+}
35+
36+// Init implements tea.Model.
37+func (m *initDialogCmp) Init() tea.Cmd {
38+ return nil
39+}
40+
41+// Update implements tea.Model.
42+func (m *initDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
43+ switch msg := msg.(type) {
44+ case tea.WindowSizeMsg:
45+ m.wWidth = msg.Width
46+ m.wHeight = msg.Height
47+ cmd := m.SetSize()
48+ return m, cmd
49+ case tea.KeyPressMsg:
50+ switch {
51+ case key.Matches(msg, m.keyMap.Close):
52+ return m, tea.Batch(
53+ util.CmdHandler(dialogs.CloseDialogMsg{}),
54+ util.CmdHandler(CloseInitDialogMsg{Initialize: false}),
55+ )
56+ case key.Matches(msg, m.keyMap.ChangeSelection):
57+ m.selected = (m.selected + 1) % 2
58+ return m, nil
59+ case key.Matches(msg, m.keyMap.Select):
60+ return m, tea.Batch(
61+ util.CmdHandler(dialogs.CloseDialogMsg{}),
62+ util.CmdHandler(CloseInitDialogMsg{Initialize: m.selected == 0}),
63+ )
64+ case key.Matches(msg, m.keyMap.Y):
65+ return m, tea.Batch(
66+ util.CmdHandler(dialogs.CloseDialogMsg{}),
67+ util.CmdHandler(CloseInitDialogMsg{Initialize: true}),
68+ )
69+ case key.Matches(msg, m.keyMap.N):
70+ return m, tea.Batch(
71+ util.CmdHandler(dialogs.CloseDialogMsg{}),
72+ util.CmdHandler(CloseInitDialogMsg{Initialize: false}),
73+ )
74+ }
75+ }
76+ return m, nil
77+}
78+
79+func (m *initDialogCmp) renderButtons() string {
80+ t := styles.CurrentTheme()
81+ baseStyle := t.S().Base
82+
83+ buttons := []core.ButtonOpts{
84+ {
85+ Text: "Yes",
86+ UnderlineIndex: 0, // "Y"
87+ Selected: m.selected == 0,
88+ },
89+ {
90+ Text: "No",
91+ UnderlineIndex: 0, // "N"
92+ Selected: m.selected == 1,
93+ },
94+ }
95+
96+ content := core.SelectableButtons(buttons, " ")
97+
98+ return baseStyle.AlignHorizontal(lipgloss.Right).Width(m.width - 4).Render(content)
99+}
100+
101+func (m *initDialogCmp) renderContent() string {
102+ t := styles.CurrentTheme()
103+ baseStyle := t.S().Base
104+
105+ explanation := t.S().Text.
106+ Width(m.width - 4).
107+ Render("Initialization generates a new Crush.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.")
108+
109+ question := t.S().Text.
110+ Width(m.width - 4).
111+ Render("Would you like to initialize this project?")
112+
113+ return baseStyle.Render(lipgloss.JoinVertical(
114+ lipgloss.Left,
115+ explanation,
116+ "",
117+ question,
118+ ))
119+}
120+
121+func (m *initDialogCmp) render() string {
122+ t := styles.CurrentTheme()
123+ baseStyle := t.S().Base
124+ title := core.Title("Initialize Project", m.width-4)
125+
126+ content := m.renderContent()
127+ buttons := m.renderButtons()
128+
129+ dialogContent := lipgloss.JoinVertical(
130+ lipgloss.Top,
131+ title,
132+ "",
133+ content,
134+ "",
135+ buttons,
136+ "",
137+ )
138+
139+ return baseStyle.
140+ Padding(0, 1).
141+ Border(lipgloss.RoundedBorder()).
142+ BorderForeground(t.BorderFocus).
143+ Width(m.width).
144+ Render(dialogContent)
145+}
146+
147+// View implements tea.Model.
148+func (m *initDialogCmp) View() tea.View {
149+ return tea.NewView(m.render())
150+}
151+
152+// SetSize sets the size of the component.
153+func (m *initDialogCmp) SetSize() tea.Cmd {
154+ m.width = min(90, m.wWidth)
155+ m.height = min(15, m.wHeight)
156+ return nil
157+}
158+
159+// ID implements DialogModel.
160+func (m *initDialogCmp) ID() dialogs.DialogID {
161+ return InitDialogID
162+}
163+
164+// Position implements DialogModel.
165+func (m *initDialogCmp) Position() (int, int) {
166+ row := (m.wHeight / 2) - (m.height / 2)
167+ col := (m.wWidth / 2) - (m.width / 2)
168+ return row, col
169+}
170+
171+// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
172+type CloseInitDialogMsg struct {
173+ Initialize bool
174+}
175+
176+// ShowInitDialogMsg is a message that is sent to show the init dialog.
177+type ShowInitDialogMsg struct {
178+ Show bool
179+}
internal/tui/components/dialogs/init/keys.gonew file+59
@@ -0,0 +1,59 @@
1+package init
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/charmbracelet/crush/internal/tui/layout"
6+)
7+
8+type KeyMap struct {
9+ ChangeSelection,
10+ Select,
11+ Y,
12+ N,
13+ Close key.Binding
14+}
15+
16+func DefaultKeyMap() KeyMap {
17+ return KeyMap{
18+ ChangeSelection: key.NewBinding(
19+ key.WithKeys("tab", "left", "right", "h", "l"),
20+ key.WithHelp("tab/←/→", "toggle selection"),
21+ ),
22+ Select: key.NewBinding(
23+ key.WithKeys("enter"),
24+ key.WithHelp("enter", "confirm"),
25+ ),
26+ Y: key.NewBinding(
27+ key.WithKeys("y"),
28+ key.WithHelp("y", "yes"),
29+ ),
30+ N: key.NewBinding(
31+ key.WithKeys("n"),
32+ key.WithHelp("n", "no"),
33+ ),
34+ Close: key.NewBinding(
35+ key.WithKeys("esc"),
36+ key.WithHelp("esc", "cancel"),
37+ ),
38+ }
39+}
40+
41+// FullHelp implements help.KeyMap.
42+func (k KeyMap) FullHelp() [][]key.Binding {
43+ m := [][]key.Binding{}
44+ slice := layout.KeyMapToSlice(k)
45+ for i := 0; i < len(slice); i += 4 {
46+ end := min(i+4, len(slice))
47+ m = append(m, slice[i:end])
48+ }
49+ return m
50+}
51+
52+// ShortHelp implements help.KeyMap.
53+func (k KeyMap) ShortHelp() []key.Binding {
54+ return []key.Binding{
55+ k.ChangeSelection,
56+ k.Select,
57+ k.Close,
58+ }
59+}
internal/tui/components/dialogs/permissions/permissions.go+18−35
@@ -133,44 +133,27 @@ func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
133
133
134func (p *permissionDialogCmp) renderButtons() string {
134func (p *permissionDialogCmp) renderButtons() string {
135 t := styles.CurrentTheme()
135 t := styles.CurrentTheme()
136+ baseStyle := t.S().Base
136
137
137− allowStyle := t.S().Text
138+ buttons := []core.ButtonOpts{
138− allowSessionStyle := allowStyle
139+ {
139− denyStyle := allowStyle
140+ Text: "Allow",
140−
141+ UnderlineIndex: 0, // "A"
141− // Style the selected button
142+ Selected: p.selectedOption == 0,
143+ },
144+ {
145+ Text: "Allow for Session",
146+ UnderlineIndex: 10, // "S" in "Session"
142− switch p.selectedOption {
147+ Selected: p.selectedOption == 1,
143− case 0:
148+ },
144− allowStyle = allowStyle.Foreground(t.White).Background(t.Secondary)
149+ {
145− allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
150+ Text: "Deny",
146− denyStyle = denyStyle.Background(t.BgSubtle)
151+ UnderlineIndex: 0, // "D"
147− case 1:
152+ Selected: p.selectedOption == 2,
148− allowStyle = allowStyle.Background(t.BgSubtle)
153+ },
149− allowSessionStyle = allowSessionStyle.Foreground(t.White).Background(t.Secondary)
150− denyStyle = denyStyle.Background(t.BgSubtle)
151− case 2:
152− allowStyle = allowStyle.Background(t.BgSubtle)
153− allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
154− denyStyle = denyStyle.Foreground(t.White).Background(t.Secondary)
155 }
154 }
156
155
157− baseStyle := t.S().Base
156+ content := core.SelectableButtons(buttons, " ")
158−
159− allowMessage := fmt.Sprintf("%s%s", allowStyle.Underline(true).Render("A"), allowStyle.Render("llow"))
160− allowButton := allowStyle.Padding(0, 2).Render(allowMessage)
161− allowSessionMessage := fmt.Sprintf("%s%s%s", allowSessionStyle.Render("Allow for "), allowSessionStyle.Underline(true).Render("S"), allowSessionStyle.Render("ession"))
162− allowSessionButton := allowSessionStyle.Padding(0, 2).Render(allowSessionMessage)
163− denyMessage := fmt.Sprintf("%s%s", denyStyle.Underline(true).Render("D"), denyStyle.Render("eny"))
164− denyButton := denyStyle.Padding(0, 2).Render(denyMessage)
165−
166− content := lipgloss.JoinHorizontal(
167− lipgloss.Left,
168− allowButton,
169− " ",
170− allowSessionButton,
171− " ",
172− denyButton,
173− )
174
157
175 return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content)
158 return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content)
176}
159}
internal/tui/tui.go+47
@@ -6,6 +6,7 @@ import (
6 "github.com/charmbracelet/bubbles/v2/key"
6 "github.com/charmbracelet/bubbles/v2/key"
7 tea "github.com/charmbracelet/bubbletea/v2"
7 tea "github.com/charmbracelet/bubbletea/v2"
8 "github.com/charmbracelet/crush/internal/app"
8 "github.com/charmbracelet/crush/internal/app"
9+ "github.com/charmbracelet/crush/internal/config"
9 "github.com/charmbracelet/crush/internal/llm/tools"
10 "github.com/charmbracelet/crush/internal/llm/tools"
10 "github.com/charmbracelet/crush/internal/logging"
11 "github.com/charmbracelet/crush/internal/logging"
11 "github.com/charmbracelet/crush/internal/permission"
12 "github.com/charmbracelet/crush/internal/permission"
@@ -16,6 +17,7 @@ import (
16 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
17 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
17 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
18 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
18 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
20+ initDialog "github.com/charmbracelet/crush/internal/tui/components/dialogs/init"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
20 "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
22 "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
23 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
@@ -58,6 +60,24 @@ func (a appModel) Init() tea.Cmd {
58
60
59 cmd = a.status.Init()
61 cmd = a.status.Init()
60 cmds = append(cmds, cmd)
62 cmds = append(cmds, cmd)
63+
64+ // Check if we should show the init dialog
65+ cmds = append(cmds, func() tea.Msg {
66+ shouldShow, err := config.ShouldShowInitDialog()
67+ if err != nil {
68+ return util.InfoMsg{
69+ Type: util.InfoTypeError,
70+ Msg: "Failed to check init status: " + err.Error(),
71+ }
72+ }
73+ if shouldShow {
74+ return dialogs.OpenDialogMsg{
75+ Model: initDialog.NewInitDialogCmp(),
76+ }
77+ }
78+ return nil
79+ })
80+
61 return tea.Batch(cmds...)
81 return tea.Batch(cmds...)
62}
82}
63
83
@@ -161,6 +181,33 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
161 a.app.Permissions.Deny(msg.Permission)
181 a.app.Permissions.Deny(msg.Permission)
162 }
182 }
163 return a, nil
183 return a, nil
184+ // Init Dialog
185+ case initDialog.CloseInitDialogMsg:
186+ if msg.Initialize {
187+ // Run the initialization command
188+ prompt := `Please analyze this codebase and create a Crush.md file containing:
189+1. Build/lint/test commands - especially for running a single test
190+2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
191+
192+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.
193+If there's already a crush.md, improve it.
194+If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
195+
196+ // Mark the project as initialized
197+ if err := config.MarkProjectInitialized(); err != nil {
198+ return a, util.ReportError(err)
199+ }
200+
201+ return a, util.CmdHandler(cmpChat.SendMsg{
202+ Text: prompt,
203+ })
204+ } else {
205+ // Mark the project as initialized without running the command
206+ if err := config.MarkProjectInitialized(); err != nil {
207+ return a, util.ReportError(err)
208+ }
209+ }
210+ return a, nil
164 // Key Press Messages
211 // Key Press Messages
165 case tea.KeyPressMsg:
212 case tea.KeyPressMsg:
166 if msg.String() == "ctrl+t" {
213 if msg.String() == "ctrl+t" {