Răsfoiți Sursa

feat(tui): expand input to fit message

adamdottv 8 luni în urmă
părinte
comite
568c04753e

+ 1 - 1
README.md

@@ -98,7 +98,7 @@ You can configure custom keybinds, the values listed below are the defaults.
     "input_clear": "ctrl+c",
     "input_clear": "ctrl+c",
     "input_paste": "ctrl+v",
     "input_paste": "ctrl+v",
     "input_submit": "enter",
     "input_submit": "enter",
-    "input_newline": "shift+enter",
+    "input_newline": "shift+enter,ctrl+j",
     "history_previous": "up",
     "history_previous": "up",
     "history_next": "down",
     "history_next": "down",
     "messages_page_up": "pgup",
     "messages_page_up": "pgup",

+ 11 - 11
packages/tui/internal/commands/command.go

@@ -208,18 +208,18 @@ func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
 		{
 		{
 			Name:        InputNewlineCommand,
 			Name:        InputNewlineCommand,
 			Description: "insert newline",
 			Description: "insert newline",
-			Keybindings: parseBindings("shift+enter"),
-		},
-		{
-			Name:        HistoryPreviousCommand,
-			Description: "previous prompt",
-			Keybindings: parseBindings("up"),
-		},
-		{
-			Name:        HistoryNextCommand,
-			Description: "next prompt",
-			Keybindings: parseBindings("down"),
+			Keybindings: parseBindings("shift+enter", "ctrl+j"),
 		},
 		},
+		// {
+		// 	Name:        HistoryPreviousCommand,
+		// 	Description: "previous prompt",
+		// 	Keybindings: parseBindings("up"),
+		// },
+		// {
+		// 	Name:        HistoryNextCommand,
+		// 	Description: "next prompt",
+		// 	Keybindings: parseBindings("down"),
+		// },
 		{
 		{
 			Name:        MessagesPageUpCommand,
 			Name:        MessagesPageUpCommand,
 			Description: "page up",
 			Description: "page up",

+ 32 - 25
packages/tui/internal/components/chat/editor.go

@@ -6,12 +6,12 @@ import (
 	"strings"
 	"strings"
 
 
 	"github.com/charmbracelet/bubbles/v2/spinner"
 	"github.com/charmbracelet/bubbles/v2/spinner"
-	"github.com/charmbracelet/bubbles/v2/textarea"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/sst/opencode/internal/app"
 	"github.com/sst/opencode/internal/app"
 	"github.com/sst/opencode/internal/commands"
 	"github.com/sst/opencode/internal/commands"
 	"github.com/sst/opencode/internal/components/dialog"
 	"github.com/sst/opencode/internal/components/dialog"
+	"github.com/sst/opencode/internal/components/textarea"
 	"github.com/sst/opencode/internal/image"
 	"github.com/sst/opencode/internal/image"
 	"github.com/sst/opencode/internal/layout"
 	"github.com/sst/opencode/internal/layout"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/styles"
@@ -23,6 +23,8 @@ type EditorComponent interface {
 	tea.Model
 	tea.Model
 	tea.ViewModel
 	tea.ViewModel
 	layout.Sizeable
 	layout.Sizeable
+	Content() string
+	Lines() int
 	Value() string
 	Value() string
 	Submit() (tea.Model, tea.Cmd)
 	Submit() (tea.Model, tea.Cmd)
 	Clear() (tea.Model, tea.Cmd)
 	Clear() (tea.Model, tea.Cmd)
@@ -50,22 +52,15 @@ func (m *editorComponent) Init() tea.Cmd {
 func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	var cmds []tea.Cmd
 	var cmd tea.Cmd
 	var cmd tea.Cmd
+
 	switch msg := msg.(type) {
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 	case tea.KeyPressMsg:
 		// Maximize editor responsiveness for printable characters
 		// Maximize editor responsiveness for printable characters
 		if msg.Text != "" {
 		if msg.Text != "" {
 			m.textarea, cmd = m.textarea.Update(msg)
 			m.textarea, cmd = m.textarea.Update(msg)
-			return m, cmd
+			cmds = append(cmds, cmd)
+			return m, tea.Batch(cmds...)
 		}
 		}
-
-		// // TODO: ?
-		// if key.Matches(msg, messageKeys.PageUp) ||
-		// 	key.Matches(msg, messageKeys.PageDown) ||
-		// 	key.Matches(msg, messageKeys.HalfPageUp) ||
-		// 	key.Matches(msg, messageKeys.HalfPageDown) {
-		// 	return m, nil
-		// }
-
 	case dialog.ThemeSelectedMsg:
 	case dialog.ThemeSelectedMsg:
 		m.textarea = createTextArea(&m.textarea)
 		m.textarea = createTextArea(&m.textarea)
 		m.spinner = createSpinner()
 		m.spinner = createSpinner()
@@ -73,10 +68,11 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case dialog.CompletionSelectedMsg:
 	case dialog.CompletionSelectedMsg:
 		if msg.IsCommand {
 		if msg.IsCommand {
 			commandName := strings.TrimPrefix(msg.CompletionValue, "/")
 			commandName := strings.TrimPrefix(msg.CompletionValue, "/")
-			m.textarea.Reset()
-			return m, util.CmdHandler(
-				commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)]),
-			)
+			updated, cmd := m.Clear()
+			m = updated.(*editorComponent)
+			cmds = append(cmds, cmd)
+			cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
+			return m, tea.Batch(cmds...)
 		} else {
 		} else {
 			existingValue := m.textarea.Value()
 			existingValue := m.textarea.Value()
 			modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
 			modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
@@ -94,7 +90,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 	return m, tea.Batch(cmds...)
 }
 }
 
 
-func (m *editorComponent) View() string {
+func (m *editorComponent) Content() string {
 	t := theme.CurrentTheme()
 	t := theme.CurrentTheme()
 	base := styles.BaseStyle().Background(t.Background()).Render
 	base := styles.BaseStyle().Background(t.Background()).Render
 	muted := styles.Muted().Background(t.Background()).Render
 	muted := styles.Muted().Background(t.Background()).Render
@@ -139,6 +135,13 @@ func (m *editorComponent) View() string {
 	return content
 	return content
 }
 }
 
 
+func (m *editorComponent) View() string {
+	if m.Lines() > 1 {
+		return ""
+	}
+	return m.Content()
+}
+
 func (m *editorComponent) GetSize() (width, height int) {
 func (m *editorComponent) GetSize() (width, height int) {
 	return m.width, m.height
 	return m.width, m.height
 }
 }
@@ -146,18 +149,21 @@ func (m *editorComponent) GetSize() (width, height int) {
 func (m *editorComponent) SetSize(width, height int) tea.Cmd {
 func (m *editorComponent) SetSize(width, height int) tea.Cmd {
 	m.width = width
 	m.width = width
 	m.height = height
 	m.height = height
-	m.textarea.SetWidth(width - 5)   // account for the prompt and padding right
-	m.textarea.SetHeight(height - 4) // account for info underneath
+	m.textarea.SetWidth(width - 5) // account for the prompt and padding right
+	// m.textarea.SetHeight(height - 4)
 	return nil
 	return nil
 }
 }
 
 
+func (m *editorComponent) Lines() int {
+	return m.textarea.LineCount()
+}
+
 func (m *editorComponent) Value() string {
 func (m *editorComponent) Value() string {
 	return m.textarea.Value()
 	return m.textarea.Value()
 }
 }
 
 
 func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
 func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
 	value := strings.TrimSpace(m.Value())
 	value := strings.TrimSpace(m.Value())
-	m.textarea.Reset()
 	if value == "" {
 	if value == "" {
 		return m, nil
 		return m, nil
 	}
 	}
@@ -167,6 +173,11 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
 		return m, nil
 		return m, nil
 	}
 	}
 
 
+	var cmds []tea.Cmd
+	updated, cmd := m.Clear()
+	m = updated.(*editorComponent)
+	cmds = append(cmds, cmd)
+
 	attachments := m.attachments
 	attachments := m.attachments
 
 
 	// Save to history if not empty and not a duplicate of the last entry
 	// Save to history if not empty and not a duplicate of the last entry
@@ -180,12 +191,8 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
 
 
 	m.attachments = nil
 	m.attachments = nil
 
 
-	return m, tea.Batch(
-		util.CmdHandler(app.SendMsg{
-			Text:        value,
-			Attachments: attachments,
-		}),
-	)
+	cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
+	return m, tea.Batch(cmds...)
 }
 }
 
 
 func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
 func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {

+ 3 - 3
packages/tui/internal/components/dialog/complete.go

@@ -101,10 +101,10 @@ type completionDialogKeyMap struct {
 
 
 var completionDialogKeys = completionDialogKeyMap{
 var completionDialogKeys = completionDialogKeyMap{
 	Complete: key.NewBinding(
 	Complete: key.NewBinding(
-		key.WithKeys("tab", "enter"),
+		key.WithKeys("tab", "enter", "right"),
 	),
 	),
 	Cancel: key.NewBinding(
 	Cancel: key.NewBinding(
-		key.WithKeys(" ", "esc", "backspace"),
+		key.WithKeys(" ", "esc", "backspace", "ctrl+c"),
 	),
 	),
 }
 }
 
 
@@ -209,7 +209,7 @@ func (c *completionDialogComponent) View() string {
 		BorderRight(true).
 		BorderRight(true).
 		BorderLeft(true).
 		BorderLeft(true).
 		BorderBackground(t.Background()).
 		BorderBackground(t.Background()).
-		BorderForeground(t.BackgroundSubtle()).
+		BorderForeground(t.BackgroundElement()).
 		Width(c.width).
 		Width(c.width).
 		Render(c.list.View())
 		Render(c.list.View())
 }
 }

+ 125 - 0
packages/tui/internal/components/textarea/memoization.go

@@ -0,0 +1,125 @@
+// Package memoization implement a simple memoization cache. It's designed to
+// improve performance in textarea.
+package textarea
+
+import (
+	"container/list"
+	"crypto/sha256"
+	"fmt"
+	"sync"
+)
+
+// Hasher is an interface that requires a Hash method. The Hash method is
+// expected to return a string representation of the hash of the object.
+type Hasher interface {
+	Hash() string
+}
+
+// entry is a struct that holds a key-value pair. It is used as an element
+// in the evictionList of the MemoCache.
+type entry[T any] struct {
+	key   string
+	value T
+}
+
+// MemoCache is a struct that represents a cache with a set capacity. It
+// uses an LRU (Least Recently Used) eviction policy. It is safe for
+// concurrent use.
+type MemoCache[H Hasher, T any] struct {
+	capacity      int
+	mutex         sync.Mutex
+	cache         map[string]*list.Element // The cache holding the results
+	evictionList  *list.List               // A list to keep track of the order for LRU
+	hashableItems map[string]T             // This map keeps track of the original hashable items (optional)
+}
+
+// NewMemoCache is a function that creates a new MemoCache with a given
+// capacity. It returns a pointer to the created MemoCache.
+func NewMemoCache[H Hasher, T any](capacity int) *MemoCache[H, T] {
+	return &MemoCache[H, T]{
+		capacity:      capacity,
+		cache:         make(map[string]*list.Element),
+		evictionList:  list.New(),
+		hashableItems: make(map[string]T),
+	}
+}
+
+// Capacity is a method that returns the capacity of the MemoCache.
+func (m *MemoCache[H, T]) Capacity() int {
+	return m.capacity
+}
+
+// Size is a method that returns the current size of the MemoCache. It is
+// the number of items currently stored in the cache.
+func (m *MemoCache[H, T]) Size() int {
+	m.mutex.Lock()
+	defer m.mutex.Unlock()
+	return m.evictionList.Len()
+}
+
+// Get is a method that returns the value associated with the given
+// hashable item in the MemoCache. If there is no corresponding value, the
+// method returns nil.
+func (m *MemoCache[H, T]) Get(h H) (T, bool) {
+	m.mutex.Lock()
+	defer m.mutex.Unlock()
+
+	hashedKey := h.Hash()
+	if element, found := m.cache[hashedKey]; found {
+		m.evictionList.MoveToFront(element)
+		return element.Value.(*entry[T]).value, true
+	}
+	var result T
+	return result, false
+}
+
+// Set is a method that sets the value for the given hashable item in the
+// MemoCache. If the cache is at capacity, it evicts the least recently
+// used item before adding the new item.
+func (m *MemoCache[H, T]) Set(h H, value T) {
+	m.mutex.Lock()
+	defer m.mutex.Unlock()
+
+	hashedKey := h.Hash()
+	if element, found := m.cache[hashedKey]; found {
+		m.evictionList.MoveToFront(element)
+		element.Value.(*entry[T]).value = value
+		return
+	}
+
+	// Check if the cache is at capacity
+	if m.evictionList.Len() >= m.capacity {
+		// Evict the least recently used item from the cache
+		toEvict := m.evictionList.Back()
+		if toEvict != nil {
+			evictedEntry := m.evictionList.Remove(toEvict).(*entry[T])
+			delete(m.cache, evictedEntry.key)
+			delete(m.hashableItems, evictedEntry.key) // if you're keeping track of original items
+		}
+	}
+
+	// Add the value to the cache and the evictionList
+	newEntry := &entry[T]{
+		key:   hashedKey,
+		value: value,
+	}
+	element := m.evictionList.PushFront(newEntry)
+	m.cache[hashedKey] = element
+	m.hashableItems[hashedKey] = value // if you're keeping track of original items
+}
+
+// HString is a type that implements the Hasher interface for strings.
+type HString string
+
+// Hash is a method that returns the hash of the string.
+func (h HString) Hash() string {
+	return fmt.Sprintf("%x", sha256.Sum256([]byte(h)))
+}
+
+// HInt is a type that implements the Hasher interface for integers.
+type HInt int
+
+// Hash is a method that returns the hash of the integer.
+func (h HInt) Hash() string {
+	return fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("%d", h))))
+}

