refactor: new init dialog

Kujtim Hoxha created

Change summary

crush.md                                                   |  34 
internal/config/init.go                                    |  32 
internal/tui/components/core/helpers.go                    |  55 
internal/tui/components/dialog/init.go                     | 181 --
internal/tui/components/dialog/permission.go               | 507 --------
internal/tui/components/dialogs/init/init.go               | 179 ++
internal/tui/components/dialogs/init/keys.go               |  59 
internal/tui/components/dialogs/permissions/permissions.go |  53 
internal/tui/tui.go                                        |  47 
9 files changed, 407 insertions(+), 740 deletions(-)

Detailed changes

crush.md 🔗

@@ -1,22 +1,22 @@
 # Crush Development Guide
 
 ## Build/Test/Lint Commands
-
-- **Build**: `go build ./...` or `go build .` (for main binary)
-- **Test**: `task test` or `go test ./...`
-- **Single test**: `go test ./internal/path/to/package -run TestName`
-- **Lint**: `task lint` or `golangci-lint run`
-- **Format**: `task fmt` or `gofumpt -w .`
+- **Build**: `go build .` or `go run .`
+- **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
+- **Lint**: `task lint` (golangci-lint run) or `task lint-fix` (with --fix)
+- **Format**: `task fmt` (gofumpt -w .)
+- **Dev**: `task dev` (runs with profiling enabled)
 
 ## Code Style Guidelines
-
-- **Imports**: Standard library first, then third-party, then internal packages (separated by blank lines)
-- **Types**: Use `any` instead of `interface{}`, prefer concrete types over interfaces when possible
-- **Naming**: Use camelCase for private, PascalCase for public, descriptive names (e.g., `messageListCmp`, `handleNewUserMessage`)
-- **Constants**: Use `const` blocks with descriptive names (e.g., `NotFound = -1`)
-- **Error handling**: Always check errors, use `require.NoError()` in tests, return errors up the stack
-- **Documentation**: Add comments for all public types/methods, explain complex logic in private methods
-- **Testing**: Use testify/assert and testify/require, table-driven tests with `t.Run()`, mark helpers with `t.Helper()`
-- **File organization**: Group related functionality, extract helper methods for complex logic, use meaningful method names
-- **TUI components**: Implement interfaces (util.Model, layout.Sizeable), document component purpose and behavior
-- **Message handling**: Use pubsub events, handle different message roles (User/Assistant/Tool), manage tool calls separately
+- **Imports**: Use goimports formatting, group stdlib, external, internal packages
+- **Formatting**: Use gofumpt (stricter than gofmt), enabled in golangci-lint
+- **Naming**: Standard Go conventions - PascalCase for exported, camelCase for unexported
+- **Types**: Prefer explicit types, use type aliases for clarity (e.g., `type AgentName string`)
+- **Error handling**: Return errors explicitly, use `fmt.Errorf` for wrapping
+- **Context**: Always pass context.Context as first parameter for operations
+- **Interfaces**: Define interfaces in consuming packages, keep them small and focused
+- **Structs**: Use struct embedding for composition, group related fields
+- **Constants**: Use typed constants with iota for enums, group in const blocks
+- **Testing**: Use testify/assert and testify/require, parallel tests with `t.Parallel()`
+- **JSON tags**: Use snake_case for JSON field names
+- **File permissions**: Use octal notation (0o755, 0o644) for file permissions

internal/config/init.go 🔗

@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"strings"
 )
 
 const (
@@ -37,10 +38,41 @@ func ShouldShowInitDialog() (bool, error) {
 		return false, fmt.Errorf("failed to check init flag file: %w", err)
 	}
 
+	// Check if any variation of crush.md already exists in working directory
+	crushExists, err := crushMdExists(WorkingDirectory())
+	if err != nil {
+		return false, fmt.Errorf("failed to check for crush.md files: %w", err)
+	}
+	if crushExists {
+		// Crush.md already exists, don't show the dialog
+		return false, nil
+	}
+
 	// File doesn't exist, show the dialog
 	return true, nil
 }
 
+// crushMdExists checks if any case variation of crush.md exists in the directory
+func crushMdExists(dir string) (bool, error) {
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		return false, err
+	}
+
+	for _, entry := range entries {
+		if entry.IsDir() {
+			continue
+		}
+		
+		name := strings.ToLower(entry.Name())
+		if name == "crush.md" {
+			return true, nil
+		}
+	}
+	
+	return false, nil
+}
+
 // MarkProjectInitialized marks the current project as initialized
 func MarkProjectInitialized() error {
 	if cfg == nil {

internal/tui/components/core/helpers.go 🔗

@@ -76,3 +76,58 @@ func Status(ops StatusOpts, width int) string {
 		description,
 	}, " ")
 }
