on main help sign in
feat(ui): variable height prompt input field (#2468)
commita81a652178eb25dde56fc5caecc9a8ebeabd13ba authorChristian Rocha <christian@rocha.is> dateThu 26 Mar 2026, 15:24 (7 months ago) parent2928dcb
5 files changed+218−52
go.mod+2−2 go.sum+4−4 internal/ui/model/history.go+10−21 internal/ui/model/layout_test.go+118 internal/ui/model/ui.go+84−25
go.mod+2−2
@@ -3,7 +3,7 @@ module github.com/charmbracelet/crush
33go 1.26.1
44
55require (
6− charm.land/bubbles/v2 v2.0.0
6+ charm.land/bubbles/v2 v2.1.0
77 charm.land/bubbletea/v2 v2.0.2
88 charm.land/catwalk v0.31.1
99 charm.land/fang/v2 v2.0.1
@@ -142,7 +142,7 @@ require (
142142 github.com/klauspost/cpuid/v2 v2.2.10 // indirect
143143 github.com/klauspost/pgzip v1.2.6 // indirect
144144 github.com/mailru/easyjson v0.7.7 // indirect
145− github.com/mattn/go-runewidth v0.0.20 // indirect
145+ github.com/mattn/go-runewidth v0.0.21 // indirect
146146 github.com/mfridman/interpolate v0.0.2 // indirect
147147 github.com/microcosm-cc/bluemonday v1.0.27 // indirect
148148 github.com/mitchellh/mapstructure v1.5.0 // indirect
go.sum+4−4
@@ -1,5 +1,5 @@
1−charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
2−charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
1+charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
2+charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
33charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
44charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
55charm.land/catwalk v0.31.1 h1:4JJmx2f1UkrBM9b3sjunkYp3G+isDdJnn99pPc2D1aU=
@@ -271,8 +271,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
271271github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
272272github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
273273github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
274−github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
275−github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
274+github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
275+github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
276276github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
277277github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
278278github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
internal/ui/model/history.go+10−21
@@ -43,14 +43,13 @@ func (m *UI) loadPromptHistory() tea.Cmd {
4343
4444// handleHistoryUp handles up arrow for history navigation.
4545func (m *UI) handleHistoryUp(msg tea.Msg) tea.Cmd {
46+ prevHeight := m.textarea.Height()
4647 // Navigate to older history entry from cursor position (0,0).
4748 if m.textarea.Length() == 0 || m.isAtEditorStart() {
4849 if m.historyPrev() {
4950 // we send this so that the textarea moves the view to the correct position
5051 // without this the cursor will show up in the wrong place.
51− ta, cmd := m.textarea.Update(nil)
52− m.textarea = ta
53− return cmd
52+ return m.updateTextareaWithPrevHeight(nil, prevHeight)
5453 }
5554 }
5655
@@ -61,54 +60,44 @@ func (m *UI) handleHistoryUp(msg tea.Msg) tea.Cmd {
6160 }
6261
6362 // Let textarea handle normal cursor movement.
64− ta, cmd := m.textarea.Update(msg)
65− m.textarea = ta
66− return cmd
63+ return m.updateTextarea(msg)
6764}
6865
6966// handleHistoryDown handles down arrow for history navigation.
7067func (m *UI) handleHistoryDown(msg tea.Msg) tea.Cmd {
68+ prevHeight := m.textarea.Height()
7169 // Navigate to newer history entry from end of text.
7270 if m.isAtEditorEnd() {
7371 if m.historyNext() {
7472 // we send this so that the textarea moves the view to the correct position
7573 // without this the cursor will show up in the wrong place.
76− ta, cmd := m.textarea.Update(nil)
77− m.textarea = ta
78− return cmd
74+ return m.updateTextareaWithPrevHeight(nil, prevHeight)
7975 }
8076 }
8177
8278 // First move cursor to end before navigating history.
8379 if m.textarea.Line() == max(m.textarea.LineCount()-1, 0) {
8480 m.textarea.MoveToEnd()
85− ta, cmd := m.textarea.Update(nil)
86− m.textarea = ta
87− return cmd
81+ return m.updateTextarea(nil)
8882 }
8983
9084 // Let textarea handle normal cursor movement.
91− ta, cmd := m.textarea.Update(msg)
92− m.textarea = ta
93− return cmd
85+ return m.updateTextarea(msg)
9486}
9587
9688// handleHistoryEscape handles escape for exiting history navigation.
9789func (m *UI) handleHistoryEscape(msg tea.Msg) tea.Cmd {
90+ prevHeight := m.textarea.Height()
9891 // Return to current draft when browsing history.
9992 if m.promptHistory.index >= 0 {
10093 m.promptHistory.index = -1
10194 m.textarea.Reset()
10295 m.textarea.InsertString(m.promptHistory.draft)
103− ta, cmd := m.textarea.Update(nil)
104− m.textarea = ta
105− return cmd
96+ return m.updateTextareaWithPrevHeight(nil, prevHeight)
10697 }
10798
10899 // Let textarea handle escape normally.
109− ta, cmd := m.textarea.Update(msg)
110− m.textarea = ta
111− return cmd
100+ return m.updateTextarea(msg)
112101}
113102
114103// updateHistoryDraft updates history state when text is modified.
internal/ui/model/layout_test.gonew file+118
@@ -0,0 +1,118 @@
1+package model
2+
3+import (
4+ "strconv"
5+ "strings"
6+ "testing"
7+
8+ "charm.land/bubbles/v2/textarea"
9+ "github.com/charmbracelet/crush/internal/ui/chat"
10+ "github.com/charmbracelet/crush/internal/ui/common"
11+)
12+
13+// testMessageItem is a minimal chat item used to populate the chat list
14+// without pulling in full message rendering machinery.
15+type testMessageItem struct {
16+ id string
17+ text string
18+}
19+
20+func (m testMessageItem) ID() string { return m.id }
21+func (m testMessageItem) Render(int) string { return m.text }
22+func (m testMessageItem) RawRender(int) string { return m.text }
23+
24+var _ chat.MessageItem = testMessageItem{}
25+
26+// newTestUI builds a focused uiChat model with dynamic textarea sizing enabled.
27+// It intentionally keeps dependencies minimal so layout behavior can be tested
28+// in isolation.
29+func newTestUI() *UI {
30+ com := common.DefaultCommon(nil)
31+
32+ ta := textarea.New()
33+ ta.SetStyles(com.Styles.TextArea)
34+ ta.ShowLineNumbers = false
35+ ta.CharLimit = -1
36+ ta.SetVirtualCursor(false)
37+ ta.DynamicHeight = true
38+ ta.MinHeight = TextareaMinHeight
39+ ta.MaxHeight = TextareaMaxHeight
40+ ta.Focus()
41+
42+ u := &UI{
43+ com: com,
44+ status: NewStatus(com, nil),
45+ chat: NewChat(com),
46+ textarea: ta,
47+ state: uiChat,
48+ focus: uiFocusEditor,
49+ width: 140,
50+ height: 45,
51+ }
52+
53+ return u
54+}
55+
56+func TestUpdateLayoutAndSize_EditorGrowthShrinksChat(t *testing.T) {
57+ t.Parallel()
58+
59+ // Baseline layout at min textarea height.
60+ u := newTestUI()
61+ u.updateLayoutAndSize()
62+
63+ initialEditorHeight := u.layout.editor.Dy()
64+ initialChatHeight := u.layout.main.Dy()
65+
66+ // Increase textarea content enough to trigger growth, then run the
67+ // same resize hook used in the real update path.
68+ prevHeight := u.textarea.Height()
69+ u.textarea.SetValue(strings.Repeat("line\n", 8))
70+ u.textarea.MoveToEnd()
71+ _ = u.handleTextareaHeightChange(prevHeight)
72+
73+ if got := u.layout.editor.Dy(); got <= initialEditorHeight {
74+ t.Fatalf("expected editor to grow: got %d, want > %d", got, initialEditorHeight)
75+ }
76+
77+ if got := u.layout.main.Dy(); got >= initialChatHeight {
78+ t.Fatalf("expected chat to shrink: got %d, want < %d", got, initialChatHeight)
79+ }
80+}
81+
82+func TestHandleTextareaHeightChange_FollowModeStaysAtBottom(t *testing.T) {
83+ t.Parallel()
84+
85+ // Use enough messages to make the chat scrollable so AtBottom/Follow
86+ // assertions are meaningful.
87+ u := newTestUI()
88+
89+ msgs := make([]chat.MessageItem, 0, 60)
90+ for i := range 60 {
91+ msgs = append(msgs, testMessageItem{
92+ id: "m-" + strconv.Itoa(i),
93+ text: "message " + strconv.Itoa(i),
94+ })
95+ }
96+ u.chat.SetMessages(msgs...)
97+ u.updateLayoutAndSize()
98+
99+ // Enter follow mode and verify we're anchored at the bottom first.
100+ u.chat.ScrollToBottom()
101+ if !u.chat.AtBottom() {
102+ t.Fatal("expected chat to start at bottom")
103+ }
104+
105+ // Grow the editor; follow mode should keep the chat pinned to the end
106+ // even as the chat viewport shrinks.
107+ prevHeight := u.textarea.Height()
108+ u.textarea.SetValue(strings.Repeat("line\n", 10))
109+ u.textarea.MoveToEnd()
110+ _ = u.handleTextareaHeightChange(prevHeight)
111+
112+ if !u.chat.Follow() {
113+ t.Fatal("expected follow mode to remain enabled")
114+ }
115+ if !u.chat.AtBottom() {
116+ t.Fatal("expected chat to remain at bottom after editor resize in follow mode")
117+ }
118+}
internal/ui/model/ui.go+84−25
@@ -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