@@ -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.
@@ -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")
+ }
+}
@@ -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