+
+type ButtonOpts struct {
+	Text            string
+	UnderlineIndex  int  // Index of character to underline (0-based)
+	Selected        bool // Whether this button is selected
+}
+
+// SelectableButton creates a button with an underlined character and selection state
+func SelectableButton(opts ButtonOpts) string {
+	t := styles.CurrentTheme()
+	
+	// Base style for the button
+	buttonStyle := t.S().Text
+	
+	// Apply selection styling
+	if opts.Selected {
+		buttonStyle = buttonStyle.Foreground(t.White).Background(t.Secondary)
+	} else {
+		buttonStyle = buttonStyle.Background(t.BgSubtle)
+	}
+	
+	// Create the button text with underlined character
+	text := opts.Text
+	if opts.UnderlineIndex >= 0 && opts.UnderlineIndex < len(text) {
+		before := text[:opts.UnderlineIndex]
+		underlined := text[opts.UnderlineIndex : opts.UnderlineIndex+1]
+		after := text[opts.UnderlineIndex+1:]
+		
+		message := buttonStyle.Render(before) + 
+			buttonStyle.Underline(true).Render(underlined) + 
+			buttonStyle.Render(after)
+		
+		return buttonStyle.Padding(0, 2).Render(message)
+	}
+	
+	// Fallback if no underline index specified
+	return buttonStyle.Padding(0, 2).Render(text)
+}
+
+// SelectableButtons creates a horizontal row of selectable buttons
+func SelectableButtons(buttons []ButtonOpts, spacing string) string {
+	if spacing == "" {
+		spacing = "  "
+	}
+	
+	var parts []string
+	for i, button := range buttons {
+		parts = append(parts, SelectableButton(button))
+		if i < len(buttons)-1 {
+			parts = append(parts, spacing)
+		}
+	}
+	
+	return lipgloss.JoinHorizontal(lipgloss.Left, parts...)
+}

internal/tui/components/dialog/init.go 🔗

@@ -1,181 +0,0 @@
-package dialog
-
-import (
-	"github.com/charmbracelet/bubbles/v2/key"
-	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/lipgloss/v2"
-
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-)
-
-// InitDialogCmp is a component that asks the user if they want to initialize the project.
-type InitDialogCmp struct {
-	width, height int
-	selected      int
-	keys          initDialogKeyMap
-}
-
-// NewInitDialogCmp creates a new InitDialogCmp.
-func NewInitDialogCmp() InitDialogCmp {
-	return InitDialogCmp{
-		selected: 0,
-		keys:     initDialogKeyMap{},
-	}
-}
-
-type initDialogKeyMap struct {
-	Tab    key.Binding
-	Left   key.Binding
-	Right  key.Binding
-	Enter  key.Binding
-	Escape key.Binding
-	Y      key.Binding
-	N      key.Binding
-}
-
-// ShortHelp implements key.Map.
-func (k initDialogKeyMap) ShortHelp() []key.Binding {
-	return []key.Binding{
-		key.NewBinding(
-			key.WithKeys("tab", "left", "right"),
-			key.WithHelp("tab/←/→", "toggle selection"),
-		),
-		key.NewBinding(
-			key.WithKeys("enter"),
-			key.WithHelp("enter", "confirm"),
-		),
-		key.NewBinding(
-			key.WithKeys("esc", "q"),
-			key.WithHelp("esc/q", "cancel"),
-		),
-		key.NewBinding(
-			key.WithKeys("y", "n"),
-			key.WithHelp("y/n", "yes/no"),
-		),
-	}
-}
-
-// FullHelp implements key.Map.
-func (k initDialogKeyMap) FullHelp() [][]key.Binding {
-	return [][]key.Binding{k.ShortHelp()}
-}
-
-// Init implements tea.Model.
-func (m InitDialogCmp) Init() tea.Cmd {
-	return nil
-}
-
-// Update implements tea.Model.
-func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.KeyPressMsg:
-		switch {
-		case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
-			return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
-		case key.Matches(msg, key.NewBinding(key.WithKeys("tab", "left", "right", "h", "l"))):
-			m.selected = (m.selected + 1) % 2
-			return m, nil
-		case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
-			return m, util.CmdHandler(CloseInitDialogMsg{Initialize: m.selected == 0})
-		case key.Matches(msg, key.NewBinding(key.WithKeys("y"))):
-			return m, util.CmdHandler(CloseInitDialogMsg{Initialize: true})
-		case key.Matches(msg, key.NewBinding(key.WithKeys("n"))):
-			return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
-		}
-	case tea.WindowSizeMsg:
-		m.width = msg.Width
-		m.height = msg.Height
-	}
-	return m, nil
-}
-
-// View implements tea.Model.
-func (m InitDialogCmp) View() string {
-	t := styles.CurrentTheme()
-	baseStyle := t.S().Base
-
-	// Calculate width needed for content
-	maxWidth := 60 // Width for explanation text
-
-	title := baseStyle.
-		Foreground(t.Primary).
-		Bold(true).
-		Width(maxWidth).
-		Padding(0, 1).
-		Render("Initialize Project")
-
-	explanation := t.S().Text.
-		Width(maxWidth).
-		Padding(0, 1).
-		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.")
-
-	question := t.S().Text.
-		Width(maxWidth).
-		Padding(1, 1).
-		Render("Would you like to initialize this project?")
-
-	maxWidth = min(maxWidth, m.width-10)
-	yesStyle := t.S().Text
-	noStyle := yesStyle
-
-	if m.selected == 0 {
-		yesStyle = yesStyle.
-			Background(t.Primary).
-			Bold(true)
-		noStyle = noStyle.
-			Background(t.BgSubtle)
-	} else {
-		noStyle = noStyle.
-			Background(t.Primary).
-			Bold(true)
-		yesStyle = yesStyle.
-			Background(t.BgSubtle)
-	}
-
-	yes := yesStyle.Padding(0, 3).Render("Yes")
-	no := noStyle.Padding(0, 3).Render("No")
-
-	buttons := lipgloss.JoinHorizontal(lipgloss.Center, yes, baseStyle.Render("  "), no)
-	buttons = baseStyle.
-		Width(maxWidth).
-		Padding(1, 0).
-		Render(buttons)
-
-	content := lipgloss.JoinVertical(
-		lipgloss.Left,
-		title,
-		baseStyle.Width(maxWidth).Render(""),
-		explanation,
-		question,
-		buttons,
-		baseStyle.Width(maxWidth).Render(""),
-	)
-
-	return baseStyle.Padding(1, 2).
-		Border(lipgloss.RoundedBorder()).
-		BorderForeground(t.BorderFocus).
-		Width(lipgloss.Width(content) + 4).
-		Render(content)
-}
-
-// SetSize sets the size of the component.
-func (m *InitDialogCmp) SetSize(width, height int) {
-	m.width = width
-	m.height = height
-}
-
-// Bindings implements layout.Bindings.
-func (m InitDialogCmp) Bindings() []key.Binding {
-	return m.keys.ShortHelp()
-}
-
-// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
-type CloseInitDialogMsg struct {
-	Initialize bool
-}
-
-// ShowInitDialogMsg is a message that is sent to show the init dialog.
-type ShowInitDialogMsg struct {
-	Show bool
-}

