Skip to content

Commit 9817e64

Browse files
authored
Merge pull request #1850 from dgageot/paste-past-pasta
Improve editing past user messages
2 parents a7e4bd8 + 621d80d commit 9817e64

4 files changed

Lines changed: 49 additions & 59 deletions

File tree

pkg/tui/components/messages/messages.go

Lines changed: 34 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,15 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
242242
}
243243
// Fall through to forward tick to all views
244244

245+
case tea.PasteMsg:
246+
// Insert paste content into the inline edit textarea
247+
if m.inlineEditMsgIndex >= 0 {
248+
m.inlineEditTextarea.InsertString(msg.Content)
249+
m.invalidateItem(m.inlineEditMsgIndex)
250+
m.renderDirty = true
251+
}
252+
return m, nil
253+
245254
case tea.KeyPressMsg:
246255
return m.handleKeyPress(msg)
247256
}
@@ -378,7 +387,6 @@ func (m *model) handleKeyPress(msg tea.KeyPressMsg) (layout.Model, tea.Cmd) {
378387
// Forward to textarea for newline insertion
379388
var cmd tea.Cmd
380389
m.inlineEditTextarea, cmd = m.inlineEditTextarea.Update(msg)
381-
m.updateInlineEditTextareaHeight()
382390
m.invalidateItem(m.inlineEditMsgIndex)
383391
m.renderDirty = true
384392
return m, cmd
@@ -397,7 +405,6 @@ func (m *model) handleKeyPress(msg tea.KeyPressMsg) (layout.Model, tea.Cmd) {
397405
// Forward all other keys to the textarea
398406
var cmd tea.Cmd
399407
m.inlineEditTextarea, cmd = m.inlineEditTextarea.Update(msg)
400-
m.updateInlineEditTextareaHeight()
401408
m.invalidateItem(m.inlineEditMsgIndex)
402409
m.renderDirty = true
403410
return m, cmd
@@ -954,55 +961,36 @@ func (m *model) renderInlineEditTextarea() string {
954961
m.inlineEditTextarea.SetWidth(innerWidth)
955962
}
956963

964+
// The textarea is set to a large height to prevent internal viewport scrolling
965+
// which causes cursor positioning bugs in multi-line content. We trim the
966+
// end-of-buffer padding lines from the rendered output.
967+
view := m.inlineEditTextarea.View()
968+
view = trimEndOfBufferLines(view)
969+
957970
// Add a minimal edit indicator at the bottom left with extra padding
958971
editHint := styles.MutedStyle.Render("[editing]")
959972

960-
content := m.inlineEditTextarea.View() + "\n\n" + editHint
973+
content := view + "\n\n" + editHint
961974
return editStyle.Width(m.contentWidth()).Render(content)
962975
}
963976

964-
// updateInlineEditTextareaHeight recalculates and sets the textarea height based on current content.
965-
func (m *model) updateInlineEditTextareaHeight() {
966-
if m.inlineEditMsgIndex < 0 {
967-
return
968-
}
977+
// trimEndOfBufferLines removes trailing end-of-buffer padding lines from a
978+
// textarea's rendered View output. The textarea pads its view to fill its
979+
// configured height; these padding lines contain only whitespace (after
980+
// stripping ANSI sequences) and appear after the actual content.
981+
func trimEndOfBufferLines(view string) string {
982+
lines := strings.Split(view, "\n")
969983

970-
editStyle := styles.UserMessageStyle
971-
innerWidth := m.contentWidth() - editStyle.GetHorizontalFrameSize()
972-
if innerWidth <= 0 {
973-
return
984+
// Trim trailing lines that are visually empty (whitespace-only after ANSI strip).
985+
// Content lines always contain visible text or cursor escape sequences.
986+
// Always keep at least one line so that an empty textarea still renders
987+
// the cursor line instead of returning the full padded view.
988+
last := len(lines)
989+
for last > 1 && strings.TrimSpace(ansi.Strip(lines[last-1])) == "" {
990+
last--
974991
}
975992

976-
content := m.inlineEditTextarea.Value()
977-
lineCount := 0
978-
for line := range strings.SplitSeq(content, "\n") {
979-
lineWidth := ansi.StringWidth(line)
980-
if lineWidth == 0 {
981-
lineCount++
982-
} else {
983-
lineCount += (lineWidth + innerWidth - 1) / innerWidth
984-
}
985-
}
986-
987-
newHeight := max(1, lineCount)
988-
if m.inlineEditTextarea.Height() == newHeight {
989-
return
990-
}
991-
992-
// Save cursor position
993-
cursorRow := m.inlineEditTextarea.Line()
994-
cursorCol := m.inlineEditTextarea.LineInfo().ColumnOffset
995-
996-
m.inlineEditTextarea.SetHeight(newHeight)
997-
998-
// Reset viewport scroll state by moving to start then restoring position
999-
// NOTE(krissetto): This is a workaround because the textarea's internal viewport
1000-
// scrolling is not updated when the height is changed.
1001-
m.inlineEditTextarea.MoveToBegin()
1002-
for range cursorRow {
1003-
m.inlineEditTextarea.CursorDown()
1004-
}
1005-
m.inlineEditTextarea.SetCursorColumn(cursorCol)
993+
return strings.Join(lines[:last], "\n")
1006994
}
1007995

1008996
func (m *model) needsSeparator(index int) bool {
@@ -1694,23 +1682,10 @@ func (m *model) StartInlineEdit(msgIndex, sessionPosition int, content string) t
16941682
ta.SetWidth(innerWidth)
16951683
}
16961684

1697-
// Calculate appropriate height based on content
1698-
// Count lines and account for word wrapping
1699-
lineCount := 0
1700-
if innerWidth > 0 {
1701-
for line := range strings.SplitSeq(content, "\n") {
1702-
lineWidth := ansi.StringWidth(line)
1703-
if lineWidth == 0 {
1704-
// Empty line counts as 1 line
1705-
lineCount++
1706-
} else {
1707-
// Account for word wrapping: ceil(lineWidth / innerWidth)
1708-
lineCount += (lineWidth + innerWidth - 1) / innerWidth
1709-
}
1710-
}
1711-
}
1712-
// Set height to match content (minimum 1 line)
1713-
ta.SetHeight(max(1, lineCount))
1685+
// Set a generous height so the textarea's internal viewport never scrolls.
1686+
// This prevents cursor positioning bugs with multi-line content. The actual
1687+
// rendered output is trimmed in renderInlineEditTextarea to remove padding.
1688+
ta.SetHeight(max(1, m.height))
17141689

17151690
// Remove the default prompt/placeholder styling for a cleaner look
17161691
ta.Prompt = ""

pkg/tui/page/chat/chat.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ type Page interface {
104104
ScrollToBottom() tea.Cmd
105105
// IsWorking returns whether the agent is currently working
106106
IsWorking() bool
107+
// IsInlineEditing returns true if a past user message is being edited inline
108+
IsInlineEditing() bool
107109
// QueueLength returns the number of queued messages
108110
QueueLength() int
109111
// FocusMessages gives focus to the messages panel for keyboard scrolling
@@ -968,6 +970,11 @@ func (p *chatPage) IsWorking() bool {
968970
return p.working
969971
}
970972

973+
// IsInlineEditing returns true if a past user message is being edited inline.
974+
func (p *chatPage) IsInlineEditing() bool {
975+
return p.messages.IsInlineEditing()
976+
}
977+
971978
// QueueLength returns the number of queued messages
972979
func (p *chatPage) QueueLength() int {
973980
return len(p.messageQueue)

pkg/tui/tui.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,13 @@ func (m *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
551551
m.dialogMgr = u.(dialog.Manager)
552552
return m, cmd
553553
}
554+
// When inline editing a past message, forward paste to the chat page
555+
// so the messages component can insert content into the inline textarea.
556+
if m.chatPage.IsInlineEditing() {
557+
updated, cmd := m.chatPage.Update(msg)
558+
m.chatPage = updated.(chat.Page)
559+
return m, cmd
560+
}
554561
// Forward paste to editor
555562
editorModel, cmd := m.editor.Update(msg)
556563
m.editor = editorModel.(editor.Editor)

pkg/tui/tui_exit_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ func (m *mockChatPage) SetSessionStarred(bool) {}
3535
func (m *mockChatPage) SetTitleRegenerating(bool) tea.Cmd { return nil }
3636
func (m *mockChatPage) ScrollToBottom() tea.Cmd { return nil }
3737
func (m *mockChatPage) IsWorking() bool { return false }
38+
func (m *mockChatPage) IsInlineEditing() bool { return false }
3839
func (m *mockChatPage) QueueLength() int { return 0 }
3940
func (m *mockChatPage) FocusMessages() tea.Cmd { return nil }
4041
func (m *mockChatPage) FocusMessageAt(int, int) tea.Cmd { return nil }

0 commit comments

Comments
 (0)