on main help sign in
refactor: initial permissions refactor
commitebcc9ae4163572be44c527787728c384fd8680eb authorKujtim Hoxha <kujtimii.h@gmail.com> dateSun 8 Jun 2025, 10:23 (1 year ago) parent8668014
9 files changed+617−28
internal/fileutil/fileutil.go+10−1 internal/tui/components/chat/messages/renderer.go+7−19 internal/tui/components/chat/messages/tool.go+4−6 internal/tui/components/dialogs/permissions/keys.go+70 internal/tui/components/dialogs/permissions/permissions.go+490 internal/tui/components/dialogs/quit/quit.go+2−2 internal/tui/styles/crush.go+2 internal/tui/styles/theme.go+2 internal/tui/tui.go+30
internal/fileutil/fileutil.go+10−1
@@ -67,7 +67,7 @@ func SkipHidden(path string) bool {
6767 }
6868
6969 commonIgnoredDirs := map[string]bool{
70− ".crush": true,
70+ ".crush": true,
7171 "node_modules": true,
7272 "vendor": true,
7373 "dist": true,
@@ -202,3 +202,12 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool,
202202 }
203203 return results, truncated, nil
204204}
205+
206+func PrettyPath(path string) string {
207+ // replace home directory with ~
208+ homeDir, err := os.UserHomeDir()
209+ if err == nil {
210+ path = strings.ReplaceAll(path, homeDir, "~")
211+ }
212+ return path
213+}
internal/tui/components/chat/messages/renderer.go+7−19
@@ -3,12 +3,11 @@ package messages
33import (
44 "encoding/json"
55 "fmt"
6− "os"
76 "strings"
87 "time"
98
10− "github.com/charmbracelet/crush/internal/config"
119 "github.com/charmbracelet/crush/internal/diff"
10+ "github.com/charmbracelet/crush/internal/fileutil"
1211 "github.com/charmbracelet/crush/internal/highlight"
1312 "github.com/charmbracelet/crush/internal/llm/agent"
1413 "github.com/charmbracelet/crush/internal/llm/tools"
@@ -210,7 +209,7 @@ func (vr viewRenderer) Render(v *toolCallCmp) string {
210209 return vr.renderError(v, "Invalid view parameters")
211210 }
212211
213− file := prettyPath(params.FilePath)
212+ file := fileutil.PrettyPath(params.FilePath)
214213 args := newParamBuilder().
215214 addMain(file).
216215 addKeyValue("limit", formatNonZero(params.Limit)).
@@ -250,7 +249,7 @@ func (er editRenderer) Render(v *toolCallCmp) string {
250249 return er.renderError(v, "Invalid edit parameters")
251250 }
252251
253− file := prettyPath(params.FilePath)
252+ file := fileutil.PrettyPath(params.FilePath)
254253 args := newParamBuilder().addMain(file).build()
255254
256255 return er.renderWithParams(v, "Edit", args, func() string {
@@ -281,7 +280,7 @@ func (wr writeRenderer) Render(v *toolCallCmp) string {
281280 return wr.renderError(v, "Invalid write parameters")
282281 }
283282
284− file := prettyPath(params.FilePath)
283+ file := fileutil.PrettyPath(params.FilePath)
285284 args := newParamBuilder().addMain(file).build()
286285
287286 return wr.renderWithParams(v, "Write", args, func() string {
@@ -411,6 +410,7 @@ func (lr lsRenderer) Render(v *toolCallCmp) string {
411410 if path == "" {
412411 path = "."
413412 }
413+ path = fileutil.PrettyPath(path)
414414
415415 args := newParamBuilder().addMain(path).build()
416416
@@ -637,6 +637,7 @@ func renderPlainContent(v *toolCallCmp, content string) string {
637637 if len(lines) > responseContextHeight {
638638 out = append(out, t.S().Muted.
639639 Background(t.BgSubtle).
640+ Width(width).
640641 Render(fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight)))
641642 }
642643 return strings.Join(out, "\n")
@@ -652,6 +653,7 @@ func renderCodeContent(v *toolCallCmp, path, content string, offset int) string
652653 if len(strings.Split(content, "\n")) > responseContextHeight {
653654 lines = append(lines, t.S().Muted.
654655 Background(t.BgSubtle).
656+ Width(v.textWidth()-2).
655657 Render(fmt.Sprintf("... (%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
656658 }
657659
@@ -679,11 +681,6 @@ func (v *toolCallCmp) renderToolError() string {
679681 return t.S().Base.Foreground(t.Error).Render(v.fit(err, v.textWidth()))
680682}
681683
682−func removeWorkingDirPrefix(path string) string {
683− wd := config.WorkingDirectory()
684− return strings.TrimPrefix(path, wd)
685−}
686−
687684func truncateHeight(s string, h int) string {
688685 lines := strings.Split(s, "\n")
689686 if len(lines) > h {
@@ -692,15 +689,6 @@ func truncateHeight(s string, h int) string {
692689 return s
693690}
694691
695−func prettyPath(path string) string {
696− // replace home directory with ~
697− homeDir, err := os.UserHomeDir()
698− if err == nil {
699− path = strings.ReplaceAll(path, homeDir, "~")
700− }
701− return path
702−}
703−
704692func prettifyToolName(name string) string {
705693 switch name {
706694 case agent.AgentToolName:
internal/tui/components/chat/messages/tool.go+4−6
@@ -220,6 +220,9 @@ func (m *toolCallCmp) renderPending() string {
220220func (m *toolCallCmp) style() lipgloss.Style {
221221 t := styles.CurrentTheme()
222222
223+ if m.isNested {
224+ return t.S().Muted
225+ }
223226 return t.S().Muted.PaddingLeft(4)
224227}
225228
@@ -275,12 +278,7 @@ func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
275278// shouldSpin determines whether the tool call should show a loading animation.
276279// Returns true if the tool call is not finished or if the result doesn't match the call ID.
277280func (m *toolCallCmp) shouldSpin() bool {
278− if !m.call.Finished {
279− return true
280− } else if m.result.ToolCallID != m.call.ID {
281− return true
282− }
283− return false
281+ return !m.call.Finished
284282}
285283
286284// Spinning returns whether the tool call is currently showing a loading animation
internal/tui/components/dialogs/permissions/keys.gonew file+70
@@ -0,0 +1,70 @@
1+package permissions
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/charmbracelet/crush/internal/tui/layout"
6+)
7+
8+type KeyMap struct {
9+ Left,
10+ Right,
11+ Tab,
12+ Select,
13+ Allow,
14+ AllowSession,
15+ Deny key.Binding
16+}
17+
18+func DefaultKeyMap() KeyMap {
19+ return KeyMap{
20+ Left: key.NewBinding(
21+ key.WithKeys("left", "h"),
22+ key.WithHelp("←", "previous"),
23+ ),
24+ Right: key.NewBinding(
25+ key.WithKeys("right", "l"),
26+ key.WithHelp("→", "next"),
27+ ),
28+ Tab: key.NewBinding(
29+ key.WithKeys("tab"),
30+ key.WithHelp("tab", "switch"),
31+ ),
32+ Allow: key.NewBinding(
33+ key.WithKeys("a", "ctrl+a"),
34+ key.WithHelp("a", "allow"),
35+ ),
36+ AllowSession: key.NewBinding(
37+ key.WithKeys("s", "ctrl+s"),
38+ key.WithHelp("s", "allow session"),
39+ ),
40+ Deny: key.NewBinding(
41+ key.WithKeys("d", "ctrl+d"),
42+ key.WithHelp("d", "deny"),
43+ ),
44+ Select: key.NewBinding(
45+ key.WithKeys("enter", "tab", "ctrl+y"),
46+ key.WithHelp("enter", "confirm"),
47+ ),
48+ }
49+}
50+
51+// FullHelp implements help.KeyMap.
52+func (k KeyMap) FullHelp() [][]key.Binding {
53+ m := [][]key.Binding{}
54+ slice := layout.KeyMapToSlice(k)
55+ for i := 0; i < len(slice); i += 4 {
56+ end := min(i+4, len(slice))
57+ m = append(m, slice[i:end])
58+ }
59+ return m
60+}
61+
62+// ShortHelp implements help.KeyMap.
63+func (k KeyMap) ShortHelp() []key.Binding {
64+ return []key.Binding{
65+ k.Allow,
66+ k.AllowSession,
67+ k.Deny,
68+ k.Select,
69+ }
70+}
internal/tui/components/dialogs/permissions/permissions.gonew file+490
@@ -0,0 +1,490 @@
1+package permissions
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/charmbracelet/bubbles/v2/key"
8+ "github.com/charmbracelet/bubbles/v2/viewport"
9+ tea "github.com/charmbracelet/bubbletea/v2"
10+ "github.com/charmbracelet/crush/internal/diff"
11+ "github.com/charmbracelet/crush/internal/fileutil"
12+ "github.com/charmbracelet/crush/internal/llm/tools"
13+ "github.com/charmbracelet/crush/internal/permission"
14+ "github.com/charmbracelet/crush/internal/tui/components/core"
15+ "github.com/charmbracelet/crush/internal/tui/components/dialogs"
16+ "github.com/charmbracelet/crush/internal/tui/styles"
17+ "github.com/charmbracelet/crush/internal/tui/util"
18+ "github.com/charmbracelet/lipgloss/v2"
19+ "github.com/charmbracelet/x/ansi"
20+)
21+
22+type PermissionAction string
23+
24+// Permission responses
25+const (
26+ PermissionAllow PermissionAction = "allow"
27+ PermissionAllowForSession PermissionAction = "allow_session"
28+ PermissionDeny PermissionAction = "deny"
29+
30+ PermissionsDialogID dialogs.DialogID = "permissions"
31+)
32+
33+// PermissionResponseMsg represents the user's response to a permission request
34+type PermissionResponseMsg struct {
35+ Permission permission.PermissionRequest
36+ Action PermissionAction
37+}
38+
39+// PermissionDialogCmp interface for permission dialog component
40+type PermissionDialogCmp interface {
41+ dialogs.DialogModel
42+}
43+
44+// permissionDialogCmp is the implementation of PermissionDialog
45+type permissionDialogCmp struct {
46+ wWidth int
47+ wHeight int
48+ width int
49+ height int
50+ permission permission.PermissionRequest
51+ contentViewPort viewport.Model
52+ selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
53+
54+ keyMap KeyMap
55+}
56+
57+func NewPermissionDialogCmp(permission permission.PermissionRequest) PermissionDialogCmp {
58+ // Create viewport for content
59+ contentViewport := viewport.New()
60+ return &permissionDialogCmp{
61+ contentViewPort: contentViewport,
62+ selectedOption: 0, // Default to "Allow"
63+ permission: permission,
64+ keyMap: DefaultKeyMap(),
65+ }
66+}
67+
68+func (p *permissionDialogCmp) Init() tea.Cmd {
69+ return p.contentViewPort.Init()
70+}
71+
72+func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
73+ var cmds []tea.Cmd
74+
75+ switch msg := msg.(type) {
76+ case tea.WindowSizeMsg:
77+ p.wWidth = msg.Width
78+ p.wHeight = msg.Height
79+ cmd := p.SetSize()
80+ cmds = append(cmds, cmd)
81+ case tea.KeyPressMsg:
82+ switch {
83+ case key.Matches(msg, p.keyMap.Right) || key.Matches(msg, p.keyMap.Tab):
84+ p.selectedOption = (p.selectedOption + 1) % 3
85+ return p, nil
86+ case key.Matches(msg, p.keyMap.Left):
87+ p.selectedOption = (p.selectedOption + 2) % 3
88+ case key.Matches(msg, p.keyMap.Select):
89+ return p, p.selectCurrentOption()
90+ case key.Matches(msg, p.keyMap.Allow):
91+ return p, tea.Batch(
92+ util.CmdHandler(dialogs.CloseDialogMsg{}),
93+ util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission}),
94+ )
95+ case key.Matches(msg, p.keyMap.AllowSession):
96+ return p, tea.Batch(
97+ util.CmdHandler(dialogs.CloseDialogMsg{}),
98+ util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission}),
99+ )
100+ case key.Matches(msg, p.keyMap.Deny):
101+ return p, tea.Batch(
102+ util.CmdHandler(dialogs.CloseDialogMsg{}),
103+ util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission}),
104+ )
105+ default:
106+ // Pass other keys to viewport
107+ viewPort, cmd := p.contentViewPort.Update(msg)
108+ p.contentViewPort = viewPort
109+ cmds = append(cmds, cmd)
110+ }
111+ }
112+
113+ return p, tea.Batch(cmds...)
114+}
115+
116+func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
117+ var action PermissionAction
118+
119+ switch p.selectedOption {
120+ case 0:
121+ action = PermissionAllow
122+ case 1:
123+ action = PermissionAllowForSession
124+ case 2:
125+ action = PermissionDeny
126+ }
127+
128+ return tea.Batch(
129+ util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission}),
130+ util.CmdHandler(dialogs.CloseDialogMsg{}),
131+ )
132+}
133+
134+func (p *permissionDialogCmp) renderButtons() string {
135+ t := styles.CurrentTheme()
136+
137+ allowStyle := t.S().Text
138+ allowSessionStyle := allowStyle
139+ denyStyle := allowStyle
140+
141+ // Style the selected button
142+ switch p.selectedOption {
143+ case 0:
144+ allowStyle = allowStyle.Foreground(t.White).Background(t.Secondary)
145+ allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
146+ denyStyle = denyStyle.Background(t.BgSubtle)
147+ case 1:
148+ allowStyle = allowStyle.Background(t.BgSubtle)
149+ allowSessionStyle = allowSessionStyle.Foreground(t.White).Background(t.Secondary)
150+ denyStyle = denyStyle.Background(t.BgSubtle)
151+ case 2:
152+ allowStyle = allowStyle.Background(t.BgSubtle)
153+ allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
154+ denyStyle = denyStyle.Foreground(t.White).Background(t.Secondary)
155+ }
156+
157+ baseStyle := t.S().Base
158+
159+ allowMessage := fmt.Sprintf("%s%s", allowStyle.Underline(true).Render("A"), allowStyle.Render("llow"))
160+ allowButton := allowStyle.Padding(0, 2).Render(allowMessage)
161+ allowSessionMessage := fmt.Sprintf("%s%s%s", allowSessionStyle.Render("Allow for "), allowSessionStyle.Underline(true).Render("S"), allowSessionStyle.Render("ession"))
162+ allowSessionButton := allowSessionStyle.Padding(0, 2).Render(allowSessionMessage)
163+ denyMessage := fmt.Sprintf("%s%s", denyStyle.Underline(true).Render("D"), denyStyle.Render("eny"))
164+ denyButton := denyStyle.Padding(0, 2).Render(denyMessage)
165+
166+ content := lipgloss.JoinHorizontal(
167+ lipgloss.Left,
168+ allowButton,
169+ " ",
170+ allowSessionButton,
171+ " ",
172+ denyButton,
173+ )
174+
175+ return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content)
176+}
177+
178+func (p *permissionDialogCmp) renderHeader() string {
179+ t := styles.CurrentTheme()
180+ baseStyle := t.S().Base
181+
182+ toolKey := t.S().Muted.Render("Tool")
183+ toolValue := t.S().Text.
184+ Width(p.width - lipgloss.Width(toolKey)).
185+ Render(fmt.Sprintf(" %s", p.permission.ToolName))
186+
187+ pathKey := t.S().Muted.Render("Path")
188+ pathValue := t.S().Text.
189+ Width(p.width - lipgloss.Width(pathKey)).
190+ Render(fmt.Sprintf(" %s", fileutil.PrettyPath(p.permission.Path)))
191+
192+ headerParts := []string{
193+ lipgloss.JoinHorizontal(
194+ lipgloss.Left,
195+ toolKey,
196+ toolValue,
197+ ),
198+ baseStyle.Render(strings.Repeat(" ", p.width)),
199+ lipgloss.JoinHorizontal(
200+ lipgloss.Left,
201+ pathKey,
202+ pathValue,
203+ ),
204+ baseStyle.Render(strings.Repeat(" ", p.width)),
205+ }
206+
207+ // Add tool-specific header information
208+ switch p.permission.ToolName {
209+ case tools.BashToolName:
210+ headerParts = append(headerParts, t.S().Muted.Width(p.width).Render("Command"))
211+ case tools.EditToolName:
212+ params := p.permission.Params.(tools.EditPermissionsParams)
213+ fileKey := t.S().Muted.Render("File")
214+ filePath := t.S().Text.
215+ Width(p.width - lipgloss.Width(fileKey)).
216+ Render(fmt.Sprintf(" %s", fileutil.PrettyPath(params.FilePath)))
217+ headerParts = append(headerParts,
218+ lipgloss.JoinHorizontal(
219+ lipgloss.Left,
220+ fileKey,
221+ filePath,
222+ ),
223+ baseStyle.Render(strings.Repeat(" ", p.width)),
224+ )
225+
226+ case tools.WriteToolName:
227+ params := p.permission.Params.(tools.WritePermissionsParams)
228+ fileKey := t.S().Muted.Render("File")
229+ filePath := t.S().Text.
230+ Width(p.width - lipgloss.Width(fileKey)).
231+ Render(fmt.Sprintf(" %s", fileutil.PrettyPath(params.FilePath)))
232+ headerParts = append(headerParts,
233+ lipgloss.JoinHorizontal(
234+ lipgloss.Left,
235+ fileKey,
236+ filePath,
237+ ),
238+ baseStyle.Render(strings.Repeat(" ", p.width)),
239+ )
240+ case tools.FetchToolName:
241+ headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
242+ }
243+
244+ return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
245+}
246+
247+func (p *permissionDialogCmp) renderBashContent() string {
248+ t := styles.CurrentTheme()
249+ baseStyle := t.S().Base.Background(t.BgSubtle)
250+ if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
251+ content := pr.Command
252+ t := styles.CurrentTheme()
253+ content = strings.TrimSpace(content)
254+ content = "\n" + content + "\n"
255+ lines := strings.Split(content, "\n")
256+
257+ width := p.width - 4
258+ var out []string
259+ for _, ln := range lines {
260+ ln = " " + ln // left padding
261+ if len(ln) > width {
262+ ln = ansi.Truncate(ln, width, "…")
263+ }
264+ out = append(out, t.S().Muted.
265+ Width(width).
266+ Foreground(t.FgBase).
267+ Background(t.BgSubtle).
268+ Render(ln))
269+ }
270+
271+ // Use the cache for markdown rendering
272+ renderedContent := strings.Join(out, "\n")
273+ finalContent := baseStyle.
274+ Width(p.contentViewPort.Width()).
275+ Render(renderedContent)
276+
277+ contentHeight := min(p.height-9, lipgloss.Height(finalContent))
278+ p.contentViewPort.SetHeight(contentHeight)
279+ p.contentViewPort.SetContent(finalContent)
280+ return p.styleViewport()
281+ }
282+ return ""
283+}
284+
285+func (p *permissionDialogCmp) renderEditContent() string {
286+ if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
287+ diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
288+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
289+ })
290+
291+ contentHeight := min(p.height-9, lipgloss.Height(diff))
292+ p.contentViewPort.SetHeight(contentHeight)
293+ p.contentViewPort.SetContent(diff)
294+ return p.styleViewport()
295+ }
296+ return ""
297+}
298+
299+func (p *permissionDialogCmp) renderPatchContent() string {
300+ if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
301+ diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
302+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
303+ })
304+
305+ contentHeight := min(p.height-9, lipgloss.Height(diff))
306+ p.contentViewPort.SetHeight(contentHeight)
307+ p.contentViewPort.SetContent(diff)
308+ return p.styleViewport()
309+ }
310+ return ""
311+}
312+
313+func (p *permissionDialogCmp) renderWriteContent() string {
314+ if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
315+ // Use the cache for diff rendering
316+ diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
317+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
318+ })
319+
320+ contentHeight := min(p.height-9, lipgloss.Height(diff))
321+ p.contentViewPort.SetHeight(contentHeight)
322+ p.contentViewPort.SetContent(diff)
323+ return p.styleViewport()
324+ }
325+ return ""
326+}
327+
328+func (p *permissionDialogCmp) renderFetchContent() string {
329+ t := styles.CurrentTheme()
330+ baseStyle := t.S().Base.Background(t.BgSubtle)
331+ if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
332+ content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
333+
334+ // Use the cache for markdown rendering
335+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
336+ r := styles.GetMarkdownRenderer(p.width - 4)
337+ s, err := r.Render(content)
338+ return s, err
339+ })
340+
341+ finalContent := baseStyle.
342+ Width(p.contentViewPort.Width()).
343+ Render(renderedContent)
344+
345+ contentHeight := min(p.height-9, lipgloss.Height(finalContent))
346+ p.contentViewPort.SetHeight(contentHeight)
347+ p.contentViewPort.SetContent(finalContent)
348+ return p.styleViewport()
349+ }
350+ return ""
351+}
352+
353+func (p *permissionDialogCmp) renderDefaultContent() string {
354+ t := styles.CurrentTheme()
355+ baseStyle := t.S().Base.Background(t.BgSubtle)
356+
357+ content := p.permission.Description
358+
359+ // Use the cache for markdown rendering
360+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
361+ r := styles.GetMarkdownRenderer(p.width - 4)
362+ s, err := r.Render(content)
363+ return s, err
364+ })
365+
366+ finalContent := baseStyle.
367+ Width(p.contentViewPort.Width()).
368+ Render(renderedContent)
369+ p.contentViewPort.SetContent(finalContent)
370+
371+ if renderedContent == "" {
372+ return ""
373+ }
374+
375+ return p.styleViewport()
376+}
377+
378+func (p *permissionDialogCmp) styleViewport() string {
379+ t := styles.CurrentTheme()
380+ return t.S().Base.Render(p.contentViewPort.View())
381+}
382+
383+func (p *permissionDialogCmp) render() string {
384+ t := styles.CurrentTheme()
385+ baseStyle := t.S().Base
386+ title := core.Title("Permission Required", p.width-4)
387+ // Render header
388+ headerContent := p.renderHeader()
389+ // Render buttons
390+ buttons := p.renderButtons()
391+
392+ p.contentViewPort.SetWidth(p.width - 4)
393+
394+ // Render content based on tool type
395+ var contentFinal string
396+ switch p.permission.ToolName {
397+ case tools.BashToolName:
398+ contentFinal = p.renderBashContent()
399+ case tools.EditToolName:
400+ contentFinal = p.renderEditContent()
401+ case tools.PatchToolName:
402+ contentFinal = p.renderPatchContent()
403+ case tools.WriteToolName:
404+ contentFinal = p.renderWriteContent()
405+ case tools.FetchToolName:
406+ contentFinal = p.renderFetchContent()
407+ default:
408+ contentFinal = p.renderDefaultContent()
409+ }
410+ // Calculate content height dynamically based on window size
411+
412+ content := lipgloss.JoinVertical(
413+ lipgloss.Top,
414+ title,
415+ "",
416+ headerContent,
417+ contentFinal,
418+ "",
419+ buttons,
420+ "",
421+ )
422+
423+ return baseStyle.
424+ Padding(0, 1).
425+ Border(lipgloss.RoundedBorder()).
426+ BorderForeground(t.BorderFocus).
427+ Width(p.width).
428+ Render(
429+ content,
430+ )
431+}
432+
433+func (p *permissionDialogCmp) View() tea.View {
434+ return tea.NewView(p.render())
435+}
436+
437+func (p *permissionDialogCmp) SetSize() tea.Cmd {
438+ if p.permission.ID == "" {
439+ return nil
440+ }
441+ switch p.permission.ToolName {
442+ case tools.BashToolName:
443+ p.width = int(float64(p.wWidth) * 0.4)
444+ p.height = int(float64(p.wHeight) * 0.3)
445+ case tools.EditToolName:
446+ p.width = int(float64(p.wWidth) * 0.8)
447+ p.height = int(float64(p.wHeight) * 0.8)
448+ case tools.WriteToolName:
449+ p.width = int(float64(p.wWidth) * 0.8)
450+ p.height = int(float64(p.wHeight) * 0.8)
451+ case tools.FetchToolName:
452+ p.width = int(float64(p.wWidth) * 0.4)
453+ p.height = int(float64(p.wHeight) * 0.3)
454+ default:
455+ p.width = int(float64(p.wWidth) * 0.7)
456+ p.height = int(float64(p.wHeight) * 0.5)
457+ }
458+ return nil
459+}
460+
461+func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
462+ content, err := generator()
463+ if err != nil {
464+ return fmt.Sprintf("Error formatting diff: %v", err)
465+ }
466+ return content
467+}
468+
469+func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
470+ content, err := generator()
471+ if err != nil {
472+ return fmt.Sprintf("Error rendering markdown: %v", err)
473+ }
474+
475+ return content
476+}
477+
478+// ID implements PermissionDialogCmp.
479+func (p *permissionDialogCmp) ID() dialogs.DialogID {
480+ return PermissionsDialogID
481+}
482+
483+// Position implements PermissionDialogCmp.
484+func (p *permissionDialogCmp) Position() (int, int) {
485+ row := (p.wHeight / 2) - 2 // Just a bit above the center
486+ row -= p.height / 2
487+ col := p.wWidth / 2
488+ col -= p.width / 2
489+ return row, col
490+}
internal/tui/components/dialogs/quit/quit.go+2−2
@@ -74,10 +74,10 @@ func (q *quitDialogCmp) View() tea.View {
7474 noStyle := yesStyle
7575
7676 if q.selectedNo {
77− noStyle = noStyle.Background(t.Secondary)
77+ noStyle = noStyle.Foreground(t.White).Background(t.Secondary)
7878 yesStyle = yesStyle.Background(t.BgSubtle)
7979 } else {
80− yesStyle = yesStyle.Background(t.Secondary)
80+ yesStyle = yesStyle.Foreground(t.White).Background(t.Secondary)
8181 noStyle = noStyle.Background(t.BgSubtle)
8282 }
8383
internal/tui/styles/crush.go+2
@@ -36,6 +36,8 @@ func NewCrushTheme() *Theme {
3636 Info: charmtone.Malibu,
3737
3838 // Colors
39+ White: charmtone.Butter,
40+
3941 Blue: charmtone.Malibu,
4042
4143 Green: charmtone.Julep,
internal/tui/styles/theme.go+2
@@ -50,6 +50,8 @@ type Theme struct {
5050 Info color.Color
5151
5252 // Colors
53+ // White
54+ White color.Color
5355 // Blues
5456 Blue color.Color
5557
internal/tui/tui.go+30
@@ -6,7 +6,9 @@ import (
66 "github.com/charmbracelet/bubbles/v2/key"
77 tea "github.com/charmbracelet/bubbletea/v2"
88 "github.com/charmbracelet/crush/internal/app"
9+ "github.com/charmbracelet/crush/internal/llm/tools"
910 "github.com/charmbracelet/crush/internal/logging"
11+ "github.com/charmbracelet/crush/internal/permission"
1012 "github.com/charmbracelet/crush/internal/pubsub"
1113 cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
1214 "github.com/charmbracelet/crush/internal/tui/components/completions"
@@ -15,6 +17,7 @@ import (
1517 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
1618 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
1719 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
20+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
1821 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
1922 "github.com/charmbracelet/crush/internal/tui/components/dialogs/sessions"
2023 "github.com/charmbracelet/crush/internal/tui/layout"
@@ -60,6 +63,7 @@ func (a appModel) Init() tea.Cmd {
6063
6164// Update handles incoming messages and updates the application state.
6265func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
66+ logging.Info("TUI Update", "msg", msg)
6367 var cmds []tea.Cmd
6468 var cmd tea.Cmd
6569
@@ -142,7 +146,33 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
142146 return a, util.CmdHandler(dialogs.OpenDialogMsg{
143147 Model: filepicker.NewFilePickerCmp(),
144148 })
149+ // Permissions
150+ case pubsub.Event[permission.PermissionRequest]:
151+ return a, util.CmdHandler(dialogs.OpenDialogMsg{
152+ Model: permissions.NewPermissionDialogCmp(msg.Payload),
153+ })
154+ case permissions.PermissionResponseMsg:
155+ switch msg.Action {
156+ case permissions.PermissionAllow:
157+ a.app.Permissions.Grant(msg.Permission)
158+ case permissions.PermissionAllowForSession:
159+ a.app.Permissions.GrantPersistent(msg.Permission)
160+ case permissions.PermissionDeny:
161+ a.app.Permissions.Deny(msg.Permission)
162+ }
163+ return a, nil
164+ // Key Press Messages
145165 case tea.KeyPressMsg:
166+ if msg.String() == "ctrl+t" {
167+ go a.app.Permissions.Request(permission.CreatePermissionRequest{
168+ SessionID: "123",
169+ ToolName: "bash",
170+ Action: "execute",
171+ Params: tools.BashPermissionsParams{
172+ Command: "ls -la",
173+ },
174+ })
175+ }
146176 return a, a.handleKeyPressMsg(msg)
147177 }
148178 s, _ := a.status.Update(msg)