refactor: initial permissions refactor

Kujtim Hoxha created

Change summary

internal/fileutil/fileutil.go                              |  11 
internal/tui/components/chat/messages/renderer.go          |  26 
internal/tui/components/chat/messages/tool.go              |  10 
internal/tui/components/dialogs/permissions/keys.go        |  70 +
internal/tui/components/dialogs/permissions/permissions.go | 490 ++++++++
internal/tui/components/dialogs/quit/quit.go               |   4 
internal/tui/styles/crush.go                               |   2 
internal/tui/styles/theme.go                               |   2 
internal/tui/tui.go                                        |  30 
9 files changed, 617 insertions(+), 28 deletions(-)

Detailed changes

internal/fileutil/fileutil.go 🔗

@@ -67,7 +67,7 @@ func SkipHidden(path string) bool {
 	}
 
 	commonIgnoredDirs := map[string]bool{
-		".crush":        true,
+		".crush":           true,
 		"node_modules":     true,
 		"vendor":           true,
 		"dist":             true,
@@ -202,3 +202,12 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool,
 	}
 	return results, truncated, nil
 }
+
+func PrettyPath(path string) string {
+	// replace home directory with ~
+	homeDir, err := os.UserHomeDir()
+	if err == nil {
+		path = strings.ReplaceAll(path, homeDir, "~")
+	}
+	return path
+}

internal/tui/components/chat/messages/renderer.go 🔗

