From a4c14dbb2da545c18c63df0dae842223da859a09 Mon Sep 17 00:00:00 2001 From: Dominik Engelhardt Date: Wed, 13 Aug 2025 21:30:36 +0200 Subject: [PATCH] feat: convert attachments to text on delete (#1863) Co-authored-by: Dax Raad Co-authored-by: Dax --- .../internal/components/textarea/textarea.go | 34 +++++++++ .../components/textarea/textarea_test.go | 75 +++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 packages/tui/internal/components/textarea/textarea_test.go diff --git a/packages/tui/internal/components/textarea/textarea.go b/packages/tui/internal/components/textarea/textarea.go index a56f2b78..6e669591 100644 --- a/packages/tui/internal/components/textarea/textarea.go +++ b/packages/tui/internal/components/textarea/textarea.go @@ -670,6 +670,28 @@ func (m *Model) InsertAttachment(att *attachment.Attachment) { m.SetCursorColumn(m.col) } +// removeAttachmentAtCursor replaces the attachment at or immediately before the +// cursor with its textual display and positions the cursor at the end of the +// inserted text. Returns true if an attachment was removed. +func (m *Model) removeAttachmentAtCursor() bool { + att, startIdx, _ := m.isAttachmentAtCursor() + if att == nil { + return false + } + // Replace the attachment element with the display runes + before := m.value[m.row][:startIdx] + after := m.value[m.row][startIdx+1:] + replacement := runesToInterfaces([]rune(att.Display)) + newRow := make([]any, 0, len(before)+len(replacement)+len(after)) + newRow = append(newRow, before...) + newRow = append(newRow, replacement...) + newRow = append(newRow, after...) + m.value[m.row] = newRow + m.col = startIdx + len(replacement) + m.SetCursorColumn(m.col) + return true +} + // ReplaceRange replaces text from startCol to endCol on the current row with the given string. // This preserves attachments outside the replaced range. func (m *Model) ReplaceRange(startCol, endCol int, replacement string) { @@ -1577,6 +1599,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { } m.deleteBeforeCursor() case key.Matches(msg, m.KeyMap.DeleteCharacterBackward): + // If the cursor is at or just after an attachment, convert it to text instead of deleting + if att, _, _ := m.isAttachmentAtCursor(); att != nil { + if m.removeAttachmentAtCursor() { + break + } + } m.col = clamp(m.col, 0, len(m.value[m.row])) if m.col <= 0 { m.mergeLineAbove(m.row) @@ -1587,6 +1615,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.SetCursorColumn(m.col - 1) } case key.Matches(msg, m.KeyMap.DeleteCharacterForward): + // If the cursor is on an attachment, convert it to text instead of deleting + if att, _, _ := m.isAttachmentAtCursor(); att != nil { + if m.removeAttachmentAtCursor() { + break + } + } if len(m.value[m.row]) > 0 && m.col < len(m.value[m.row]) { m.value[m.row] = slices.Delete(m.value[m.row], m.col, m.col+1) } diff --git a/packages/tui/internal/components/textarea/textarea_test.go b/packages/tui/internal/components/textarea/textarea_test.go new file mode 100644 index 00000000..fb3c5b8b --- /dev/null +++ b/packages/tui/internal/components/textarea/textarea_test.go @@ -0,0 +1,75 @@ +package textarea + +import ( + "testing" + + "github.com/sst/opencode/internal/attachment" +) + +func TestRemoveAttachmentAtCursor_ConvertsToText_WhenCursorAfterAttachment(t *testing.T) { + m := New() + m.InsertString("a ") + att := &attachment.Attachment{ID: "1", Display: "@file.txt"} + m.InsertAttachment(att) + m.InsertString(" b") + + // Position cursor immediately after the attachment (index 3: 'a',' ',att,' ', 'b') + m.SetCursorColumn(3) + + if ok := m.removeAttachmentAtCursor(); !ok { + t.Fatalf("expected removal to occur") + } + got := m.Value() + want := "a @file.txt b" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestRemoveAttachmentAtCursor_ConvertsToText_WhenCursorOnAttachment(t *testing.T) { + m := New() + m.InsertString("x ") + att := &attachment.Attachment{ID: "2", Display: "@img.png"} + m.InsertAttachment(att) + m.InsertString(" y") + + // Position cursor on the attachment token (index 2: 'x',' ',att,' ', 'y') + m.SetCursorColumn(2) + + if ok := m.removeAttachmentAtCursor(); !ok { + t.Fatalf("expected removal to occur") + } + got := m.Value() + want := "x @img.png y" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestRemoveAttachmentAtCursor_StartOfLine(t *testing.T) { + m := New() + att := &attachment.Attachment{ID: "3", Display: "@a.txt"} + m.InsertAttachment(att) + m.InsertString(" tail") + + // Position cursor immediately after the attachment at start of line (index 1) + m.SetCursorColumn(1) + if ok := m.removeAttachmentAtCursor(); !ok { + t.Fatalf("expected removal to occur at start of line") + } + if got := m.Value(); got != "@a.txt tail" { + t.Fatalf("unexpected value: %q", got) + } +} + +func TestRemoveAttachmentAtCursor_NoAttachment_NoChange(t *testing.T) { + m := New() + m.InsertString("hello world") + col := m.CursorColumn() + if ok := m.removeAttachmentAtCursor(); ok { + t.Fatalf("did not expect removal to occur") + } + if m.Value() != "hello world" || m.CursorColumn() != col { + t.Fatalf("value or cursor unexpectedly changed") + } +}