+ 102 - 0
packages/tui/internal/components/textarea/runeutil.go

@@ -0,0 +1,102 @@
+// Package runeutil provides utility functions for tidying up incoming runes
+// from Key messages.
+package textarea
+
+import (
+	"unicode"
+	"unicode/utf8"
+)
+
+// Sanitizer is a helper for bubble widgets that want to process
+// Runes from input key messages.
+type Sanitizer interface {
+	// Sanitize removes control characters from runes in a KeyRunes
+	// message, and optionally replaces newline/carriage return/tabs by a
+	// specified character.
+	//
+	// The rune array is modified in-place if possible. In that case, the
+	// returned slice is the original slice shortened after the control
+	// characters have been removed/translated.
+	Sanitize(runes []rune) []rune
+}
+
+// NewSanitizer constructs a rune sanitizer.
+func NewSanitizer(opts ...Option) Sanitizer {
+	s := sanitizer{
+		replaceNewLine: []rune("\n"),
+		replaceTab:     []rune("    "),
+	}
+	for _, o := range opts {
+		s = o(s)
+	}
+	return &s
+}
+
+// Option is the type of option that can be passed to Sanitize().
+type Option func(sanitizer) sanitizer
+
+// ReplaceTabs replaces tabs by the specified string.
+func ReplaceTabs(tabRepl string) Option {
+	return func(s sanitizer) sanitizer {
+		s.replaceTab = []rune(tabRepl)
+		return s
+	}
+}
+
+// ReplaceNewlines replaces newline characters by the specified string.
+func ReplaceNewlines(nlRepl string) Option {
+	return func(s sanitizer) sanitizer {
+		s.replaceNewLine = []rune(nlRepl)
+		return s
+	}
+}
+
+func (s *sanitizer) Sanitize(runes []rune) []rune {
+	// dstrunes are where we are storing the result.
+	dstrunes := runes[:0:len(runes)]
+	// copied indicates whether dstrunes is an alias of runes
+	// or a copy. We need a copy when dst moves past src.
+	// We use this as an optimization to avoid allocating
+	// a new rune slice in the common case where the output
+	// is smaller or equal to the input.
+	copied := false
+
+	for src := 0; src < len(runes); src++ {
+		r := runes[src]
+		switch {
+		case r == utf8.RuneError:
+			// skip
+
+		case r == '\r' || r == '\n':
+			if len(dstrunes)+len(s.replaceNewLine) > src && !copied {
+				dst := len(dstrunes)
+				dstrunes = make([]rune, dst, len(runes)+len(s.replaceNewLine))
+				copy(dstrunes, runes[:dst])
+				copied = true
+			}
+			dstrunes = append(dstrunes, s.replaceNewLine...)
+
+		case r == '\t':
+			if len(dstrunes)+len(s.replaceTab) > src && !copied {
+				dst := len(dstrunes)
+				dstrunes = make([]rune, dst, len(runes)+len(s.replaceTab))
+				copy(dstrunes, runes[:dst])
+				copied = true
+			}
+			dstrunes = append(dstrunes, s.replaceTab...)
+
+		case unicode.IsControl(r):
+			// Other control characters: skip.
+
+		default:
+			// Keep the character.
+			dstrunes = append(dstrunes, runes[src])
+		}
+	}
+	return dstrunes
+}
+
+type sanitizer struct {
+	replaceNewLine []rune
+	replaceTab     []rune
+}

+ 1632 - 0
packages/tui/internal/components/textarea/textarea.go