@@ -3,12 +3,11 @@ package messages
 import (
 	"encoding/json"
 	"fmt"
-	"os"
 	"strings"
 	"time"
 
-	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/diff"
+	"github.com/charmbracelet/crush/internal/fileutil"
 	"github.com/charmbracelet/crush/internal/highlight"
 	"github.com/charmbracelet/crush/internal/llm/agent"
 	"github.com/charmbracelet/crush/internal/llm/tools"
@@ -210,7 +209,7 @@ func (vr viewRenderer) Render(v *toolCallCmp) string {
 		return vr.renderError(v, "Invalid view parameters")
 	}
 
-	file := prettyPath(params.FilePath)
+	file := fileutil.PrettyPath(params.FilePath)
 	args := newParamBuilder().
 		addMain(file).
 		addKeyValue("limit", formatNonZero(params.Limit)).
@@ -250,7 +249,7 @@ func (er editRenderer) Render(v *toolCallCmp) string {
 		return er.renderError(v, "Invalid edit parameters")
 	}
 
-	file := prettyPath(params.FilePath)
+	file := fileutil.PrettyPath(params.FilePath)
 	args := newParamBuilder().addMain(file).build()
 
 	return er.renderWithParams(v, "Edit", args, func() string {
@@ -281,7 +280,7 @@ func (wr writeRenderer) Render(v *toolCallCmp) string {
 		return wr.renderError(v, "Invalid write parameters")
 	}
 
-	file := prettyPath(params.FilePath)
+	file := fileutil.PrettyPath(params.FilePath)
 	args := newParamBuilder().addMain(file).build()
 
 	return wr.renderWithParams(v, "Write", args, func() string {
@@ -411,6 +410,7 @@ func (lr lsRenderer) Render(v *toolCallCmp) string {
 	if path == "" {
 		path = "."
 	}
+	path = fileutil.PrettyPath(path)
 
 	args := newParamBuilder().addMain(path).build()
 
@@ -637,6 +637,7 @@ func renderPlainContent(v *toolCallCmp, content string) string {
 	if len(lines) > responseContextHeight {
 		out = append(out, t.S().Muted.
 			Background(t.BgSubtle).
+			Width(width).
 			Render(fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight)))
 	}
 	return strings.Join(out, "\n")
@@ -652,6 +653,7 @@ func renderCodeContent(v *toolCallCmp, path, content string, offset int) string
 	if len(strings.Split(content, "\n")) > responseContextHeight {
 		lines = append(lines, t.S().Muted.
 			Background(t.BgSubtle).
+			Width(v.textWidth()-2).
 			Render(fmt.Sprintf("... (%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
 	}
 
@@ -679,11 +681,6 @@ func (v *toolCallCmp) renderToolError() string {
 	return t.S().Base.Foreground(t.Error).Render(v.fit(err, v.textWidth()))
 }
 
-func removeWorkingDirPrefix(path string) string {
-	wd := config.WorkingDirectory()
-	return strings.TrimPrefix(path, wd)
-}
-
 func truncateHeight(s string, h int) string {
 	lines := strings.Split(s, "\n")
 	if len(lines) > h {
@@ -692,15 +689,6 @@ func truncateHeight(s string, h int) string {
 	return s
 }
 
-func prettyPath(path string) string {
-	// replace home directory with ~
-	homeDir, err := os.UserHomeDir()
-	if err == nil {
-		path = strings.ReplaceAll(path, homeDir, "~")
-	}
-	return path
-}
-
 func prettifyToolName(name string) string {
 	switch name {
 	case agent.AgentToolName:

internal/tui/components/chat/messages/tool.go 🔗

@@ -220,6 +220,9 @@ func (m *toolCallCmp) renderPending() string {
 func (m *toolCallCmp) style() lipgloss.Style {
 	t := styles.CurrentTheme()
 
+	if m.isNested {
+		return t.S().Muted
+	}
 	return t.S().Muted.PaddingLeft(4)
 }
 
@@ -275,12 +278,7 @@ func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
 // shouldSpin determines whether the tool call should show a loading animation.
 // Returns true if the tool call is not finished or if the result doesn't match the call ID.
 func (m *toolCallCmp) shouldSpin() bool {
-	if !m.call.Finished {
-		return true
-	} else if m.result.ToolCallID != m.call.ID {
-		return true
-	}
-	return false
+	return !m.call.Finished
 }
 
 // Spinning returns whether the tool call is currently showing a loading animation

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

@@ -0,0 +1,70 @@
+package permissions
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+)
+
+type KeyMap struct {
+	Left,
+	Right,
+	Tab,
+	Select,
+	Allow,
+	AllowSession,
+	Deny key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+	return KeyMap{
+		Left: key.NewBinding(
+			key.WithKeys("left", "h"),
+			key.WithHelp("←", "previous"),
+		),
+		Right: key.NewBinding(
+			key.WithKeys("right", "l"),
+			key.WithHelp("→", "next"),
+		),
+		Tab: key.NewBinding(
+			key.WithKeys("tab"),
+			key.WithHelp("tab", "switch"),
+		),
+		Allow: key.NewBinding(
+			key.WithKeys("a", "ctrl+a"),
+			key.WithHelp("a", "allow"),
+		),
+		AllowSession: key.NewBinding(
+			key.WithKeys("s", "ctrl+s"),
+			key.WithHelp("s", "allow session"),
+		),
+		Deny: key.NewBinding(
+			key.WithKeys("d", "ctrl+d"),
+			key.WithHelp("d", "deny"),
+		),
+		Select: key.NewBinding(
+			key.WithKeys("enter", "tab", "ctrl+y"),
+			key.WithHelp("enter", "confirm"),
+		),
+	}
+}
+
+// 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.Allow,
+		k.AllowSession,
+		k.Deny,
+		k.Select,
+	}
+}

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

@@ -0,0 +1,490 @@
+package permissions
+
+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/fileutil"
+	"github.com/charmbracelet/crush/internal/llm/tools"
+	"github.com/charmbracelet/crush/internal/permission"
+	"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"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/ansi"
+)
+
+type PermissionAction string
+
+// Permission responses
+const (
+	PermissionAllow           PermissionAction = "allow"
+	PermissionAllowForSession PermissionAction = "allow_session"
+	PermissionDeny            PermissionAction = "deny"
+
+	PermissionsDialogID dialogs.DialogID = "permissions"
+)
+
+// 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 {
+	dialogs.DialogModel
+}
+
+// permissionDialogCmp is the implementation of PermissionDialog
+type permissionDialogCmp struct {
+	wWidth          int
+	wHeight         int
+	width           int
+	height          int
+	permission      permission.PermissionRequest
+	contentViewPort viewport.Model
+	selectedOption  int // 0: Allow, 1: Allow for session, 2: Deny
+
+	keyMap KeyMap
+}
+
+func NewPermissionDialogCmp(permission permission.PermissionRequest) PermissionDialogCmp {
+	// Create viewport for content
+	contentViewport := viewport.New()
+	return &permissionDialogCmp{
+		contentViewPort: contentViewport,
+		selectedOption:  0, // Default to "Allow"
+		permission:      permission,
+		keyMap:          DefaultKeyMap(),
+	}
+}
+
+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.wWidth = msg.Width
+		p.wHeight = msg.Height
+		cmd := p.SetSize()
+		cmds = append(cmds, cmd)
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, p.keyMap.Right) || key.Matches(msg, p.keyMap.Tab):
+			p.selectedOption = (p.selectedOption + 1) % 3
+			return p, nil
+		case key.Matches(msg, p.keyMap.Left):
+			p.selectedOption = (p.selectedOption + 2) % 3
+		case key.Matches(msg, p.keyMap.Select):
+			return p, p.selectCurrentOption()
+		case key.Matches(msg, p.keyMap.Allow):
+			return p, tea.Batch(
+				util.CmdHandler(dialogs.CloseDialogMsg{}),
+				util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission}),
+			)
+		case key.Matches(msg, p.keyMap.AllowSession):
+			return p, tea.Batch(
+				util.CmdHandler(dialogs.CloseDialogMsg{}),
+				util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission}),
+			)
+		case key.Matches(msg, p.keyMap.Deny):
+			return p, tea.Batch(
+				util.CmdHandler(dialogs.CloseDialogMsg{}),
+				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 tea.Batch(
+		util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission}),
+		util.CmdHandler(dialogs.CloseDialogMsg{}),
+	)
+}
+
+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.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)
+	}
+
+	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,
+	)
+
+	return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content)
+}
+
+func (p *permissionDialogCmp) renderHeader() string {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base
+
+	toolKey := t.S().Muted.Render("Tool")
+	toolValue := t.S().Text.
+		Width(p.width - lipgloss.Width(toolKey)).
+		Render(fmt.Sprintf(" %s", p.permission.ToolName))
+
+	pathKey := t.S().Muted.Render("Path")
+	pathValue := t.S().Text.
+		Width(p.width - lipgloss.Width(pathKey)).
+		Render(fmt.Sprintf(" %s", fileutil.PrettyPath(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).Render("Command"))
+	case tools.EditToolName:
+		params := p.permission.Params.(tools.EditPermissionsParams)
+		fileKey := t.S().Muted.Render("File")
+		filePath := t.S().Text.
+			Width(p.width - lipgloss.Width(fileKey)).
+			Render(fmt.Sprintf(" %s", fileutil.PrettyPath(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.Render("File")
+		filePath := t.S().Text.
+			Width(p.width - lipgloss.Width(fileKey)).
+			Render(fmt.Sprintf(" %s", fileutil.PrettyPath(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 {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base.Background(t.BgSubtle)
+	if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
+		content := pr.Command
+		t := styles.CurrentTheme()
+		content = strings.TrimSpace(content)
+		content = "\n" + content + "\n"
+		lines := strings.Split(content, "\n")
+
+		width := p.width - 4
+		var out []string
+		for _, ln := range lines {
+			ln = " " + ln // left padding
+			if len(ln) > width {
+				ln = ansi.Truncate(ln, width, "…")
+			}
+			out = append(out, t.S().Muted.
+				Width(width).
+				Foreground(t.FgBase).
+				Background(t.BgSubtle).
+				Render(ln))
+		}
+
+		// Use the cache for markdown rendering
+		renderedContent := strings.Join(out, "\n")
+		finalContent := baseStyle.
+			Width(p.contentViewPort.Width()).
+			Render(renderedContent)
+
+		contentHeight := min(p.height-9, lipgloss.Height(finalContent))
+		p.contentViewPort.SetHeight(contentHeight)
+		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()))
+		})
+
+		contentHeight := min(p.height-9, lipgloss.Height(diff))
+		p.contentViewPort.SetHeight(contentHeight)
+		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()))
+		})
+
+		contentHeight := min(p.height-9, lipgloss.Height(diff))
+		p.contentViewPort.SetHeight(contentHeight)
+		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()))
+		})
+
+		contentHeight := min(p.height-9, lipgloss.Height(diff))
+		p.contentViewPort.SetHeight(contentHeight)
+		p.contentViewPort.SetContent(diff)
+		return p.styleViewport()
+	}
+	return ""
+}
+
+func (p *permissionDialogCmp) renderFetchContent() string {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base.Background(t.BgSubtle)
+	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 - 4)
+			s, err := r.Render(content)
+			return s, err
+		})
+
+		finalContent := baseStyle.
+			Width(p.contentViewPort.Width()).
+			Render(renderedContent)
+
+		contentHeight := min(p.height-9, lipgloss.Height(finalContent))
+		p.contentViewPort.SetHeight(contentHeight)
+		p.contentViewPort.SetContent(finalContent)
+		return p.styleViewport()
+	}
+	return ""
+}
+
+func (p *permissionDialogCmp) renderDefaultContent() string {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base.Background(t.BgSubtle)
+
+	content := p.permission.Description
+
+	// Use the cache for markdown rendering
+	renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
+		r := styles.GetMarkdownRenderer(p.width - 4)
+		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 := core.Title("Permission Required", p.width-4)
+	// Render header
+	headerContent := p.renderHeader()
+	// Render buttons
+	buttons := p.renderButtons()
+
+	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()
+	}
+	// Calculate content height dynamically based on window size
+
+	content := lipgloss.JoinVertical(
+		lipgloss.Top,
+		title,
+		"",
+		headerContent,
+		contentFinal,
+		"",
+		buttons,
+		"",
+	)
+
+	return baseStyle.
+		Padding(0, 1).
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(t.BorderFocus).
+		Width(p.width).
+		Render(
+			content,
+		)
+}
+
+func (p *permissionDialogCmp) View() tea.View {
+	return tea.NewView(p.render())
+}
+
+func (p *permissionDialogCmp) SetSize() tea.Cmd {
+	if p.permission.ID == "" {
+		return nil
+	}
+	switch p.permission.ToolName {
+	case tools.BashToolName:
+		p.width = int(float64(p.wWidth) * 0.4)
+		p.height = int(float64(p.wHeight) * 0.3)
+	case tools.EditToolName:
+		p.width = int(float64(p.wWidth) * 0.8)
+		p.height = int(float64(p.wHeight) * 0.8)
+	case tools.WriteToolName:
+		p.width = int(float64(p.wWidth) * 0.8)
+		p.height = int(float64(p.wHeight) * 0.8)
+	case tools.FetchToolName:
+		p.width = int(float64(p.wWidth) * 0.4)
+		p.height = int(float64(p.wHeight) * 0.3)
+	default:
+		p.width = int(float64(p.wWidth) * 0.7)
+		p.height = int(float64(p.wHeight) * 0.5)
+	}
+	return nil
+}
+
+func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
+	content, err := generator()
+	if err != nil {
+		return fmt.Sprintf("Error formatting diff: %v", err)
+	}
+	return content
+}
+
+func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
+	content, err := generator()
+	if err != nil {
+		return fmt.Sprintf("Error rendering markdown: %v", err)
+	}
+
+	return content
+}
+
+// ID implements PermissionDialogCmp.
+func (p *permissionDialogCmp) ID() dialogs.DialogID {
+	return PermissionsDialogID
+}
+
+// Position implements PermissionDialogCmp.
+func (p *permissionDialogCmp) Position() (int, int) {
+	row := (p.wHeight / 2) - 2 // Just a bit above the center
+	row -= p.height / 2
+	col := p.wWidth / 2
+	col -= p.width / 2
+	return row, col
+}

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

