feat(ui): variable height prompt input field (#2468)

Christian Rocha created

Change summary

go.mod                           |   4 
go.sum                           |   8 +-
internal/ui/model/history.go     |  31 ++------
internal/ui/model/layout_test.go | 118 ++++++++++++++++++++++++++++++++++
internal/ui/model/ui.go          | 109 ++++++++++++++++++++++++-------
5 files changed, 218 insertions(+), 52 deletions(-)

Detailed changes

go.mod 🔗

@@ -3,7 +3,7 @@ module github.com/charmbracelet/crush
 go 1.26.1
 
 require (
-	charm.land/bubbles/v2 v2.0.0
+	charm.land/bubbles/v2 v2.1.0
 	charm.land/bubbletea/v2 v2.0.2
 	charm.land/catwalk v0.31.1
 	charm.land/fang/v2 v2.0.1
@@ -142,7 +142,7 @@ require (
 	github.com/klauspost/cpuid/v2 v2.2.10 // indirect
 	github.com/klauspost/pgzip v1.2.6 // indirect
 	github.com/mailru/easyjson v0.7.7 // indirect
-	github.com/mattn/go-runewidth v0.0.20 // indirect
+	github.com/mattn/go-runewidth v0.0.21 // indirect
 	github.com/mfridman/interpolate v0.0.2 // indirect
 	github.com/microcosm-cc/bluemonday v1.0.27 // indirect
 	github.com/mitchellh/mapstructure v1.5.0 // indirect

go.sum 🔗

@@ -1,5 +1,5 @@
-charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
-charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
+charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
+charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
 charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
 charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
 charm.land/catwalk v0.31.1 h1:4JJmx2f1UkrBM9b3sjunkYp3G+isDdJnn99pPc2D1aU=
@@ -271,8 +271,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
-github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
+github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
 github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
 github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=

internal/ui/model/history.go 🔗

@@ -43,14 +43,13 @@ func (m *UI) loadPromptHistory() tea.Cmd {
 
 // handleHistoryUp handles up arrow for history navigation.
 func (m *UI) handleHistoryUp(msg tea.Msg) tea.Cmd {
+	prevHeight := m.textarea.Height()
 	// Navigate to older history entry from cursor position (0,0).
 	if m.textarea.Length() == 0 || m.isAtEditorStart() {
 		if m.historyPrev() {
 			// we send this so that the textarea moves the view to the correct position
 			// without this the cursor will show up in the wrong place.
-			ta, cmd := m.textarea.Update(nil)
-			m.textarea = ta
-			return cmd
+			return m.updateTextareaWithPrevHeight(nil, prevHeight)
 		}
 	}
 
@@ -61,54 +60,44 @@ func (m *UI) handleHistoryUp(msg tea.Msg) tea.Cmd {
 	}
 
 	// Let textarea handle normal cursor movement.
-	ta, cmd := m.textarea.Update(msg)
-	m.textarea = ta
-	return cmd
+	return m.updateTextarea(msg)
 }
 
 // handleHistoryDown handles down arrow for history navigation.
 func (m *UI) handleHistoryDown(msg tea.Msg) tea.Cmd {
+	prevHeight := m.textarea.Height()
 	// Navigate to newer history entry from end of text.
 	if m.isAtEditorEnd() {
 		if m.historyNext() {
 			// we send this so that the textarea moves the view to the correct position
 			// without this the cursor will show up in the wrong place.
-			ta, cmd := m.textarea.Update(nil)
-			m.textarea = ta
-			return cmd
+			return m.updateTextareaWithPrevHeight(nil, prevHeight)
 		}
 	}
 
 	// First move cursor to end before navigating history.
 	if m.textarea.Line() == max(m.textarea.LineCount()-1, 0) {
 		m.textarea.MoveToEnd()
-		ta, cmd := m.textarea.Update(nil)
-		m.textarea = ta
-		return cmd
+		return m.updateTextarea(nil)
 	}
 
 	// Let textarea handle normal cursor movement.
-	ta, cmd := m.textarea.Update(msg)
-	m.textarea = ta
-	return cmd
+	return m.updateTextarea(msg)
 }
 
 // handleHistoryEscape handles escape for exiting history navigation.
 func (m *UI) handleHistoryEscape(msg tea.Msg) tea.Cmd {
+	prevHeight := m.textarea.Height()
 	// Return to current draft when browsing history.
 	if m.promptHistory.index >= 0 {
 		m.promptHistory.index = -1
 		m.textarea.Reset()
 		m.textarea.InsertString(m.promptHistory.draft)
-		ta, cmd := m.textarea.Update(nil)
-		m.textarea = ta
-		return cmd
+		return m.updateTextareaWithPrevHeight(nil, prevHeight)
 	}
 
 	// Let textarea handle escape normally.
-	ta, cmd := m.textarea.Update(msg)
-	m.textarea = ta
-	return cmd
+	return m.updateTextarea(msg)
 }
 
 // updateHistoryDraft updates history state when text is modified.

internal/ui/model/layout_test.go 🔗

@@ -0,0 +1,118 @@
+package model
+
+import (
+	"strconv"
+	"strings"
+	"testing"
+
+	"charm.land/bubbles/v2/textarea"
+	"github.com/charmbracelet/crush/internal/ui/chat"
+	"github.com/charmbracelet/crush/internal/ui/common"
+)
+
+// testMessageItem is a minimal chat item used to populate the chat list
+// without pulling in full message rendering machinery.
+type testMessageItem struct {
+	id   string
+	text string
+}
+
+func (m testMessageItem) ID() string           { return m.id }
+func (m testMessageItem) Render(int) string    { return m.text }
+func (m testMessageItem) RawRender(int) string { return m.text }
+
+var _ chat.MessageItem = testMessageItem{}
+
+// newTestUI builds a focused uiChat model with dynamic textarea sizing enabled.
+// It intentionally keeps dependencies minimal so layout behavior can be tested
+// in isolation.
+func newTestUI() *UI {
+	com := common.DefaultCommon(nil)
+
+	ta := textarea.New()
+	ta.SetStyles(com.Styles.TextArea)
+	ta.ShowLineNumbers = false
+	ta.CharLimit = -1
+	ta.SetVirtualCursor(false)
+	ta.DynamicHeight = true
+	ta.MinHeight = TextareaMinHeight
+	ta.MaxHeight = TextareaMaxHeight
+	ta.Focus()
+
+	u := &UI{
+		com:      com,
+		status:   NewStatus(com, nil),
+		chat:     NewChat(com),
+		textarea: ta,
+		state:    uiChat,
+		focus:    uiFocusEditor,
+		width:    140,
+		height:   45,
+	}
+
+	return u
+}
+
+func TestUpdateLayoutAndSize_EditorGrowthShrinksChat(t *testing.T) {
+	t.Parallel()
+
+	// Baseline layout at min textarea height.
+	u := newTestUI()
+	u.updateLayoutAndSize()
+
+	initialEditorHeight := u.layout.editor.Dy()
+	initialChatHeight := u.layout.main.Dy()
+
+	// Increase textarea content enough to trigger growth, then run the
+	// same resize hook used in the real update path.
+	prevHeight := u.textarea.Height()
+	u.textarea.SetValue(strings.Repeat("line\n", 8))
+	u.textarea.MoveToEnd()
+	_ = u.handleTextareaHeightChange(prevHeight)
+
+	if got := u.layout.editor.Dy(); got <= initialEditorHeight {
+		t.Fatalf("expected editor to grow: got %d, want > %d", got, initialEditorHeight)
+	}
+
+	if got := u.layout.main.Dy(); got >= initialChatHeight {
+		t.Fatalf("expected chat to shrink: got %d, want < %d", got, initialChatHeight)
+	}
+}
+
+func TestHandleTextareaHeightChange_FollowModeStaysAtBottom(t *testing.T) {
+	t.Parallel()
+
+	// Use enough messages to make the chat scrollable so AtBottom/Follow
+	// assertions are meaningful.
+	u := newTestUI()
+
+	msgs := make([]chat.MessageItem, 0, 60)
+	for i := range 60 {
+		msgs = append(msgs, testMessageItem{
+			id:   "m-" + strconv.Itoa(i),
+			text: "message " + strconv.Itoa(i),
+		})
+	}
+	u.chat.SetMessages(msgs...)
+	u.updateLayoutAndSize()
+
+	// Enter follow mode and verify we're anchored at the bottom first.
+	u.chat.ScrollToBottom()
+	if !u.chat.AtBottom() {
+		t.Fatal("expected chat to start at bottom")
+	}
+
+	// Grow the editor; follow mode should keep the chat pinned to the end
+	// even as the chat viewport shrinks.
+	prevHeight := u.textarea.Height()
+	u.textarea.SetValue(strings.Repeat("line\n", 10))
+	u.textarea.MoveToEnd()
+	_ = u.handleTextareaHeightChange(prevHeight)
+
+	if !u.chat.Follow() {
+		t.Fatal("expected follow mode to remain enabled")
+	}
+	if !u.chat.AtBottom() {
+		t.Fatal("expected chat to remain at bottom after editor resize in follow mode")
+	}
+}

internal/ui/model/ui.go 🔗

@@ -75,6 +75,16 @@ const pasteColsThreshold = 1000
 // Session details panel max height.
 const sessionDetailsMaxHeight = 20
 
+// TextareaMaxHeight is the maximum height of the prompt textarea.
+const TextareaMaxHeight = 15
+
+// editorHeightMargin is the vertical margin added to the textarea height to
+// account for the attachments row (top) and bottom margin.
+const editorHeightMargin = 2
+
+// TextareaMinHeight is the minimum height of the prompt textarea.
+const TextareaMinHeight = 3
+
 // uiFocusState represents the current focus state of the UI.
 type uiFocusState uint8
 
@@ -257,6 +267,9 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
 	ta.ShowLineNumbers = false
 	ta.CharLimit = -1
 	ta.SetVirtualCursor(false)
+	ta.DynamicHeight = true
+	ta.MinHeight = TextareaMinHeight
+	ta.MaxHeight = TextareaMaxHeight
 	ta.Focus()
 
 	ch := NewChat(com)
@@ -818,13 +831,10 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			cmds = append(cmds, cmd)
 		}
 	case openEditorMsg:
-		var cmd tea.Cmd
+		prevHeight := m.textarea.Height()
 		m.textarea.SetValue(msg.Text)
 		m.textarea.MoveToEnd()
-		m.textarea, cmd = m.textarea.Update(msg)
-		if cmd != nil {
-			cmds = append(cmds, cmd)
-		}
+		cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
 	case util.InfoMsg:
 		m.status.SetInfoMsg(msg)
 		ttl := msg.TTL
@@ -1720,15 +1730,22 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 				cmds = append(cmds, m.pasteImageFromClipboard)
 
 			case key.Matches(msg, m.keyMap.Editor.SendMessage):
+				prevHeight := m.textarea.Height()
 				value := m.textarea.Value()
 				if before, ok := strings.CutSuffix(value, "\\"); ok {
 					// If the last character is a backslash, remove it and add a newline.
 					m.textarea.SetValue(before)
+					if cmd := m.handleTextareaHeightChange(prevHeight); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
 					break
 				}
 
 				// Otherwise, send the message
 				m.textarea.Reset()
+				if cmd := m.handleTextareaHeightChange(prevHeight); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 
 				value = strings.TrimSpace(value)
 				if value == "exit" || value == "quit" {
@@ -1770,11 +1787,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 				}
 				cmds = append(cmds, m.openEditor(m.textarea.Value()))
 			case key.Matches(msg, m.keyMap.Editor.Newline):
+				prevHeight := m.textarea.Height()
 				m.textarea.InsertRune('\n')
 				m.closeCompletions()
-				ta, cmd := m.textarea.Update(msg)
-				m.textarea = ta
-				cmds = append(cmds, cmd)
+				cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
 			case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
 				cmd := m.handleHistoryUp(msg)
 				if cmd != nil {
@@ -1823,9 +1839,8 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 					m.updateLayoutAndSize()
 				}
 
-				ta, cmd := m.textarea.Update(msg)
-				m.textarea = ta
-				cmds = append(cmds, cmd)
+				prevHeight := m.textarea.Height()
+				cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
 
 				// Any text modification becomes the current draft.
 				m.updateHistoryDraft(curValue)
@@ -2344,17 +2359,59 @@ func (m *UI) updateLayoutAndSize() {
 	if m.state == uiChat {
 		if m.forceCompactMode {
 			m.isCompact = true
-			return
-		}
-		if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
+		} else if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
 			m.isCompact = true
 		} else {
 			m.isCompact = false
 		}
 	}
 
+	// First pass sizes components from the current textarea height.
 	m.layout = m.generateLayout(m.width, m.height)
+	prevHeight := m.textarea.Height()
 	m.updateSize()
+
+	// SetWidth can change textarea height due to soft-wrap recalculation.
+	// If that happens, run one reconciliation pass with the new height.
+	if m.textarea.Height() != prevHeight {
+		m.layout = m.generateLayout(m.width, m.height)
+		m.updateSize()
+	}
+}
+
+// handleTextareaHeightChange checks whether the textarea height changed and,
+// if so, recalculates the layout. When the chat is in follow mode it keeps
+// the view scrolled to the bottom. The returned command, if non-nil, must be
+// batched by the caller.
+func (m *UI) handleTextareaHeightChange(prevHeight int) tea.Cmd {
+	if m.textarea.Height() == prevHeight {
+		return nil
+	}
+	m.updateLayoutAndSize()
+	if m.state == uiChat && m.chat.Follow() {
+		return m.chat.ScrollToBottomAndAnimate()
+	}
+	return nil
+}
+
+// updateTextarea updates the textarea for msg and then reconciles layout if
+// the textarea height changed as a result.
+func (m *UI) updateTextarea(msg tea.Msg) tea.Cmd {
+	return m.updateTextareaWithPrevHeight(msg, m.textarea.Height())
+}
+
+// updateTextareaWithPrevHeight is for cases when the height of the layout may
+// have changed.
+//
+// Particularly, it's for cases where the textarea changes before
+// textarea.Update is called (for example, SetValue, Reset, and InsertRune). We
+// pass the height from before those changes took place so we can compare
+// "before" vs "after" sizing and recalculate the layout if the textarea grew
+// or shrank.
+func (m *UI) updateTextareaWithPrevHeight(msg tea.Msg, prevHeight int) tea.Cmd {
+	ta, cmd := m.textarea.Update(msg)
+	m.textarea = ta
+	return tea.Batch(cmd, m.handleTextareaHeightChange(prevHeight))
 }
 
 // updateSize updates the sizes of UI components based on the current layout.
@@ -2363,11 +2420,8 @@ func (m *UI) updateSize() {
 	m.status.SetWidth(m.layout.status.Dx())
 
 	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
+	m.textarea.MaxHeight = TextareaMaxHeight
 	m.textarea.SetWidth(m.layout.editor.Dx())
-	// TODO: Abstract the textarea and attachments into a single editor
-	// component so we don't have to manually account for the attachments
-	// height here.
-	m.textarea.SetHeight(m.layout.editor.Dy() - 2) // Account for top margin/attachments and bottom margin
 	m.renderPills()
 
 	// Handle different app states
@@ -2387,8 +2441,8 @@ func (m *UI) generateLayout(w, h int) uiLayout {
 
 	// The help height
 	helpHeight := 1
-	// The editor height
-	editorHeight := 5
+	// The editor height: textarea height + margin for attachments and bottom spacing.
+	editorHeight := m.textarea.Height() + editorHeightMargin
 	// The sidebar width
 	sidebarWidth := 30
 	// The header height
@@ -2661,11 +2715,13 @@ func (m *UI) insertCompletionText(text string) bool {
 // insertFileCompletion inserts the selected file path into the textarea,
 // replacing the @query, and adds the file as an attachment.
 func (m *UI) insertFileCompletion(path string) tea.Cmd {
+	prevHeight := m.textarea.Height()
 	if !m.insertCompletionText(path) {
 		return nil
 	}
+	heightCmd := m.handleTextareaHeightChange(prevHeight)
 
-	return func() tea.Msg {
+	fileCmd := func() tea.Msg {
 		absPath, _ := filepath.Abs(path)
 
 		if m.hasSession() {
@@ -2696,6 +2752,7 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
 			Content:  content,
 		}
 	}
+	return tea.Batch(heightCmd, fileCmd)
 }
 
 // insertMCPResourceCompletion inserts the selected resource into the textarea,
@@ -2703,11 +2760,13 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
 func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
 	displayText := cmp.Or(item.Title, item.URI)
 
+	prevHeight := m.textarea.Height()
 	if !m.insertCompletionText(displayText) {
 		return nil
 	}
+	heightCmd := m.handleTextareaHeightChange(prevHeight)
 
-	return func() tea.Msg {
+	resourceCmd := func() tea.Msg {
 		contents, err := mcp.ReadResource(
 			context.Background(),
 			m.com.Store(),
@@ -2748,6 +2807,7 @@ func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValu
 			Content:  data,
 		}
 	}
+	return tea.Batch(heightCmd, resourceCmd)
 }
 
 // completionsPosition returns the X and Y position for the completions popup.
@@ -3208,9 +3268,8 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
 		return true
 	}
 	if !allExistsAndValid() {
-		var cmd tea.Cmd
-		m.textarea, cmd = m.textarea.Update(msg)
-		return cmd
+		prevHeight := m.textarea.Height()
+		return m.updateTextareaWithPrevHeight(msg, prevHeight)
 	}
 
 	var cmds []tea.Cmd