diff --git a/pkg/tui/components/messages/messages.go b/pkg/tui/components/messages/messages.go index 67f2cce34..66f005398 100644 --- a/pkg/tui/components/messages/messages.go +++ b/pkg/tui/components/messages/messages.go @@ -242,6 +242,15 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) { } // Fall through to forward tick to all views + case tea.PasteMsg: + // Insert paste content into the inline edit textarea + if m.inlineEditMsgIndex >= 0 { + m.inlineEditTextarea.InsertString(msg.Content) + m.invalidateItem(m.inlineEditMsgIndex) + m.renderDirty = true + } + return m, nil + case tea.KeyPressMsg: return m.handleKeyPress(msg) } @@ -378,7 +387,6 @@ func (m *model) handleKeyPress(msg tea.KeyPressMsg) (layout.Model, tea.Cmd) { // Forward to textarea for newline insertion var cmd tea.Cmd m.inlineEditTextarea, cmd = m.inlineEditTextarea.Update(msg) - m.updateInlineEditTextareaHeight() m.invalidateItem(m.inlineEditMsgIndex) m.renderDirty = true return m, cmd @@ -397,7 +405,6 @@ func (m *model) handleKeyPress(msg tea.KeyPressMsg) (layout.Model, tea.Cmd) { // Forward all other keys to the textarea var cmd tea.Cmd m.inlineEditTextarea, cmd = m.inlineEditTextarea.Update(msg) - m.updateInlineEditTextareaHeight() m.invalidateItem(m.inlineEditMsgIndex) m.renderDirty = true return m, cmd @@ -954,55 +961,36 @@ func (m *model) renderInlineEditTextarea() string { m.inlineEditTextarea.SetWidth(innerWidth) } + // The textarea is set to a large height to prevent internal viewport scrolling + // which causes cursor positioning bugs in multi-line content. We trim the + // end-of-buffer padding lines from the rendered output. + view := m.inlineEditTextarea.View() + view = trimEndOfBufferLines(view) + // Add a minimal edit indicator at the bottom left with extra padding editHint := styles.MutedStyle.Render("[editing]") - content := m.inlineEditTextarea.View() + "\n\n" + editHint + content := view + "\n\n" + editHint return editStyle.Width(m.contentWidth()).Render(content) } -// updateInlineEditTextareaHeight recalculates and sets the textarea height based on current content. -func (m *model) updateInlineEditTextareaHeight() { - if m.inlineEditMsgIndex < 0 { - return - } +// trimEndOfBufferLines removes trailing end-of-buffer padding lines from a +// textarea's rendered View output. The textarea pads its view to fill its +// configured height; these padding lines contain only whitespace (after +// stripping ANSI sequences) and appear after the actual content. +func trimEndOfBufferLines(view string) string { + lines := strings.Split(view, "\n") - editStyle := styles.UserMessageStyle - innerWidth := m.contentWidth() - editStyle.GetHorizontalFrameSize() - if innerWidth <= 0 { - return + // Trim trailing lines that are visually empty (whitespace-only after ANSI strip). + // Content lines always contain visible text or cursor escape sequences. + // Always keep at least one line so that an empty textarea still renders + // the cursor line instead of returning the full padded view. + last := len(lines) + for last > 1 && strings.TrimSpace(ansi.Strip(lines[last-1])) == "" { + last-- } - content := m.inlineEditTextarea.Value() - lineCount := 0 - for line := range strings.SplitSeq(content, "\n") { - lineWidth := ansi.StringWidth(line) - if lineWidth == 0 { - lineCount++ - } else { - lineCount += (lineWidth + innerWidth - 1) / innerWidth - } - } - - newHeight := max(1, lineCount) - if m.inlineEditTextarea.Height() == newHeight { - return - } - - // Save cursor position - cursorRow := m.inlineEditTextarea.Line() - cursorCol := m.inlineEditTextarea.LineInfo().ColumnOffset - - m.inlineEditTextarea.SetHeight(newHeight) - - // Reset viewport scroll state by moving to start then restoring position - // NOTE(krissetto): This is a workaround because the textarea's internal viewport - // scrolling is not updated when the height is changed. - m.inlineEditTextarea.MoveToBegin() - for range cursorRow { - m.inlineEditTextarea.CursorDown() - } - m.inlineEditTextarea.SetCursorColumn(cursorCol) + return strings.Join(lines[:last], "\n") } func (m *model) needsSeparator(index int) bool { @@ -1694,23 +1682,10 @@ func (m *model) StartInlineEdit(msgIndex, sessionPosition int, content string) t ta.SetWidth(innerWidth) } - // Calculate appropriate height based on content - // Count lines and account for word wrapping - lineCount := 0 - if innerWidth > 0 { - for line := range strings.SplitSeq(content, "\n") { - lineWidth := ansi.StringWidth(line) - if lineWidth == 0 { - // Empty line counts as 1 line - lineCount++ - } else { - // Account for word wrapping: ceil(lineWidth / innerWidth) - lineCount += (lineWidth + innerWidth - 1) / innerWidth - } - } - } - // Set height to match content (minimum 1 line) - ta.SetHeight(max(1, lineCount)) + // Set a generous height so the textarea's internal viewport never scrolls. + // This prevents cursor positioning bugs with multi-line content. The actual + // rendered output is trimmed in renderInlineEditTextarea to remove padding. + ta.SetHeight(max(1, m.height)) // Remove the default prompt/placeholder styling for a cleaner look ta.Prompt = "" diff --git a/pkg/tui/page/chat/chat.go b/pkg/tui/page/chat/chat.go index 090f37635..66f42ec03 100644 --- a/pkg/tui/page/chat/chat.go +++ b/pkg/tui/page/chat/chat.go @@ -104,6 +104,8 @@ type Page interface { ScrollToBottom() tea.Cmd // IsWorking returns whether the agent is currently working IsWorking() bool + // IsInlineEditing returns true if a past user message is being edited inline + IsInlineEditing() bool // QueueLength returns the number of queued messages QueueLength() int // FocusMessages gives focus to the messages panel for keyboard scrolling @@ -968,6 +970,11 @@ func (p *chatPage) IsWorking() bool { return p.working } +// IsInlineEditing returns true if a past user message is being edited inline. +func (p *chatPage) IsInlineEditing() bool { + return p.messages.IsInlineEditing() +} + // QueueLength returns the number of queued messages func (p *chatPage) QueueLength() int { return len(p.messageQueue) diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 1576ec242..d307d4bd9 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -551,6 +551,13 @@ func (m *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.dialogMgr = u.(dialog.Manager) return m, cmd } + // When inline editing a past message, forward paste to the chat page + // so the messages component can insert content into the inline textarea. + if m.chatPage.IsInlineEditing() { + updated, cmd := m.chatPage.Update(msg) + m.chatPage = updated.(chat.Page) + return m, cmd + } // Forward paste to editor editorModel, cmd := m.editor.Update(msg) m.editor = editorModel.(editor.Editor) diff --git a/pkg/tui/tui_exit_test.go b/pkg/tui/tui_exit_test.go index 7a979ec6f..4ab7f7d68 100644 --- a/pkg/tui/tui_exit_test.go +++ b/pkg/tui/tui_exit_test.go @@ -35,6 +35,7 @@ func (m *mockChatPage) SetSessionStarred(bool) {} func (m *mockChatPage) SetTitleRegenerating(bool) tea.Cmd { return nil } func (m *mockChatPage) ScrollToBottom() tea.Cmd { return nil } func (m *mockChatPage) IsWorking() bool { return false } +func (m *mockChatPage) IsInlineEditing() bool { return false } func (m *mockChatPage) QueueLength() int { return 0 } func (m *mockChatPage) FocusMessages() tea.Cmd { return nil } func (m *mockChatPage) FocusMessageAt(int, int) tea.Cmd { return nil }