1package dialog
2
3import (
4 "encoding/json"
5 "fmt"
6 "strings"
7
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"
18)
19
20// PermissionsID is the identifier for the permissions dialog.
21const PermissionsID = "permissions"
22
23// PermissionAction represents the user's response to a permission request.
24type PermissionAction string
25
26const (
27 PermissionAllow PermissionAction = "allow"
28 PermissionAllowForSession PermissionAction = "allow_session"
29 PermissionDeny PermissionAction = "deny"
30)
31
32// Permissions dialog sizing constants.
33const (
34 // diffMaxWidth is the maximum width for diff views.
35 diffMaxWidth = 180
36 // diffSizeRatio is the size ratio for diff views relative to window.
37 diffSizeRatio = 0.8
38 // simpleMaxWidth is the maximum width for simple content dialogs.
39 simpleMaxWidth = 100
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.
49 minWindowWidth = 60
50 // minWindowHeight is the minimum window height before forcing fullscreen.
51 minWindowHeight = 20
52)
53
54// Permissions represents a dialog for permission requests.
55type Permissions struct {
56 com *common.Common
57 windowWidth int // Terminal window dimensions.
58 windowHeight int
59 fullscreen bool // true when dialog is fullscreen
60
61 permission permission.PermissionRequest
62 selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
63
64 viewport viewport.Model
65 viewportDirty bool // true when viewport content needs to be re-rendered
66 viewportWidth int
67
68 // Diff view state.
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
73
74 help help.Model
75 keyMap permissionsKeyMap
76}
77
78type permissionsKeyMap struct {
79 Left key.Binding
80 Right key.Binding
81 Tab key.Binding
82 Select key.Binding
83 Allow key.Binding
84 AllowSession key.Binding
85 Deny key.Binding
86 Close key.Binding
87 ToggleDiffMode key.Binding
88 ToggleFullscreen key.Binding
89 ScrollUp key.Binding
90 ScrollDown key.Binding
91 ScrollLeft key.Binding
92 ScrollRight key.Binding
93 Choose key.Binding
94 Scroll key.Binding
95}
96
97func defaultPermissionsKeyMap() permissionsKeyMap {
98 return permissionsKeyMap{
99 Left: key.NewBinding(
100 key.WithKeys("left", "h"),
101 key.WithHelp("←", "previous"),
102 ),
103 Right: key.NewBinding(
104 key.WithKeys("right", "l"),
105 key.WithHelp("→", "next"),
106 ),
107 Tab: key.NewBinding(
108 key.WithKeys("tab"),
109 key.WithHelp("tab", "next option"),
110 ),
111 Select: key.NewBinding(
112 key.WithKeys("enter", "ctrl+y"),
113 key.WithHelp("enter", "confirm"),
114 ),
115 Allow: key.NewBinding(
116 key.WithKeys("a", "A", "ctrl+a"),
117 key.WithHelp("a", "allow"),
118 ),
119 AllowSession: key.NewBinding(
120 key.WithKeys("s", "S", "ctrl+s"),
121 key.WithHelp("s", "allow session"),
122 ),
123 Deny: key.NewBinding(
124 key.WithKeys("d", "D"),
125 key.WithHelp("d", "deny"),
126 ),
127 Close: CloseKey,
128 ToggleDiffMode: key.NewBinding(
129 key.WithKeys("t"),
130 key.WithHelp("t", "toggle diff view"),
131 ),
132 ToggleFullscreen: key.NewBinding(
133 key.WithKeys("f"),
134 key.WithHelp("f", "toggle fullscreen"),
135 ),
136 ScrollUp: key.NewBinding(
137 key.WithKeys("shift+up", "K"),
138 key.WithHelp("shift+↑", "scroll up"),
139 ),
140 ScrollDown: key.NewBinding(
141 key.WithKeys("shift+down", "J"),
142 key.WithHelp("shift+↓", "scroll down"),
143 ),
144 ScrollLeft: key.NewBinding(
145 key.WithKeys("shift+left", "H"),
146 key.WithHelp("shift+←", "scroll left"),
147 ),
148 ScrollRight: key.NewBinding(
149 key.WithKeys("shift+right", "L"),
150 key.WithHelp("shift+→", "scroll right"),
151 ),
152 Choose: key.NewBinding(
153 key.WithKeys("left", "right"),
154 key.WithHelp("←/→", "choose"),
155 ),
156 Scroll: key.NewBinding(
157 key.WithKeys("shift+left", "shift+down", "shift+up", "shift+right"),
158 key.WithHelp("shift+←↓↑→", "scroll"),
159 ),
160 }
161}
162
163var _ Dialog = (*Permissions)(nil)
164
165// PermissionsOption configures the permissions dialog.
166type PermissionsOption func(*Permissions)
167
168// WithDiffMode sets the initial diff mode (split or unified).
169func WithDiffMode(split bool) PermissionsOption {
170 return func(p *Permissions) {
171 p.diffSplitMode = &split
172 }
173}
174
175// NewPermissions creates a new permissions dialog.
176func NewPermissions(com *common.Common, perm permission.PermissionRequest, opts ...PermissionsOption) *Permissions {
177 h := help.New()
178 h.Styles = com.Styles.DialogHelpStyles()
179
180 km := defaultPermissionsKeyMap()
181
182 // Configure viewport with matching keybindings.
183 vp := viewport.New()
184 vp.KeyMap = viewport.KeyMap{
185 Up: km.ScrollUp,
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()),
194 }
195
196 p := &Permissions{
197 com: com,
198 permission: perm,
199 selectedOption: 0,
200 viewport: vp,
201 help: h,
202 keyMap: km,
203 }
204
205 for _, opt := range opts {
206 opt(p)
207 }
208
209 return p
210}
211
212// Calculate usable content width (dialog border + horizontal padding).
213func (p *Permissions) calculateContentWidth(width int) int {
214 t := p.com.Styles
215 const dialogHorizontalPadding = 2
216 return width - t.Dialog.View.GetHorizontalFrameSize() - dialogHorizontalPadding
217}
218
219// ID implements [Dialog].
220func (*Permissions) ID() string {
221 return PermissionsID
222}
223
224// HandleMsg implements [Dialog].
225func (p *Permissions) HandleMsg(msg tea.Msg) Action {
226 switch msg := msg.(type) {
227 case tea.KeyPressMsg:
228 switch {
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
250 }
251 case key.Matches(msg, p.keyMap.ToggleFullscreen):
252 if p.hasDiffView() {
253 p.fullscreen = !p.fullscreen
254 }
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)
263 }
264 case tea.MouseWheelMsg:
265 p.viewport, _ = p.viewport.Update(msg)
266 default:
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
271 }
272 }
273
274 return nil
275}
276
277func (p *Permissions) selectCurrentOption() tea.Msg {
278 switch p.selectedOption {
279 case 0:
280 return p.respond(PermissionAllow)
281 case 1:
282 return p.respond(PermissionAllowForSession)
283 default:
284 return p.respond(PermissionDeny)
285 }
286}
287
288func (p *Permissions) respond(action PermissionAction) tea.Msg {
289 return ActionPermissionResponse{
290 Permission: p.permission,
291 Action: action,
292 }
293}
294
295func (p *Permissions) hasDiffView() bool {
296 switch p.permission.ToolName {
297 case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName:
298 return true
299 }
300 return false
301}
302
303func (p *Permissions) isSplitMode() bool {
304 if p.diffSplitMode != nil {
305 return *p.diffSplitMode
306 }
307 return p.defaultDiffSplitMode
308}
309
310// Draw implements [Dialog].
311func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
312 t := p.com.Styles
313 // Force fullscreen when window is too small.
314 forceFullscreen := area.Dx() <= minWindowWidth || area.Dy() <= minWindowHeight
315
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.
320 width = area.Dx()
321 height = area.Dy()
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)
326 } else {
327 // Narrower for simple content like commands/URLs.
328 width = min(int(float64(area.Dx())*simpleSizeRatio), simpleMaxWidth)
329 height = int(float64(area.Dy()) * simpleHeightRatio)
330 }
331
332 dialogStyle := t.Dialog.View.Width(width).Padding(0, 1)
333
334 contentWidth := p.calculateContentWidth(width)
335 header := p.renderHeader(contentWidth)
336 buttons := p.renderButtons(contentWidth)
337 helpView := p.help.View(p)
338
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
345
346 p.defaultDiffSplitMode = width >= splitModeMinWidth
347
348 if p.viewport.Width() != contentWidth-1 {
349 // Mark diff content as dirty if width has changed
350 p.viewportDirty = true
351 }
352
353 var content string
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
362 }
363 content = p.viewport.View()
364 if p.canScroll() {
365 scrollbar = common.Scrollbar(t, availableHeight, p.viewport.TotalLineCount(), availableHeight, p.viewport.YOffset())
366 }
367
368 // Join content with scrollbar if present.
369 if scrollbar != "" {
370 content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar)
371 }
372
373 parts := []string{header}
374 if content != "" {
375 parts = append(parts, "", content)
376 }
377 parts = append(parts, "", buttons, "", helpView)
378
379 innerContent := lipgloss.JoinVertical(lipgloss.Left, parts...)
380 DrawCenterCursor(scr, area, dialogStyle.Render(innerContent), nil)
381 return nil
382}
383
384func (p *Permissions) renderHeader(contentWidth int) string {
385 t := p.com.Styles
386
387 title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize())
388 title = t.Dialog.Title.Render(title)
389
390 // Tool info.
391 toolLine := p.renderKeyValue("Tool", p.permission.ToolName, contentWidth)
392 pathLine := p.renderKeyValue("Path", fsext.PrettyPath(p.permission.Path), contentWidth)
393
394 lines := []string{title, "", toolLine, pathLine}
395
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))
401 }
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))
406 }
407 case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName, tools.ViewToolName:
408 var filePath string
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
418 }
419 if filePath != "" {
420 lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(filePath), contentWidth))
421 }
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))
425 }
426 }
427
428 return lipgloss.JoinVertical(lipgloss.Left, lines...)
429}
430
431func (p *Permissions) renderKeyValue(key, value string, width int) string {
432 t := p.com.Styles
433 keyStyle := t.Muted
434 valueStyle := t.Base
435
436 keyStr := keyStyle.Render(key)
437 valueStr := valueStyle.Width(width - lipgloss.Width(keyStr) - 1).Render(" " + value)
438
439 return lipgloss.JoinHorizontal(lipgloss.Left, keyStr, valueStr)
440}
441
442func (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()
462 default:
463 return p.renderDefaultContent()
464 }
465}
466
467func (p *Permissions) renderBashContent() string {
468 params, ok := p.permission.Params.(tools.BashPermissionsParams)
469 if !ok {
470 return ""
471 }
472
473 return p.com.Styles.Dialog.ContentPanel.Render(params.Command)
474}
475
476func (p *Permissions) renderEditContent(contentWidth int) string {
477 params, ok := p.permission.Params.(tools.EditPermissionsParams)
478 if !ok {
479 return ""
480 }
481 return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
482}
483
484func (p *Permissions) renderWriteContent(contentWidth int) string {
485 params, ok := p.permission.Params.(tools.WritePermissionsParams)
486 if !ok {
487 return ""
488 }
489 return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
490}
491
492func (p *Permissions) renderMultiEditContent(contentWidth int) string {
493 params, ok := p.permission.Params.(tools.MultiEditPermissionsParams)
494 if !ok {
495 return ""
496 }
497 return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
498}
499
500func (p *Permissions) renderDiff(filePath, oldContent, newContent string, contentWidth int) string {
501 if !p.viewportDirty {
502 if p.isSplitMode() {
503 return p.splitDiffContent
504 }
505 return p.unifiedDiffContent
506 }
507
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.
516 Width(contentWidth)
517
518 var result string
519 if isSplitMode {
520 formatter = formatter.Split()
521 p.splitDiffContent = formatter.String()
522 result = p.splitDiffContent
523 } else {
524 formatter = formatter.Unified()
525 p.unifiedDiffContent = formatter.String()
526 result = p.unifiedDiffContent
527 }
528
529 return result
530}
531
532func (p *Permissions) renderDownloadContent() string {
533 params, ok := p.permission.Params.(tools.DownloadPermissionsParams)
534 if !ok {
535 return ""
536 }
537
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)
541 }
542
543 return p.com.Styles.Dialog.ContentPanel.Render(content)
544}
545
546func (p *Permissions) renderFetchContent() string {
547 params, ok := p.permission.Params.(tools.FetchPermissionsParams)
548 if !ok {
549 return ""
550 }
551
552 return p.com.Styles.Dialog.ContentPanel.Render(params.URL)
553}
554
555func (p *Permissions) renderAgenticFetchContent() string {
556 params, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams)
557 if !ok {
558 return ""
559 }
560
561 var content string
562 if params.URL != "" {
563 content = fmt.Sprintf("URL: %s\n\nPrompt: %s", params.URL, params.Prompt)
564 } else {
565 content = fmt.Sprintf("Prompt: %s", params.Prompt)
566 }
567
568 return p.com.Styles.Dialog.ContentPanel.Render(content)
569}
570
571func (p *Permissions) renderViewContent() string {
572 params, ok := p.permission.Params.(tools.ViewPermissionsParams)
573 if !ok {
574 return ""
575 }
576
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)
580 }
581 if params.Limit > 0 && params.Limit != 2000 {
582 content += fmt.Sprintf("\nLines to read: %d", params.Limit)
583 }
584
585 return p.com.Styles.Dialog.ContentPanel.Render(content)
586}
587
588func (p *Permissions) renderLSContent() string {
589 params, ok := p.permission.Params.(tools.LSPermissionsParams)
590 if !ok {
591 return ""
592 }
593
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, ", "))
597 }
598
599 return p.com.Styles.Dialog.ContentPanel.Render(content)
600}
601
602func (p *Permissions) renderDefaultContent() string {
603 content := p.permission.Description
604
605 // Pretty-print JSON params if available.
606 if p.permission.Params != nil {
607 var paramStr string
608 if str, ok := p.permission.Params.(string); ok {
609 paramStr = str
610 } else {
611 paramStr = fmt.Sprintf("%v", p.permission.Params)
612 }
613
614 var parsed any
615 if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
616 if b, err := json.MarshalIndent(parsed, "", " "); err == nil {
617 if content != "" {
618 content += "\n\n"
619 }
620 content += string(b)
621 }
622 } else if paramStr != "" {
623 if content != "" {
624 content += "\n\n"
625 }
626 content += paramStr
627 }
628 }
629
630 if content == "" {
631 return ""
632 }
633
634 return p.com.Styles.Dialog.ContentPanel.Render(strings.TrimSpace(content))
635}
636
637func (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},
642 }
643
644 content := common.ButtonGroup(p.com.Styles, buttons, " ")
645
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).
652 Render(content)
653 }
654
655 return lipgloss.NewStyle().
656 Width(contentWidth).
657 Align(lipgloss.Right).
658 Render(content)
659}
660
661func (p *Permissions) canScroll() bool {
662 if p.hasDiffView() {
663 // Diff views can always scroll.
664 return true
665 }
666 // For non-diff content, check if viewport has scrollable content.
667 return !p.viewport.AtTop() || !p.viewport.AtBottom()
668}
669
670// ShortHelp implements [help.KeyMap].
671func (p *Permissions) ShortHelp() []key.Binding {
672 bindings := []key.Binding{
673 p.keyMap.Choose,
674 p.keyMap.Select,
675 p.keyMap.Close,
676 }
677
678 if p.canScroll() {
679 bindings = append(bindings, p.keyMap.Scroll)
680 }
681
682 if p.hasDiffView() {
683 bindings = append(bindings,
684 p.keyMap.ToggleDiffMode,
685 p.keyMap.ToggleFullscreen,
686 )
687 }
688
689 return bindings
690}
691
692// FullHelp implements [help.KeyMap].
693func (p *Permissions) FullHelp() [][]key.Binding {
694 return [][]key.Binding{p.ShortHelp()}
695}