internal/tui/components/dialog/permission.go 🔗

@@ -1,507 +0,0 @@
-package dialog
-
-import (
-	"fmt"
-	"strings"
-
-	"github.com/charmbracelet/bubbles/v2/key"
-	"github.com/charmbracelet/bubbles/v2/viewport"
-	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/crush/internal/diff"
-	"github.com/charmbracelet/crush/internal/llm/tools"
-	"github.com/charmbracelet/crush/internal/permission"
-	"github.com/charmbracelet/crush/internal/tui/layout"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/lipgloss/v2"
-)
-
-type PermissionAction string
-
-// Permission responses
-const (
-	PermissionAllow           PermissionAction = "allow"
-	PermissionAllowForSession PermissionAction = "allow_session"
-	PermissionDeny            PermissionAction = "deny"
-)
-
-// PermissionResponseMsg represents the user's response to a permission request
-type PermissionResponseMsg struct {
-	Permission permission.PermissionRequest
-	Action     PermissionAction
-}
-
-// PermissionDialogCmp interface for permission dialog component
-type PermissionDialogCmp interface {
-	util.Model
-	layout.Bindings
-	SetPermissions(permission permission.PermissionRequest) tea.Cmd
-}
-
-type permissionsMapping struct {
-	Left         key.Binding
-	Right        key.Binding
-	EnterSpace   key.Binding
-	Allow        key.Binding
-	AllowSession key.Binding
-	Deny         key.Binding
-	Tab          key.Binding
-}
-
-var permissionsKeys = permissionsMapping{
-	Left: key.NewBinding(
-		key.WithKeys("left"),
-		key.WithHelp("←", "switch options"),
-	),
-	Right: key.NewBinding(
-		key.WithKeys("right"),
-		key.WithHelp("→", "switch options"),
-	),
-	EnterSpace: key.NewBinding(
-		key.WithKeys("enter", " "),
-		key.WithHelp("enter/space", "confirm"),
-	),
-	Allow: key.NewBinding(
-		key.WithKeys("a"),
-		key.WithHelp("a", "allow"),
-	),
-	AllowSession: key.NewBinding(
-		key.WithKeys("s"),
-		key.WithHelp("s", "allow for session"),
-	),
-	Deny: key.NewBinding(
-		key.WithKeys("d"),
-		key.WithHelp("d", "deny"),
-	),
-	Tab: key.NewBinding(
-		key.WithKeys("tab"),
-		key.WithHelp("tab", "switch options"),
-	),
-}
-
-// permissionDialogCmp is the implementation of PermissionDialog
-type permissionDialogCmp struct {
-	width           int
-	height          int
-	permission      permission.PermissionRequest
-	windowSize      tea.WindowSizeMsg
-	contentViewPort viewport.Model
-	selectedOption  int // 0: Allow, 1: Allow for session, 2: Deny
-
-	diffCache     map[string]string
-	markdownCache map[string]string
-}
-
-func (p *permissionDialogCmp) Init() tea.Cmd {
-	return p.contentViewPort.Init()
-}
-
-func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmds []tea.Cmd
-
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		p.windowSize = msg
-		cmd := p.SetSize()
-		cmds = append(cmds, cmd)
-		p.markdownCache = make(map[string]string)
-		p.diffCache = make(map[string]string)
-	case tea.KeyPressMsg:
-		switch {
-		case key.Matches(msg, permissionsKeys.Right) || key.Matches(msg, permissionsKeys.Tab):
-			p.selectedOption = (p.selectedOption + 1) % 3
-			return p, nil
-		case key.Matches(msg, permissionsKeys.Left):
-			p.selectedOption = (p.selectedOption + 2) % 3
-		case key.Matches(msg, permissionsKeys.EnterSpace):
-			return p, p.selectCurrentOption()
-		case key.Matches(msg, permissionsKeys.Allow):
-			return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission})
-		case key.Matches(msg, permissionsKeys.AllowSession):
-			return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission})
-		case key.Matches(msg, permissionsKeys.Deny):
-			return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission})
-		default:
-			// Pass other keys to viewport
-			viewPort, cmd := p.contentViewPort.Update(msg)
-			p.contentViewPort = viewPort
-			cmds = append(cmds, cmd)
-		}
-	}
-
-	return p, tea.Batch(cmds...)
-}
-
-func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
-	var action PermissionAction
-
-	switch p.selectedOption {
-	case 0:
-		action = PermissionAllow
-	case 1:
-		action = PermissionAllowForSession
-	case 2:
-		action = PermissionDeny
-	}
-
-	return util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission})
-}
-
-func (p *permissionDialogCmp) renderButtons() string {
-	t := styles.CurrentTheme()
-
-	allowStyle := t.S().Text
-	allowSessionStyle := allowStyle
-	denyStyle := allowStyle
-
-	// Style the selected button
-	switch p.selectedOption {
-	case 0:
-		allowStyle = allowStyle.Background(t.Secondary)
-		allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
-		denyStyle = denyStyle.Background(t.BgSubtle)
-	case 1:
-		allowStyle = allowStyle.Background(t.BgSubtle)
-		allowSessionStyle = allowSessionStyle.Background(t.Secondary)
-		denyStyle = denyStyle.Background(t.BgSubtle)
-	case 2:
-		allowStyle = allowStyle.Background(t.BgSubtle)
-		allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
-		denyStyle = denyStyle.Background(t.Secondary)
-	}
-
-	allowButton := allowStyle.Padding(0, 1).Render("Allow (a)")
-	allowSessionButton := allowSessionStyle.Padding(0, 1).Render("Allow for session (s)")
-	denyButton := denyStyle.Padding(0, 1).Render("Deny (d)")
-
-	content := lipgloss.JoinHorizontal(
-		lipgloss.Left,
-		allowButton,
-		"  ",
-		allowSessionButton,
-		"  ",
-		denyButton,
-		"  ",
-	)
-
-	remainingWidth := p.width - lipgloss.Width(content)
-	if remainingWidth > 0 {
-		content = strings.Repeat(" ", remainingWidth) + content
-	}
-	return content
-}
-
-func (p *permissionDialogCmp) renderHeader() string {
-	t := styles.CurrentTheme()
-	baseStyle := t.S().Base
-
-	toolKey := t.S().Muted.Bold(true).Render("Tool")
-	toolValue := t.S().Text.
-		Width(p.width - lipgloss.Width(toolKey)).
-		Render(fmt.Sprintf(": %s", p.permission.ToolName))
-
-	pathKey := t.S().Muted.Bold(true).Render("Path")
-	pathValue := t.S().Text.
-		Width(p.width - lipgloss.Width(pathKey)).
-		Render(fmt.Sprintf(": %s", p.permission.Path))
-
-	headerParts := []string{
-		lipgloss.JoinHorizontal(
-			lipgloss.Left,
-			toolKey,
-			toolValue,
-		),
-		baseStyle.Render(strings.Repeat(" ", p.width)),
-		lipgloss.JoinHorizontal(
-			lipgloss.Left,
-			pathKey,
-			pathValue,
-		),
-		baseStyle.Render(strings.Repeat(" ", p.width)),
-	}
-
-	// Add tool-specific header information
-	switch p.permission.ToolName {
-	case tools.BashToolName:
-		headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("Command"))
-	case tools.EditToolName:
-		params := p.permission.Params.(tools.EditPermissionsParams)
-		fileKey := t.S().Muted.Bold(true).Render("File")
-		filePath := t.S().Text.
-			Width(p.width - lipgloss.Width(fileKey)).
-			Render(fmt.Sprintf(": %s", params.FilePath))
-		headerParts = append(headerParts,
-			lipgloss.JoinHorizontal(
-				lipgloss.Left,
-				fileKey,
-				filePath,
-			),
-			baseStyle.Render(strings.Repeat(" ", p.width)),
-		)
-
-	case tools.WriteToolName:
-		params := p.permission.Params.(tools.WritePermissionsParams)
-		fileKey := t.S().Muted.Bold(true).Render("File")
-		filePath := t.S().Text.
-			Width(p.width - lipgloss.Width(fileKey)).
-			Render(fmt.Sprintf(": %s", params.FilePath))
-		headerParts = append(headerParts,
-			lipgloss.JoinHorizontal(
-				lipgloss.Left,
-				fileKey,
-				filePath,
-			),
-			baseStyle.Render(strings.Repeat(" ", p.width)),
-		)
-	case tools.FetchToolName:
-		headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
-	}
-
-	return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
-}
-
-func (p *permissionDialogCmp) renderBashContent() string {
-	baseStyle := styles.CurrentTheme().S().Base
-	if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
-		content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
-
-		// Use the cache for markdown rendering
-		renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
-			r := styles.GetMarkdownRenderer(p.width - 10)
-			s, err := r.Render(content)
-			return s, err
-		})
-
-		finalContent := baseStyle.
-			Width(p.contentViewPort.Width()).
-			Render(renderedContent)
-		p.contentViewPort.SetContent(finalContent)
-		return p.styleViewport()
-	}
-	return ""
-}
-
-func (p *permissionDialogCmp) renderEditContent() string {
-	if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
-		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
-			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
-		})
-
-		p.contentViewPort.SetContent(diff)
-		return p.styleViewport()
-	}
-	return ""
-}
-
-func (p *permissionDialogCmp) renderPatchContent() string {
-	if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
-		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
-			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
-		})
-
-		p.contentViewPort.SetContent(diff)
-		return p.styleViewport()
-	}
-	return ""
-}
-
-func (p *permissionDialogCmp) renderWriteContent() string {
-	if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
-		// Use the cache for diff rendering
-		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
-			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
-		})
-
-		p.contentViewPort.SetContent(diff)
-		return p.styleViewport()
-	}
-	return ""
-}
-
-func (p *permissionDialogCmp) renderFetchContent() string {
-	baseStyle := styles.CurrentTheme().S().Base
-	if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
-		content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
-
-		// Use the cache for markdown rendering
-		renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
-			r := styles.GetMarkdownRenderer(p.width - 10)
-			s, err := r.Render(content)
-			return s, err
-		})
-
-		finalContent := baseStyle.
-			Width(p.contentViewPort.Width()).
-			Render(renderedContent)
-		p.contentViewPort.SetContent(finalContent)
-		return p.styleViewport()
-	}
-	return ""
-}
-
-func (p *permissionDialogCmp) renderDefaultContent() string {
-	baseStyle := styles.CurrentTheme().S().Base
-
-	content := p.permission.Description
-
-	// Use the cache for markdown rendering
-	renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
-		r := styles.GetMarkdownRenderer(p.width - 10)
-		s, err := r.Render(content)
-		return s, err
-	})
-
-	finalContent := baseStyle.
-		Width(p.contentViewPort.Width()).
-		Render(renderedContent)
-	p.contentViewPort.SetContent(finalContent)
-
-	if renderedContent == "" {
-		return ""
-	}
-
-	return p.styleViewport()
-}
-
-func (p *permissionDialogCmp) styleViewport() string {
-	t := styles.CurrentTheme()
-
-	return t.S().Base.Render(p.contentViewPort.View())
-}
-
-func (p *permissionDialogCmp) render() string {
-	t := styles.CurrentTheme()
-	baseStyle := t.S().Base
-
-	title := baseStyle.
-		Bold(true).
-		Width(p.width - 4).
-		Foreground(t.Primary).
-		Render("Permission Required")
-	// Render header
-	headerContent := p.renderHeader()
-	// Render buttons
-	buttons := p.renderButtons()
-
-	// Calculate content height dynamically based on window size
-	p.contentViewPort.SetHeight(p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title))
-	p.contentViewPort.SetWidth(p.width - 4)
-
-	// Render content based on tool type
-	var contentFinal string
-	switch p.permission.ToolName {
-	case tools.BashToolName:
-		contentFinal = p.renderBashContent()
-	case tools.EditToolName:
-		contentFinal = p.renderEditContent()
-	case tools.PatchToolName:
-		contentFinal = p.renderPatchContent()
-	case tools.WriteToolName:
-		contentFinal = p.renderWriteContent()
-	case tools.FetchToolName:
-		contentFinal = p.renderFetchContent()
-	default:
-		contentFinal = p.renderDefaultContent()
-	}
-
-	content := lipgloss.JoinVertical(
-		lipgloss.Top,
-		title,
-		baseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
-		headerContent,
-		contentFinal,
-		buttons,
-		baseStyle.Render(strings.Repeat(" ", p.width-4)),
-	)
-
-	return baseStyle.
-		Padding(1, 0, 0, 1).
-		Border(lipgloss.RoundedBorder()).
-		BorderForeground(t.BorderFocus).
-		Width(p.width).
-		Height(p.height).
-		Render(
-			content,
-		)
-}
-
-func (p *permissionDialogCmp) View() tea.View {
-	return tea.NewView(p.render())
-}
-
-func (p *permissionDialogCmp) BindingKeys() []key.Binding {
-	return layout.KeyMapToSlice(permissionsKeys)
-}
-
-func (p *permissionDialogCmp) SetSize() tea.Cmd {
-	if p.permission.ID == "" {
-		return nil
-	}
-	switch p.permission.ToolName {
-	case tools.BashToolName:
-		p.width = int(float64(p.windowSize.Width) * 0.4)
-		p.height = int(float64(p.windowSize.Height) * 0.3)
-	case tools.EditToolName:
-		p.width = int(float64(p.windowSize.Width) * 0.8)
-		p.height = int(float64(p.windowSize.Height) * 0.8)
-	case tools.WriteToolName:
-		p.width = int(float64(p.windowSize.Width) * 0.8)
-		p.height = int(float64(p.windowSize.Height) * 0.8)
-	case tools.FetchToolName:
-		p.width = int(float64(p.windowSize.Width) * 0.4)
-		p.height = int(float64(p.windowSize.Height) * 0.3)
-	default:
-		p.width = int(float64(p.windowSize.Width) * 0.7)
-		p.height = int(float64(p.windowSize.Height) * 0.5)
-	}
-	return nil
-}
-
-func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd {
-	p.permission = permission
-	return p.SetSize()
-}
-
-// Helper to get or set cached diff content
-func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
-	if cached, ok := c.diffCache[key]; ok {
-		return cached
-	}
-
-	content, err := generator()
-	if err != nil {
-		return fmt.Sprintf("Error formatting diff: %v", err)
-	}
-
-	c.diffCache[key] = content
-
-	return content
-}
-
-// Helper to get or set cached markdown content
-func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
-	if cached, ok := c.markdownCache[key]; ok {
-		return cached
-	}
-
-	content, err := generator()
-	if err != nil {
-		return fmt.Sprintf("Error rendering markdown: %v", err)
-	}
-
-	c.markdownCache[key] = content
-
-	return content
-}
-
-func NewPermissionDialogCmp() PermissionDialogCmp {
-	// Create viewport for content
-	contentViewport := viewport.New()
-
-	return &permissionDialogCmp{
-		contentViewPort: contentViewport,
-		selectedOption:  0, // Default to "Allow"
-		diffCache:       make(map[string]string),
-		markdownCache:   make(map[string]string),
-	}
-}

