From e58f597b6927d05285ab988a046e40602cb3a5d4 Mon Sep 17 00:00:00 2001 From: EduardF1 <50618110+EduardF1@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:04:18 +0200 Subject: [PATCH] feat(editor): add snippet paste dialog (alt+v) Adds a new snippet paste dialog (Alt+V) that lets users paste or type multi-line code into an overlay textarea. On confirmation (Ctrl+D), the content is wrapped in a fenced code block and inserted at the cursor in the chat editor. Pressing Esc closes the dialog without changes. - internal/ui/dialog/snippet.go: new Snippet dialog backed by textarea - internal/ui/dialog/actions.go: new ActionInsertSnippet action type - internal/ui/model/keys.go: new Editor.PasteSnippet binding (alt+v) - internal/ui/model/ui.go: open dialog on alt+v; handle ActionInsertSnippet; add PasteSnippet to FullHelp bindings - internal/ui/dialog/snippet_test.go: 5 unit tests for dialog behavior Co-Authored-By: Claude Sonnet 4.6 --- internal/ui/dialog/actions.go | 6 ++ internal/ui/dialog/snippet.go | 152 +++++++++++++++++++++++++++++ internal/ui/dialog/snippet_test.go | 77 +++++++++++++++ internal/ui/model/keys.go | 7 ++ internal/ui/model/ui.go | 30 ++++++ 5 files changed, 272 insertions(+) create mode 100644 internal/ui/dialog/snippet.go create mode 100644 internal/ui/dialog/snippet_test.go diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index 0e96b06ad8..3dd03e78a8 100644 --- a/internal/ui/dialog/actions.go +++ b/internal/ui/dialog/actions.go @@ -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. diff --git a/internal/ui/dialog/snippet.go b/internal/ui/dialog/snippet.go new file mode 100644 index 0000000000..e01c31bb71 --- /dev/null +++ b/internal/ui/dialog/snippet.go @@ -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}, + } +} diff --git a/internal/ui/dialog/snippet_test.go b/internal/ui/dialog/snippet_test.go new file mode 100644 index 0000000000..c71885b101 --- /dev/null +++ b/internal/ui/dialog/snippet_test.go @@ -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") +} diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index ebf377035e..a39c1e1c53 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -21,6 +21,9 @@ type KeyMap struct { // History navigation HistoryPrev key.Binding HistoryNext key.Binding + + // Snippet paste + PasteSnippet key.Binding } Chat struct { @@ -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"), diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 9dfe722796..29b804183f 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -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) @@ -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() @@ -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) @@ -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.