From 451540c15857d069c7550c9127f427f9dc1f2bae Mon Sep 17 00:00:00 2001 From: David Gageot Date: Wed, 25 Feb 2026 17:48:05 +0100 Subject: [PATCH 1/3] Support clipboard paste during inline message editing Route tea.PasteMsg to the messages component when a past user message is being edited inline, instead of always forwarding it to the main editor. - Add IsInlineEditing() to chat.Page interface and chatPage - Handle tea.PasteMsg in messages component to insert into inline textarea - Update mock in tui_exit_test.go to satisfy the new interface Assisted-By: cagent --- pkg/tui/components/messages/messages.go | 10 ++++++++++ pkg/tui/page/chat/chat.go | 7 +++++++ pkg/tui/tui.go | 7 +++++++ pkg/tui/tui_exit_test.go | 1 + 4 files changed, 25 insertions(+) diff --git a/pkg/tui/components/messages/messages.go b/pkg/tui/components/messages/messages.go index 67f2cce34..ee8062d15 100644 --- a/pkg/tui/components/messages/messages.go +++ b/pkg/tui/components/messages/messages.go @@ -242,6 +242,16 @@ 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.updateInlineEditTextareaHeight() + m.invalidateItem(m.inlineEditMsgIndex) + m.renderDirty = true + } + return m, nil + case tea.KeyPressMsg: return m.handleKeyPress(msg) } 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 } From a4e7fa1892dcaa9f60b3099077d050887c088c8b Mon Sep 17 00:00:00 2001 From: David Gageot Date: Wed, 25 Feb 2026 18:00:00 +0100 Subject: [PATCH 2/3] Fix multi-line inline editing of past user messages The textarea's internal viewport was scrolling due to an incorrect height calculation that used simple character division instead of matching the textarea's word-aware wrapping. This caused ctrl-e, ctrl-a, ctrl-w to operate on wrong lines, and typing to go to unexpected positions. Fix by setting the textarea height to a generous value (the messages panel height) so the internal viewport never scrolls, then trimming end-of-buffer padding lines from the rendered output. This eliminates the height calculation entirely, along with the fragile cursor save/restore workaround. Assisted-By: cagent --- pkg/tui/components/messages/messages.go | 83 ++++++++----------------- 1 file changed, 25 insertions(+), 58 deletions(-) diff --git a/pkg/tui/components/messages/messages.go b/pkg/tui/components/messages/messages.go index ee8062d15..155ccb312 100644 --- a/pkg/tui/components/messages/messages.go +++ b/pkg/tui/components/messages/messages.go @@ -246,7 +246,6 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) { // Insert paste content into the inline edit textarea if m.inlineEditMsgIndex >= 0 { m.inlineEditTextarea.InsertString(msg.Content) - m.updateInlineEditTextareaHeight() m.invalidateItem(m.inlineEditMsgIndex) m.renderDirty = true } @@ -388,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 @@ -407,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 @@ -964,55 +961,38 @@ 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 - } - - 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 - } + // Trim trailing lines that are visually empty (whitespace-only after ANSI strip). + // Content lines always contain visible text or cursor escape sequences. + last := len(lines) + for last > 0 && strings.TrimSpace(ansi.Strip(lines[last-1])) == "" { + last-- } - newHeight := max(1, lineCount) - if m.inlineEditTextarea.Height() == newHeight { - return + if last == 0 { + return view } - // 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 { @@ -1704,23 +1684,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 = "" From 621d80df800a712fa6f2b25f9159a27651ed3946 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Wed, 25 Feb 2026 18:03:13 +0100 Subject: [PATCH 3/3] Fix empty inline edit textarea expanding to full height When the textarea content was empty, trimEndOfBufferLines stripped all lines (including the cursor line) since they all appear as whitespace after ANSI stripping. The fallback returned the full untrimmed view, causing the edit box to fill the entire panel. Fix by stopping the trim loop at 1 instead of 0, so the cursor line is always preserved. Assisted-By: cagent --- pkg/tui/components/messages/messages.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/pkg/tui/components/messages/messages.go b/pkg/tui/components/messages/messages.go index 155ccb312..66f005398 100644 --- a/pkg/tui/components/messages/messages.go +++ b/pkg/tui/components/messages/messages.go @@ -983,15 +983,13 @@ func trimEndOfBufferLines(view string) string { // 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 > 0 && strings.TrimSpace(ansi.Strip(lines[last-1])) == "" { + for last > 1 && strings.TrimSpace(ansi.Strip(lines[last-1])) == "" { last-- } - if last == 0 { - return view - } - return strings.Join(lines[:last], "\n") }