on main help sign in
feat(ui): variable height prompt input field (#2468)
a81a652Christian Rocha
go.mod+2−2
@@ -3,7 +3,7 @@ module github.com/charmbracelet/crush
3go 1.26.1
3go 1.26.1
4
4
5require (
5require (
6− charm.land/bubbles/v2 v2.0.0
6+ charm.land/bubbles/v2 v2.1.0
7 charm.land/bubbletea/v2 v2.0.2
7 charm.land/bubbletea/v2 v2.0.2
8 charm.land/catwalk v0.31.1
8 charm.land/catwalk v0.31.1
9 charm.land/fang/v2 v2.0.1
9 charm.land/fang/v2 v2.0.1
@@ -142,7 +142,7 @@ require (
142 github.com/klauspost/cpuid/v2 v2.2.10 // indirect
142 github.com/klauspost/cpuid/v2 v2.2.10 // indirect
143 github.com/klauspost/pgzip v1.2.6 // indirect
143 github.com/klauspost/pgzip v1.2.6 // indirect
144 github.com/mailru/easyjson v0.7.7 // indirect
144 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
146 github.com/mfridman/interpolate v0.0.2 // indirect
146 github.com/mfridman/interpolate v0.0.2 // indirect
147 github.com/microcosm-cc/bluemonday v1.0.27 // indirect
147 github.com/microcosm-cc/bluemonday v1.0.27 // indirect
148 github.com/mitchellh/mapstructure v1.5.0 // indirect
148 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=
1+charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
2−charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
2+charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
3charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
3charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
4charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
4charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
5charm.land/catwalk v0.31.1 h1:4JJmx2f1UkrBM9b3sjunkYp3G+isDdJnn99pPc2D1aU=
5charm.land/catwalk v0.31.1 h1:4JJmx2f1UkrBM9b3sjunkYp3G+isDdJnn99pPc2D1aU=
@@ -271,8 +271,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
271github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
271github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
272github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
272github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
273github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
273github.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=
274+github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
275−github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
275+github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
276github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
276github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
277github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
277github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
278github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
278github.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 {
43
43
44// handleHistoryUp handles up arrow for history navigation.
44// handleHistoryUp handles up arrow for history navigation.
45func (m *UI) handleHistoryUp(msg tea.Msg) tea.Cmd {
45func (m *UI) handleHistoryUp(msg tea.Msg) tea.Cmd {
46+ prevHeight := m.textarea.Height()
46 // Navigate to older history entry from cursor position (0,0).
47 // Navigate to older history entry from cursor position (0,0).
47 if m.textarea.Length() == 0 || m.isAtEditorStart() {
48 if m.textarea.Length() == 0 || m.isAtEditorStart() {
48 if m.historyPrev() {
49 if m.historyPrev() {
49 // we send this so that the textarea moves the view to the correct position
50 // we send this so that the textarea moves the view to the correct position
50 // without this the cursor will show up in the wrong place.
51 // without this the cursor will show up in the wrong place.
51− ta, cmd := m.textarea.Update(nil)
52+ return m.updateTextareaWithPrevHeight(nil, prevHeight)
52− m.textarea = ta
53− return cmd
54 }
53 }
55 }
54 }
56
55
@@ -61,54 +60,44 @@ func (m *UI) handleHistoryUp(msg tea.Msg) tea.Cmd {
61 }
60 }
62
61
63 // Let textarea handle normal cursor movement.
62 // Let textarea handle normal cursor movement.
64− ta, cmd := m.textarea.Update(msg)
63+ return m.updateTextarea(msg)
65− m.textarea = ta
66− return cmd
67}
64}
68
65
69// handleHistoryDown handles down arrow for history navigation.
66// handleHistoryDown handles down arrow for history navigation.
70func (m *UI) handleHistoryDown(msg tea.Msg) tea.Cmd {
67func (m *UI) handleHistoryDown(msg tea.Msg) tea.Cmd {
68+ prevHeight := m.textarea.Height()
71 // Navigate to newer history entry from end of text.
69 // Navigate to newer history entry from end of text.
72 if m.isAtEditorEnd() {
70 if m.isAtEditorEnd() {
73 if m.historyNext() {
71 if m.historyNext() {
74 // we send this so that the textarea moves the view to the correct position
72 // we send this so that the textarea moves the view to the correct position
75 // without this the cursor will show up in the wrong place.
73 // without this the cursor will show up in the wrong place.
76− ta, cmd := m.textarea.Update(nil)
74+ return m.updateTextareaWithPrevHeight(nil, prevHeight)
77− m.textarea = ta
78− return cmd
79 }
75 }
80 }
76 }
81
77
82 // First move cursor to end before navigating history.
78 // First move cursor to end before navigating history.
83 if m.textarea.Line() == max(m.textarea.LineCount()-1, 0) {
79 if m.textarea.Line() == max(m.textarea.LineCount()-1, 0) {
84 m.textarea.MoveToEnd()
80 m.textarea.MoveToEnd()
85− ta, cmd := m.textarea.Update(nil)
81+ return m.updateTextarea(nil)
86− m.textarea = ta
87− return cmd
88 }
82 }
89
83
90 // Let textarea handle normal cursor movement.
84 // Let textarea handle normal cursor movement.
91− ta, cmd := m.textarea.Update(msg)
85+ return m.updateTextarea(msg)
92− m.textarea = ta
93− return cmd
94}
86}
95
87
96// handleHistoryEscape handles escape for exiting history navigation.
88// handleHistoryEscape handles escape for exiting history navigation.
97func (m *UI) handleHistoryEscape(msg tea.Msg) tea.Cmd {
89func (m *UI) handleHistoryEscape(msg tea.Msg) tea.Cmd {
90+ prevHeight := m.textarea.Height()
98 // Return to current draft when browsing history.
91 // Return to current draft when browsing history.
99 if m.promptHistory.index >= 0 {
92 if m.promptHistory.index >= 0 {
100 m.promptHistory.index = -1
93 m.promptHistory.index = -1
101 m.textarea.Reset()
94 m.textarea.Reset()
102 m.textarea.InsertString(m.promptHistory.draft)
95 m.textarea.InsertString(m.promptHistory.draft)
103− ta, cmd := m.textarea.Update(nil)
96+ return m.updateTextareaWithPrevHeight(nil, prevHeight)
104− m.textarea = ta
105− return cmd
106 }
97 }
107
98
108 // Let textarea handle escape normally.
99 // Let textarea handle escape normally.
109− ta, cmd := m.textarea.Update(msg)
100+ return m.updateTextarea(msg)
110− m.textarea = ta
111− return cmd
112}
101}
113
102
114// updateHistoryDraft updates history state when text is modified.
103// 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
75// Session details panel max height.
75// Session details panel max height.
76const sessionDetailsMaxHeight = 20
76const sessionDetailsMaxHeight = 20
77
77
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+
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
80
90
@@ -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()
261
274
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)
819 }
832 }
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))
825− if cmd != nil {
826− cmds = append(cmds, cmd)
827− }
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)
1721
1731
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)
1740+ }
1727 break
1741 break
1728 }
1742 }
1729
1743
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)
1748+ }
1732
1749
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 {
1770 }
1787 }
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))
1776− m.textarea = ta
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()
1824 }
1840 }
1825
1841
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)
1829
1844
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
2347− return
2348− }
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
2353 }
2366 }
2354 }
2367 }
2355
2368
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()
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))
2358}
2415}
2359
2416
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())
2364
2421
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
2369− // height here.
2370− m.textarea.SetHeight(m.layout.editor.Dy() - 2) // Account for top margin/attachments and bottom margin
2371 m.renderPills()
2425 m.renderPills()
2372
2426
2373 // Handle different app states
2427 // Handle different app states
@@ -2387,8 +2441,8 @@ func (m *UI) generateLayout(w, h int) uiLayout {
2387
2441
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
2666 }
2721 }
2722+ heightCmd := m.handleTextareaHeightChange(prevHeight)
2667
2723
2668− return func() tea.Msg {
2724+ fileCmd := func() tea.Msg {
2669 absPath, _ := filepath.Abs(path)
2725 absPath, _ := filepath.Abs(path)
2670
2726
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,
2697 }
2753 }
2698 }
2754 }
2755+ return tea.Batch(heightCmd, fileCmd)
2699}
2756}
2700
2757
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)
2705
2762
2763+ prevHeight := m.textarea.Height()
2706 if !m.insertCompletionText(displayText) {
2764 if !m.insertCompletionText(displayText) {
2707 return nil
2765 return nil
2708 }
2766 }
2767+ heightCmd := m.handleTextareaHeightChange(prevHeight)
2709
2768
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,
2749 }
2808 }
2750 }
2809 }
2810+ return tea.Batch(heightCmd, resourceCmd)
2751}
2811}
2752
2812
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
3209 }
3269 }
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)
3213− return cmd
3214 }
3273 }
3215
3274
3216 var cmds []tea.Cmd
3275 var cmds []tea.Cmd