summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDominik Engelhardt <[email protected]>2025-08-13 21:30:36 +0200
committeropencode <[email protected]>2025-08-13 19:33:38 +0000
commita4c14dbb2da545c18c63df0dae842223da859a09 (patch)
tree14e1fb123b5b3734eed38db38ab070cdf4106a3b
parent036b24791dbc60ededc0589c19cacdff6e84821e (diff)
downloadopencode-a4c14dbb2da545c18c63df0dae842223da859a09.tar.gz
opencode-a4c14dbb2da545c18c63df0dae842223da859a09.zip
feat: convert attachments to text on delete (#1863)
Co-authored-by: Dax Raad <[email protected]> Co-authored-by: Dax <[email protected]>
-rw-r--r--packages/tui/internal/components/textarea/textarea.go34
-rw-r--r--packages/tui/internal/components/textarea/textarea_test.go75
2 files changed, 109 insertions, 0 deletions
diff --git a/packages/tui/internal/components/textarea/textarea.go b/packages/tui/internal/components/textarea/textarea.go
index a56f2b788..6e6695917 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 000000000..fb3c5b8ba
--- /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")
+ }
+}