From a81a652178eb25dde56fc5caecc9a8ebeabd13ba Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 26 Mar 2026 15:24:04 -0400 Subject: [PATCH] feat(ui): variable height prompt input field (#2468) --- 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(-) create mode 100644 internal/ui/model/layout_test.go diff --git a/go.mod b/go.mod index bb7f108fd41e155836b44459ba06006a36e06915..e96ed3726ac7c54f32e03f9ea921593fe5adf2c6 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 0d981ab9ef88cf538ba40b03a1de320b1722ccb5..c044f430e64d4f497d704ea00d3b1ff6e68ff5e7 100644 --- a/go.sum +++ b/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= diff --git a/internal/ui/model/history.go b/internal/ui/model/history.go index 5d2284ab1756257cc06b76de4621849f1e3071ba..d5eb7c464f410359f7a6dc1af84fe9c92c5aed28 100644 --- a/internal/ui/model/history.go +++ b/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. diff --git a/internal/ui/model/layout_test.go b/internal/ui/model/layout_test.go new file mode 100644 index 0000000000000000000000000000000000000000..780a5e8dcf91ab2de794e52f1a1ce27401ebac18 --- /dev/null +++ b/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") + } +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index f8ec2d9e12abbe5a5fcc892e4d65a86124039113..01d54feaf117e6a22b7015c27186ea94063d55cc 100644 --- a/internal/ui/model/ui.go +++ b/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