@@ -287,6 +287,23 @@ type Model struct {
287287 // there's no limit.
288288 MaxWidth int
289289
290+ // DynamicHeight, when true, causes the textarea to automatically grow
291+ // and shrink its height to fit the content. The height is clamped between
292+ // MinHeight and MaxHeight.
293+ DynamicHeight bool
294+
295+ // MinHeight is the minimum height of the text area in rows when
296+ // DynamicHeight is enabled. If 0 or less, defaults to 1.
297+ MinHeight int
298+
299+ // MaxContentHeight is the maximum content height in visual rows
300+ // (accounting for soft wraps). When set (> 0), input is blocked once
301+ // the total visual lines reach this limit, while MaxHeight controls
302+ // only the visible viewport height. When 0, the content guard falls
303+ // back to the legacy MaxHeight behavior (blocking at MaxHeight
304+ // logical lines) for backward compatibility.
305+ MaxContentHeight int
306+
290307 // Styling. Styles are defined in [Styles]. Use [SetStyles] and [GetStyles]
291308 // to work with this value publicly.
292309 styles Styles
@@ -464,16 +481,19 @@ func (m *Model) updateVirtualCursorStyle() {
464481func (m * Model ) SetValue (s string ) {
465482 m .Reset ()
466483 m .InsertString (s )
484+ m .recalculateHeight ()
467485}
468486
469487// InsertString inserts a string at the cursor position.
470488func (m * Model ) InsertString (s string ) {
471489 m .insertRunesFromUserInput ([]rune (s ))
490+ m .recalculateHeight ()
472491}
473492
474493// InsertRune inserts a rune at the cursor position.
475494func (m * Model ) InsertRune (r rune ) {
476495 m .insertRunesFromUserInput ([]rune {r })
496+ m .recalculateHeight ()
477497}
478498
479499// insertRunesFromUserInput inserts runes at the current cursor position.
@@ -521,6 +541,18 @@ func (m *Model) insertRunesFromUserInput(runes []rune) {
521541 lines = lines [:allowedHeight ]
522542 }
523543
544+ // Obey MaxContentHeight in visual rows when set.
545+ if m .MaxContentHeight > 0 {
546+ budget := m .MaxContentHeight - m .totalVisualLines ()
547+ // Trim lines from the end until we fit within the budget.
548+ for len (lines ) > 1 && m .visualLinesForInsert (lines ) > budget {
549+ lines = lines [:len (lines )- 1 ]
550+ }
551+ if m .visualLinesForInsert (lines ) > budget {
552+ return
553+ }
554+ }
555+
524556 if len (lines ) == 0 {
525557 // Nothing left to insert.
526558 return
@@ -740,6 +772,7 @@ func (m *Model) Reset() {
740772 m .row = 0
741773 m .viewport .GotoTop ()
742774 m .SetCursorColumn (0 )
775+ m .recalculateHeight ()
743776}
744777
745778// Word returns the word at the cursor position.
@@ -1134,6 +1167,7 @@ func (m *Model) SetWidth(w int) {
11341167
11351168 m .viewport .SetWidth (inputWidth - reservedOuter )
11361169 m .width = inputWidth - reservedOuter - reservedInner
1170+ m .recalculateHeight ()
11371171}
11381172
11391173// SetPromptFunc supersedes the Prompt field and sets a dynamic prompt instead.
@@ -1238,7 +1272,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
12381272 }
12391273 m .deleteWordRight ()
12401274 case key .Matches (msg , m .KeyMap .InsertNewline ):
1241- if m .MaxHeight > 0 && len ( m . value ) >= m . MaxHeight {
1275+ if m .atContentLimit () {
12421276 return m , nil
12431277 }
12441278 m .col = clamp (m .col , 0 , len (m .value [m .row ]))
@@ -1289,6 +1323,8 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
12891323 m .Err = msg
12901324 }
12911325
1326+ m .recalculateHeight ()
1327+
12921328 // Make sure we set the content of the viewport before updating it.
12931329 view := m .view ()
12941330 m .viewport .SetContent (view )
@@ -1627,6 +1663,76 @@ func (m Model) cursorLineNumber() int {
16271663 return line
16281664}
16291665
1666+ // totalVisualLines returns the total number of display lines across all
1667+ // logical lines, accounting for soft wraps.
1668+ func (m * Model ) totalVisualLines () int {
1669+ n := 0
1670+ for _ , line := range m .value {
1671+ n += len (m .memoizedWrap (line , m .width ))
1672+ }
1673+ return n
1674+ }
1675+
1676+ // recalculateHeight recomputes and applies the textarea height based on
1677+ // content when DynamicHeight is enabled. It is a no-op otherwise.
1678+ func (m * Model ) recalculateHeight () {
1679+ if ! m .DynamicHeight {
1680+ return
1681+ }
1682+ minH := max (m .MinHeight , minHeight )
1683+ total := m .totalVisualLines ()
1684+ h := max (total , minH )
1685+ if m .MaxHeight > 0 {
1686+ h = min (h , m .MaxHeight )
1687+ }
1688+ if maxOffset := total - h ; m .viewport .YOffset () > maxOffset {
1689+ m .viewport .SetYOffset (max (0 , maxOffset ))
1690+ }
1691+ m .SetHeight (h )
1692+ }
1693+
1694+ // atContentLimit reports whether the textarea has reached its content limit.
1695+ // When MaxContentHeight is set (> 0), it checks total visual lines.
1696+ // Otherwise it falls back to the legacy MaxHeight logical-line check for
1697+ // backward compatibility.
1698+ func (m * Model ) atContentLimit () bool {
1699+ if m .MaxContentHeight > 0 {
1700+ return m .totalVisualLines () >= m .MaxContentHeight
1701+ }
1702+ return m .MaxHeight > 0 && len (m .value ) >= m .MaxHeight
1703+ }
1704+
1705+ // visualLinesForInsert estimates how many additional visual lines would result
1706+ // from inserting the given lines at the current cursor position. The first
1707+ // element merges into the current line; subsequent elements become new lines.
1708+ func (m * Model ) visualLinesForInsert (lines [][]rune ) int {
1709+ if len (lines ) == 0 {
1710+ return 0
1711+ }
1712+
1713+ // The current row's visual line count before insertion.
1714+ currentRowVisual := len (m .memoizedWrap (m .value [m .row ], m .width ))
1715+
1716+ // Simulate merging the first paste line into the current row.
1717+ merged := make ([]rune , m .col + len (lines [0 ]))
1718+ copy (merged , m .value [m .row ][:m .col ])
1719+ copy (merged [m .col :], lines [0 ])
1720+ if len (lines ) == 1 {
1721+ merged = append (merged , m .value [m .row ][m .col :]... )
1722+ }
1723+ delta := len (m .memoizedWrap (merged , m .width )) - currentRowVisual
1724+
1725+ // Each additional line is a new logical line.
1726+ for i , content := range lines {
1727+ if i == len (lines )- 1 {
1728+ content = append (content , m .value [m .row ][m .col :]... )
1729+ }
1730+ delta += len (m .memoizedWrap (content , m .width ))
1731+ }
1732+
1733+ return delta
1734+ }
1735+
16301736// mergeLineBelow merges the current line the cursor is on with the line below.
16311737func (m * Model ) mergeLineBelow (row int ) {
16321738 if row >= len (m .value )- 1 {
0 commit comments