@@ -0,0 +1,1632 @@
+package textarea
+
+import (
+	"crypto/sha256"
+	"fmt"
+	"image/color"
+	"strconv"
+	"strings"
+	"time"
+	"unicode"
+
+	"github.com/atotto/clipboard"
+	"github.com/charmbracelet/bubbles/v2/cursor"
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/ansi"
+	rw "github.com/mattn/go-runewidth"
+	"github.com/rivo/uniseg"
+	"slices"
+)
+
+const (
+	minHeight        = 1
+	defaultHeight    = 1
+	defaultWidth     = 40
+	defaultCharLimit = 0 // no limit
+	defaultMaxHeight = 99
+	defaultMaxWidth  = 500
+
+	// XXX: in v2, make max lines dynamic and default max lines configurable.
+	maxLines = 10000
+)
+
+// Internal messages for clipboard operations.
+type (
+	pasteMsg    string
+	pasteErrMsg struct{ error }
+)
+
+// KeyMap is the key bindings for different actions within the textarea.
+type KeyMap struct {
+	CharacterBackward       key.Binding
+	CharacterForward        key.Binding
+	DeleteAfterCursor       key.Binding
+	DeleteBeforeCursor      key.Binding
+	DeleteCharacterBackward key.Binding
+	DeleteCharacterForward  key.Binding
+	DeleteWordBackward      key.Binding
+	DeleteWordForward       key.Binding
+	InsertNewline           key.Binding
+	LineEnd                 key.Binding
+	LineNext                key.Binding
+	LinePrevious            key.Binding
+	LineStart               key.Binding
+	Paste                   key.Binding
+	WordBackward            key.Binding
+	WordForward             key.Binding
+	InputBegin              key.Binding
+	InputEnd                key.Binding
+
+	UppercaseWordForward  key.Binding
+	LowercaseWordForward  key.Binding
+	CapitalizeWordForward key.Binding
+
+	TransposeCharacterBackward key.Binding
+}
+
+// DefaultKeyMap returns the default set of key bindings for navigating and acting
+// upon the textarea.
+func DefaultKeyMap() KeyMap {
+	return KeyMap{
+		CharacterForward:        key.NewBinding(key.WithKeys("right", "ctrl+f"), key.WithHelp("right", "character forward")),
+		CharacterBackward:       key.NewBinding(key.WithKeys("left", "ctrl+b"), key.WithHelp("left", "character backward")),
+		WordForward:             key.NewBinding(key.WithKeys("alt+right", "alt+f"), key.WithHelp("alt+right", "word forward")),
+		WordBackward:            key.NewBinding(key.WithKeys("alt+left", "alt+b"), key.WithHelp("alt+left", "word backward")),
+		LineNext:                key.NewBinding(key.WithKeys("down", "ctrl+n"), key.WithHelp("down", "next line")),
+		LinePrevious:            key.NewBinding(key.WithKeys("up", "ctrl+p"), key.WithHelp("up", "previous line")),
+		DeleteWordBackward:      key.NewBinding(key.WithKeys("alt+backspace", "ctrl+w"), key.WithHelp("alt+backspace", "delete word backward")),
+		DeleteWordForward:       key.NewBinding(key.WithKeys("alt+delete", "alt+d"), key.WithHelp("alt+delete", "delete word forward")),
+		DeleteAfterCursor:       key.NewBinding(key.WithKeys("ctrl+k"), key.WithHelp("ctrl+k", "delete after cursor")),
+		DeleteBeforeCursor:      key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "delete before cursor")),
+		InsertNewline:           key.NewBinding(key.WithKeys("enter", "ctrl+m"), key.WithHelp("enter", "insert newline")),
+		DeleteCharacterBackward: key.NewBinding(key.WithKeys("backspace", "ctrl+h"), key.WithHelp("backspace", "delete character backward")),
+		DeleteCharacterForward:  key.NewBinding(key.WithKeys("delete", "ctrl+d"), key.WithHelp("delete", "delete character forward")),
+		LineStart:               key.NewBinding(key.WithKeys("home", "ctrl+a"), key.WithHelp("home", "line start")),
+		LineEnd:                 key.NewBinding(key.WithKeys("end", "ctrl+e"), key.WithHelp("end", "line end")),
+		Paste:                   key.NewBinding(key.WithKeys("ctrl+v"), key.WithHelp("ctrl+v", "paste")),
+		InputBegin:              key.NewBinding(key.WithKeys("alt+<", "ctrl+home"), key.WithHelp("alt+<", "input begin")),
+		InputEnd:                key.NewBinding(key.WithKeys("alt+>", "ctrl+end"), key.WithHelp("alt+>", "input end")),
+
+		CapitalizeWordForward: key.NewBinding(key.WithKeys("alt+c"), key.WithHelp("alt+c", "capitalize word forward")),
+		LowercaseWordForward:  key.NewBinding(key.WithKeys("alt+l"), key.WithHelp("alt+l", "lowercase word forward")),
+		UppercaseWordForward:  key.NewBinding(key.WithKeys("alt+u"), key.WithHelp("alt+u", "uppercase word forward")),
+
+		TransposeCharacterBackward: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("ctrl+t", "transpose character backward")),
+	}
+}
+
+// LineInfo is a helper for keeping track of line information regarding
+// soft-wrapped lines.
+type LineInfo struct {
+	// Width is the number of columns in the line.
+	Width int
+
+	// CharWidth is the number of characters in the line to account for
+	// double-width runes.
+	CharWidth int
+
+	// Height is the number of rows in the line.
+	Height int
+
+	// StartColumn is the index of the first column of the line.
+	StartColumn int
+
+	// ColumnOffset is the number of columns that the cursor is offset from the
+	// start of the line.
+	ColumnOffset int
+
+	// RowOffset is the number of rows that the cursor is offset from the start
+	// of the line.
+	RowOffset int
+
+	// CharOffset is the number of characters that the cursor is offset
+	// from the start of the line. This will generally be equivalent to
+	// ColumnOffset, but will be different there are double-width runes before
+	// the cursor.
+	CharOffset int
+}
+
+// CursorStyle is the style for real and virtual cursors.
+type CursorStyle struct {
+	// Style styles the cursor block.
+	//
+	// For real cursors, the foreground color set here will be used as the
+	// cursor color.
+	Color color.Color
+
+	// Shape is the cursor shape. The following shapes are available:
+	//
+	// - tea.CursorBlock
+	// - tea.CursorUnderline
+	// - tea.CursorBar
+	//
+	// This is only used for real cursors.
+	Shape tea.CursorShape
+
+	// CursorBlink determines whether or not the cursor should blink.
+	Blink bool
+
+	// BlinkSpeed is the speed at which the virtual cursor blinks. This has no
+	// effect on real cursors as well as no effect if the cursor is set not to
+	// [CursorBlink].
+	//
+	// By default, the blink speed is set to about 500ms.
+	BlinkSpeed time.Duration
+}
+
+// Styles are the styles for the textarea, separated into focused and blurred
+// states. The appropriate styles will be chosen based on the focus state of
+// the textarea.
+type Styles struct {
+	Focused StyleState
+	Blurred StyleState
+	Cursor  CursorStyle
+}
+
+// StyleState that will be applied to the text area.
+//
+// StyleState can be applied to focused and unfocused states to change the styles
+// depending on the focus state.
+//
+// For an introduction to styling with Lip Gloss see:
+// https://github.com/charmbracelet/lipgloss
+type StyleState struct {
+	Base             lipgloss.Style
+	Text             lipgloss.Style
+	LineNumber       lipgloss.Style
+	CursorLineNumber lipgloss.Style
+	CursorLine       lipgloss.Style
+	EndOfBuffer      lipgloss.Style
+	Placeholder      lipgloss.Style
+	Prompt           lipgloss.Style
+}
+
+func (s StyleState) computedCursorLine() lipgloss.Style {
+	return s.CursorLine.Inherit(s.Base).Inline(true)
+}
+
+func (s StyleState) computedCursorLineNumber() lipgloss.Style {
+	return s.CursorLineNumber.
+		Inherit(s.CursorLine).
+		Inherit(s.Base).
+		Inline(true)
+}
+
+func (s StyleState) computedEndOfBuffer() lipgloss.Style {
+	return s.EndOfBuffer.Inherit(s.Base).Inline(true)
+}
+
+func (s StyleState) computedLineNumber() lipgloss.Style {
+	return s.LineNumber.Inherit(s.Base).Inline(true)
+}
+
+func (s StyleState) computedPlaceholder() lipgloss.Style {
+	return s.Placeholder.Inherit(s.Base).Inline(true)
+}
+
+func (s StyleState) computedPrompt() lipgloss.Style {
+	return s.Prompt.Inherit(s.Base).Inline(true)
+}
+
+func (s StyleState) computedText() lipgloss.Style {
+	return s.Text.Inherit(s.Base).Inline(true)
+}
+
+// line is the input to the text wrapping function. This is stored in a struct
+// so that it can be hashed and memoized.
+type line struct {
+	runes []rune
+	width int
+}
+
+// Hash returns a hash of the line.
+func (w line) Hash() string {
+	v := fmt.Sprintf("%s:%d", string(w.runes), w.width)
+	return fmt.Sprintf("%x", sha256.Sum256([]byte(v)))
+}
+
+// Model is the Bubble Tea model for this text area element.
+type Model struct {
+	Err error
+
+	// General settings.
+	cache *MemoCache[line, [][]rune]
+
+	// Prompt is printed at the beginning of each line.
+	//
+	// When changing the value of Prompt after the model has been
+	// initialized, ensure that SetWidth() gets called afterwards.
+	//
+	// See also [SetPromptFunc] for a dynamic prompt.
+	Prompt string
+
+	// Placeholder is the text displayed when the user
+	// hasn't entered anything yet.
+	Placeholder string
+
+	// ShowLineNumbers, if enabled, causes line numbers to be printed
+	// after the prompt.
+	ShowLineNumbers bool
+
+	// EndOfBufferCharacter is displayed at the end of the input.
+	EndOfBufferCharacter rune
+
+	// KeyMap encodes the keybindings recognized by the widget.
+	KeyMap KeyMap
+
+	// Styling. FocusedStyle and BlurredStyle are used to style the textarea in
+	// focused and blurred states.
+	Styles Styles
+
+	// virtualCursor manages the virtual cursor.
+	virtualCursor cursor.Model
+
+	// VirtualCursor determines whether or not to use the virtual cursor. If
+	// set to false, use [Model.Cursor] to return a real cursor for rendering.
+	VirtualCursor bool
+
+	// CharLimit is the maximum number of characters this input element will
+	// accept. If 0 or less, there's no limit.
+	CharLimit int
+
+	// MaxHeight is the maximum height of the text area in rows. If 0 or less,
+	// there's no limit.
+	MaxHeight int
+
+	// MaxWidth is the maximum width of the text area in columns. If 0 or less,
+	// there's no limit.
+	MaxWidth int
+
+	// If promptFunc is set, it replaces Prompt as a generator for
+	// prompt strings at the beginning of each line.
+	promptFunc func(line int) string
+
+	// promptWidth is the width of the prompt.
+	promptWidth int
+
+	// width is the maximum number of characters that can be displayed at once.
+	// If 0 or less this setting is ignored.
+	width int
+
+	// height is the maximum number of lines that can be displayed at once. It
+	// essentially treats the text field like a vertically scrolling viewport
+	// if there are more lines than the permitted height.
+	height int
+
+	// Underlying text value.
+	value [][]rune
+
+	// focus indicates whether user input focus should be on this input
+	// component. When false, ignore keyboard input and hide the cursor.
+	focus bool
+
+	// Cursor column.
+	col int
+
+	// Cursor row.
+	row int
+
+	// Last character offset, used to maintain state when the cursor is moved
+	// vertically such that we can maintain the same navigating position.
+	lastCharOffset int
+
+	// rune sanitizer for input.
+	rsan Sanitizer
+}
+
+// New creates a new model with default settings.
+func New() Model {
+	cur := cursor.New()
+
+	styles := DefaultDarkStyles()
+
+	m := Model{
+		CharLimit:            defaultCharLimit,
+		MaxHeight:            defaultMaxHeight,
+		MaxWidth:             defaultMaxWidth,
+		Prompt:               lipgloss.ThickBorder().Left + " ",
+		Styles:               styles,
+		cache:                NewMemoCache[line, [][]rune](maxLines),
+		EndOfBufferCharacter: ' ',
+		ShowLineNumbers:      true,
+		VirtualCursor:        true,
+		virtualCursor:        cur,
+		KeyMap:               DefaultKeyMap(),
+
+		value: make([][]rune, minHeight, maxLines),
+		focus: false,
+		col:   0,
+		row:   0,
+	}
+
+	m.SetWidth(defaultWidth)
+	m.SetHeight(defaultHeight)
+
+	return m
+}
+
+// DefaultStyles returns the default styles for focused and blurred states for
+// the textarea.
+func DefaultStyles(isDark bool) Styles {
+	lightDark := lipgloss.LightDark(isDark)
+
+	var s Styles
+	s.Focused = StyleState{
+		Base:             lipgloss.NewStyle(),
+		CursorLine:       lipgloss.NewStyle().Background(lightDark(lipgloss.Color("255"), lipgloss.Color("0"))),
+		CursorLineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("240"), lipgloss.Color("240"))),
+		EndOfBuffer:      lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))),
+		LineNumber:       lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))),
+		Placeholder:      lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
+		Prompt:           lipgloss.NewStyle().Foreground(lipgloss.Color("7")),
+		Text:             lipgloss.NewStyle(),
+	}
+	s.Blurred = StyleState{
+		Base:             lipgloss.NewStyle(),
+		CursorLine:       lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))),
+		CursorLineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))),
+		EndOfBuffer:      lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))),
+		LineNumber:       lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))),
+		Placeholder:      lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
+		Prompt:           lipgloss.NewStyle().Foreground(lipgloss.Color("7")),
+		Text:             lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))),
+	}
+	s.Cursor = CursorStyle{
+		Color: lipgloss.Color("7"),
+		Shape: tea.CursorBlock,
+		Blink: true,
+	}
+	return s
+}
+
+// DefaultLightStyles returns the default styles for a light background.
+func DefaultLightStyles() Styles {
+	return DefaultStyles(false)
+}
+
+// DefaultDarkStyles returns the default styles for a dark background.
+func DefaultDarkStyles() Styles {
+	return DefaultStyles(true)
+}
+
+// updateVirtualCursorStyle sets styling on the virtual cursor based on the
+// textarea's style settings.
+func (m *Model) updateVirtualCursorStyle() {
+	if !m.VirtualCursor {
+		m.virtualCursor.SetMode(cursor.CursorHide)
+		return
+	}
+
+	m.virtualCursor.Style = lipgloss.NewStyle().Foreground(m.Styles.Cursor.Color)
+
+	// By default, the blink speed of the cursor is set to a default
+	// internally.
+	if m.Styles.Cursor.Blink {
+		if m.Styles.Cursor.BlinkSpeed > 0 {
+			m.virtualCursor.BlinkSpeed = m.Styles.Cursor.BlinkSpeed
+		}
+		m.virtualCursor.SetMode(cursor.CursorBlink)
+		return
+	}
+	m.virtualCursor.SetMode(cursor.CursorStatic)
+}
+
+// SetValue sets the value of the text input.
+func (m *Model) SetValue(s string) {
+	m.Reset()
+	m.InsertString(s)
+}
+
+// InsertString inserts a string at the cursor position.
+func (m *Model) InsertString(s string) {
+	m.insertRunesFromUserInput([]rune(s))
+}
+
+// InsertRune inserts a rune at the cursor position.
+func (m *Model) InsertRune(r rune) {
+	m.insertRunesFromUserInput([]rune{r})
+}
+
+// insertRunesFromUserInput inserts runes at the current cursor position.
+func (m *Model) insertRunesFromUserInput(runes []rune) {
+	// Clean up any special characters in the input provided by the
+	// clipboard. This avoids bugs due to e.g. tab characters and
+	// whatnot.
+	runes = m.san().Sanitize(runes)
+
+	if m.CharLimit > 0 {
+		availSpace := m.CharLimit - m.Length()
+		// If the char limit's been reached, cancel.
+		if availSpace <= 0 {
+			return
+		}
+		// If there's not enough space to paste the whole thing cut the pasted
+		// runes down so they'll fit.
+		if availSpace < len(runes) {
+			runes = runes[:availSpace]
+		}
+	}
+
+	// Split the input into lines.
+	var lines [][]rune
+	lstart := 0
+	for i := range runes {
+		if runes[i] == '\n' {
+			// Queue a line to become a new row in the text area below.
+			// Beware to clamp the max capacity of the slice, to ensure no
+			// data from different rows get overwritten when later edits
+			// will modify this line.
+			lines = append(lines, runes[lstart:i:i])
+			lstart = i + 1
+		}
+	}
+	if lstart <= len(runes) {
+		// The last line did not end with a newline character.
+		// Take it now.
+		lines = append(lines, runes[lstart:])
+	}
+
+	// Obey the maximum line limit.
+	if maxLines > 0 && len(m.value)+len(lines)-1 > maxLines {
+		allowedHeight := max(0, maxLines-len(m.value)+1)
+		lines = lines[:allowedHeight]
+	}
+
+	if len(lines) == 0 {
+		// Nothing left to insert.
+		return
+	}
+
+	// Save the remainder of the original line at the current
+	// cursor position.
+	tail := make([]rune, len(m.value[m.row][m.col:]))
+	copy(tail, m.value[m.row][m.col:])
+
+	// Paste the first line at the current cursor position.
+	m.value[m.row] = append(m.value[m.row][:m.col], lines[0]...)
+	m.col += len(lines[0])
+
+	if numExtraLines := len(lines) - 1; numExtraLines > 0 {
+		// Add the new lines.
+		// We try to reuse the slice if there's already space.
+		var newGrid [][]rune
+		if cap(m.value) >= len(m.value)+numExtraLines {
+			// Can reuse the extra space.
+			newGrid = m.value[:len(m.value)+numExtraLines]
+		} else {
+			// No space left; need a new slice.
+			newGrid = make([][]rune, len(m.value)+numExtraLines)
+			copy(newGrid, m.value[:m.row+1])
+		}
+		// Add all the rows that were after the cursor in the original
+		// grid at the end of the new grid.
+		copy(newGrid[m.row+1+numExtraLines:], m.value[m.row+1:])
+		m.value = newGrid
+		// Insert all the new lines in the middle.
+		for _, l := range lines[1:] {
+			m.row++
+			m.value[m.row] = l
+			m.col = len(l)
+		}
+	}
+
+	// Finally add the tail at the end of the last line inserted.
+	m.value[m.row] = append(m.value[m.row], tail...)
+
+	m.SetCursorColumn(m.col)
+}
+
+// Value returns the value of the text input.
+func (m Model) Value() string {
+	if m.value == nil {
+		return ""
+	}
+
+	var v strings.Builder
+	for _, l := range m.value {
+		v.WriteString(string(l))
+		v.WriteByte('\n')
+	}
+
+	return strings.TrimSuffix(v.String(), "\n")
+}
+
+// Length returns the number of characters currently in the text input.
+func (m *Model) Length() int {
+	var l int
+	for _, row := range m.value {
+		l += uniseg.StringWidth(string(row))
+	}
+	// We add len(m.value) to include the newline characters.
+	return l + len(m.value) - 1
+}
+
+// LineCount returns the number of lines that are currently in the text input.
+func (m *Model) LineCount() int {
+	return m.ContentHeight()
+}
+
+// Line returns the line position.
+func (m Model) Line() int {
+	return m.row
+}
+
+// CursorDown moves the cursor down by one line.
+// Returns whether or not the cursor blink should be reset.
+func (m *Model) CursorDown() {
+	li := m.LineInfo()
+	charOffset := max(m.lastCharOffset, li.CharOffset)
+	m.lastCharOffset = charOffset
+
+	if li.RowOffset+1 >= li.Height && m.row < len(m.value)-1 {
+		m.row++
+		m.col = 0
+	} else {
+		// Move the cursor to the start of the next line so that we can get
+		// the line information. We need to add 2 columns to account for the
+		// trailing space wrapping.
+		const trailingSpace = 2
+		m.col = min(li.StartColumn+li.Width+trailingSpace, len(m.value[m.row])-1)
+	}
+
+	nli := m.LineInfo()
+	m.col = nli.StartColumn
+
+	if nli.Width <= 0 {
+		return
+	}
+
+	offset := 0
+	for offset < charOffset {
+		if m.row >= len(m.value) || m.col >= len(m.value[m.row]) || offset >= nli.CharWidth-1 {
+			break
+		}
+		offset += rw.RuneWidth(m.value[m.row][m.col])
+		m.col++
+	}
+}
+
+// CursorUp moves the cursor up by one line.
+func (m *Model) CursorUp() {
+	li := m.LineInfo()
+	charOffset := max(m.lastCharOffset, li.CharOffset)
+	m.lastCharOffset = charOffset
+
+	if li.RowOffset <= 0 && m.row > 0 {
+		m.row--
+		m.col = len(m.value[m.row])
+	} else {
+		// Move the cursor to the end of the previous line.
+		// This can be done by moving the cursor to the start of the line and
+		// then subtracting 2 to account for the trailing space we keep on
+		// soft-wrapped lines.
+		const trailingSpace = 2
+		m.col = li.StartColumn - trailingSpace
+	}
+
+	nli := m.LineInfo()
+	m.col = nli.StartColumn
+
+	if nli.Width <= 0 {
+		return
+	}
+
+	offset := 0
+	for offset < charOffset {
+		if m.col >= len(m.value[m.row]) || offset >= nli.CharWidth-1 {
+			break
+		}
+		offset += rw.RuneWidth(m.value[m.row][m.col])
+		m.col++
+	}
+}
+
+// SetCursorColumn moves the cursor to the given position. If the position is
+// out of bounds the cursor will be moved to the start or end accordingly.
+func (m *Model) SetCursorColumn(col int) {
+	m.col = clamp(col, 0, len(m.value[m.row]))
+	// Any time that we move the cursor horizontally we need to reset the last
+	// offset so that the horizontal position when navigating is adjusted.
+	m.lastCharOffset = 0
+}
+
+// CursorStart moves the cursor to the start of the input field.
+func (m *Model) CursorStart() {
+	m.SetCursorColumn(0)
+}
+
+// CursorEnd moves the cursor to the end of the input field.
+func (m *Model) CursorEnd() {
+	m.SetCursorColumn(len(m.value[m.row]))
+}
+
+// Focused returns the focus state on the model.
+func (m Model) Focused() bool {
+	return m.focus
+}
+
+// activeStyle returns the appropriate set of styles to use depending on
+// whether the textarea is focused or blurred.
+func (m Model) activeStyle() *StyleState {
+	if m.focus {
+		return &m.Styles.Focused
+	}
+	return &m.Styles.Blurred
+}
+
+// Focus sets the focus state on the model. When the model is in focus it can
+// receive keyboard input and the cursor will be hidden.
+func (m *Model) Focus() tea.Cmd {
+	m.focus = true
+	return m.virtualCursor.Focus()
+}
+
+// Blur removes the focus state on the model. When the model is blurred it can
+// not receive keyboard input and the cursor will be hidden.
+func (m *Model) Blur() {
+	m.focus = false
+	m.virtualCursor.Blur()
+}
+
+// Reset sets the input to its default state with no input.
+func (m *Model) Reset() {
+	m.value = make([][]rune, minHeight, maxLines)
+	m.col = 0
+	m.row = 0
+	m.SetCursorColumn(0)
+}
+
+// san initializes or retrieves the rune sanitizer.
+func (m *Model) san() Sanitizer {
+	if m.rsan == nil {
+		// Textinput has all its input on a single line so collapse
+		// newlines/tabs to single spaces.
+		m.rsan = NewSanitizer()
+	}
+	return m.rsan
+}
+
+// deleteBeforeCursor deletes all text before the cursor. Returns whether or
+// not the cursor blink should be reset.
+func (m *Model) deleteBeforeCursor() {
+	m.value[m.row] = m.value[m.row][m.col:]
+	m.SetCursorColumn(0)
+}
+
+// deleteAfterCursor deletes all text after the cursor. Returns whether or not
+// the cursor blink should be reset. If input is masked delete everything after
+// the cursor so as not to reveal word breaks in the masked input.
+func (m *Model) deleteAfterCursor() {
+	m.value[m.row] = m.value[m.row][:m.col]
+	m.SetCursorColumn(len(m.value[m.row]))
+}
+
+// transposeLeft exchanges the runes at the cursor and immediately
+// before. No-op if the cursor is at the beginning of the line.  If
+// the cursor is not at the end of the line yet, moves the cursor to
+// the right.
+func (m *Model) transposeLeft() {
+	if m.col == 0 || len(m.value[m.row]) < 2 {
+		return
+	}
+	if m.col >= len(m.value[m.row]) {
+		m.SetCursorColumn(m.col - 1)
+	}
+	m.value[m.row][m.col-1], m.value[m.row][m.col] = m.value[m.row][m.col], m.value[m.row][m.col-1]
+	if m.col < len(m.value[m.row]) {
+		m.SetCursorColumn(m.col + 1)
+	}
+}
+
+// deleteWordLeft deletes the word left to the cursor. Returns whether or not
+// the cursor blink should be reset.
+func (m *Model) deleteWordLeft() {
+	if m.col == 0 || len(m.value[m.row]) == 0 {
+		return
+	}
+
+	// Linter note: it's critical that we acquire the initial cursor position
+	// here prior to altering it via SetCursor() below. As such, moving this
+	// call into the corresponding if clause does not apply here.
+	oldCol := m.col //nolint:ifshort
+
+	m.SetCursorColumn(m.col - 1)
+	for unicode.IsSpace(m.value[m.row][m.col]) {
+		if m.col <= 0 {
+			break
+		}
+		// ignore series of whitespace before cursor
+		m.SetCursorColumn(m.col - 1)
+	}
+
+	for m.col > 0 {
+		if !unicode.IsSpace(m.value[m.row][m.col]) {
+			m.SetCursorColumn(m.col - 1)
+		} else {
+			if m.col > 0 {
+				// keep the previous space
+				m.SetCursorColumn(m.col + 1)
+			}
+			break
+		}
+	}
+
+	if oldCol > len(m.value[m.row]) {
+		m.value[m.row] = m.value[m.row][:m.col]
+	} else {
+		m.value[m.row] = append(m.value[m.row][:m.col], m.value[m.row][oldCol:]...)
+	}
+}
+
+// deleteWordRight deletes the word right to the cursor.
+func (m *Model) deleteWordRight() {
+	if m.col >= len(m.value[m.row]) || len(m.value[m.row]) == 0 {
+		return
+	}
+
+	oldCol := m.col
+
+	for m.col < len(m.value[m.row]) && unicode.IsSpace(m.value[m.row][m.col]) {
+		// ignore series of whitespace after cursor
+		m.SetCursorColumn(m.col + 1)
+	}
+
+	for m.col < len(m.value[m.row]) {
+		if !unicode.IsSpace(m.value[m.row][m.col]) {
+			m.SetCursorColumn(m.col + 1)
+		} else {
+			break
+		}
+	}
+
+	if m.col > len(m.value[m.row]) {
+		m.value[m.row] = m.value[m.row][:oldCol]
+	} else {
+		m.value[m.row] = append(m.value[m.row][:oldCol], m.value[m.row][m.col:]...)
+	}
+
+	m.SetCursorColumn(oldCol)
+}
+
+// characterRight moves the cursor one character to the right.
+func (m *Model) characterRight() {
+	if m.col < len(m.value[m.row]) {
+		m.SetCursorColumn(m.col + 1)
+	} else {
+		if m.row < len(m.value)-1 {
+			m.row++
+			m.CursorStart()
+		}
+	}
+}
+
+// characterLeft moves the cursor one character to the left.
+// If insideLine is set, the cursor is moved to the last
+// character in the previous line, instead of one past that.
+func (m *Model) characterLeft(insideLine bool) {
+	if m.col == 0 && m.row != 0 {
+		m.row--
+		m.CursorEnd()
+		if !insideLine {
+			return
+		}
+	}
+	if m.col > 0 {
+		m.SetCursorColumn(m.col - 1)
+	}
+}
+
+// wordLeft moves the cursor one word to the left. Returns whether or not the
+// cursor blink should be reset. If input is masked, move input to the start
+// so as not to reveal word breaks in the masked input.
+func (m *Model) wordLeft() {
+	for {
+		m.characterLeft(true /* insideLine */)
+		if m.col < len(m.value[m.row]) && !unicode.IsSpace(m.value[m.row][m.col]) {
+			break
+		}
+	}
+
+	for m.col > 0 {
+		if unicode.IsSpace(m.value[m.row][m.col-1]) {
+			break
+		}
+		m.SetCursorColumn(m.col - 1)
+	}
+}
+
+// wordRight moves the cursor one word to the right. Returns whether or not the
+// cursor blink should be reset. If the input is masked, move input to the end
+// so as not to reveal word breaks in the masked input.
+func (m *Model) wordRight() {
+	m.doWordRight(func(int, int) { /* nothing */ })
+}
+
+func (m *Model) doWordRight(fn func(charIdx int, pos int)) {
+	// Skip spaces forward.
+	for m.col >= len(m.value[m.row]) || unicode.IsSpace(m.value[m.row][m.col]) {
+		if m.row == len(m.value)-1 && m.col == len(m.value[m.row]) {
+			// End of text.
+			break
+		}
+		m.characterRight()
+	}
+
+	charIdx := 0
+	for m.col < len(m.value[m.row]) {
+		if unicode.IsSpace(m.value[m.row][m.col]) {
+			break
+		}
+		fn(charIdx, m.col)
+		m.SetCursorColumn(m.col + 1)
+		charIdx++
+	}
+}
+
+// uppercaseRight changes the word to the right to uppercase.
+func (m *Model) uppercaseRight() {
+	m.doWordRight(func(_ int, i int) {
+		m.value[m.row][i] = unicode.ToUpper(m.value[m.row][i])
+	})
+}
+
+// lowercaseRight changes the word to the right to lowercase.
+func (m *Model) lowercaseRight() {
+	m.doWordRight(func(_ int, i int) {
+		m.value[m.row][i] = unicode.ToLower(m.value[m.row][i])
+	})
+}
+
+// capitalizeRight changes the word to the right to title case.
+func (m *Model) capitalizeRight() {
+	m.doWordRight(func(charIdx int, i int) {
+		if charIdx == 0 {
+			m.value[m.row][i] = unicode.ToTitle(m.value[m.row][i])
+		}
+	})
+}
+
+// LineInfo returns the number of characters from the start of the
+// (soft-wrapped) line and the (soft-wrapped) line width.
+func (m Model) LineInfo() LineInfo {
+	grid := m.memoizedWrap(m.value[m.row], m.width)
+
+	// Find out which line we are currently on. This can be determined by the
+	// m.col and counting the number of runes that we need to skip.
+	var counter int
+	for i, line := range grid {
+		// We've found the line that we are on
+		if counter+len(line) == m.col && i+1 < len(grid) {
+			// We wrap around to the next line if we are at the end of the
+			// previous line so that we can be at the very beginning of the row
+			return LineInfo{
+				CharOffset:   0,
+				ColumnOffset: 0,
+				Height:       len(grid),
+				RowOffset:    i + 1,
+				StartColumn:  m.col,
+				Width:        len(grid[i+1]),
+				CharWidth:    uniseg.StringWidth(string(line)),
+			}
+		}
+
+		if counter+len(line) >= m.col {
+			return LineInfo{
+				CharOffset:   uniseg.StringWidth(string(line[:max(0, m.col-counter)])),
+				ColumnOffset: m.col - counter,
+				Height:       len(grid),
+				RowOffset:    i,
+				StartColumn:  counter,
+				Width:        len(line),
+				CharWidth:    uniseg.StringWidth(string(line)),
+			}
+		}
+
+		counter += len(line)
+	}
+	return LineInfo{}
+}
+
+// Width returns the width of the textarea.
+func (m Model) Width() int {
+	return m.width
+}
+
+// moveToBegin moves the cursor to the beginning of the input.
+func (m *Model) moveToBegin() {
+	m.row = 0
+	m.SetCursorColumn(0)
+}
+
+// moveToEnd moves the cursor to the end of the input.
+func (m *Model) moveToEnd() {
+	m.row = len(m.value) - 1
+	m.SetCursorColumn(len(m.value[m.row]))
+}
+
+// SetWidth sets the width of the textarea to fit exactly within the given width.
+// This means that the textarea will account for the width of the prompt and
+// whether or not line numbers are being shown.
+//
+// Ensure that SetWidth is called after setting the Prompt and ShowLineNumbers,
+// It is important that the width of the textarea be exactly the given width
+// and no more.
+func (m *Model) SetWidth(w int) {
+	// Update prompt width only if there is no prompt function as
+	// [SetPromptFunc] updates the prompt width when it is called.
+	if m.promptFunc == nil {
+		// XXX: Do we even need this or can we calculate the prompt width
+		// at render time?
+		m.promptWidth = uniseg.StringWidth(m.Prompt)
+	}
+
+	// Add base style borders and padding to reserved outer width.
+	reservedOuter := m.activeStyle().Base.GetHorizontalFrameSize()
+
+	// Add prompt width to reserved inner width.
+	reservedInner := m.promptWidth
+
+	// Add line number width to reserved inner width.
+	if m.ShowLineNumbers {
+		// XXX: this was originally documented as needing "1 cell" but was,
+		// in practice, effectively hardcoded to 2 cells. We can, and should,
+		// reduce this to one gap and update the tests accordingly.
+		const gap = 2
+
+		// Number of digits plus 1 cell for the margin.
+		reservedInner += numDigits(m.MaxHeight) + gap
+	}
+
+	// Input width must be at least one more than the reserved inner and outer
+	// width. This gives us a minimum input width of 1.
+	minWidth := reservedInner + reservedOuter + 1
+	inputWidth := max(w, minWidth)
+
+	// Input width must be no more than maximum width.
+	if m.MaxWidth > 0 {
+		inputWidth = min(inputWidth, m.MaxWidth)
+	}
+
+	// Since the width of the viewport and input area is dependent on the width of
+	// borders, prompt and line numbers, we need to calculate it by subtracting
+	// the reserved width from them.
+
+	m.width = inputWidth - reservedOuter - reservedInner
+}
+
+// SetPromptFunc supersedes the Prompt field and sets a dynamic prompt instead.
+//
+// If the function returns a prompt that is shorter than the specified
+// promptWidth, it will be padded to the left. If it returns a prompt that is
+// longer, display artifacts may occur; the caller is responsible for computing
+// an adequate promptWidth.
+func (m *Model) SetPromptFunc(promptWidth int, fn func(lineIndex int) string) {
+	m.promptFunc = fn
+	m.promptWidth = promptWidth
+}
+
+// Height returns the current height of the textarea.
+func (m Model) Height() int {
+	return m.height
+}
+
+// ContentHeight returns the actual height needed to display all content
+// including wrapped lines.
+func (m Model) ContentHeight() int {
+	totalLines := 0
+	for _, line := range m.value {
+		wrappedLines := m.memoizedWrap(line, m.width)
+		totalLines += len(wrappedLines)
+	}
+	// Ensure at least one line is shown
+	if totalLines == 0 {
+		totalLines = 1
+	}
+	return totalLines
+}
+
+// SetHeight sets the height of the textarea.
+func (m *Model) SetHeight(h int) {
+	// Calculate the actual content height
+	contentHeight := m.ContentHeight()
+
+	// Use the content height as the actual height
+	if m.MaxHeight > 0 {
+		m.height = clamp(contentHeight, minHeight, m.MaxHeight)
+	} else {
+		m.height = max(contentHeight, minHeight)
+	}
+}
+
+// Update is the Bubble Tea update loop.
+func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
+	if !m.focus {
+		m.virtualCursor.Blur()
+		return m, nil
+	}
+
+	// Used to determine if the cursor should blink.
+	oldRow, oldCol := m.cursorLineNumber(), m.col
+
+	var cmds []tea.Cmd
+
+	if m.value[m.row] == nil {
+		m.value[m.row] = make([]rune, 0)
+	}
+
+	if m.MaxHeight > 0 && m.MaxHeight != m.cache.Capacity() {
+		m.cache = NewMemoCache[line, [][]rune](m.MaxHeight)
+	}
+
+	switch msg := msg.(type) {
+	case tea.PasteMsg:
+		m.insertRunesFromUserInput([]rune(msg))
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, m.KeyMap.DeleteAfterCursor):
+			m.col = clamp(m.col, 0, len(m.value[m.row]))
+			if m.col >= len(m.value[m.row]) {
+				m.mergeLineBelow(m.row)
+				break
+			}
+			m.deleteAfterCursor()
+		case key.Matches(msg, m.KeyMap.DeleteBeforeCursor):
+			m.col = clamp(m.col, 0, len(m.value[m.row]))
+			if m.col <= 0 {
+				m.mergeLineAbove(m.row)
+				break
+			}
+			m.deleteBeforeCursor()
+		case key.Matches(msg, m.KeyMap.DeleteCharacterBackward):
+			m.col = clamp(m.col, 0, len(m.value[m.row]))
+			if m.col <= 0 {
+				m.mergeLineAbove(m.row)
+				break
+			}
+			if len(m.value[m.row]) > 0 {
+				m.value[m.row] = append(m.value[m.row][:max(0, m.col-1)], m.value[m.row][m.col:]...)
+				if m.col > 0 {
+					m.SetCursorColumn(m.col - 1)
+				}
+			}
+		case key.Matches(msg, m.KeyMap.DeleteCharacterForward):
+			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)
+			}
+			if m.col >= len(m.value[m.row]) {
+				m.mergeLineBelow(m.row)
+				break
+			}
+		case key.Matches(msg, m.KeyMap.DeleteWordBackward):
+			if m.col <= 0 {
+				m.mergeLineAbove(m.row)
+				break
+			}
+			m.deleteWordLeft()
+		case key.Matches(msg, m.KeyMap.DeleteWordForward):
+			m.col = clamp(m.col, 0, len(m.value[m.row]))
+			if m.col >= len(m.value[m.row]) {
+				m.mergeLineBelow(m.row)
+				break
+			}
+			m.deleteWordRight()
+		case key.Matches(msg, m.KeyMap.InsertNewline):
+			if m.MaxHeight > 0 && len(m.value) >= m.MaxHeight {
+				return m, nil
+			}
+			m.col = clamp(m.col, 0, len(m.value[m.row]))
+			m.splitLine(m.row, m.col)
+		case key.Matches(msg, m.KeyMap.LineEnd):
+			m.CursorEnd()
+		case key.Matches(msg, m.KeyMap.LineStart):
+			m.CursorStart()
+		case key.Matches(msg, m.KeyMap.CharacterForward):
+			m.characterRight()
+		case key.Matches(msg, m.KeyMap.LineNext):
+			m.CursorDown()
+		case key.Matches(msg, m.KeyMap.WordForward):
+			m.wordRight()
+		case key.Matches(msg, m.KeyMap.Paste):
+			return m, Paste
+		case key.Matches(msg, m.KeyMap.CharacterBackward):
+			m.characterLeft(false /* insideLine */)
+		case key.Matches(msg, m.KeyMap.LinePrevious):
+			m.CursorUp()
+		case key.Matches(msg, m.KeyMap.WordBackward):
+			m.wordLeft()
+		case key.Matches(msg, m.KeyMap.InputBegin):
+			m.moveToBegin()
+		case key.Matches(msg, m.KeyMap.InputEnd):
+			m.moveToEnd()
+		case key.Matches(msg, m.KeyMap.LowercaseWordForward):
+			m.lowercaseRight()
+		case key.Matches(msg, m.KeyMap.UppercaseWordForward):
+			m.uppercaseRight()
+		case key.Matches(msg, m.KeyMap.CapitalizeWordForward):
+			m.capitalizeRight()
+		case key.Matches(msg, m.KeyMap.TransposeCharacterBackward):
+			m.transposeLeft()
+
+		default:
+			m.insertRunesFromUserInput([]rune(msg.Text))
+		}
+
+	case pasteMsg:
+		m.insertRunesFromUserInput([]rune(msg))
+
+	case pasteErrMsg:
+		m.Err = msg
+	}
+
+	var cmd tea.Cmd
+	cmds = append(cmds, cmd)
+
+	newRow, newCol := m.cursorLineNumber(), m.col
+	m.virtualCursor, cmd = m.virtualCursor.Update(msg)
+	if (newRow != oldRow || newCol != oldCol) && m.virtualCursor.Mode() == cursor.CursorBlink {
+		m.virtualCursor.Blink = false
+		cmd = m.virtualCursor.BlinkCmd()
+	}
+	cmds = append(cmds, cmd)
+
+	m.SetHeight(m.ContentHeight())
+	return m, tea.Batch(cmds...)
+}
+
+// View renders the text area in its current state.
+func (m Model) View() string {
+	m.updateVirtualCursorStyle()
+	if m.Value() == "" && m.row == 0 && m.col == 0 && m.Placeholder != "" {
+		return m.placeholderView()
+	}
+	m.virtualCursor.TextStyle = m.activeStyle().computedCursorLine()
+
+	var (
+		s                strings.Builder
+		style            lipgloss.Style
+		newLines         int
+		widestLineNumber int
+		lineInfo         = m.LineInfo()
+		styles           = m.activeStyle()
+	)
+
+	displayLine := 0
+	for l, line := range m.value {
+		wrappedLines := m.memoizedWrap(line, m.width)
+
+		if m.row == l {
+			style = styles.computedCursorLine()
+		} else {
+			style = styles.computedText()
+		}
+
+		for wl, wrappedLine := range wrappedLines {
+			prompt := m.promptView(displayLine)
+			prompt = styles.computedPrompt().Render(prompt)
+			s.WriteString(style.Render(prompt))
+			displayLine++
+
+			var ln string
+			if m.ShowLineNumbers {
+				if wl == 0 { // normal line
+					isCursorLine := m.row == l
+					s.WriteString(m.lineNumberView(l+1, isCursorLine))
+				} else { // soft wrapped line
+					isCursorLine := m.row == l
+					s.WriteString(m.lineNumberView(-1, isCursorLine))
+				}
+			}
+
+			// Note the widest line number for padding purposes later.
+			lnw := uniseg.StringWidth(ln)
+			if lnw > widestLineNumber {
+				widestLineNumber = lnw
+			}
+
+			strwidth := uniseg.StringWidth(string(wrappedLine))
+			padding := m.width - strwidth
+			// If the trailing space causes the line to be wider than the
+			// width, we should not draw it to the screen since it will result
+			// in an extra space at the end of the line which can look off when
+			// the cursor line is showing.
+			if strwidth > m.width {
+				// The character causing the line to be wider than the width is
+				// guaranteed to be a space since any other character would
+				// have been wrapped.
+				wrappedLine = []rune(strings.TrimSuffix(string(wrappedLine), " "))
+				padding -= m.width - strwidth
+			}
+			if m.row == l && lineInfo.RowOffset == wl {
+				s.WriteString(style.Render(string(wrappedLine[:lineInfo.ColumnOffset])))
+				if m.col >= len(line) && lineInfo.CharOffset >= m.width {
+					m.virtualCursor.SetChar(" ")
+					s.WriteString(m.virtualCursor.View())
+				} else {
+					m.virtualCursor.SetChar(string(wrappedLine[lineInfo.ColumnOffset]))
+					s.WriteString(style.Render(m.virtualCursor.View()))
+					s.WriteString(style.Render(string(wrappedLine[lineInfo.ColumnOffset+1:])))
+				}
+			} else {
+				s.WriteString(style.Render(string(wrappedLine)))
+			}
+			s.WriteString(style.Render(strings.Repeat(" ", max(0, padding))))
+			s.WriteRune('\n')
+			newLines++
+		}
+	}
+
+	// Remove the trailing newline from the last line
+	result := s.String()
+	if len(result) > 0 && result[len(result)-1] == '\n' {
+		result = result[:len(result)-1]
+	}
+
+	return styles.Base.Render(result)
+}
+
+// promptView renders a single line of the prompt.
+func (m Model) promptView(displayLine int) (prompt string) {
+	prompt = m.Prompt
+	if m.promptFunc == nil {
+		return prompt
+	}
+	prompt = m.promptFunc(displayLine)
+	width := lipgloss.Width(prompt)
+	if width < m.promptWidth {
+		prompt = fmt.Sprintf("%*s%s", m.promptWidth-width, "", prompt)
+	}
+
+	return m.activeStyle().computedPrompt().Render(prompt)
+}
+
+// lineNumberView renders the line number.
+//
+// If the argument is less than 0, a space styled as a line number is returned
+// instead. Such cases are used for soft-wrapped lines.
+//
+// The second argument indicates whether this line number is for a 'cursorline'
+// line number.
+func (m Model) lineNumberView(n int, isCursorLine bool) (str string) {
+	if !m.ShowLineNumbers {
+		return ""
+	}
+
+	if n <= 0 {
+		str = " "
+	} else {
+		str = strconv.Itoa(n)
+	}
+
+	// XXX: is textStyle really necessary here?
+	textStyle := m.activeStyle().computedText()
+	lineNumberStyle := m.activeStyle().computedLineNumber()
+	if isCursorLine {
+		textStyle = m.activeStyle().computedCursorLine()
+		lineNumberStyle = m.activeStyle().computedCursorLineNumber()
+	}
+
+	// Format line number dynamically based on the maximum number of lines.
+	digits := len(strconv.Itoa(m.MaxHeight))
+	str = fmt.Sprintf(" %*v ", digits, str)
+
+	return textStyle.Render(lineNumberStyle.Render(str))
+}
+
+// placeholderView returns the prompt and placeholder, if any.
+func (m Model) placeholderView() string {
+	var (
+		s      strings.Builder
+		p      = m.Placeholder
+		styles = m.activeStyle()
+	)
+	// word wrap lines
+	pwordwrap := ansi.Wordwrap(p, m.width, "")
+	// hard wrap lines (handles lines that could not be word wrapped)
+	pwrap := ansi.Hardwrap(pwordwrap, m.width, true)
+	// split string by new lines
+	plines := strings.Split(strings.TrimSpace(pwrap), "\n")
+
+	// Only render the actual placeholder lines, not padded to m.height
+	maxLines := max(len(plines), 1) // At least show one line for cursor
+	for i := range maxLines {
+		isLineNumber := len(plines) > i
+
+		lineStyle := styles.computedPlaceholder()
+		if len(plines) > i {
+			lineStyle = styles.computedCursorLine()
+		}
+
+		// render prompt
+		prompt := m.promptView(i)
+		prompt = styles.computedPrompt().Render(prompt)
+		s.WriteString(lineStyle.Render(prompt))
+
+		// when show line numbers enabled:
+		// - render line number for only the cursor line
+		// - indent other placeholder lines
+		// this is consistent with vim with line numbers enabled
+		if m.ShowLineNumbers {
+			var ln int
+
+			switch {
+			case i == 0:
+				ln = i + 1
+				fallthrough
+			case len(plines) > i:
+				s.WriteString(m.lineNumberView(ln, isLineNumber))
+			default:
+			}
+		}
+
+		switch {
+		// first line
+		case i == 0:
+			// first character of first line as cursor with character
+			m.virtualCursor.TextStyle = styles.computedPlaceholder()
+			m.virtualCursor.SetChar(string(plines[0][0]))
+			s.WriteString(lineStyle.Render(m.virtualCursor.View()))
+
+			// the rest of the first line
+			placeholderTail := plines[0][1:]
+			gap := strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[0])))
+			renderedPlaceholder := styles.computedPlaceholder().Render(placeholderTail + gap)
+			s.WriteString(lineStyle.Render(renderedPlaceholder))
+		// remaining lines
+		case len(plines) > i:
+			// current line placeholder text
+			if len(plines) > i {
+				placeholderLine := plines[i]
+				gap := strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[i])))
+				s.WriteString(lineStyle.Render(placeholderLine + gap))
+			}
+		default:
+			// end of line buffer character
+			eob := styles.computedEndOfBuffer().Render(string(m.EndOfBufferCharacter))
+			s.WriteString(eob)
+		}
+
+		// terminate with new line (except for last line)
+		if i < maxLines-1 {
+			s.WriteRune('\n')
+		}
+	}
+
+	return styles.Base.Render(s.String())
+}
+
+// Blink returns the blink command for the virtual cursor.
+func Blink() tea.Msg {
+	return cursor.Blink()
+}
+
+// Cursor returns a [tea.Cursor] for rendering a real cursor in a Bubble Tea
+// program. This requires that [Model.VirtualCursor] is set to false.
+//
+// Note that you will almost certainly also need to adjust the offset cursor
+// position per the textarea's per the textarea's position in the terminal.
+//
+// Example:
+//
+//	// In your top-level View function:
+//	f := tea.NewFrame(m.textarea.View())
+//	f.Cursor = m.textarea.Cursor()
+//	f.Cursor.Position.X += offsetX
+//	f.Cursor.Position.Y += offsetY
+func (m Model) Cursor() *tea.Cursor {
+	if m.VirtualCursor {
+		return nil
+	}
+
+	lineInfo := m.LineInfo()
+	w := lipgloss.Width
+	baseStyle := m.activeStyle().Base
+
+	xOffset := lineInfo.CharOffset +
+		w(m.promptView(0)) +
+		w(m.lineNumberView(0, false)) +
+		baseStyle.GetMarginLeft() +
+		baseStyle.GetPaddingLeft() +
+		baseStyle.GetBorderLeftSize()
+
+	yOffset := m.cursorLineNumber() -
+		baseStyle.GetMarginTop() +
+		baseStyle.GetPaddingTop() +
+		baseStyle.GetBorderTopSize()
+
+	c := tea.NewCursor(xOffset, yOffset)
+	c.Blink = m.Styles.Cursor.Blink
+	c.Color = m.Styles.Cursor.Color
+	c.Shape = m.Styles.Cursor.Shape
+	return c
+}
+
+func (m Model) memoizedWrap(runes []rune, width int) [][]rune {
+	input := line{runes: runes, width: width}
+	if v, ok := m.cache.Get(input); ok {
+		return v
+	}
+	v := wrap(runes, width)
+	m.cache.Set(input, v)
+	return v
+}
+
+// cursorLineNumber returns the line number that the cursor is on.
+// This accounts for soft wrapped lines.
+func (m Model) cursorLineNumber() int {
+	line := 0
+	for i := range m.row {
+		// Calculate the number of lines that the current line will be split
+		// into.
+		line += len(m.memoizedWrap(m.value[i], m.width))
+	}
+	line += m.LineInfo().RowOffset
+	return line
+}
+
+// mergeLineBelow merges the current line the cursor is on with the line below.
+func (m *Model) mergeLineBelow(row int) {
+	if row >= len(m.value)-1 {
+		return
+	}
+
+	// To perform a merge, we will need to combine the two lines and then
+	m.value[row] = append(m.value[row], m.value[row+1]...)
+
+	// Shift all lines up by one
+	for i := row + 1; i < len(m.value)-1; i++ {
+		m.value[i] = m.value[i+1]
+	}
+
+	// And, remove the last line
+	if len(m.value) > 0 {
+		m.value = m.value[:len(m.value)-1]
+	}
+}
+
+// mergeLineAbove merges the current line the cursor is on with the line above.
+func (m *Model) mergeLineAbove(row int) {
+	if row <= 0 {
+		return
+	}
+
+	m.col = len(m.value[row-1])
+	m.row = m.row - 1
+
+	// To perform a merge, we will need to combine the two lines and then
+	m.value[row-1] = append(m.value[row-1], m.value[row]...)
+
+	// Shift all lines up by one
+	for i := row; i < len(m.value)-1; i++ {
+		m.value[i] = m.value[i+1]
+	}
+
+	// And, remove the last line
+	if len(m.value) > 0 {
+		m.value = m.value[:len(m.value)-1]
+	}
+}
+
+func (m *Model) splitLine(row, col int) {
+	// To perform a split, take the current line and keep the content before
+	// the cursor, take the content after the cursor and make it the content of
+	// the line underneath, and shift the remaining lines down by one
+	head, tailSrc := m.value[row][:col], m.value[row][col:]
+	tail := make([]rune, len(tailSrc))
+	copy(tail, tailSrc)
+
+	m.value = append(m.value[:row+1], m.value[row:]...)
+
+	m.value[row] = head
+	m.value[row+1] = tail
+
+	m.col = 0
+	m.row++
+}
+
+// Paste is a command for pasting from the clipboard into the text input.
+func Paste() tea.Msg {
+	str, err := clipboard.ReadAll()
+	if err != nil {
+		return pasteErrMsg{err}
+	}
+	return pasteMsg(str)
+}
+
+func wrap(runes []rune, width int) [][]rune {
+	var (
+		lines  = [][]rune{{}}
+		word   = []rune{}
+		row    int
+		spaces int
+	)
+
+	// Word wrap the runes
+	for _, r := range runes {
+		if unicode.IsSpace(r) {
+			spaces++
+		} else {
+			word = append(word, r)
+		}
+
+		if spaces > 0 { //nolint:nestif
+			if uniseg.StringWidth(string(lines[row]))+uniseg.StringWidth(string(word))+spaces > width {
+				row++
+				lines = append(lines, []rune{})
+				lines[row] = append(lines[row], word...)
+				lines[row] = append(lines[row], repeatSpaces(spaces)...)
+				spaces = 0
+				word = nil
+			} else {
+				lines[row] = append(lines[row], word...)
+				lines[row] = append(lines[row], repeatSpaces(spaces)...)
+				spaces = 0
+				word = nil
+			}
+		} else {
+			// If the last character is a double-width rune, then we may not be able to add it to this line
+			// as it might cause us to go past the width.
+			lastCharLen := rw.RuneWidth(word[len(word)-1])
+			if uniseg.StringWidth(string(word))+lastCharLen > width {
+				// If the current line has any content, let's move to the next
+				// line because the current word fills up the entire line.
+				if len(lines[row]) > 0 {
+					row++
+					lines = append(lines, []rune{})
+				}
+				lines[row] = append(lines[row], word...)
+				word = nil
+			}
+		}
+	}
+
+	if uniseg.StringWidth(string(lines[row]))+uniseg.StringWidth(string(word))+spaces >= width {
+		lines = append(lines, []rune{})
+		lines[row+1] = append(lines[row+1], word...)
+		// We add an extra space at the end of the line to account for the
+		// trailing space at the end of the previous soft-wrapped lines so that
+		// behaviour when navigating is consistent and so that we don't need to
+		// continually add edges to handle the last line of the wrapped input.
+		spaces++
+		lines[row+1] = append(lines[row+1], repeatSpaces(spaces)...)
+	} else {
+		lines[row] = append(lines[row], word...)
+		spaces++
+		lines[row] = append(lines[row], repeatSpaces(spaces)...)
+	}
+
+	return lines
+}
+
+func repeatSpaces(n int) []rune {
+	return []rune(strings.Repeat(string(' '), n))
+}
+
+// numDigits returns the number of digits in an integer.
+func numDigits(n int) int {
+	if n == 0 {
+		return 1
+	}
+	count := 0
+	num := abs(n)
+	for num > 0 {
+		count++
+		num /= 10
+	}
+	return count
+}
+
+func clamp(v, low, high int) int {
+	if high < low {
+		low, high = high, low
+	}
+	return min(high, max(low, v))
+}
+
+func abs(n int) int {
+	if n < 0 {
+		return -n
+	}
+	return n
+}

