diff --git a/internal/fileutil/fileutil.go b/internal/fileutil/fileutil.go index b9619b8ada2cf3b9df29b30cc51238bf829ebd8e..92fc9d39c585f7784c7fe8ca21a0cf8d6958cbcb 100644 --- a/internal/fileutil/fileutil.go +++ b/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 +} diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go index eda0565d26c1113d1b856b51cc254fde4dac1bc6..339aa51b299d368a5d8f3c31b0c10d6d00a8a784 100644 --- a/internal/tui/components/chat/messages/renderer.go +++ b/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: diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index 94bff77c4c9fb85a72a5f8230bf38edba303c8c7..cbc5548904217b3ea14595eaf676229dd0b54bc1 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/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 diff --git a/internal/tui/components/dialogs/permissions/keys.go b/internal/tui/components/dialogs/permissions/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..837deb74b4846e4592a61acde0a5dada706279dd --- /dev/null +++ b/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, + } +} diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go new file mode 100644 index 0000000000000000000000000000000000000000..fdf0f6a7c43a7d7c74a9ea02f477c444d9f455da --- /dev/null +++ b/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 +} diff --git a/internal/tui/components/dialogs/quit/quit.go b/internal/tui/components/dialogs/quit/quit.go index da0d5baa76efe58c12521d7b19419aa84df2aff4..9f57afac7d609212d82999aa2e57fb0c13ca5d28 100644 --- a/internal/tui/components/dialogs/quit/quit.go +++ b/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) } diff --git a/internal/tui/styles/crush.go b/internal/tui/styles/crush.go index 618e0cb496664d18a01a82e3ffe46a9dd6ea7fdf..b35008c2a65e9c8b19ec456515d0a72823185c0b 100644 --- a/internal/tui/styles/crush.go +++ b/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, diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go index 79a56959ca178f34ad974777e095e91285380911..bb3a11aa554062964d81bf37bca00c6f1220d8ca 100644 --- a/internal/tui/styles/theme.go +++ b/internal/tui/styles/theme.go @@ -50,6 +50,8 @@ type Theme struct { Info color.Color // Colors + // White + White color.Color // Blues Blue color.Color diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 58405d81fa8ee94b6d369702987fd19c7d0a9d1d..2e81b0fdbeecff9d1e637ea6dd4a383d9f9093b3 100644 --- a/internal/tui/tui.go +++ b/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)