internal/tui/components/dialogs/init/init.go 🔗

@@ -0,0 +1,179 @@
+package init
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/lipgloss/v2"
+
+	"github.com/charmbracelet/crush/internal/tui/components/core"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+)
+
+const InitDialogID dialogs.DialogID = "init"
+
+// InitDialogCmp is a component that asks the user if they want to initialize the project.
+type InitDialogCmp interface {
+	dialogs.DialogModel
+}
+
+type initDialogCmp struct {
+	wWidth, wHeight int
+	width, height   int
+	selected        int
+	keyMap          KeyMap
+}
+
+// NewInitDialogCmp creates a new InitDialogCmp.
+func NewInitDialogCmp() InitDialogCmp {
+	return &initDialogCmp{
+		selected: 0,
+		keyMap:   DefaultKeyMap(),
+	}
+}
+
+// Init implements tea.Model.
+func (m *initDialogCmp) Init() tea.Cmd {
+	return nil
+}
+
+// Update implements tea.Model.
+func (m *initDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		m.wWidth = msg.Width
+		m.wHeight = msg.Height
+		cmd := m.SetSize()
+		return m, cmd
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, m.keyMap.Close):
+			return m, tea.Batch(
+				util.CmdHandler(dialogs.CloseDialogMsg{}),
+				util.CmdHandler(CloseInitDialogMsg{Initialize: false}),
+			)
+		case key.Matches(msg, m.keyMap.ChangeSelection):
+			m.selected = (m.selected + 1) % 2
+			return m, nil
+		case key.Matches(msg, m.keyMap.Select):
+			return m, tea.Batch(
+				util.CmdHandler(dialogs.CloseDialogMsg{}),
+				util.CmdHandler(CloseInitDialogMsg{Initialize: m.selected == 0}),
+			)
+		case key.Matches(msg, m.keyMap.Y):
+			return m, tea.Batch(
+				util.CmdHandler(dialogs.CloseDialogMsg{}),
+				util.CmdHandler(CloseInitDialogMsg{Initialize: true}),
+			)
+		case key.Matches(msg, m.keyMap.N):
+			return m, tea.Batch(
+				util.CmdHandler(dialogs.CloseDialogMsg{}),
+				util.CmdHandler(CloseInitDialogMsg{Initialize: false}),
+			)
+		}
+	}
+	return m, nil
+}
+
+func (m *initDialogCmp) renderButtons() string {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base
+
+	buttons := []core.ButtonOpts{
+		{
+			Text:           "Yes",
+			UnderlineIndex: 0, // "Y"
+			Selected:       m.selected == 0,
+		},
+		{
+			Text:           "No",
+			UnderlineIndex: 0, // "N"
+			Selected:       m.selected == 1,
+		},
+	}
+
+	content := core.SelectableButtons(buttons, "  ")
+
+	return baseStyle.AlignHorizontal(lipgloss.Right).Width(m.width - 4).Render(content)
+}
+
+func (m *initDialogCmp) renderContent() string {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base
+
+	explanation := t.S().Text.
+		Width(m.width - 4).
+		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.")
+
+	question := t.S().Text.
+		Width(m.width - 4).
+		Render("Would you like to initialize this project?")
+
+	return baseStyle.Render(lipgloss.JoinVertical(
+		lipgloss.Left,
+		explanation,
+		"",
+		question,
+	))
+}
+
+func (m *initDialogCmp) render() string {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base
+	title := core.Title("Initialize Project", m.width-4)
+
+	content := m.renderContent()
+	buttons := m.renderButtons()
+
+	dialogContent := lipgloss.JoinVertical(
+		lipgloss.Top,
+		title,
+		"",
+		content,
+		"",
+		buttons,
+		"",
+	)
+
+	return baseStyle.
+		Padding(0, 1).
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(t.BorderFocus).
+		Width(m.width).
+		Render(dialogContent)
+}
+
+// View implements tea.Model.
+func (m *initDialogCmp) View() tea.View {
+	return tea.NewView(m.render())
+}
+
+// SetSize sets the size of the component.
+func (m *initDialogCmp) SetSize() tea.Cmd {
+	m.width = min(90, m.wWidth)
+	m.height = min(15, m.wHeight)
+	return nil
+}
+
+// ID implements DialogModel.
+func (m *initDialogCmp) ID() dialogs.DialogID {
+	return InitDialogID
+}
+
+// Position implements DialogModel.
+func (m *initDialogCmp) Position() (int, int) {
+	row := (m.wHeight / 2) - (m.height / 2)
+	col := (m.wWidth / 2) - (m.width / 2)
+	return row, col
+}
+
+// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
+type CloseInitDialogMsg struct {
+	Initialize bool
+}
+
+// ShowInitDialogMsg is a message that is sent to show the init dialog.
+type ShowInitDialogMsg struct {
+	Show bool
+}

