1package editor
  2
  3import (
  4	"context"
  5	"fmt"
  6	"io/fs"
  7	"log/slog"
  8	"math/rand"
  9	"net/http"
 10	"os"
 11	"os/exec"
 12	"path/filepath"
 13	"runtime"
 14	"slices"
 15	"strings"
 16	"unicode"
 17
 18	"github.com/charmbracelet/bubbles/v2/key"
 19	"github.com/charmbracelet/bubbles/v2/textarea"
 20	tea "github.com/charmbracelet/bubbletea/v2"
 21	"github.com/charmbracelet/crush/internal/app"
 22	"github.com/charmbracelet/crush/internal/config"
 23	"github.com/charmbracelet/crush/internal/fsext"
 24	"github.com/charmbracelet/crush/internal/message"
 25	"github.com/charmbracelet/crush/internal/session"
 26	"github.com/charmbracelet/crush/internal/tui/components/chat"
 27	"github.com/charmbracelet/crush/internal/tui/components/completions"
 28	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 29	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 30	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
 31	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
 32	"github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
 33	"github.com/charmbracelet/crush/internal/tui/styles"
 34	"github.com/charmbracelet/crush/internal/tui/util"
 35	"github.com/charmbracelet/lipgloss/v2"
 36)
 37
 38type Editor interface {
 39	util.Model
 40	layout.Sizeable
 41	layout.Focusable
 42	layout.Help
 43	layout.Positional
 44
 45	SetSession(session session.Session) tea.Cmd
 46	IsCompletionsOpen() bool
 47	HasAttachments() bool
 48	Cursor() *tea.Cursor
 49}
 50
 51type FileCompletionItem struct {
 52	Path string // The file path
 53}
 54
 55type editorCmp struct {
 56	width              int
 57	height             int
 58	x, y               int
 59	app                *app.App
 60	session            session.Session
 61	textarea           *textarea.Model
 62	attachments        []message.Attachment
 63	deleteMode         bool
 64	readyPlaceholder   string
 65	workingPlaceholder string
 66
 67	keyMap EditorKeyMap
 68
 69	// injected file dir lister
 70	listDirResolver fsext.DirectoryListerResolver
 71
 72	// File path completions
 73	currentQuery          string
 74	completionsStartIndex int
 75	isCompletionsOpen     bool
 76
 77	previouslyScrollingPromptHistory bool
 78	scrollingPromptHistory           bool
 79	promptHistoryIndex               int
 80	historyCache                     []string
 81}
 82
 83var DeleteKeyMaps = DeleteAttachmentKeyMaps{
 84	AttachmentDeleteMode: key.NewBinding(
 85		key.WithKeys("ctrl+r"),
 86		key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
 87	),
 88	Escape: key.NewBinding(
 89		key.WithKeys("esc", "alt+esc"),
 90		key.WithHelp("esc", "cancel delete mode"),
 91	),
 92	DeleteAllAttachments: key.NewBinding(
 93		key.WithKeys("r"),
 94		key.WithHelp("ctrl+r+r", "delete all attachments"),
 95	),
 96}
 97
 98const (
 99	maxAttachments = 5
100)
101
102type OpenEditorMsg struct {
103	Text string
104}
105
106func (m *editorCmp) openEditor(value string) tea.Cmd {
107	editor := os.Getenv("EDITOR")
108	if editor == "" {
109		// Use platform-appropriate default editor
110		if runtime.GOOS == "windows" {
111			editor = "notepad"
112		} else {
113			editor = "nvim"
114		}
115	}
116
117	tmpfile, err := os.CreateTemp("", "msg_*.md")
118	if err != nil {
119		return util.ReportError(err)
120	}
121	defer tmpfile.Close() //nolint:errcheck
122	if _, err := tmpfile.WriteString(value); err != nil {
123		return util.ReportError(err)
124	}
125	c := exec.CommandContext(context.TODO(), editor, tmpfile.Name())
126	c.Stdin = os.Stdin
127	c.Stdout = os.Stdout
128	c.Stderr = os.Stderr
129	return tea.ExecProcess(c, func(err error) tea.Msg {
130		if err != nil {
131			return util.ReportError(err)
132		}
133		content, err := os.ReadFile(tmpfile.Name())
134		if err != nil {
135			return util.ReportError(err)
136		}
137		if len(content) == 0 {
138			return util.ReportWarn("Message is empty")
139		}
140		os.Remove(tmpfile.Name())
141		return OpenEditorMsg{
142			Text: strings.TrimSpace(string(content)),
143		}
144	})
145}
146
147func (m *editorCmp) Init() tea.Cmd {
148	return nil
149}
150
151func (m *editorCmp) send() tea.Cmd {
152	defer func() {
153		m.resetHistory()
154	}()
155	value := m.textarea.Value()
156	value = strings.TrimSpace(value)
157
158	switch value {
159	case "exit", "quit":
160		m.textarea.Reset()
161		return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()})
162	}
163
164	m.textarea.Reset()
165	attachments := m.attachments
166
167	m.attachments = nil
168	if value == "" {
169		return nil
170	}
171
172	// Change the placeholder when sending a new message.
173	m.randomizePlaceholders()
174
175	return tea.Batch(
176		util.CmdHandler(chat.SendMsg{
177			Text:        value,
178			Attachments: attachments,
179		}),
180	)
181}
182
183func (m *editorCmp) repositionCompletions() tea.Msg {
184	x, y := m.completionsPosition()
185	return completions.RepositionCompletionsMsg{X: x, Y: y}
186}
187
188func onCompletionItemSelect(fsys fs.FS, activeModelHasImageSupport func() (bool, string), item FileCompletionItem, insert bool, m *editorCmp) (tea.Model, tea.Cmd) {
189	var cmd tea.Cmd
190	path := item.Path
191	// check if item is an image
192	if isExtOfAllowedImageType(path) {
193		if imagesSupported, modelName := activeModelHasImageSupport(); !imagesSupported {
194			// TODO(tauraamui): consolidate this kind of standard image attachment related warning
195			return m, util.ReportWarn("File attachments are not supported by the current model: " + modelName)
196		}
197		slog.Debug("checking if image is too big", path, 1)
198		tooBig, _ := filepicker.IsFileTooBigWithFS(os.DirFS(filepath.Dir(path)), path, filepicker.MaxAttachmentSize)
199		if tooBig {
200			return m, nil
201		}
202
203		content, err := fs.ReadFile(fsys, path)
204		if err != nil {
205			return m, nil
206		}
207		mimeBufferSize := min(512, len(content))
208		mimeType := http.DetectContentType(content[:mimeBufferSize])
209		fileName := filepath.Base(path)
210		attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
211		cmd = util.CmdHandler(filepicker.FilePickedMsg{
212			Attachment: attachment,
213		})
214	}
215
216	word := m.textarea.Word()
217	// If the selected item is a file, insert its path into the textarea
218	originalValue := m.textarea.Value()
219	newValue := originalValue[:m.completionsStartIndex] // Remove the current query
220	if cmd == nil {
221		newValue += path // insert the file path for non-images
222	}
223	newValue += originalValue[m.completionsStartIndex+len(word):] // Append the rest of the value
224	// XXX: This will always move the cursor to the end of the textarea.
225	m.textarea.SetValue(newValue)
226	m.textarea.MoveToEnd()
227	if !insert {
228		m.isCompletionsOpen = false
229		m.currentQuery = ""
230		m.completionsStartIndex = 0
231	}
232
233	return m, cmd
234}
235
236func isExtOfAllowedImageType(path string) bool {
237	isAllowedType := false
238	// TODO(tauraamui) [17/09/2025]: this needs to be combined with the actual data inference/checking
239	//                  of the contents that happens when we resolve the "mime" type
240	for _, ext := range filepicker.AllowedTypes {
241		if strings.HasSuffix(path, ext) {
242			isAllowedType = true
243			break
244		}
245	}
246	return isAllowedType
247}
248
249type ResolveAbs func(path string) (string, error)
250
251func onPaste(msg tea.PasteMsg) tea.Msg {
252	return filepicker.OnPaste(filepicker.ResolveFS, string(msg))
253}
254
255func activeModelHasImageSupport() (bool, string) {
256	agentCfg := config.Get().Agents["coder"]
257	model := config.Get().GetModelByType(agentCfg.Model)
258	return model.SupportsImages, model.Name
259}
260
261func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
262	var cmd tea.Cmd
263	var cmds []tea.Cmd
264	switch msg := msg.(type) {
265	case tea.WindowSizeMsg:
266		return m, m.repositionCompletions
267	case filepicker.FilePickedMsg:
268		if len(m.attachments) >= maxAttachments {
269			return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
270		}
271		m.attachments = append(m.attachments, msg.Attachment)
272		return m, nil
273	case completions.CompletionsOpenedMsg:
274		m.isCompletionsOpen = true
275	case completions.CompletionsClosedMsg:
276		m.isCompletionsOpen = false
277		m.currentQuery = ""
278		m.completionsStartIndex = 0
279	case completions.SelectCompletionMsg:
280		if !m.isCompletionsOpen {
281			return m, nil
282		}
283		if item, ok := msg.Value.(FileCompletionItem); ok {
284			return onCompletionItemSelect(os.DirFS("."), activeModelHasImageSupport, item, msg.Insert, m)
285		}
286	case commands.OpenExternalEditorMsg:
287		if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
288			return m, util.ReportWarn("Agent is working, please wait...")
289		}
290		return m, m.openEditor(m.textarea.Value())
291	case OpenEditorMsg:
292		m.textarea.SetValue(msg.Text)
293		m.textarea.MoveToEnd()
294	case tea.PasteMsg:
295		agentCfg := config.Get().Agents["coder"]
296		model := config.Get().GetModelByType(agentCfg.Model)
297		if !model.SupportsImages {
298			return m, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
299		}
300		return m, util.CmdHandler(onPaste(msg)) // inject fsys accessible from PWD
301	case commands.ToggleYoloModeMsg:
302		m.setEditorPrompt()
303		return m, nil
304	case tea.KeyPressMsg:
305		cur := m.textarea.Cursor()
306		curIdx := m.textarea.Width()*cur.Y + cur.X
307		switch {
308		// Completions
309		case msg.String() == "/" && !m.isCompletionsOpen &&
310			// only show if beginning of prompt, or if previous char is a space or newline:
311			(len(m.textarea.Value()) == 0 || unicode.IsSpace(rune(m.textarea.Value()[len(m.textarea.Value())-1]))):
312			m.isCompletionsOpen = true
313			m.currentQuery = ""
314			m.completionsStartIndex = curIdx
315
316			cmds = append(cmds, m.startCompletions())
317		case m.isCompletionsOpen && curIdx <= m.completionsStartIndex:
318			cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
319		}
320		if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
321			m.deleteMode = true
322			return m, nil
323		}
324		if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
325			m.deleteMode = false
326			m.attachments = nil
327			return m, nil
328		}
329		rune := msg.Code
330		if m.deleteMode && unicode.IsDigit(rune) {
331			num := int(rune - '0')
332			m.deleteMode = false
333			if num < 10 && len(m.attachments) > num {
334				if num == 0 {
335					m.attachments = m.attachments[num+1:]
336				} else {
337					m.attachments = slices.Delete(m.attachments, num, num+1)
338				}
339				return m, nil
340			}
341		}
342		if key.Matches(msg, m.keyMap.OpenEditor) {
343			if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
344				return m, util.ReportWarn("Agent is working, please wait...")
345			}
346			return m, m.openEditor(m.textarea.Value())
347		}
348		if key.Matches(msg, DeleteKeyMaps.Escape) {
349			m.deleteMode = false
350			return m, nil
351		}
352		if key.Matches(msg, m.keyMap.Newline) {
353			m.textarea.InsertRune('\n')
354			cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
355		}
356		// History
357		if key.Matches(msg, m.keyMap.Previous) || key.Matches(msg, m.keyMap.Next) {
358			m.textarea.SetValue(m.stepOverHistory(m.getUserMessagesAsText, m.getDirectionFromKey(msg)))
359		}
360		// Handle Enter key
361		if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
362			value := m.textarea.Value()
363			if strings.HasSuffix(value, "\\") {
364				// If the last character is a backslash, remove it and add a newline.
365				m.textarea.SetValue(strings.TrimSuffix(value, "\\"))
366			} else {
367				// Otherwise, send the message
368				return m, m.send()
369			}
370		}
371	}
372
373	m.textarea, cmd = m.textarea.Update(msg)
374	cmds = append(cmds, cmd)
375
376	if m.textarea.Focused() {
377		kp, ok := msg.(tea.KeyPressMsg)
378		if ok {
379			if kp.String() == "space" || m.textarea.Value() == "" {
380				m.isCompletionsOpen = false
381				m.currentQuery = ""
382				m.completionsStartIndex = 0
383				cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
384			} else {
385				word := m.textarea.Word()
386				if strings.HasPrefix(word, "/") {
387					// XXX: wont' work if editing in the middle of the field.
388					m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
389					m.currentQuery = word[1:]
390					x, y := m.completionsPosition()
391					x -= len(m.currentQuery)
392					m.isCompletionsOpen = true
393					cmds = append(cmds,
394						util.CmdHandler(completions.FilterCompletionsMsg{
395							Query:  m.currentQuery,
396							Reopen: m.isCompletionsOpen,
397							X:      x,
398							Y:      y,
399						}),
400					)
401				} else if m.isCompletionsOpen {
402					m.isCompletionsOpen = false
403					m.currentQuery = ""
404					m.completionsStartIndex = 0
405					cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
406				}
407			}
408		}
409	}
410
411	return m, tea.Batch(cmds...)
412}
413
414func (m *editorCmp) setEditorPrompt() {
415	if perm := m.app.Permissions; perm != nil {
416		if perm.SkipRequests() {
417			m.textarea.SetPromptFunc(4, yoloPromptFunc)
418			return
419		}
420	}
421	m.textarea.SetPromptFunc(4, normalPromptFunc)
422}
423
424func (m *editorCmp) completionsPosition() (int, int) {
425	cur := m.textarea.Cursor()
426	if cur == nil {
427		return m.x, m.y + 1 // adjust for padding
428	}
429	x := cur.X + m.x
430	y := cur.Y + m.y + 1 // adjust for padding
431	return x, y
432}
433
434func (m *editorCmp) Cursor() *tea.Cursor {
435	cursor := m.textarea.Cursor()
436	if cursor != nil {
437		cursor.X = cursor.X + m.x + 1
438		cursor.Y = cursor.Y + m.y + 1 // adjust for padding
439	}
440	return cursor
441}
442
443var readyPlaceholders = [...]string{
444	"Ready!",
445	"Ready...",
446	"Ready?",
447	"Ready for instructions",
448}
449
450var workingPlaceholders = [...]string{
451	"Working!",
452	"Working...",
453	"Brrrrr...",
454	"Prrrrrrrr...",
455	"Processing...",
456	"Thinking...",
457}
458
459func (m *editorCmp) randomizePlaceholders() {
460	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
461	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
462}
463
464func (m *editorCmp) View() string {
465	t := styles.CurrentTheme()
466	// Update placeholder
467	if m.app.CoderAgent != nil && m.app.CoderAgent.IsBusy() {
468		m.textarea.Placeholder = m.workingPlaceholder
469	} else {
470		m.textarea.Placeholder = m.readyPlaceholder
471	}
472	if m.app.Permissions.SkipRequests() {
473		m.textarea.Placeholder = "Yolo mode!"
474	}
475	if len(m.attachments) == 0 {
476		content := t.S().Base.Padding(1).Render(
477			m.textarea.View(),
478		)
479		return content
480	}
481	content := t.S().Base.Padding(0, 1, 1, 1).Render(
482		lipgloss.JoinVertical(lipgloss.Top,
483			m.attachmentsContent(),
484			m.textarea.View(),
485		),
486	)
487	return content
488}
489
490func (m *editorCmp) SetSize(width, height int) tea.Cmd {
491	m.width = width
492	m.height = height
493	m.textarea.SetWidth(width - 2)   // adjust for padding
494	m.textarea.SetHeight(height - 2) // adjust for padding
495	return nil
496}
497
498func (m *editorCmp) GetSize() (int, int) {
499	return m.textarea.Width(), m.textarea.Height()
500}
501
502func (m *editorCmp) attachmentsContent() string {
503	var styledAttachments []string
504	t := styles.CurrentTheme()
505	attachmentStyles := t.S().Base.
506		MarginLeft(1).
507		Background(t.FgMuted).
508		Foreground(t.FgBase)
509	for i, attachment := range m.attachments {
510		var filename string
511		if len(attachment.FileName) > 10 {
512			filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
513		} else {
514			filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
515		}
516		if m.deleteMode {
517			filename = fmt.Sprintf("%d%s", i, filename)
518		}
519		styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
520	}
521	content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
522	return content
523}
524
525func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
526	m.x = x
527	m.y = y
528	return nil
529}
530
531func (m *editorCmp) startCompletions() func() tea.Msg {
532	return func() tea.Msg {
533		files, _, _ := m.listDirResolver()(".", nil)
534		slices.Sort(files)
535		completionItems := make([]completions.Completion, 0, len(files))
536		for _, file := range files {
537			file = strings.TrimPrefix(file, "./")
538			completionItems = append(completionItems, completions.Completion{
539				Title: file,
540				Value: FileCompletionItem{
541					Path: file,
542				},
543			})
544		}
545
546		x, y := m.completionsPosition()
547		return completions.OpenCompletionsMsg{
548			Completions: completionItems,
549			X:           x,
550			Y:           y,
551		}
552	}
553}
554
555// Blur implements Container.
556func (c *editorCmp) Blur() tea.Cmd {
557	c.textarea.Blur()
558	return nil
559}
560
561// Focus implements Container.
562func (c *editorCmp) Focus() tea.Cmd {
563	return c.textarea.Focus()
564}
565
566// IsFocused implements Container.
567func (c *editorCmp) IsFocused() bool {
568	return c.textarea.Focused()
569}
570
571// Bindings implements Container.
572func (c *editorCmp) Bindings() []key.Binding {
573	return c.keyMap.KeyBindings()
574}
575
576// TODO: most likely we do not need to have the session here
577// we need to move some functionality to the page level
578func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
579	c.session = session
580	return nil
581}
582
583func (c *editorCmp) IsCompletionsOpen() bool {
584	return c.isCompletionsOpen
585}
586
587func (c *editorCmp) HasAttachments() bool {
588	return len(c.attachments) > 0
589}
590
591func normalPromptFunc(info textarea.PromptInfo) string {
592	t := styles.CurrentTheme()
593	if info.LineNumber == 0 {
594		return "  > "
595	}
596	if info.Focused {
597		return t.S().Base.Foreground(t.GreenDark).Render("::: ")
598	}
599	return t.S().Muted.Render("::: ")
600}
601
602func yoloPromptFunc(info textarea.PromptInfo) string {
603	t := styles.CurrentTheme()
604	if info.LineNumber == 0 {
605		if info.Focused {
606			return fmt.Sprintf("%s ", t.YoloIconFocused)
607		} else {
608			return fmt.Sprintf("%s ", t.YoloIconBlurred)
609		}
610	}
611	if info.Focused {
612		return fmt.Sprintf("%s ", t.YoloDotsFocused)
613	}
614	return fmt.Sprintf("%s ", t.YoloDotsBlurred)
615}
616
617func (m *editorCmp) getUserMessagesAsText(ctx context.Context) ([]string, error) {
618	if len(m.historyCache) > 0 {
619		return m.historyCache, nil
620	}
621	allMessages, err := m.app.Messages.List(ctx, m.session.ID)
622	if err != nil {
623		return nil, err
624	}
625
626	var userMessages []string
627	for _, msg := range allMessages {
628		if msg.Role == message.User {
629			userMessages = append(userMessages, msg.Content().Text)
630		}
631	}
632
633	userMessages = append(userMessages, m.textarea.Value())
634	m.historyCache = userMessages
635	return userMessages, nil
636}
637
638type direction int
639
640const (
641	previous = iota
642	next
643)
644
645func (m *editorCmp) getDirectionFromKey(msg tea.KeyPressMsg) func() direction {
646	return func() direction {
647		if key.Matches(msg, m.keyMap.Previous) {
648			return previous
649		}
650		return next
651	}
652}
653
654func (m *editorCmp) stepOverHistory(resolveHistoricMessages func(context.Context) ([]string, error), resolveDirection func() direction) string {
655	// NOTE(tauraamui): the last entry in this list will be the current contents of the input field/box
656	messageHistory, err := resolveHistoricMessages(context.Background())
657	if err != nil {
658		return ""
659	}
660
661	// the list will/should always have at least the current message in the input in the list
662	if len(messageHistory) == 1 {
663		return messageHistory[0]
664	}
665
666	// the first time we invoke scroll we need to start from top of the list
667	if !m.previouslyScrollingPromptHistory {
668		m.promptHistoryIndex = len(messageHistory) - 1
669		m.previouslyScrollingPromptHistory = true
670	}
671
672	switch resolveDirection() {
673	case previous:
674		m.scrollingPromptHistory = true
675		return m.stepBack(messageHistory)
676	case next:
677		return m.stepForward(messageHistory)
678	}
679	return ""
680}
681
682func (m *editorCmp) stepBack(history []string) string {
683	m.promptHistoryIndex -= 1
684	if m.promptHistoryIndex < 0 {
685		m.promptHistoryIndex = 0
686	}
687	return history[m.promptHistoryIndex]
688}
689
690func (m *editorCmp) stepForward(history []string) string {
691	m.promptHistoryIndex += 1
692	maxIndex := len(history) - 1
693	if m.promptHistoryIndex > maxIndex {
694		m.promptHistoryIndex = maxIndex
695	}
696	return history[m.promptHistoryIndex]
697}
698
699func (m *editorCmp) resetHistory() {
700	m.historyCache = nil
701	m.promptHistoryIndex = 0
702	m.previouslyScrollingPromptHistory = false
703	m.scrollingPromptHistory = false
704}
705
706func (m *editorCmp) handleMessageHistory(msg tea.KeyMsg) string {
707	ctx := context.Background()
708	userMessages, err := m.getUserMessagesAsText(ctx)
709	if err != nil {
710		return "" // Do nothing.
711	}
712	userMessages = append(userMessages, "") // Give the user a reset option.
713	if len(userMessages) > 0 {
714		if key.Matches(msg, m.keyMap.Previous) {
715			if m.promptHistoryIndex == 0 {
716				m.promptHistoryIndex = len(userMessages) - 1
717			} else {
718				m.promptHistoryIndex -= 1
719			}
720		}
721		if key.Matches(msg, m.keyMap.Next) {
722			if m.promptHistoryIndex == len(userMessages)-1 {
723				m.promptHistoryIndex = 0
724			} else {
725				m.promptHistoryIndex += 1
726			}
727		}
728	}
729	return userMessages[m.promptHistoryIndex]
730}
731
732func newTextArea() *textarea.Model {
733	t := styles.CurrentTheme()
734	ta := textarea.New()
735	ta.SetStyles(t.S().TextArea)
736	ta.ShowLineNumbers = false
737	ta.CharLimit = -1
738	ta.SetVirtualCursor(false)
739	ta.Focus()
740	return ta
741}
742
743func newEditor(app *app.App, resolveDirLister fsext.DirectoryListerResolver) *editorCmp {
744	e := editorCmp{
745		// TODO: remove the app instance from here
746		app:             app,
747		textarea:        newTextArea(),
748		keyMap:          DefaultEditorKeyMap(),
749		listDirResolver: resolveDirLister,
750	}
751	e.setEditorPrompt()
752
753	e.randomizePlaceholders()
754	e.textarea.Placeholder = e.readyPlaceholder
755
756	return &e
757}
758
759func New(app *app.App) Editor {
760	ls := app.Config().Options.TUI.Completions.Limits
761	return newEditor(app, fsext.ResolveDirectoryLister(ls()))
762}