@@ -75,6 +75,16 @@ const pasteColsThreshold = 1000
75// Session details panel max height.
75// Session details panel max height.
76const sessionDetailsMaxHeight = 20
76const sessionDetailsMaxHeight = 20
78+// TextareaMaxHeight is the maximum height of the prompt textarea.
79+const TextareaMaxHeight = 15
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
85+// TextareaMinHeight is the minimum height of the prompt textarea.
86+const TextareaMinHeight = 3
78// uiFocusState represents the current focus state of the UI.
88// uiFocusState represents the current focus state of the UI.
79type uiFocusState uint8
89type uiFocusState uint8
@@ -257,6 +267,9 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
257 ta.ShowLineNumbers = false
267 ta.ShowLineNumbers = false
258 ta.CharLimit = -1
268 ta.CharLimit = -1
259 ta.SetVirtualCursor(false)
269 ta.SetVirtualCursor(false)
270+ ta.DynamicHeight = true
271+ ta.MinHeight = TextareaMinHeight
272+ ta.MaxHeight = TextareaMaxHeight
260 ta.Focus()
273 ta.Focus()
262 ch := NewChat(com)
275 ch := NewChat(com)
@@ -818,13 +831,10 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
818 cmds = append(cmds, cmd)
831 cmds = append(cmds, cmd)
820 case openEditorMsg:
833 case openEditorMsg:
821− var cmd tea.Cmd
834+ prevHeight := m.textarea.Height()
822 m.textarea.SetValue(msg.Text)
835 m.textarea.SetValue(msg.Text)
823 m.textarea.MoveToEnd()
836 m.textarea.MoveToEnd()
824− m.textarea, cmd = m.textarea.Update(msg)
837+ cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
826− cmds = append(cmds, cmd)
828 case util.InfoMsg:
838 case util.InfoMsg:
829 m.status.SetInfoMsg(msg)
839 m.status.SetInfoMsg(msg)
830 ttl := msg.TTL
840 ttl := msg.TTL
@@ -1720,15 +1730,22 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1720 cmds = append(cmds, m.pasteImageFromClipboard)
1730 cmds = append(cmds, m.pasteImageFromClipboard)
1722 case key.Matches(msg, m.keyMap.Editor.SendMessage):
1732 case key.Matches(msg, m.keyMap.Editor.SendMessage):
1733+ prevHeight := m.textarea.Height()
1723 value := m.textarea.Value()
1734 value := m.textarea.Value()
1724 if before, ok := strings.CutSuffix(value, "\\"); ok {
1735 if before, ok := strings.CutSuffix(value, "\\"); ok {
1725 // If the last character is a backslash, remove it and add a newline.
1736 // If the last character is a backslash, remove it and add a newline.
1726 m.textarea.SetValue(before)
1737 m.textarea.SetValue(before)
1738+ if cmd := m.handleTextareaHeightChange(prevHeight); cmd != nil {
1739+ cmds = append(cmds, cmd)
1730 // Otherwise, send the message
1744 // Otherwise, send the message
1731 m.textarea.Reset()
1745 m.textarea.Reset()
1746+ if cmd := m.handleTextareaHeightChange(prevHeight); cmd != nil {
1747+ cmds = append(cmds, cmd)
1733 value = strings.TrimSpace(value)
1750 value = strings.TrimSpace(value)
1734 if value == "exit" || value == "quit" {
1751 if value == "exit" || value == "quit" {
@@ -1770,11 +1787,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1771 cmds = append(cmds, m.openEditor(m.textarea.Value()))
1788 cmds = append(cmds, m.openEditor(m.textarea.Value()))
1772 case key.Matches(msg, m.keyMap.Editor.Newline):
1789 case key.Matches(msg, m.keyMap.Editor.Newline):
1790+ prevHeight := m.textarea.Height()
1773 m.textarea.InsertRune('\n')
1791 m.textarea.InsertRune('\n')
1774 m.closeCompletions()
1792 m.closeCompletions()
1775− ta, cmd := m.textarea.Update(msg)
1793+ cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
1777− cmds = append(cmds, cmd)
1778 case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
1794 case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
1779 cmd := m.handleHistoryUp(msg)
1795 cmd := m.handleHistoryUp(msg)
1780 if cmd != nil {
1796 if cmd != nil {
@@ -1823,9 +1839,8 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1823 m.updateLayoutAndSize()
1839 m.updateLayoutAndSize()
1826− ta, cmd := m.textarea.Update(msg)
1842+ prevHeight := m.textarea.Height()
1827− m.textarea = ta
1843+ cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
1828− cmds = append(cmds, cmd)
1830 // Any text modification becomes the current draft.
1845 // Any text modification becomes the current draft.
1831 m.updateHistoryDraft(curValue)
1846 m.updateHistoryDraft(curValue)
@@ -2344,17 +2359,59 @@ func (m *UI) updateLayoutAndSize() {
2344 if m.state == uiChat {
2359 if m.state == uiChat {
2345 if m.forceCompactMode {
2360 if m.forceCompactMode {
2346 m.isCompact = true
2361 m.isCompact = true
2349− if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
2362+ } else if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
2350 m.isCompact = true
2363 m.isCompact = true
2351 } else {
2364 } else {
2352 m.isCompact = false
2365 m.isCompact = false
2369+ // First pass sizes components from the current textarea height.
2356 m.layout = m.generateLayout(m.width, m.height)
2370 m.layout = m.generateLayout(m.width, m.height)
2371+ prevHeight := m.textarea.Height()
2357 m.updateSize()
2372 m.updateSize()
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)
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 {
2390+ m.updateLayoutAndSize()
2391+ if m.state == uiChat && m.chat.Follow() {
2392+ return m.chat.ScrollToBottomAndAnimate()
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())
2403+// updateTextareaWithPrevHeight is for cases when the height of the layout may
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
2411+func (m *UI) updateTextareaWithPrevHeight(msg tea.Msg, prevHeight int) tea.Cmd {
2412+ ta, cmd := m.textarea.Update(msg)
2414+ return tea.Batch(cmd, m.handleTextareaHeightChange(prevHeight))
2360// updateSize updates the sizes of UI components based on the current layout.
2417// updateSize updates the sizes of UI components based on the current layout.
@@ -2363,11 +2420,8 @@ func (m *UI) updateSize() {
2363 m.status.SetWidth(m.layout.status.Dx())
2420 m.status.SetWidth(m.layout.status.Dx())
2365 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2422 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2423+ m.textarea.MaxHeight = TextareaMaxHeight
2366 m.textarea.SetWidth(m.layout.editor.Dx())
2424 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
2370− m.textarea.SetHeight(m.layout.editor.Dy() - 2) // Account for top margin/attachments and bottom margin
2371 m.renderPills()
2425 m.renderPills()
2373 // Handle different app states
2427 // Handle different app states
@@ -2387,8 +2441,8 @@ func (m *UI) generateLayout(w, h int) uiLayout {
2388 // The help height
2442 // The help height
2389 helpHeight := 1
2443 helpHeight := 1
2390− // The editor height
2444+ // The editor height: textarea height + margin for attachments and bottom spacing.
2391− editorHeight := 5
2445+ editorHeight := m.textarea.Height() + editorHeightMargin
2392 // The sidebar width
2446 // The sidebar width
2393 sidebarWidth := 30
2447 sidebarWidth := 30
2394 // The header height
2448 // The header height
@@ -2661,11 +2715,13 @@ func (m *UI) insertCompletionText(text string) bool {
2661// insertFileCompletion inserts the selected file path into the textarea,
2715// insertFileCompletion inserts the selected file path into the textarea,
2662// replacing the @query, and adds the file as an attachment.
2716// replacing the @query, and adds the file as an attachment.
2663func (m *UI) insertFileCompletion(path string) tea.Cmd {
2717func (m *UI) insertFileCompletion(path string) tea.Cmd {
2718+ prevHeight := m.textarea.Height()
2664 if !m.insertCompletionText(path) {
2719 if !m.insertCompletionText(path) {
2665 return nil
2720 return nil
2722+ heightCmd := m.handleTextareaHeightChange(prevHeight)
2668− return func() tea.Msg {
2724+ fileCmd := func() tea.Msg {
2669 absPath, _ := filepath.Abs(path)
2725 absPath, _ := filepath.Abs(path)
2671 if m.hasSession() {
2727 if m.hasSession() {
@@ -2696,6 +2752,7 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
2696 Content: content,
2752 Content: content,
2755+ return tea.Batch(heightCmd, fileCmd)
2701// insertMCPResourceCompletion inserts the selected resource into the textarea,
2758// insertMCPResourceCompletion inserts the selected resource into the textarea,
@@ -2703,11 +2760,13 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
2703func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
2760func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
2704 displayText := cmp.Or(item.Title, item.URI)
2761 displayText := cmp.Or(item.Title, item.URI)
2763+ prevHeight := m.textarea.Height()
2706 if !m.insertCompletionText(displayText) {
2764 if !m.insertCompletionText(displayText) {
2707 return nil
2765 return nil
2767+ heightCmd := m.handleTextareaHeightChange(prevHeight)
2710− return func() tea.Msg {
2769+ resourceCmd := func() tea.Msg {
2711 contents, err := mcp.ReadResource(
2770 contents, err := mcp.ReadResource(
2712 context.Background(),
2771 context.Background(),
2713 m.com.Store(),
2772 m.com.Store(),
@@ -2748,6 +2807,7 @@ func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValu
2748 Content: data,
2807 Content: data,
2810+ return tea.Batch(heightCmd, resourceCmd)
2753// completionsPosition returns the X and Y position for the completions popup.
2813// completionsPosition returns the X and Y position for the completions popup.
@@ -3208,9 +3268,8 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
3208 return true
3268 return true
3210 if !allExistsAndValid() {
3270 if !allExistsAndValid() {
3211− var cmd tea.Cmd
3271+ prevHeight := m.textarea.Height()
3212− m.textarea, cmd = m.textarea.Update(msg)
3272+ return m.updateTextareaWithPrevHeight(msg, prevHeight)
3216 var cmds []tea.Cmd
3275 var cmds []tea.Cmd