Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 34 additions & 59 deletions pkg/tui/components/messages/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 = ""
Expand Down
7 changes: 7 additions & 0 deletions pkg/tui/page/chat/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions pkg/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions pkg/tui/tui_exit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down