@@ -75,6 +75,16 @@ const pasteColsThreshold = 1000
7575// Session details panel max height.
7676const sessionDetailsMaxHeight = 20
7777
78+// TextareaMaxHeight is the maximum height of the prompt textarea.
79+const TextareaMaxHeight = 15
80+
81+// editorHeightMargin is the vertical margin added to the textarea height to
82+// account for the attachments row (top) and bottom margin.
83+const editorHeightMargin = 2
84+
85+// TextareaMinHeight is the minimum height of the prompt textarea.
86+const TextareaMinHeight = 3
87+
7888// uiFocusState represents the current focus state of the UI.
7989type uiFocusState uint8
8090
@@ -257,6 +267,9 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
257267 ta.ShowLineNumbers = false
258268 ta.CharLimit = -1
259269 ta.SetVirtualCursor(false)
270+ ta.DynamicHeight = true
271+ ta.MinHeight = TextareaMinHeight
272+ ta.MaxHeight = TextareaMaxHeight
260273 ta.Focus()
261274
262275 ch := NewChat(com)
@@ -818,13 +831,10 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
818831 cmds = append(cmds, cmd)
819832 }
820833 case openEditorMsg:
821− var cmd tea.Cmd
834+ prevHeight := m.textarea.Height()
822835 m.textarea.SetValue(msg.Text)
823836 m.textarea.MoveToEnd()
824− m.textarea, cmd = m.textarea.Update(msg)
825− if cmd != nil {
826− cmds = append(cmds, cmd)
827− }
837+ cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
828838 case util.InfoMsg:
829839 m.status.SetInfoMsg(msg)
830840 ttl := msg.TTL
@@ -1720,15 +1730,22 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
17201730 cmds = append(cmds, m.pasteImageFromClipboard)
17211731
17221732 case key.Matches(msg, m.keyMap.Editor.SendMessage):
1733+ prevHeight := m.textarea.Height()
17231734 value := m.textarea.Value()
17241735 if before, ok := strings.CutSuffix(value, "\\"); ok {
17251736 // If the last character is a backslash, remove it and add a newline.
17261737 m.textarea.SetValue(before)
1738+ if cmd := m.handleTextareaHeightChange(prevHeight); cmd != nil {
1739+ cmds = append(cmds, cmd)
1740+ }
17271741 break
17281742 }
17291743
17301744 // Otherwise, send the message
17311745 m.textarea.Reset()
1746+ if cmd := m.handleTextareaHeightChange(prevHeight); cmd != nil {
1747+ cmds = append(cmds, cmd)
1748+ }
17321749
17331750 value = strings.TrimSpace(value)
17341751 if value == "exit" || value == "quit" {
@@ -1770,11 +1787,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
17701787 }
17711788 cmds = append(cmds, m.openEditor(m.textarea.Value()))
17721789 case key.Matches(msg, m.keyMap.Editor.Newline):
1790+ prevHeight := m.textarea.Height()
17731791 m.textarea.InsertRune('\n')
17741792 m.closeCompletions()
1775− ta, cmd := m.textarea.Update(msg)
1776− m.textarea = ta
1777− cmds = append(cmds, cmd)
1793+ cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
17781794 case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
17791795 cmd := m.handleHistoryUp(msg)
17801796 if cmd != nil {
@@ -1823,9 +1839,8 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
18231839 m.updateLayoutAndSize()
18241840 }
18251841
1826− ta, cmd := m.textarea.Update(msg)
1827− m.textarea = ta
1828− cmds = append(cmds, cmd)
1842+ prevHeight := m.textarea.Height()
1843+ cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
18291844
18301845 // Any text modification becomes the current draft.
18311846 m.updateHistoryDraft(curValue)
@@ -2344,17 +2359,59 @@ func (m *UI) updateLayoutAndSize() {
23442359 if m.state == uiChat {
23452360 if m.forceCompactMode {
23462361 m.isCompact = true
2347− return
2348− }
2349− if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
2362+ } else if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
23502363 m.isCompact = true
23512364 } else {
23522365 m.isCompact = false
23532366 }
23542367 }
23552368
2369+ // First pass sizes components from the current textarea height.
23562370 m.layout = m.generateLayout(m.width, m.height)
2371+ prevHeight := m.textarea.Height()
23572372 m.updateSize()
2373+
2374+ // SetWidth can change textarea height due to soft-wrap recalculation.
2375+ // If that happens, run one reconciliation pass with the new height.
2376+ if m.textarea.Height() != prevHeight {
2377+ m.layout = m.generateLayout(m.width, m.height)
2378+ m.updateSize()
2379+ }
2380+}
2381+
2382+// handleTextareaHeightChange checks whether the textarea height changed and,
2383+// if so, recalculates the layout. When the chat is in follow mode it keeps
2384+// the view scrolled to the bottom. The returned command, if non-nil, must be
2385+// batched by the caller.
2386+func (m *UI) handleTextareaHeightChange(prevHeight int) tea.Cmd {
2387+ if m.textarea.Height() == prevHeight {
2388+ return nil
2389+ }
2390+ m.updateLayoutAndSize()
2391+ if m.state == uiChat && m.chat.Follow() {
2392+ return m.chat.ScrollToBottomAndAnimate()
2393+ }
2394+ return nil
2395+}
2396+
2397+// updateTextarea updates the textarea for msg and then reconciles layout if
2398+// the textarea height changed as a result.
2399+func (m *UI) updateTextarea(msg tea.Msg) tea.Cmd {
2400+ return m.updateTextareaWithPrevHeight(msg, m.textarea.Height())
2401+}
2402+
2403+// updateTextareaWithPrevHeight is for cases when the height of the layout may
2404+// have changed.
2405+//
2406+// Particularly, it's for cases where the textarea changes before
2407+// textarea.Update is called (for example, SetValue, Reset, and InsertRune). We
2408+// pass the height from before those changes took place so we can compare
2409+// "before" vs "after" sizing and recalculate the layout if the textarea grew
2410+// or shrank.
2411+func (m *UI) updateTextareaWithPrevHeight(msg tea.Msg, prevHeight int) tea.Cmd {
2412+ ta, cmd := m.textarea.Update(msg)
2413+ m.textarea = ta
2414+ return tea.Batch(cmd, m.handleTextareaHeightChange(prevHeight))
23582415}
23592416
23602417// updateSize updates the sizes of UI components based on the current layout.
@@ -2363,11 +2420,8 @@ func (m *UI) updateSize() {
23632420 m.status.SetWidth(m.layout.status.Dx())
23642421
23652422 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2423+ m.textarea.MaxHeight = TextareaMaxHeight
23662424 m.textarea.SetWidth(m.layout.editor.Dx())
2367− // TODO: Abstract the textarea and attachments into a single editor
2368− // component so we don't have to manually account for the attachments
2369− // height here.
2370− m.textarea.SetHeight(m.layout.editor.Dy() - 2) // Account for top margin/attachments and bottom margin
23712425 m.renderPills()
23722426
23732427 // Handle different app states
@@ -2387,8 +2441,8 @@ func (m *UI) generateLayout(w, h int) uiLayout {
23872441
23882442 // The help height
23892443 helpHeight := 1
2390− // The editor height
2391− editorHeight := 5
2444+ // The editor height: textarea height + margin for attachments and bottom spacing.
2445+ editorHeight := m.textarea.Height() + editorHeightMargin
23922446 // The sidebar width
23932447 sidebarWidth := 30
23942448 // The header height
@@ -2661,11 +2715,13 @@ func (m *UI) insertCompletionText(text string) bool {
26612715// insertFileCompletion inserts the selected file path into the textarea,
26622716// replacing the @query, and adds the file as an attachment.
26632717func (m *UI) insertFileCompletion(path string) tea.Cmd {
2718+ prevHeight := m.textarea.Height()
26642719 if !m.insertCompletionText(path) {
26652720 return nil
26662721 }
2722+ heightCmd := m.handleTextareaHeightChange(prevHeight)
26672723
2668− return func() tea.Msg {
2724+ fileCmd := func() tea.Msg {
26692725 absPath, _ := filepath.Abs(path)
26702726
26712727 if m.hasSession() {
@@ -2696,6 +2752,7 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
26962752 Content: content,
26972753 }
26982754 }
2755+ return tea.Batch(heightCmd, fileCmd)
26992756}
27002757
27012758// insertMCPResourceCompletion inserts the selected resource into the textarea,
@@ -2703,11 +2760,13 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
27032760func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
27042761 displayText := cmp.Or(item.Title, item.URI)
27052762
2763+ prevHeight := m.textarea.Height()
27062764 if !m.insertCompletionText(displayText) {
27072765 return nil
27082766 }
2767+ heightCmd := m.handleTextareaHeightChange(prevHeight)
27092768
2710− return func() tea.Msg {
2769+ resourceCmd := func() tea.Msg {
27112770 contents, err := mcp.ReadResource(
27122771 context.Background(),
27132772 m.com.Store(),
@@ -2748,6 +2807,7 @@ func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValu
27482807 Content: data,
27492808 }
27502809 }
2810+ return tea.Batch(heightCmd, resourceCmd)
27512811}
27522812
27532813// completionsPosition returns the X and Y position for the completions popup.
@@ -3208,9 +3268,8 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
32083268 return true
32093269 }
32103270 if !allExistsAndValid() {
3211− var cmd tea.Cmd
3212− m.textarea, cmd = m.textarea.Update(msg)
3213− return cmd
3271+ prevHeight := m.textarea.Height()
3272+ return m.updateTextareaWithPrevHeight(msg, prevHeight)
32143273 }
32153274
32163275 var cmds []tea.Cmd