Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions internal/ui/dialog/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ type (
ActionEnableDockerMCP struct{}
// ActionDisableDockerMCP is a message to disable Docker MCP.
ActionDisableDockerMCP struct{}
// ActionInsertSnippet is sent when the user confirms a code snippet in the
// snippet dialog. Content is the raw text; the caller wraps it in a fenced
// code block before inserting into the editor.
ActionInsertSnippet struct {
Content string
}
)

// Messages for API key input dialog.
Expand Down
152 changes: 152 additions & 0 deletions internal/ui/dialog/snippet.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package dialog

import (
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/crush/internal/ui/common"
uv "github.com/charmbracelet/ultraviolet"
)

// SnippetID is the identifier for the snippet dialog.
const SnippetID = "snippet"

const (
snippetMinWidth = 40
snippetMaxWidth = 100
snippetMinHeight = 8
snippetMaxHeight = 20
)

// Snippet is a dialog that collects a multi-line code snippet from the user
// and inserts it into the chat editor wrapped in a fenced code block.
type Snippet struct {
com *common.Common
editor textarea.Model
keyMap struct {
Submit key.Binding
Newline key.Binding
Close key.Binding
}
help help.Model
}

var _ Dialog = (*Snippet)(nil)

// NewSnippet creates a new snippet dialog.
func NewSnippet(com *common.Common) *Snippet {
ta := textarea.New()
ta.SetStyles(com.Styles.Editor.Textarea)
ta.ShowLineNumbers = false
ta.CharLimit = -1
ta.SetVirtualCursor(false)
ta.DynamicHeight = true
ta.MinHeight = snippetMinHeight
ta.MaxHeight = snippetMaxHeight
ta.Placeholder = "Paste or type your code here…"
ta.Focus()

s := &Snippet{
com: com,
editor: ta,
}

s.keyMap.Submit = key.NewBinding(
key.WithKeys("ctrl+d"),
key.WithHelp("ctrl+d", "insert"),
)
s.keyMap.Newline = key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "newline"),
)
s.keyMap.Close = CloseKey

s.help = help.New()
s.help.Styles = com.Styles.DialogHelpStyles()

return s
}

// ID implements Dialog.
func (s *Snippet) ID() string {
return SnippetID
}

// HandleMsg implements Dialog.
func (s *Snippet) HandleMsg(msg tea.Msg) Action {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch {
case key.Matches(msg, s.keyMap.Close):
return ActionClose{}
case key.Matches(msg, s.keyMap.Submit):
content := s.editor.Value()
if content == "" {
return ActionClose{}
}
return ActionInsertSnippet{Content: content}
default:
var cmd tea.Cmd
s.editor, cmd = s.editor.Update(msg)
return ActionCmd{Cmd: cmd}
}
case tea.PasteMsg:
var cmd tea.Cmd
s.editor, cmd = s.editor.Update(msg)
return ActionCmd{Cmd: cmd}
}
return nil
}

// Draw implements Dialog.
func (s *Snippet) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
st := s.com.Styles

dialogW := min(snippetMaxWidth, max(snippetMinWidth, area.Dx()-st.Dialog.View.GetHorizontalFrameSize()-4))
s.editor.SetWidth(dialogW - st.Dialog.View.GetHorizontalFrameSize())

helpView := st.Dialog.HelpView.Width(dialogW - st.Dialog.View.GetHorizontalFrameSize()).Render(s.help.View(s))

header := common.DialogTitle(st, "Paste Code Snippet", dialogW-st.Dialog.View.GetHorizontalFrameSize(), st.Dialog.TitleGradFromColor, st.Dialog.TitleGradToColor)

editorView := s.editor.View()

content := st.Dialog.Arguments.Content.Render(editorView)

view := lipgloss.JoinVertical(
lipgloss.Left,
st.Dialog.Title.Render(header),
content,
helpView,
)

dialog := st.Dialog.View.Render(view)

cur := s.cursor(lipgloss.Height(st.Dialog.Title.Render(header))+st.Dialog.View.GetVerticalPadding()/2, dialogW)
DrawCenterCursor(scr, area, dialog, cur)
return cur
}

// cursor returns the cursor position relative to the dialog content area.
func (s *Snippet) cursor(headerHeight, _ int) *tea.Cursor {
cur := InputCursor(s.com.Styles, s.editor.Cursor())
if cur == nil {
return nil
}
cur.Y += headerHeight + 1
return cur
}

// ShortHelp implements help.KeyMap.
func (s *Snippet) ShortHelp() []key.Binding {
return []key.Binding{s.keyMap.Submit, s.keyMap.Close}
}

// FullHelp implements help.KeyMap.
func (s *Snippet) FullHelp() [][]key.Binding {
return [][]key.Binding{
{s.keyMap.Submit, s.keyMap.Newline, s.keyMap.Close},
}
}
77 changes: 77 additions & 0 deletions internal/ui/dialog/snippet_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package dialog

import (
"testing"

tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/styles"
"github.com/stretchr/testify/require"
)

func newTestSnippet(t *testing.T) *Snippet {
t.Helper()
s := styles.CharmtonePantera()
com := &common.Common{Styles: &s}
return NewSnippet(com)
}

