-
Notifications
You must be signed in to change notification settings - Fork 260
Expand file tree
/
Copy patheditor.go
More file actions
1693 lines (1451 loc) · 48.2 KB
/
editor.go
File metadata and controls
1693 lines (1451 loc) · 48.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package editor
import (
"context"
"fmt"
"log"
"log/slog"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/atotto/clipboard"
"github.com/docker/go-units"
"github.com/mattn/go-runewidth"
"github.com/rivo/uniseg"
"github.com/docker/cagent/pkg/app"
"github.com/docker/cagent/pkg/history"
"github.com/docker/cagent/pkg/paths"
"github.com/docker/cagent/pkg/tui/components/completion"
"github.com/docker/cagent/pkg/tui/components/editor/completions"
"github.com/docker/cagent/pkg/tui/core"
"github.com/docker/cagent/pkg/tui/core/layout"
"github.com/docker/cagent/pkg/tui/messages"
"github.com/docker/cagent/pkg/tui/styles"
)
// ansiRegexp matches ANSI escape sequences so they can be removed when
// computing layout measurements.
var ansiRegexp = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`)
const (
// maxInlinePasteLines is the maximum number of lines for inline paste.
// Pastes exceeding this are buffered to a temp file attachment.
maxInlinePasteLines = 5
// maxInlinePasteChars is the character limit for inline pastes.
// This catches very long single-line pastes that would clutter the editor.
maxInlinePasteChars = 500
)
type attachment struct {
path string // Path to file (temp for pastes, real for file refs)
placeholder string // @paste-1 or @filename
label string // Display label like "paste-1 (21.1 KB)"
sizeBytes int
isTemp bool // True for paste temp files that need cleanup
}
// AttachmentPreview describes an attachment and its contents for dialog display.
type AttachmentPreview struct {
Title string
Content string
}
// Editor represents an input editor component
type Editor interface {
layout.Model
layout.Sizeable
layout.Focusable
SetWorking(working bool) tea.Cmd
AcceptSuggestion() tea.Cmd
ScrollByWheel(delta int)
// Value returns the current editor content
Value() string
// SetValue updates the editor content
SetValue(content string)
// InsertText inserts text at the current cursor position
InsertText(text string)
// AttachFile adds a file as an attachment and inserts @filepath into the editor
AttachFile(filePath string)
Cleanup()
GetSize() (width, height int)
BannerHeight() int
AttachmentAt(x int) (AttachmentPreview, bool)
// SetRecording sets the recording mode which shows animated dots as the cursor
SetRecording(recording bool) tea.Cmd
// IsRecording returns true if the editor is in recording mode
IsRecording() bool
// IsHistorySearchActive returns true if the editor is in history search mode
IsHistorySearchActive() bool
// EnterHistorySearch activates incremental history search
EnterHistorySearch() (layout.Model, tea.Cmd)
// SendContent triggers sending the current editor content
SendContent() tea.Cmd
}
// fileLoadResultMsg is sent when async file loading completes.
type fileLoadResultMsg struct {
loadID uint64
items []completion.Item
isFullLoad bool // true for full load, false for initial shallow load
}
// historySearchState holds the state for incremental history search.
type historySearchState struct {
active bool
query string
origTextValue string
origTextPlaceholderValue string
match string
matchIndex int
failing bool
}
// editor implements [Editor]
type editor struct {
textarea textarea.Model
hist *history.History
width int
height int
working bool
// completions are the available completions
completions []completions.Completion
// completionWord stores the word being completed
completionWord string
currentCompletion completions.Completion
suggestion string
hasSuggestion bool
// userTyped tracks whether the user has manually typed content (vs loaded from history)
userTyped bool
// keyboardEnhancementsSupported tracks whether the terminal supports keyboard enhancements
keyboardEnhancementsSupported bool
// pendingFileRef tracks the current @word being typed (for manual file ref detection).
// Only set when cursor is in a word starting with @, cleared when cursor leaves.
pendingFileRef string
// banner renders pending attachments so the user can see what's queued.
banner *attachmentBanner
// attachments tracks all file attachments (pastes and file refs).
attachments []attachment
// pasteCounter tracks the next paste number for display purposes.
pasteCounter int
// recording tracks whether the editor is in recording mode (speech-to-text)
recording bool
// recordingDotPhase tracks the animation phase for the recording dots cursor
recordingDotPhase int
// fileLoadID is incremented each time we start a new file load to ignore stale results
fileLoadID uint64
// fileLoadStarted tracks whether we've started initial loading for the current completion
fileLoadStarted bool
// fileFullLoadStarted tracks whether we've started full file loading (triggered by typing)
fileFullLoadStarted bool
// fileLoadCancel cancels any in-progress file loading
fileLoadCancel context.CancelFunc
// historySearch holds state for history search mode
historySearch historySearchState
// searchInput is the input field for history search queries
searchInput textinput.Model
}
// New creates a new editor component
func New(a *app.App, hist *history.History) Editor {
ta := textarea.New()
ta.SetStyles(styles.InputStyle)
ta.Placeholder = "Type your message here…"
ta.Prompt = ""
ta.CharLimit = -1
ta.SetWidth(50)
ta.SetHeight(3) // Set minimum 3 lines for multi-line input
ta.Focus()
ta.ShowLineNumbers = false
si := textinput.New()
si.Prompt = ""
si.Placeholder = "Type to search..."
// Customize styles for search input
s := styles.DialogInputStyle
s.Focused.Text = styles.MutedStyle
s.Focused.Placeholder = styles.MutedStyle
s.Blurred.Text = styles.MutedStyle
s.Blurred.Placeholder = styles.MutedStyle
si.SetStyles(s)
e := &editor{
textarea: ta,
searchInput: si,
hist: hist,
completions: completions.Completions(a),
keyboardEnhancementsSupported: false,
banner: newAttachmentBanner(),
}
e.configureNewlineKeybinding()
return e
}
// Init initializes the component
func (e *editor) Init() tea.Cmd {
return textarea.Blink
}
// stripANSI removes ANSI escape sequences from the provided string so width
// calculations can be performed on plain text.
func stripANSI(s string) string {
return ansiRegexp.ReplaceAllString(s, "")
}
// lineHasContent reports whether the rendered line has user input after the
// prompt has been stripped.
func lineHasContent(line, prompt string) bool {
plain := stripANSI(line)
if prompt != "" && strings.HasPrefix(plain, prompt) {
plain = strings.TrimPrefix(plain, prompt)
}
return strings.TrimSpace(plain) != ""
}
// extractLineText extracts the user input text from a rendered view line,
// stripping ANSI codes and the prompt prefix.
func extractLineText(line, prompt string) string {
plain := stripANSI(line)
if prompt != "" && strings.HasPrefix(plain, prompt) {
plain = strings.TrimPrefix(plain, prompt)
}
return strings.TrimRight(plain, " ")
}
// computeWrappedLines uses a textarea to compute how text would be wrapped,
// matching the textarea's word-wrap behavior exactly.
func (e *editor) computeWrappedLines(text string, startOffset int) []string {
// Create a temporary textarea with the same settings
ta := textarea.New()
ta.Prompt = e.textarea.Prompt
ta.ShowLineNumbers = e.textarea.ShowLineNumbers
ta.SetWidth(e.textarea.Width())
ta.SetHeight(100) // Large enough to see all wrapped lines
// For the first line, we need to account for the cursor position.
// We do this by prefixing with spaces to simulate the existing text.
prefix := strings.Repeat(" ", startOffset)
ta.SetValue(prefix + text)
view := ta.View()
viewLines := strings.Split(view, "\n")
// Extract the text content from each visual line
var result []string
for i, line := range viewLines {
plain := extractLineText(line, ta.Prompt)
if i == 0 {
// First line: remove the prefix spaces we added
if len(plain) >= startOffset {
plain = plain[startOffset:]
}
}
// Stop at empty lines (end of content)
if plain == "" && i > 0 {
break
}
result = append(result, plain)
}
if len(result) == 0 {
result = []string{text}
}
return result
}
// applySuggestionOverlay draws the inline suggestion on top of the textarea
// view using the configured ghost style. The first character appears with
// cursor styling (reverse video) so it's visible inside the cursor block.
// Multi-line suggestions are rendered across multiple visual lines.
func (e *editor) applySuggestionOverlay(view string) string {
lines := strings.Split(view, "\n")
value := e.textarea.Value()
promptWidth := runewidth.StringWidth(stripANSI(e.textarea.Prompt))
// Use LineInfo to get the actual cursor position within soft-wrapped lines
lineInfo := e.textarea.LineInfo()
// The cursor's column offset within the current visual line
textWidth := lineInfo.ColumnOffset
// Determine the target visual line for the overlay.
// For soft-wrapped text, we need to find where the cursor actually is.
var targetLine int
if strings.HasSuffix(value, "\n") {
// Cursor is on the line after the last content line.
// Find the first empty line after content.
contentLine := -1
for i := len(lines) - 1; i >= 0; i-- {
if lineHasContent(lines[i], e.textarea.Prompt) {
contentLine = i
break
}
}
if contentLine == -1 {
return view // No content found
}
// The cursor line is the one after the content line
targetLine = contentLine + 1
if targetLine >= len(lines) {
// Edge case: cursor line is beyond view (shouldn't happen normally)
targetLine = contentLine
textWidth = runewidth.StringWidth(extractLineText(lines[targetLine], e.textarea.Prompt))
}
} else {
// For normal text (including soft-wrapped), use the row offset from LineInfo
// to find the correct visual line within the viewport.
// LineInfo().RowOffset gives us how many visual rows down the cursor is
// from the start of the current logical line.
// First, find the last visual line with content
lastContentLine := -1
for i := len(lines) - 1; i >= 0; i-- {
if lineHasContent(lines[i], e.textarea.Prompt) {
lastContentLine = i
break
}
}
if lastContentLine == -1 {
return view
}
// Calculate the target line based on the logical line's row offset
// For multi-line content, we need to account for previous lines
logicalLine := e.textarea.Line()
rowOffset := lineInfo.RowOffset
// Count how many visual lines come before the current logical line
visualLinesBeforeCursor := 0
valueLines := strings.Split(value, "\n")
for i := 0; i < logicalLine && i < len(valueLines); i++ {
lineWidth := runewidth.StringWidth(valueLines[i])
editorWidth := e.textarea.Width()
if editorWidth > 0 {
// Each logical line takes at least 1 visual line, plus extra for wrapping
visualLinesBeforeCursor += 1 + lineWidth/editorWidth
} else {
visualLinesBeforeCursor++
}
}
targetLine = visualLinesBeforeCursor + rowOffset
// Clamp to valid range
if targetLine >= len(lines) {
targetLine = lastContentLine
}
targetLine = max(targetLine, 0)
}
// Use textarea's word-wrap logic to compute how the suggestion would be displayed.
// This ensures the suggestion wraps at the same points as when the text is accepted.
wrappedLines := e.computeWrappedLines(e.suggestion, textWidth)
baseLayer := lipgloss.NewLayer(view)
var overlays []*lipgloss.Layer
for i, suggLine := range wrappedLines {
if suggLine == "" && i > 0 {
// Empty line in middle of suggestion - skip but keep line count
continue
}
currentY := targetLine + i
// Note: We intentionally don't skip lines beyond the view.
// Lipgloss canvas will extend the output to accommodate overlays
// that are positioned beyond the base layer's boundaries.
var xOffset int
if i == 0 {
// First line starts at cursor position
xOffset = promptWidth + textWidth
} else {
// Subsequent lines start at the prompt position (column 0 after prompt)
xOffset = promptWidth
}
if i == 0 {
// First line: first character gets cursor styling, rest gets ghost styling
firstRune, restOfLine := splitFirstRune(suggLine)
cursorChar := styles.SuggestionCursorStyle.Render(firstRune)
cursorOverlay := lipgloss.NewLayer(cursorChar).
X(xOffset).
Y(currentY)
overlays = append(overlays, cursorOverlay)
if restOfLine != "" {
ghostRest := styles.SuggestionGhostStyle.Render(restOfLine)
restOverlay := lipgloss.NewLayer(ghostRest).
X(xOffset + runewidth.StringWidth(firstRune)).
Y(currentY)
overlays = append(overlays, restOverlay)
}
} else {
// Subsequent lines: all ghost styling
ghostLine := styles.SuggestionGhostStyle.Render(suggLine)
lineOverlay := lipgloss.NewLayer(ghostLine).
X(xOffset).
Y(currentY)
overlays = append(overlays, lineOverlay)
}
}
if len(overlays) == 0 {
return view
}
// Build canvas with all layers
allLayers := make([]*lipgloss.Layer, 0, len(overlays)+1)
allLayers = append(allLayers, baseLayer)
allLayers = append(allLayers, overlays...)
canvas := lipgloss.NewCanvas(allLayers...)
return canvas.Render()
}
// splitFirstRune splits a string into its first rune and the rest.
func splitFirstRune(s string) (string, string) {
if s == "" {
return "", ""
}
runes := []rune(s)
return string(runes[0]), string(runes[1:])
}
// deleteLastGraphemeCluster removes the last grapheme cluster from the string.
// This handles multi-codepoint characters like emoji sequences correctly.
func deleteLastGraphemeCluster(s string) string {
if s == "" {
return s
}
// Iterate through grapheme clusters to find where the last one starts
var lastClusterStart int
gr := uniseg.NewGraphemes(s)
for gr.Next() {
start, _ := gr.Positions()
lastClusterStart = start
}
return s[:lastClusterStart]
}
// refreshSuggestion updates the cached suggestion to reflect the current
// textarea value and available history entries.
func (e *editor) refreshSuggestion() {
if e.hist == nil {
e.clearSuggestion()
return
}
// Don't show history suggestions when completion popup is active.
// The completion's selected item takes precedence.
if e.currentCompletion != nil {
return
}
current := e.textarea.Value()
if current == "" {
e.clearSuggestion()
return
}
// Only show suggestions when cursor is at the end of the text.
// If cursor is not at the end, moving left/right would cause the
// suggestion overlay to overwrite existing characters.
if !e.isCursorAtEnd() {
e.clearSuggestion()
return
}
match := e.hist.LatestMatch(current)
if match == "" || match == current || len(match) <= len(current) {
e.clearSuggestion()
return
}
e.suggestion = match[len(current):]
if e.suggestion == "" {
e.clearSuggestion()
return
}
e.hasSuggestion = true
// Keep cursor visible - suggestion is rendered as overlay after cursor position
}
// clearSuggestion removes any pending suggestion.
func (e *editor) clearSuggestion() {
if !e.hasSuggestion {
return
}
e.hasSuggestion = false
e.suggestion = ""
}
// isCursorAtEnd returns true if the cursor is at the end of the text.
func (e *editor) isCursorAtEnd() bool {
value := e.textarea.Value()
if value == "" {
return true
}
// Check if cursor is on the last logical line
lines := strings.Split(value, "\n")
lastLineIdx := len(lines) - 1
if e.textarea.Line() != lastLineIdx {
return false
}
// Check if cursor is at the end of the last line
lastLine := lines[lastLineIdx]
lastLineLen := len([]rune(lastLine))
lineInfo := e.textarea.LineInfo()
// For soft-wrapped lines, we need to calculate the total character position
// from the start of the logical line. CharOffset is relative to the visual line,
// so we need to add the characters from previous visual rows.
// StartColumn gives us the character index where the current visual line starts.
totalCharPos := lineInfo.StartColumn + lineInfo.ColumnOffset
return totalCharPos >= lastLineLen
}
// AcceptSuggestion applies the current suggestion into the textarea value and
// returns a command to update the completion query, or nil if no suggestion was applied.
func (e *editor) AcceptSuggestion() tea.Cmd {
if !e.hasSuggestion || e.suggestion == "" {
return nil
}
current := e.textarea.Value()
e.textarea.SetValue(current + e.suggestion)
e.textarea.MoveToEnd()
e.clearSuggestion()
// Update the completion query to reflect the new editor content
return e.updateCompletionQuery()
}
func (e *editor) ScrollByWheel(delta int) {
if delta == 0 {
return
}
steps := delta
if steps < 0 {
steps = -steps
for range steps {
e.textarea.CursorUp()
}
return
}
for range steps {
e.textarea.CursorDown()
}
}
// resetAndSend prepares a message for sending: processes pending file refs,
// collects attachments, resets editor state, and returns the SendMsg command.
func (e *editor) resetAndSend(content string) tea.Cmd {
e.tryAddFileRef(e.pendingFileRef)
e.pendingFileRef = ""
attachments := e.collectAttachments(content)
e.textarea.Reset()
e.userTyped = false
e.clearSuggestion()
return core.CmdHandler(messages.SendMsg{Content: content, Attachments: attachments})
}
// configureNewlineKeybinding sets up the appropriate newline keybinding
// based on terminal keyboard enhancement support.
func (e *editor) configureNewlineKeybinding() {
// Configure textarea's InsertNewline binding based on terminal capabilities
if e.keyboardEnhancementsSupported {
// Modern terminals:
e.textarea.KeyMap.InsertNewline.SetKeys("shift+enter", "ctrl+j")
e.textarea.KeyMap.InsertNewline.SetEnabled(true)
} else {
// Legacy terminals:
e.textarea.KeyMap.InsertNewline.SetKeys("ctrl+j")
e.textarea.KeyMap.InsertNewline.SetEnabled(true)
}
}
// Update handles messages and updates the component state
func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
defer e.updateAttachmentBanner()
var cmds []tea.Cmd
switch msg := msg.(type) {
case recordingDotsTickMsg:
if !e.recording {
return e, nil
}
// Cycle through dot phases: "·", "··", "···"
e.recordingDotPhase = (e.recordingDotPhase + 1) % 4
dots := strings.Repeat("·", e.recordingDotPhase)
if e.recordingDotPhase == 0 {
dots = ""
}
e.textarea.Placeholder = "🎤 Listening" + dots
cmd := e.tickRecordingDots()
return e, cmd
case tea.PasteMsg:
if e.handlePaste(msg.Content) {
return e, nil
}
case tea.KeyboardEnhancementsMsg:
// Track keyboard enhancement support and configure newline keybinding accordingly
e.keyboardEnhancementsSupported = msg.Flags != 0
e.configureNewlineKeybinding()
return e, nil
case messages.ThemeChangedMsg:
e.textarea.SetStyles(styles.InputStyle)
return e, nil
case tea.WindowSizeMsg:
e.textarea.SetWidth(msg.Width - 2)
return e, nil
// Handle mouse events
case tea.MouseWheelMsg:
// Forward mouse wheel as cursor movements to textarea for scrolling
// This bypasses history navigation and allows viewport scrolling
switch msg.Button.String() {
case "wheelup":
// Move cursor up (scrolls viewport if needed)
e.textarea.CursorUp()
case "wheeldown":
// Move cursor down (scrolls viewport if needed)
e.textarea.CursorDown()
}
return e, nil
case tea.MouseClickMsg, tea.MouseMotionMsg, tea.MouseReleaseMsg:
var cmd tea.Cmd
e.textarea, cmd = e.textarea.Update(msg)
// Give focus to editor on click
if _, ok := msg.(tea.MouseClickMsg); ok {
return e, tea.Batch(cmd, e.Focus())
}
return e, cmd
case completion.SelectedMsg:
// If the item has an Execute function, run it instead of inserting text
if msg.Execute != nil {
// Remove the trigger character and any typed completion word from the textarea
// before executing. For example, typing "@" then selecting "Browse files..."
// should remove the "@" so AttachFile doesn't produce a double "@@".
if e.currentCompletion != nil {
triggerWord := e.currentCompletion.Trigger() + e.completionWord
currentValue := e.textarea.Value()
if idx := strings.LastIndex(currentValue, triggerWord); idx >= 0 {
e.textarea.SetValue(currentValue[:idx] + currentValue[idx+len(triggerWord):])
e.textarea.MoveToEnd()
}
}
e.clearSuggestion()
return e, msg.Execute()
}
if e.currentCompletion.AutoSubmit() {
// For auto-submit completions (like commands), use the selected
// command value (e.g., "/exit") instead of what the user typed
// (e.g., "/e"). Append any extra text after the trigger word
// to preserve arguments (e.g., "/export /tmp/file").
triggerWord := e.currentCompletion.Trigger() + e.completionWord
extraText := ""
if _, after, found := strings.Cut(e.textarea.Value(), triggerWord); found {
extraText = after
}
cmd := e.resetAndSend(msg.Value + extraText)
return e, cmd
}
// For non-auto-submit completions (like file paths), replace the completion word
currentValue := e.textarea.Value()
if lastIdx := strings.LastIndex(currentValue, e.completionWord); lastIdx >= 0 {
newValue := currentValue[:lastIdx-1] + msg.Value + " " + currentValue[lastIdx+len(e.completionWord):]
e.textarea.SetValue(newValue)
e.textarea.MoveToEnd()
}
// Track file references when using @ completion (but not paste placeholders)
if e.currentCompletion != nil && e.currentCompletion.Trigger() == "@" && !strings.HasPrefix(msg.Value, "@paste-") {
e.addFileAttachment(msg.Value)
}
e.clearSuggestion()
return e, nil
case completion.ClosedMsg:
e.completionWord = ""
e.currentCompletion = nil
e.clearSuggestion()
e.refreshSuggestion()
// Reset file loading state
e.fileLoadStarted = false
e.fileFullLoadStarted = false
if e.fileLoadCancel != nil {
e.fileLoadCancel()
e.fileLoadCancel = nil
}
return e, e.textarea.Focus()
case fileLoadResultMsg:
// Ignore stale results from older loads.
if msg.loadID != e.fileLoadID {
return e, nil
}
// Always stop the loading indicator for the active load, even if it was cancelled/errored.
if msg.items == nil {
return e, core.CmdHandler(completion.SetLoadingMsg{Loading: false})
}
// For full load, replace items (keeping pinned); for initial, append
var itemsCmd tea.Cmd
if msg.isFullLoad {
itemsCmd = core.CmdHandler(completion.ReplaceItemsMsg{Items: msg.items})
} else {
itemsCmd = core.CmdHandler(completion.AppendItemsMsg{Items: msg.items})
}
return e, tea.Batch(
core.CmdHandler(completion.SetLoadingMsg{Loading: false}),
itemsCmd,
)
case completion.SelectionChangedMsg:
// Show the selected completion item as a suggestion in the editor
if msg.Value != "" && e.currentCompletion != nil {
// Calculate the suggestion: what needs to be added after current text
currentText := e.textarea.Value()
if strings.HasPrefix(msg.Value, currentText) {
e.suggestion = msg.Value[len(currentText):]
e.hasSuggestion = e.suggestion != ""
} else {
e.clearSuggestion()
}
} else {
e.clearSuggestion()
}
return e, nil
case tea.KeyPressMsg:
if e.historySearch.active {
return e.handleHistorySearchKey(msg)
}
if key.Matches(msg, e.textarea.KeyMap.Paste) {
return e.handleClipboardPaste()
}
// Handle backspace with grapheme cluster awareness.
// The default textarea.Model only deletes a single rune, which breaks
// multi-codepoint characters like emoji (e.g., ⚠️ = U+26A0 + U+FE0F).
if key.Matches(msg, e.textarea.KeyMap.DeleteCharacterBackward) {
return e.handleGraphemeBackspace()
}
// Handle send/newline keys:
// - Enter: submit current input (if textarea inserted a newline, submit previous buffer).
// - Shift+Enter: insert newline when keyboard enhancements are supported.
// - Ctrl+J: fallback to insert '\n' when keyboard enhancements are not supported.
if msg.String() == "enter" || key.Matches(msg, e.textarea.KeyMap.InsertNewline) {
if !e.textarea.Focused() {
return e, nil
}
// Let textarea process the key - it handles newlines via InsertNewline binding
prev := e.textarea.Value()
e.textarea, _ = e.textarea.Update(msg)
value := e.textarea.Value()
// If textarea inserted a newline, just refresh and return
if value != prev && msg.String() != "enter" {
e.refreshSuggestion()
return e, nil
}
// If plain enter and textarea inserted a newline, submit the previous value
if value != prev && msg.String() == "enter" {
if prev != "" {
e.textarea.SetValue(prev)
e.textarea.MoveToEnd()
cmd := e.resetAndSend(prev)
return e, cmd
}
return e, nil
}
// Normal enter submit: send current value
if value != "" {
cmd := e.resetAndSend(value)
return e, cmd
}
return e, nil
}
// Handle other special keys
switch msg.String() {
case "up":
// Only navigate history if the user hasn't manually typed content
if !e.userTyped {
e.textarea.SetValue(e.hist.Previous())
e.textarea.MoveToEnd()
e.refreshSuggestion()
return e, nil
}
// Otherwise, let the textarea handle cursor navigation
case "down":
// Only navigate history if the user hasn't manually typed content
if !e.userTyped {
e.textarea.SetValue(e.hist.Next())
e.textarea.MoveToEnd()
e.refreshSuggestion()
return e, nil
}
// Otherwise, let the textarea handle cursor navigation
default:
for _, completion := range e.completions {
if msg.String() == completion.Trigger() {
if completion.RequiresEmptyEditor() && e.textarea.Value() != "" {
continue
}
cmds = append(cmds, e.startCompletion(completion))
}
}
}
}
prevValue := e.textarea.Value()
var cmd tea.Cmd
e.textarea, cmd = e.textarea.Update(msg)
cmds = append(cmds, cmd)
// If the value changed due to user input (not history navigation), mark as user typed
if keyMsg, ok := msg.(tea.KeyPressMsg); ok {
// Check if content changed and it wasn't a history navigation key
if e.textarea.Value() != prevValue && keyMsg.String() != "up" && keyMsg.String() != "down" {
e.userTyped = true
}
// Also check if textarea became empty - reset userTyped flag
if e.textarea.Value() == "" {
e.userTyped = false
}
currentWord := e.textarea.Word()
// Track manual @filepath refs - only runs when we're in/leaving an @ word
if e.pendingFileRef != "" && currentWord != e.pendingFileRef {
// Left the @ word - try to add it as file ref
e.tryAddFileRef(e.pendingFileRef)
e.pendingFileRef = ""
}
if e.pendingFileRef == "" && strings.HasPrefix(currentWord, "@") && len(currentWord) > 1 {
// Entered an @ word - start tracking
e.pendingFileRef = currentWord
} else if e.pendingFileRef != "" && strings.HasPrefix(currentWord, "@") {
// Still in @ word but it changed (user typing more) - update tracking
e.pendingFileRef = currentWord
}
if keyMsg.String() == "space" {
e.currentCompletion = nil
}
cmds = append(cmds, e.updateCompletionQuery())
}
e.refreshSuggestion()
return e, tea.Batch(cmds...)
}
func (e *editor) handleClipboardPaste() (layout.Model, tea.Cmd) {
content, err := clipboard.ReadAll()
if err != nil {
slog.Warn("failed to read clipboard", "error", err)
return e, nil
}
// handlePaste returns true if content was buffered to disk (large paste),
// false if it's small enough for inline insertion.
if !e.handlePaste(content) {
e.textarea.InsertString(content)
}
return e, textarea.Blink
}
// handleGraphemeBackspace implements backspace with grapheme cluster awareness.
// It removes the entire last grapheme cluster, not just the last rune.
// This fixes deletion of multi-codepoint characters like emoji sequences.
func (e *editor) handleGraphemeBackspace() (layout.Model, tea.Cmd) {
value := e.textarea.Value()
if value == "" {
return e, nil
}
// Get cursor position info
lines := strings.Split(value, "\n")
currentLine := e.textarea.Line()
lineInfo := e.textarea.LineInfo()
// CharOffset within the current visual line segment
colPos := lineInfo.CharOffset + lineInfo.StartColumn
if currentLine < 0 || currentLine >= len(lines) {
return e, nil
}
if colPos == 0 && currentLine > 0 {
// At beginning of line but not first line - let textarea handle line merge
var cmd tea.Cmd
e.textarea, cmd = e.textarea.Update(tea.KeyPressMsg{Code: tea.KeyBackspace})
e.refreshSuggestion()
return e, tea.Batch(cmd, e.updateCompletionQuery())
}
if colPos == 0 {
// At beginning of first line - nothing to delete
return e, nil
}
// Delete the last grapheme cluster from the text before the cursor
currentLineText := lines[currentLine]
// Convert column position (based on display width) to rune position
runePos := 0
width := 0
for _, r := range currentLineText {
if width >= colPos {
break
}
width += runewidth.RuneWidth(r)
runePos++
}
// Text before cursor
runes := []rune(currentLineText)
if runePos > len(runes) {
runePos = len(runes)
}
beforeCursor := string(runes[:runePos])
afterCursor := string(runes[runePos:])
// Delete the last grapheme cluster from text before cursor
newBeforeCursor := deleteLastGraphemeCluster(beforeCursor)
// Rebuild the line
lines[currentLine] = newBeforeCursor + afterCursor
newValue := strings.Join(lines, "\n")
// Calculate new cursor column position within the current line
newCol := len([]rune(newBeforeCursor))
// Build text before cursor position (all lines before current + new before cursor)
var beforeParts []string
for i := range currentLine {
beforeParts = append(beforeParts, lines[i])
}
beforeParts = append(beforeParts, newBeforeCursor)
textBeforeCursor := strings.Join(beforeParts, "\n")
// Build text after cursor position (after cursor on current line + remaining lines)
var textAfterCursor string
textAfterCursor = afterCursor
for i := currentLine + 1; i < len(lines); i++ {
textAfterCursor += "\n" + lines[i]
}
// Set the text before cursor and move to end
e.textarea.SetValue(textBeforeCursor)
e.textarea.MoveToEnd()
// Now insert the text after cursor - this positions cursor correctly
if textAfterCursor != "" {
e.textarea.SetValue(newValue)
e.textarea.MoveToBegin()
// Keep calling CursorDown until we're on the target logical line
for e.textarea.Line() < currentLine {
e.textarea.CursorDown()
}
e.textarea.SetCursorColumn(newCol)
}
e.refreshSuggestion()
return e, tea.Batch(textarea.Blink, e.updateCompletionQuery())
}
// updateCompletionQuery sends the appropriate completion message based on current editor state.
// It returns a command that either updates the completion query or closes the completion popup.
func (e *editor) updateCompletionQuery() tea.Cmd {