editor.go

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