func ctrlKeyMsg(ch rune) tea.KeyPressMsg {
return tea.KeyPressMsg{Code: ch, Mod: tea.ModCtrl}
}

// TestSnippet_IDIsSnippet verifies the dialog ID constant.
func TestSnippet_IDIsSnippet(t *testing.T) {
t.Parallel()

s := newTestSnippet(t)
require.Equal(t, SnippetID, s.ID())
}

// TestSnippet_EscClosesDialog verifies that pressing Esc returns ActionClose.
func TestSnippet_EscClosesDialog(t *testing.T) {
t.Parallel()

s := newTestSnippet(t)
action := s.HandleMsg(tea.KeyPressMsg{Code: tea.KeyEscape})
require.IsType(t, ActionClose{}, action)
}

// TestSnippet_EmptySubmitClosesDialog verifies that ctrl+d on empty content
// returns ActionClose rather than an empty snippet.
func TestSnippet_EmptySubmitClosesDialog(t *testing.T) {
t.Parallel()

s := newTestSnippet(t)
action := s.HandleMsg(ctrlKeyMsg('d'))
require.IsType(t, ActionClose{}, action, "empty snippet should close without inserting")
}

// TestSnippet_SubmitReturnsContent verifies that ctrl+d with typed content
// returns ActionInsertSnippet with the correct text.
func TestSnippet_SubmitReturnsContent(t *testing.T) {
t.Parallel()

s := newTestSnippet(t)

// Type some content character-by-character.
for _, r := range "hello world" {
s.HandleMsg(keyMsg(r))
}

action := s.HandleMsg(ctrlKeyMsg('d'))
ins, ok := action.(ActionInsertSnippet)
require.True(t, ok, "ctrl+d with content should return ActionInsertSnippet")
require.Equal(t, "hello world", ins.Content)
}

// TestSnippet_PasteIsForwardedToEditor verifies that paste messages reach the
// underlying textarea (returned as an ActionCmd for deferred processing).
func TestSnippet_PasteIsForwardedToEditor(t *testing.T) {
t.Parallel()

s := newTestSnippet(t)
action := s.HandleMsg(tea.PasteMsg{Content: "pasted code"})
_, ok := action.(ActionCmd)
require.True(t, ok, "PasteMsg should produce an ActionCmd")
}
7 changes: 7 additions & 0 deletions internal/ui/model/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ type KeyMap struct {
// History navigation
HistoryPrev key.Binding
HistoryNext key.Binding

// Snippet paste
PasteSnippet key.Binding
}

Chat struct {
Expand Down Expand Up @@ -156,6 +159,10 @@ func DefaultKeyMap() KeyMap {
km.Editor.HistoryNext = key.NewBinding(
key.WithKeys("down"),
)
km.Editor.PasteSnippet = key.NewBinding(
key.WithKeys("alt+v"),
key.WithHelp("alt+v", "paste snippet"),
)

km.Chat.NewSession = key.NewBinding(
key.WithKeys("ctrl+n"),
Expand Down
30 changes: 30 additions & 0 deletions internal/ui/model/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -1518,6 +1518,20 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionQuit:
cmds = append(cmds, tea.Quit)
case dialog.ActionInsertSnippet:
m.dialog.CloseDialog(dialog.SnippetID)
snippet := "```\n" + msg.Content + "\n```"
prevHeight := m.textarea.Height()
existing := m.textarea.Value()
if existing != "" {
snippet = "\n" + snippet
}
m.textarea.InsertString(snippet)
if cmd := m.handleTextareaHeightChange(prevHeight); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.textarea.Focus())

case dialog.ActionEnableDockerMCP:
m.dialog.CloseDialog(dialog.CommandsID)
cmds = append(cmds, m.enableDockerMCP)
Expand Down Expand Up @@ -1955,6 +1969,11 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
}
cmds = append(cmds, m.pasteImageFromClipboard)

case key.Matches(msg, m.keyMap.Editor.PasteSnippet):
if cmd := m.openSnippetDialog(); cmd != nil {
cmds = append(cmds, cmd)
}

case key.Matches(msg, m.keyMap.Editor.SendMessage):
prevHeight := m.textarea.Height()
value := m.textarea.Value()
Expand Down Expand Up @@ -2497,6 +2516,7 @@ func (m *UI) FullHelp() [][]key.Binding {
k.Editor.Newline,
k.Editor.MentionFile,
k.Editor.OpenEditor,
k.Editor.PasteSnippet,
}
if m.currentModelSupportsImages() {
editorBinds = append(editorBinds, k.Editor.AddImage, k.Editor.PasteImage)
Expand Down Expand Up @@ -3507,6 +3527,16 @@ func (m *UI) openFilesDialog() tea.Cmd {
return cmd
}

// openSnippetDialog opens the snippet paste dialog.
func (m *UI) openSnippetDialog() tea.Cmd {
if m.dialog.ContainsDialog(dialog.SnippetID) {
m.dialog.BringToFront(dialog.SnippetID)
return nil
}
m.dialog.OpenDialog(dialog.NewSnippet(m.com))
return nil
}

// openPermissionsDialog opens the permissions dialog for a permission request.
func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
// Close any existing permissions dialog first.
Expand Down
Loading