+ 14 - 6
packages/tui/internal/tui/tui.go

@@ -127,7 +127,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 
 		if a.showCompletionDialog {
 		if a.showCompletionDialog {
 			switch msg.String() {
 			switch msg.String() {
-			case "tab", "enter", "esc":
+			case "tab", "enter", "esc", "ctrl+c":
 				context, contextCmd := a.completions.Update(msg)
 				context, contextCmd := a.completions.Update(msg)
 				a.completions = context.(dialog.CompletionDialog)
 				a.completions = context.(dialog.CompletionDialog)
 				cmds = append(cmds, contextCmd)
 				cmds = append(cmds, contextCmd)
@@ -290,14 +290,22 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 
 func (a appModel) View() string {
 func (a appModel) View() string {
 	layoutView := a.layout.View()
 	layoutView := a.layout.View()
+	editorWidth, _ := a.editorContainer.GetSize()
+	editorX, editorY := a.editorContainer.GetPosition()
 
 
-	if a.showCompletionDialog {
-		editorWidth, _ := a.editorContainer.GetSize()
-		editorX, editorY := a.editorContainer.GetPosition()
+	if a.editor.Lines() > 1 {
+		editorY = editorY - a.editor.Lines() + 1
+		layoutView = layout.PlaceOverlay(
+			editorX,
+			editorY,
+			a.editor.Content(),
+			layoutView,
+		)
+	}
 
 
+	if a.showCompletionDialog {
 		a.completions.SetWidth(editorWidth)
 		a.completions.SetWidth(editorWidth)
 		overlay := a.completions.View()
 		overlay := a.completions.View()
-
 		layoutView = layout.PlaceOverlay(
 		layoutView = layout.PlaceOverlay(
 			editorX,
 			editorX,
 			editorY-lipgloss.Height(overlay)+2,
 			editorY-lipgloss.Height(overlay)+2,
@@ -530,7 +538,7 @@ func NewModel(app *app.App) tea.Model {
 			layout.WithDirection(layout.FlexDirectionVertical),
 			layout.WithDirection(layout.FlexDirectionVertical),
 			layout.WithSizes(
 			layout.WithSizes(
 				layout.FlexChildSizeGrow,
 				layout.FlexChildSizeGrow,
-				layout.FlexChildSizeFixed(6),
+				layout.FlexChildSizeFixed(5),
 			),
 			),
 		),
 		),
 	}
 	}