internal/tui/components/dialogs/init/keys.go 🔗

@@ -0,0 +1,59 @@
+package init
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+)
+
+type KeyMap struct {
+	ChangeSelection,
+	Select,
+	Y,
+	N,
+	Close key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+	return KeyMap{
+		ChangeSelection: key.NewBinding(
+			key.WithKeys("tab", "left", "right", "h", "l"),
+			key.WithHelp("tab/←/→", "toggle selection"),
+		),
+		Select: key.NewBinding(
+			key.WithKeys("enter"),
+			key.WithHelp("enter", "confirm"),
+		),
+		Y: key.NewBinding(
+			key.WithKeys("y"),
+			key.WithHelp("y", "yes"),
+		),
+		N: key.NewBinding(
+			key.WithKeys("n"),
+			key.WithHelp("n", "no"),
+		),
+		Close: key.NewBinding(
+			key.WithKeys("esc"),
+			key.WithHelp("esc", "cancel"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.ChangeSelection,
+		k.Select,
+		k.Close,
+	}
+}

internal/tui/components/dialogs/permissions/permissions.go 🔗

@@ -133,44 +133,27 @@ func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
 
 func (p *permissionDialogCmp) renderButtons() string {
 	t := styles.CurrentTheme()
+	baseStyle := t.S().Base
 
-	allowStyle := t.S().Text
-	allowSessionStyle := allowStyle
-	denyStyle := allowStyle
-
-	// Style the selected button
-	switch p.selectedOption {
-	case 0:
-		allowStyle = allowStyle.Foreground(t.White).Background(t.Secondary)
-		allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
-		denyStyle = denyStyle.Background(t.BgSubtle)
-	case 1:
-		allowStyle = allowStyle.Background(t.BgSubtle)
-		allowSessionStyle = allowSessionStyle.Foreground(t.White).Background(t.Secondary)
-		denyStyle = denyStyle.Background(t.BgSubtle)
-	case 2:
-		allowStyle = allowStyle.Background(t.BgSubtle)
-		allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
-		denyStyle = denyStyle.Foreground(t.White).Background(t.Secondary)
+	buttons := []core.ButtonOpts{
+		{
+			Text:           "Allow",
+			UnderlineIndex: 0, // "A"
+			Selected:       p.selectedOption == 0,
+		},
+		{
+			Text:           "Allow for Session",
+			UnderlineIndex: 10, // "S" in "Session"
+			Selected:       p.selectedOption == 1,
+		},
+		{
+			Text:           "Deny",
+			UnderlineIndex: 0, // "D"
+			Selected:       p.selectedOption == 2,
+		},
 	}
 
-	baseStyle := t.S().Base
-
-	allowMessage := fmt.Sprintf("%s%s", allowStyle.Underline(true).Render("A"), allowStyle.Render("llow"))
-	allowButton := allowStyle.Padding(0, 2).Render(allowMessage)
-	allowSessionMessage := fmt.Sprintf("%s%s%s", allowSessionStyle.Render("Allow for "), allowSessionStyle.Underline(true).Render("S"), allowSessionStyle.Render("ession"))
-	allowSessionButton := allowSessionStyle.Padding(0, 2).Render(allowSessionMessage)
-	denyMessage := fmt.Sprintf("%s%s", denyStyle.Underline(true).Render("D"), denyStyle.Render("eny"))
-	denyButton := denyStyle.Padding(0, 2).Render(denyMessage)
-
-	content := lipgloss.JoinHorizontal(
-		lipgloss.Left,
-		allowButton,
-		"  ",
-		allowSessionButton,
-		"  ",
-		denyButton,
-	)
+	content := core.SelectableButtons(buttons, "  ")
 
 	return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content)
 }

internal/tui/tui.go 🔗

@@ -6,6 +6,7 @@ import (
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/llm/tools"
 	"github.com/charmbracelet/crush/internal/logging"
 	"github.com/charmbracelet/crush/internal/permission"
@@ -16,6 +17,7 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
+	initDialog "github.com/charmbracelet/crush/internal/tui/components/dialogs/init"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
@@ -58,6 +60,24 @@ func (a appModel) Init() tea.Cmd {
 
 	cmd = a.status.Init()
 	cmds = append(cmds, cmd)
+
+	// Check if we should show the init dialog
+	cmds = append(cmds, func() tea.Msg {
+		shouldShow, err := config.ShouldShowInitDialog()
+		if err != nil {
+			return util.InfoMsg{
+				Type: util.InfoTypeError,
+				Msg:  "Failed to check init status: " + err.Error(),
+			}
+		}
+		if shouldShow {
+			return dialogs.OpenDialogMsg{
+				Model: initDialog.NewInitDialogCmp(),
+			}
+		}
+		return nil
+	})
+
 	return tea.Batch(cmds...)
 }
 
@@ -161,6 +181,33 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			a.app.Permissions.Deny(msg.Permission)
 		}
 		return a, nil
+	// Init Dialog
+	case initDialog.CloseInitDialogMsg:
+		if msg.Initialize {
+			// Run the initialization command
+			prompt := `Please analyze this codebase and create a Crush.md file containing:
+1. Build/lint/test commands - especially for running a single test
+2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
+
+The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
+If there's already a crush.md, improve it.
+If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
+			
+			// Mark the project as initialized
+			if err := config.MarkProjectInitialized(); err != nil {
+				return a, util.ReportError(err)
+			}
+			
+			return a, util.CmdHandler(cmpChat.SendMsg{
+				Text: prompt,
+			})
+		} else {
+			// Mark the project as initialized without running the command
+			if err := config.MarkProjectInitialized(); err != nil {
+				return a, util.ReportError(err)
+			}
+		}
+		return a, nil
 	// Key Press Messages
 	case tea.KeyPressMsg:
 		if msg.String() == "ctrl+t" {