@@ -0,0 +1,695 @@
8+ "charm.land/bubbles/v2/help"
9+ "charm.land/bubbles/v2/key"
10+ "charm.land/bubbles/v2/viewport"
11+ tea "charm.land/bubbletea/v2"
12+ "charm.land/lipgloss/v2"
13+ "github.com/charmbracelet/crush/internal/agent/tools"
14+ "github.com/charmbracelet/crush/internal/fsext"
15+ "github.com/charmbracelet/crush/internal/permission"
16+ "github.com/charmbracelet/crush/internal/ui/common"
17+ uv "github.com/charmbracelet/ultraviolet"
20+// PermissionsID is the identifier for the permissions dialog.
21+const PermissionsID = "permissions"
23+// PermissionAction represents the user's response to a permission request.
24+type PermissionAction string
27+ PermissionAllow PermissionAction = "allow"
28+ PermissionAllowForSession PermissionAction = "allow_session"
29+ PermissionDeny PermissionAction = "deny"
32+// Permissions dialog sizing constants.
34+ // diffMaxWidth is the maximum width for diff views.
36+ // diffSizeRatio is the size ratio for diff views relative to window.
38+ // simpleMaxWidth is the maximum width for simple content dialogs.
40+ // simpleSizeRatio is the size ratio for simple content dialogs.
41+ simpleSizeRatio = 0.6
42+ // simpleHeightRatio is the height ratio for simple content dialogs.
43+ simpleHeightRatio = 0.5
44+ // splitModeMinWidth is the minimum width to enable split diff mode.
45+ splitModeMinWidth = 140
46+ // layoutSpacingLines is the number of empty lines used for layout spacing.
47+ layoutSpacingLines = 4
48+ // minWindowWidth is the minimum window width before forcing fullscreen.
50+ // minWindowHeight is the minimum window height before forcing fullscreen.
54+// Permissions represents a dialog for permission requests.
55+type Permissions struct {
57+ windowWidth int // Terminal window dimensions.
59+ fullscreen bool // true when dialog is fullscreen
61+ permission permission.PermissionRequest
62+ selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
64+ viewport viewport.Model
65+ viewportDirty bool // true when viewport content needs to be re-rendered
69+ diffSplitMode *bool // nil means use default based on width
70+ defaultDiffSplitMode bool // default split mode based on width
71+ unifiedDiffContent string
72+ splitDiffContent string
75+ keyMap permissionsKeyMap
78+type permissionsKeyMap struct {
84+ AllowSession key.Binding
87+ ToggleDiffMode key.Binding
88+ ToggleFullscreen key.Binding
90+ ScrollDown key.Binding
91+ ScrollLeft key.Binding
92+ ScrollRight key.Binding
97+func defaultPermissionsKeyMap() permissionsKeyMap {
98+ return permissionsKeyMap{
99+ Left: key.NewBinding(
100+ key.WithKeys("left", "h"),
101+ key.WithHelp("←", "previous"),
103+ Right: key.NewBinding(
104+ key.WithKeys("right", "l"),
105+ key.WithHelp("→", "next"),
107+ Tab: key.NewBinding(
108+ key.WithKeys("tab"),
109+ key.WithHelp("tab", "next option"),
111+ Select: key.NewBinding(
112+ key.WithKeys("enter", "ctrl+y"),
113+ key.WithHelp("enter", "confirm"),
115+ Allow: key.NewBinding(
116+ key.WithKeys("a", "A", "ctrl+a"),
117+ key.WithHelp("a", "allow"),
119+ AllowSession: key.NewBinding(
120+ key.WithKeys("s", "S", "ctrl+s"),
121+ key.WithHelp("s", "allow session"),
123+ Deny: key.NewBinding(
124+ key.WithKeys("d", "D"),
125+ key.WithHelp("d", "deny"),
128+ ToggleDiffMode: key.NewBinding(
130+ key.WithHelp("t", "toggle diff view"),
132+ ToggleFullscreen: key.NewBinding(
134+ key.WithHelp("f", "toggle fullscreen"),
136+ ScrollUp: key.NewBinding(
137+ key.WithKeys("shift+up", "K"),
138+ key.WithHelp("shift+↑", "scroll up"),
140+ ScrollDown: key.NewBinding(
141+ key.WithKeys("shift+down", "J"),
142+ key.WithHelp("shift+↓", "scroll down"),
144+ ScrollLeft: key.NewBinding(
145+ key.WithKeys("shift+left", "H"),
146+ key.WithHelp("shift+←", "scroll left"),
148+ ScrollRight: key.NewBinding(
149+ key.WithKeys("shift+right", "L"),
150+ key.WithHelp("shift+→", "scroll right"),
152+ Choose: key.NewBinding(
153+ key.WithKeys("left", "right"),
154+ key.WithHelp("←/→", "choose"),
156+ Scroll: key.NewBinding(
157+ key.WithKeys("shift+left", "shift+down", "shift+up", "shift+right"),
158+ key.WithHelp("shift+←↓↑→", "scroll"),
163+var _ Dialog = (*Permissions)(nil)
165+// PermissionsOption configures the permissions dialog.
166+type PermissionsOption func(*Permissions)
168+// WithDiffMode sets the initial diff mode (split or unified).
169+func WithDiffMode(split bool) PermissionsOption {
170+ return func(p *Permissions) {
171+ p.diffSplitMode = &split
175+// NewPermissions creates a new permissions dialog.
176+func NewPermissions(com *common.Common, perm permission.PermissionRequest, opts ...PermissionsOption) *Permissions {
178+ h.Styles = com.Styles.DialogHelpStyles()
180+ km := defaultPermissionsKeyMap()
182+ // Configure viewport with matching keybindings.
183+ vp := viewport.New()
184+ vp.KeyMap = viewport.KeyMap{
186+ Down: km.ScrollDown,
187+ Left: km.ScrollLeft,
188+ Right: km.ScrollRight,
189+ // Disable other viewport keys to avoid conflicts with dialog shortcuts.
190+ PageUp: key.NewBinding(key.WithDisabled()),
191+ PageDown: key.NewBinding(key.WithDisabled()),
192+ HalfPageUp: key.NewBinding(key.WithDisabled()),
193+ HalfPageDown: key.NewBinding(key.WithDisabled()),
205+ for _, opt := range opts {
212+// Calculate usable content width (dialog border + horizontal padding).
213+func (p *Permissions) calculateContentWidth(width int) int {
215+ const dialogHorizontalPadding = 2
216+ return width - t.Dialog.View.GetHorizontalFrameSize() - dialogHorizontalPadding
219+// ID implements [Dialog].
220+func (*Permissions) ID() string {
221+ return PermissionsID
224+// HandleMsg implements [Dialog].
225+func (p *Permissions) HandleMsg(msg tea.Msg) Action {
226+ switch msg := msg.(type) {
227+ case tea.KeyPressMsg:
229+ case key.Matches(msg, p.keyMap.Close):
230+ // Escape denies the permission request.
231+ return p.respond(PermissionDeny)
232+ case key.Matches(msg, p.keyMap.Right), key.Matches(msg, p.keyMap.Tab):
233+ p.selectedOption = (p.selectedOption + 1) % 3
234+ case key.Matches(msg, p.keyMap.Left):
235+ // Add 2 instead of subtracting 1 to avoid negative modulo.
236+ p.selectedOption = (p.selectedOption + 2) % 3
237+ case key.Matches(msg, p.keyMap.Select):
238+ return p.selectCurrentOption()
239+ case key.Matches(msg, p.keyMap.Allow):
240+ return p.respond(PermissionAllow)
241+ case key.Matches(msg, p.keyMap.AllowSession):
242+ return p.respond(PermissionAllowForSession)
243+ case key.Matches(msg, p.keyMap.Deny):
244+ return p.respond(PermissionDeny)
245+ case key.Matches(msg, p.keyMap.ToggleDiffMode):
246+ if p.hasDiffView() {
247+ newMode := !p.isSplitMode()
248+ p.diffSplitMode = &newMode
249+ p.viewportDirty = true
251+ case key.Matches(msg, p.keyMap.ToggleFullscreen):
252+ if p.hasDiffView() {
253+ p.fullscreen = !p.fullscreen
255+ case key.Matches(msg, p.keyMap.ScrollDown):
256+ p.viewport, _ = p.viewport.Update(msg)
257+ case key.Matches(msg, p.keyMap.ScrollUp):
258+ p.viewport, _ = p.viewport.Update(msg)
259+ case key.Matches(msg, p.keyMap.ScrollLeft):
260+ p.viewport, _ = p.viewport.Update(msg)
261+ case key.Matches(msg, p.keyMap.ScrollRight):
262+ p.viewport, _ = p.viewport.Update(msg)
264+ case tea.MouseWheelMsg:
265+ p.viewport, _ = p.viewport.Update(msg)
267+ // Pass unhandled keys to viewport for non-diff content scrolling.
268+ if !p.hasDiffView() {
269+ p.viewport, _ = p.viewport.Update(msg)
270+ p.viewportDirty = true
277+func (p *Permissions) selectCurrentOption() tea.Msg {
278+ switch p.selectedOption {
280+ return p.respond(PermissionAllow)
282+ return p.respond(PermissionAllowForSession)
284+ return p.respond(PermissionDeny)
288+func (p *Permissions) respond(action PermissionAction) tea.Msg {
289+ return ActionPermissionResponse{
290+ Permission: p.permission,
295+func (p *Permissions) hasDiffView() bool {
296+ switch p.permission.ToolName {
297+ case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName:
303+func (p *Permissions) isSplitMode() bool {
304+ if p.diffSplitMode != nil {
305+ return *p.diffSplitMode
307+ return p.defaultDiffSplitMode
310+// Draw implements [Dialog].
311+func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
313+ // Force fullscreen when window is too small.
314+ forceFullscreen := area.Dx() <= minWindowWidth || area.Dy() <= minWindowHeight
316+ // Calculate dialog dimensions based on fullscreen state and content type.
317+ var width, height int
318+ if forceFullscreen || (p.fullscreen && p.hasDiffView()) {
319+ // Use nearly full window for fullscreen.
322+ } else if p.hasDiffView() {
323+ // Wide for side-by-side diffs, capped for readability.
324+ width = min(int(float64(area.Dx())*diffSizeRatio), diffMaxWidth)
325+ height = int(float64(area.Dy()) * diffSizeRatio)
327+ // Narrower for simple content like commands/URLs.
328+ width = min(int(float64(area.Dx())*simpleSizeRatio), simpleMaxWidth)
329+ height = int(float64(area.Dy()) * simpleHeightRatio)
332+ dialogStyle := t.Dialog.View.Width(width).Padding(0, 1)
334+ contentWidth := p.calculateContentWidth(width)
335+ header := p.renderHeader(contentWidth)
336+ buttons := p.renderButtons(contentWidth)
337+ helpView := p.help.View(p)
339+ // Calculate available height for content.
340+ headerHeight := lipgloss.Height(header)
341+ buttonsHeight := lipgloss.Height(buttons)
342+ helpHeight := lipgloss.Height(helpView)
343+ frameHeight := dialogStyle.GetVerticalFrameSize() + layoutSpacingLines
344+ availableHeight := height - headerHeight - buttonsHeight - helpHeight - frameHeight
346+ p.defaultDiffSplitMode = width >= splitModeMinWidth
348+ if p.viewport.Width() != contentWidth-1 {
349+ // Mark diff content as dirty if width has changed
350+ p.viewportDirty = true
354+ var scrollbar string
355+ // Non-diff content uses the viewport for scrolling.
356+ p.viewport.SetWidth(contentWidth - 1) // -1 for scrollbar
357+ p.viewport.SetHeight(availableHeight)
358+ if p.viewportDirty {
359+ p.viewport.SetContent(p.renderContent(contentWidth - 1))
360+ p.viewportWidth = p.viewport.Width()
361+ p.viewportDirty = false
363+ content = p.viewport.View()
365+ scrollbar = common.Scrollbar(t, availableHeight, p.viewport.TotalLineCount(), availableHeight, p.viewport.YOffset())
368+ // Join content with scrollbar if present.
369+ if scrollbar != "" {
370+ content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar)
373+ parts := []string{header}
375+ parts = append(parts, "", content)
377+ parts = append(parts, "", buttons, "", helpView)
379+ innerContent := lipgloss.JoinVertical(lipgloss.Left, parts...)
380+ DrawCenterCursor(scr, area, dialogStyle.Render(innerContent), nil)
384+func (p *Permissions) renderHeader(contentWidth int) string {
387+ title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize())
388+ title = t.Dialog.Title.Render(title)
391+ toolLine := p.renderKeyValue("Tool", p.permission.ToolName, contentWidth)
392+ pathLine := p.renderKeyValue("Path", fsext.PrettyPath(p.permission.Path), contentWidth)
394+ lines := []string{title, "", toolLine, pathLine}
396+ // Add tool-specific header info.
397+ switch p.permission.ToolName {
398+ case tools.BashToolName:
399+ if params, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
400+ lines = append(lines, p.renderKeyValue("Desc", params.Description, contentWidth))
402+ case tools.DownloadToolName:
403+ if params, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok {
404+ lines = append(lines, p.renderKeyValue("URL", params.URL, contentWidth))
405+ lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(params.FilePath), contentWidth))
407+ case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName, tools.ViewToolName:
409+ switch params := p.permission.Params.(type) {
410+ case tools.EditPermissionsParams:
411+ filePath = params.FilePath
412+ case tools.WritePermissionsParams:
413+ filePath = params.FilePath
414+ case tools.MultiEditPermissionsParams:
415+ filePath = params.FilePath
416+ case tools.ViewPermissionsParams:
417+ filePath = params.FilePath
420+ lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(filePath), contentWidth))
422+ case tools.LSToolName:
423+ if params, ok := p.permission.Params.(tools.LSPermissionsParams); ok {
424+ lines = append(lines, p.renderKeyValue("Directory", fsext.PrettyPath(params.Path), contentWidth))
428+ return lipgloss.JoinVertical(lipgloss.Left, lines...)
431+func (p *Permissions) renderKeyValue(key, value string, width int) string {
434+ valueStyle := t.Base
436+ keyStr := keyStyle.Render(key)
437+ valueStr := valueStyle.Width(width - lipgloss.Width(keyStr) - 1).Render(" " + value)
439+ return lipgloss.JoinHorizontal(lipgloss.Left, keyStr, valueStr)
442+func (p *Permissions) renderContent(width int) string {
443+ switch p.permission.ToolName {
444+ case tools.BashToolName:
445+ return p.renderBashContent()
446+ case tools.EditToolName:
447+ return p.renderEditContent(width)
448+ case tools.WriteToolName:
449+ return p.renderWriteContent(width)
450+ case tools.MultiEditToolName:
451+ return p.renderMultiEditContent(width)
452+ case tools.DownloadToolName:
453+ return p.renderDownloadContent()
454+ case tools.FetchToolName:
455+ return p.renderFetchContent()
456+ case tools.AgenticFetchToolName:
457+ return p.renderAgenticFetchContent()
458+ case tools.ViewToolName:
459+ return p.renderViewContent()
460+ case tools.LSToolName:
461+ return p.renderLSContent()
463+ return p.renderDefaultContent()
467+func (p *Permissions) renderBashContent() string {
468+ params, ok := p.permission.Params.(tools.BashPermissionsParams)
473+ return p.com.Styles.Dialog.ContentPanel.Render(params.Command)
476+func (p *Permissions) renderEditContent(contentWidth int) string {
477+ params, ok := p.permission.Params.(tools.EditPermissionsParams)
481+ return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
484+func (p *Permissions) renderWriteContent(contentWidth int) string {
485+ params, ok := p.permission.Params.(tools.WritePermissionsParams)
489+ return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
492+func (p *Permissions) renderMultiEditContent(contentWidth int) string {
493+ params, ok := p.permission.Params.(tools.MultiEditPermissionsParams)
497+ return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
500+func (p *Permissions) renderDiff(filePath, oldContent, newContent string, contentWidth int) string {
501+ if !p.viewportDirty {
502+ if p.isSplitMode() {
503+ return p.splitDiffContent
505+ return p.unifiedDiffContent
508+ isSplitMode := p.isSplitMode()
509+ formatter := common.DiffFormatter(p.com.Styles).
510+ Before(fsext.PrettyPath(filePath), oldContent).
511+ After(fsext.PrettyPath(filePath), newContent).
512+ // TODO: Allow horizontal scrolling instead of cropping. However, the
513+ // diffview currently would only background color the width of the
514+ // content. If the viewport is wider than the content, the rest of the
515+ // line would not be colored properly.
520+ formatter = formatter.Split()
521+ p.splitDiffContent = formatter.String()
522+ result = p.splitDiffContent
524+ formatter = formatter.Unified()
525+ p.unifiedDiffContent = formatter.String()
526+ result = p.unifiedDiffContent
532+func (p *Permissions) renderDownloadContent() string {
533+ params, ok := p.permission.Params.(tools.DownloadPermissionsParams)
538+ content := fmt.Sprintf("URL: %s\nFile: %s", params.URL, fsext.PrettyPath(params.FilePath))
539+ if params.Timeout > 0 {
540+ content += fmt.Sprintf("\nTimeout: %ds", params.Timeout)
543+ return p.com.Styles.Dialog.ContentPanel.Render(content)
546+func (p *Permissions) renderFetchContent() string {
547+ params, ok := p.permission.Params.(tools.FetchPermissionsParams)
552+ return p.com.Styles.Dialog.ContentPanel.Render(params.URL)
555+func (p *Permissions) renderAgenticFetchContent() string {
556+ params, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams)
562+ if params.URL != "" {
563+ content = fmt.Sprintf("URL: %s\n\nPrompt: %s", params.URL, params.Prompt)
565+ content = fmt.Sprintf("Prompt: %s", params.Prompt)
568+ return p.com.Styles.Dialog.ContentPanel.Render(content)
571+func (p *Permissions) renderViewContent() string {
572+ params, ok := p.permission.Params.(tools.ViewPermissionsParams)
577+ content := fmt.Sprintf("File: %s", fsext.PrettyPath(params.FilePath))
578+ if params.Offset > 0 {
579+ content += fmt.Sprintf("\nStarting from line: %d", params.Offset+1)
581+ if params.Limit > 0 && params.Limit != 2000 {
582+ content += fmt.Sprintf("\nLines to read: %d", params.Limit)
585+ return p.com.Styles.Dialog.ContentPanel.Render(content)
588+func (p *Permissions) renderLSContent() string {
589+ params, ok := p.permission.Params.(tools.LSPermissionsParams)
594+ content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(params.Path))
595+ if len(params.Ignore) > 0 {
596+ content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(params.Ignore, ", "))
599+ return p.com.Styles.Dialog.ContentPanel.Render(content)
602+func (p *Permissions) renderDefaultContent() string {
603+ content := p.permission.Description
605+ // Pretty-print JSON params if available.
606+ if p.permission.Params != nil {
608+ if str, ok := p.permission.Params.(string); ok {
611+ paramStr = fmt.Sprintf("%v", p.permission.Params)
615+ if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
616+ if b, err := json.MarshalIndent(parsed, "", " "); err == nil {
620+ content += string(b)
622+ } else if paramStr != "" {
634+ return p.com.Styles.Dialog.ContentPanel.Render(strings.TrimSpace(content))
637+func (p *Permissions) renderButtons(contentWidth int) string {
638+ buttons := []common.ButtonOpts{
639+ {Text: "Allow", UnderlineIndex: 0, Selected: p.selectedOption == 0},
640+ {Text: "Allow for Session", UnderlineIndex: 10, Selected: p.selectedOption == 1},
641+ {Text: "Deny", UnderlineIndex: 0, Selected: p.selectedOption == 2},
644+ content := common.ButtonGroup(p.com.Styles, buttons, " ")
646+ // If buttons are too wide, stack them vertically.
647+ if lipgloss.Width(content) > contentWidth {
648+ content = common.ButtonGroup(p.com.Styles, buttons, "\n")
649+ return lipgloss.NewStyle().
650+ Width(contentWidth).
651+ Align(lipgloss.Center).
655+ return lipgloss.NewStyle().
656+ Width(contentWidth).
657+ Align(lipgloss.Right).
661+func (p *Permissions) canScroll() bool {
662+ if p.hasDiffView() {
663+ // Diff views can always scroll.
666+ // For non-diff content, check if viewport has scrollable content.
667+ return !p.viewport.AtTop() || !p.viewport.AtBottom()
670+// ShortHelp implements [help.KeyMap].
671+func (p *Permissions) ShortHelp() []key.Binding {
672+ bindings := []key.Binding{
679+ bindings = append(bindings, p.keyMap.Scroll)
682+ if p.hasDiffView() {
683+ bindings = append(bindings,
684+ p.keyMap.ToggleDiffMode,
685+ p.keyMap.ToggleFullscreen,
692+// FullHelp implements [help.KeyMap].
693+func (p *Permissions) FullHelp() [][]key.Binding {
694+ return [][]key.Binding{p.ShortHelp()}