@@ -74,10 +74,10 @@ func (q *quitDialogCmp) View() tea.View {
 	noStyle := yesStyle
 
 	if q.selectedNo {
-		noStyle = noStyle.Background(t.Secondary)
+		noStyle = noStyle.Foreground(t.White).Background(t.Secondary)
 		yesStyle = yesStyle.Background(t.BgSubtle)
 	} else {
-		yesStyle = yesStyle.Background(t.Secondary)
+		yesStyle = yesStyle.Foreground(t.White).Background(t.Secondary)
 		noStyle = noStyle.Background(t.BgSubtle)
 	}
 

internal/tui/styles/crush.go 🔗

@@ -36,6 +36,8 @@ func NewCrushTheme() *Theme {
 		Info:    charmtone.Malibu,
 
 		// Colors
+		White: charmtone.Butter,
+
 		Blue: charmtone.Malibu,
 
 		Green:      charmtone.Julep,

internal/tui/tui.go 🔗

@@ -6,7 +6,9 @@ import (
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/llm/tools"
 	"github.com/charmbracelet/crush/internal/logging"
+	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/completions"
@@ -15,6 +17,7 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
 	"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"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/sessions"
 	"github.com/charmbracelet/crush/internal/tui/layout"
@@ -60,6 +63,7 @@ func (a appModel) Init() tea.Cmd {
 
 // Update handles incoming messages and updates the application state.
 func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	logging.Info("TUI Update", "msg", msg)
 	var cmds []tea.Cmd
 	var cmd tea.Cmd
 
@@ -142,7 +146,33 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return a, util.CmdHandler(dialogs.OpenDialogMsg{
 			Model: filepicker.NewFilePickerCmp(),
 		})
+	// Permissions
+	case pubsub.Event[permission.PermissionRequest]:
+		return a, util.CmdHandler(dialogs.OpenDialogMsg{
+			Model: permissions.NewPermissionDialogCmp(msg.Payload),
+		})
+	case permissions.PermissionResponseMsg:
+		switch msg.Action {
+		case permissions.PermissionAllow:
+			a.app.Permissions.Grant(msg.Permission)
+		case permissions.PermissionAllowForSession:
+			a.app.Permissions.GrantPersistent(msg.Permission)
+		case permissions.PermissionDeny:
+			a.app.Permissions.Deny(msg.Permission)
+		}
+		return a, nil
+	// Key Press Messages
 	case tea.KeyPressMsg:
+		if msg.String() == "ctrl+t" {
+			go a.app.Permissions.Request(permission.CreatePermissionRequest{
+				SessionID: "123",
+				ToolName:  "bash",
+				Action:    "execute",
+				Params: tools.BashPermissionsParams{
+					Command: "ls -la",
+				},
+			})
+		}
 		return a, a.handleKeyPressMsg(msg)
 	}
 	s, _ := a.status.Update(msg)